Skip to content

refactor(mecatui): surface abstraction for modals/overlays (keys + wheel + render + lifecycle in one place) #555

Open

Description

@jbeda

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.

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:

  1. a phase or Model flag (e.g. argsViewOpen),
  2. a renderBody arm,
  3. an onKey/dispatchPhaseKey branch (e.g. onApprovalKey's cascade),
  4. an onMouseWheel branch,
  5. an askButtonAt/hit-test arm in clickgeom.go,
  6. lifecycle clears (advanceAsk, endRun, resetSession, retract),
  7. 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

  1. added
    enhancementNew feature or request
    tuimecatui terminal UI (rendering, keybindings, footer, panes, scrollback)
    on Aug 15, 2026
  2. jbeda commented on Aug 15, 2026

    @jbeda
    ContributorAuthor

    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, buttons
    • update.go — the behavioral half: applyPermissionAsk (reducer), onApprovalKey (key cascade), advanceAsk, resolveAsk, markAskResolved, focusVerdict, dispatchClick
    • model.go — the state fields: ask, askQueue, askResumePhase, resolvedAsks, askVPOffset, planVP* cluster, argsVP* cluster, argsViewOpen, argsViewRaw
    • clickgeom.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 flat ui/):

    ui/approval_state.go    — pendingAsk, queue, askResumePhase, resolvedAsks, the Model field cluster as one approvalState struct
    ui/approval_surface.go  — the approvalSurface: Update routing (keys/clicks/wheel), lifecycle (advance/resolve/retract/clear)
    ui/approval_render.go   — modal body, plan-review view, args view, wrap helpers, buttons (most of today's permission.go)
    ui/approval_regions.go  — per-flavour click geometry (small; the layouts emit most of it already)
    

    Key moves:

    1. State consolidation: Model holds ONE approval approvalState value instead of ~12 loose fields. Every reference m.ask.X → m.approval.ask.X — mechanical but broad. Win: resetSession/endRun clear 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).
    2. Deps seam, not a Model back-reference: the surface needs theme, keymap markings, the stream (SendApproval), notices, refreshView — passed as a narrow approvalDeps struct. No *Model back-pointer (that would re-tangle the boundary).
    3. Delegation shims: renderBody's approval arm and onKey'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 approvalSurface to the surface interface 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.

  3. jbeda commented on Aug 15, 2026

    @jbeda
    ContributorAuthor

    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 typed ClickAction payloads.

    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

    • Gembro (mouse-driven Gemini browser) — Charm's own pointer when asked for a mouse-exemplar.
    • gh-dash — complex Bubble Tea UI with clickable rows/lists at scale.
    • lipgloss examples/canvas/main.go — the LayerHitMsg wiring, for the vocabulary.
  4. self-assigned this
    on Aug 15, 2026
  5. 7 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requesttuimecatui terminal UI (rendering, keybindings, footer, panes, scrollback)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions