Repository navigation
refactor(mecatui): surface abstraction for modals/overlays (keys + wheel + render + lifecycle in one place) #555
Description
Current implementation and remaining tracking (2026-10-16)
The surface abstraction exists in cmd/mecatui/ui/surface.go. It uses one active modal, not a surface stack. The implemented contract is Render(width, height), HandleKey, HandleMsg, HandleWheel, and Close, with regions returned by the same render pass. Root-owned effects use narrow surface intents; the root owns placement and pointer mapping. docs/tui.md is the contributor guidance.
- Establish shared surface routing/lifecycle and explicit parent-owned geometry.
- Migrate
/effort(mecatui: bound /effort picker at constrained terminal heights #2170). - Migrate
/worktrees(mecatui: bound /worktrees picker #2172; PR feat(mecatui): bound worktree picker and move it to a modal surface #2279). - Evaluate and track remaining legacy overlays in mecatui: track remaining surface migrations #2237.
- Next coordinated migration:
/schedule(mecatui: bound /schedule inventory and fire-history browsing #2173), with bounded inventory/fire history tracked in mecatui: track remaining bounded browsing adoption #1742 under mecatui: converge bounded scrolling and selection controls #1589. Compare with/sessionsand record reusable consistency guidelines indocs/tui.md.
Keep this umbrella open while remaining adoption is tracked. The original proposal below is historical context, not authorization to introduce a stack or repeat completed infrastructure.
Original problem
Adding a new modal/overlay to mecatui today touches seven places, all held in lockstep by convention:
- a phase or Model flag (e.g.
argsViewOpen), - a
renderBodyarm, - an
onKey/dispatchPhaseKeybranch (e.g.onApprovalKey's cascade), - an
onMouseWheelbranch, - an
askButtonAt/hit-test arm inclickgeom.go, - lifecycle clears (
advanceAsk,endRun,resetSession, retract), - the help overlay's key listing.
PR #553 (issue #488) drifted on two of these within one feature (the render path didn't thread the scroll offset; the idle-resume phase was wrong), which shows the convention is a weak gate. This came out of the code review discussion on #553.
Proposal
A surface interface so each modal/overlay owns its behavior in one file:
type surface interface {
Render() string // body content
Regions() []ClickableRegion // emitted by the layout pass, not re-derived
HandleKey(tea.KeyPressMsg) (Action, bool) // keys the surface consumes
HandleWheel(tea.MouseWheelMsg) (Action, bool)
Close() // lifecycle teardown
}The Model holds a stack of active surfaces and routes render/keys/wheel/clicks to the top one — replacing the per-surface branches in renderBody, onKey, onMouseWheel, and the hit-test arms. Clickable regions carry a semantic Action payload (a sum type, not a func — value-safe across Model copies, testable), so the hit-test returns the action and ONE dispatcher executes it; no second mapping table.
Migration plan
- The click half (region-emitting builders +
ClickableRegion) lands in PR feat(mecatui): wrap + scroll permission-modal args, full-screen ask-args view (#488) #553 as the vocabulary/pattern proof. - Migrate the approval surface first (it has the most arms: plan / args-view / generic modal), then the other overlays (MCP, team/agents, skills, soul, sessions, models, worktrees, schedule) one at a time.
- The seven-touchpoint list above becomes the acceptance checklist: a new surface should touch ONE file + ONE registration point.
Non-goals
- No widget hierarchy / retained-mode GUI framework — this stays Elm-architecture; surfaces are rebuilt from state every render (regions are emitted per layout pass, so they are always as fresh as the frame).
- No change to the wire/proto surface or the keymap registry semantics.
See also: PR #553 discussion (click geometry origin), ADR 0108.
UI-package structure tracking: #2077. This issue owns the surface-abstraction slice; #2077 collects related refactor opportunities.
Activity
refactor(mecatui): region-emitting hit-test registry for clickable su…
jbeda commentedon Aug 15, 2026
Milestone plan (from the PR #553 review discussion)
Current state: the approval surface is split across six files along technical lines rather than domain lines:
permission.go(907 lines) — builders/renderers:pendingAsk, modal body, plan-review trio, args-view trio, wrap helpers, buttonsupdate.go— the behavioral half:applyPermissionAsk(reducer),onApprovalKey(key cascade),advanceAsk,resolveAsk,markAskResolved,focusVerdict,dispatchClickmodel.go— the state fields:ask,askQueue,askResumePhase,resolvedAsks,askVPOffset,planVP*cluster,argsVP*cluster,argsViewOpen,argsViewRawclickgeom.go—approvalClickRegions(per-flavour geometry switch; consolidated here by the region-registry refactor in feat(mecatui): wrap + scroll permission-modal args, full-screen ask-args view (#488) #553)view.go—renderApprovalBody(render arm)keys.go— keymap bindings (Allow/AllowAlways/Deny/RawArgs)Milestone 1 — approval surface file ownership
Target shape (package
ui, subdirectory only if the file count justifies it — the repo convention is flatui/):Key moves:
Modelholds ONEapproval approvalStatevalue instead of ~12 loose fields. Every referencem.ask.X→m.approval.ask.X— mechanical but broad. Win:resetSession/endRunclear one struct instead of a field list (a drift point in feat(mecatui): wrap + scroll permission-modal args, full-screen ask-args view (#488) #553).approvalDepsstruct. No*Modelback-pointer (that would re-tangle the boundary).renderBody's approval arm andonKey's approval branch become one-liners (if m.approval.active() { return m.approval.render() }/return m.approval.handleKey(msg)).Milestone 2 — surface interface + stack
Generalize
approvalSurfaceto thesurfaceinterface from the issue body; Model holds a stack; render/keys/wheel/clicks route to the top. Migrate approval first (the proof), then MCP/team/skills/soul/sessions/models/worktrees/schedule one overlay at a time.Milestone 3 — acceptance check
The seven-touchpoint list becomes: a NEW surface touches ONE file + ONE registration point. Add a structural test asserting overlay X's symbols live in its own file (the layering-test precedent from
engine/arch).Sequencing note: Milestone 1 is a pure migration — the existing clickgeom/args-view/plan-approval test suites are the no-behavior-change oracle. Do NOT mix it into a feature PR.
jbeda commentedon Aug 15, 2026
Mouse/hit-test landscape (evaluated during the PR #553 review)
Charm's own stance: "Mouse integration is still pretty naïve: you'll need to map click coordinates to the cell coordinates of items in your UI and test hit detection accordingly." There is no built-in hit-test API in bubbletea proper — every serious Bubble Tea app layers its own. Three options for us:
Option A — lipgloss v2 compositor (native; we already import lipgloss v2)
Compositor+Layer(.X/.Y/.Z/.ID) +Hit(x,y)+LayerHitMsg: Z-ordered floating layers with native hit-testing — the classic retained-mode hit model. Catch: it's a canvas/composition model, not our region-stack layout (we render one string per region and join). Adopting it means re-platforming every overlay onto compositor layers — a much bigger migration than the surface abstraction alone. Its vocabulary (layer id, bounds, z-order) is still worth borrowing for the surface stack design.Option B — BubbleZone (the de-facto standard for string-rendered TUIs)
zone.Mark(id, content)zero-width markers +zone.Scan()at the root +zone.Get(id).InBounds(msg). Source of truth is the rendered frame itself — strongest anti-drift property. Catches: (1) the author's README warns "bubblezone v2 may not work when using the lipgloss v2 canvas/compositor" and he's planning a successor library (mature but in transition); (2) a global manager + scanner goroutine + marker string surgery; (3) string zone IDs vs our typedClickActionpayloads.Option C — keep our region-emitting registry (landed in PR #553)
Layouts emit
[]ClickableRegion{cellRect, ClickAction}alongside content — the same single-derivation property as BubbleZone without string magic, without a new dependency, with typed actions. ~150 lines we own and test.Recommendation: Option C for the mechanism, borrowing Option A's vocabulary for the surface stack (each surface = a layer with bounds + z-order; the Model's surface stack is the Z dimension). Revisit the compositor only if a future UI genuinely needs Z-overlapping floating panels (drag-repositioned windows, etc.). Fallback if arithmetic ever drifts from lipgloss reality: BubbleZone-style frame scanning.
Exemplars to read before starting Milestone 2
examples/canvas/main.go— theLayerHitMsgwiring, for the vocabulary.refactor(mecatui): region-emitting hit-test registry for clickable su…
feat(mecatui): wrap + scroll permission-modal args, full-screen ask-a…
feat(mecatui): wrap + scroll permission-modal args, full-screen ask-a…
feat(mecatui): wrap + scroll permission-modal args, full-screen ask-a…
refactor(mecatui): dedupe approval resume clamp; stdlib min/max/slices (
test(mecatui): pin the three approval invariants the goldens miss (#555…
refactor(mecatui): approval modal consolidation (#555 Phase 1) (#608)
refactor(mecatui): address PR #644 review feedback (#555 Phase 2)
refactor(mecatui): revert Resize; Render takes width/height, parents …
7 remaining items
docs(mecatui): skills comments to contract-only (#555)
refactor(mecatui): ctx into surfaceDeps (ambient); drop stale UNSCENT…
refactor(mecatui): surface interface + soul proof-of-pattern (#555 Ph…
refactor(mecatui): address PR #644 review feedback (#555 Phase 2)
refactor(mecatui): revert Resize; Render takes width/height, parents …
docs(design): note lipgloss/v2 compositor as Phase-3 placement primit…
docs(design): link surface-migration-plan from README (#555)
refactor(mecatui): surface deps-on-state + no-Resize interface (#555 …
refactor(mecatui): migrate /skills onto the surface interface (#555 P…
docs(design): fix matlatl dead-end; link plan doc onward (#555)
docs(mecatui): skills comments to contract-only (#555)
docs(mecatui): surface comments to contract-only; move updateInventor…
refactor(mecatui): ctx into surfaceDeps (ambient); drop stale UNSCENT…
docs(mecatui): review fixes for /mcp surface migration (#555)
docs(mecatui): review fixes for /mcp surface migration (#555)