From d19193aa55be55db60e7e4270eaf95125eba3074 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Mon, 12 Oct 2026 19:41:55 -0700 Subject: [PATCH 1/3] feat(mecatui): bound worktree picker and move it to a modal surface Preserve snapshot-on-open filtering, explicit confirmation, and create-first handoff while bounding list geometry and owning discovery in the surface. Co-authored-by: Mecatl --- cmd/mecatui/ui/card_layout_regions_test.go | 6 +- cmd/mecatui/ui/double_escape_test.go | 2 +- cmd/mecatui/ui/fake_test.go | 42 +- cmd/mecatui/ui/keymark_liveness_test.go | 17 +- cmd/mecatui/ui/model.go | 1 - cmd/mecatui/ui/prompt_input_test.go | 2 +- cmd/mecatui/ui/selection.go | 2 +- cmd/mecatui/ui/selection_test.go | 2 +- .../ui/session_continuity_scenario5_test.go | 4 +- cmd/mecatui/ui/surface_arch_test.go | 37 +- cmd/mecatui/ui/update.go | 22 +- cmd/mecatui/ui/view.go | 2 - cmd/mecatui/ui/worktrees.go | 473 +++++++++--------- cmd/mecatui/ui/worktrees_surface_test.go | 403 +++++++++++++++ cmd/mecatui/ui/worktrees_test.go | 37 +- user-docs/mecatui/keybindings.md | 1 + 16 files changed, 790 insertions(+), 263 deletions(-) create mode 100644 cmd/mecatui/ui/worktrees_surface_test.go diff --git a/cmd/mecatui/ui/card_layout_regions_test.go b/cmd/mecatui/ui/card_layout_regions_test.go index fbdcf196a3..97632ec459 100644 --- a/cmd/mecatui/ui/card_layout_regions_test.go +++ b/cmd/mecatui/ui/card_layout_regions_test.go @@ -62,8 +62,10 @@ func TestMecatuiCardLayout_Scenario3_InventoryAndMCPFitWidth(t *testing.T) { t.Run("sessions and worktrees", func(t *testing.T) { st := sessionsState{filtered: []client.SessionListItem{{ID: "s", Title: long, State: long, ModelID: long, Relationship: client.SessionRelationship{MemberName: long}}}, handles: map[string]string{"s": long}} assertFits(t, "sessions", renderSessionsPanel(th, st, client.Capabilities{}, hk, width, 20)) - wt := worktreesState{view: worktreesPanel, filtered: []client.Worktree{{Label: long, Branch: long}}} - assertFits(t, "worktrees", renderWorktreesPanel(th, wt, client.Capabilities{}, hk, width, 20)) + wt := worktreesState{deps: surfaceDeps{theme: th, keys: defaultKeys(), marks: hk}, identities: make(map[client.WorktreeSelector]string), worktrees: []client.Worktree{{Selector: testWorktreeSelector("long"), Label: long, Branch: long}}} + wt.syncFilter() + out, _ := wt.Render(width, 20) + assertFits(t, "worktrees", out) }) t.Run("agents team and parallel", func(t *testing.T) { diff --git a/cmd/mecatui/ui/double_escape_test.go b/cmd/mecatui/ui/double_escape_test.go index 1dde94ccd0..4f332afdb2 100644 --- a/cmd/mecatui/ui/double_escape_test.go +++ b/cmd/mecatui/ui/double_escape_test.go @@ -305,7 +305,7 @@ func TestDoubleEscapeAllEscapeOwnersSuppressGesture(t *testing.T) { {"dream", func(m Model) Model { m.modal = &dreamState{view: dreamGenerating}; return m }}, {"connect", func(m Model) Model { m.connect.open = true; return m }}, {"effort", func(m Model) Model { m.caps.ModelSelection = true; mm, _ := m.runEffort(); return mm.(Model) }}, - {"worktrees", func(m Model) Model { m.worktrees.view = worktreesPanel; return m }}, + {"worktrees", func(m Model) Model { m.modal = &worktreesState{deps: (&m).surfaceDeps()}; return m }}, {"schedule", func(m Model) Model { m.schedule.view = schedulePanel; return m }}, {"running turn", func(m Model) Model { m.phase = phaseRunning; return m }}, } diff --git a/cmd/mecatui/ui/fake_test.go b/cmd/mecatui/ui/fake_test.go index 1bf59ca996..aa86b2d78d 100644 --- a/cmd/mecatui/ui/fake_test.go +++ b/cmd/mecatui/ui/fake_test.go @@ -109,6 +109,11 @@ func (f *fakeSender) frames() []*mecatlv1.ConverseRequest { return out } +type fakeClearRequest struct { + sourceID string + selector *client.WorktreeSelector +} + // fakeConv is the ui's Converser+SessionCreator backed by a fakeRecver/Sender. It // builds a real client.Stream so the test exercises the production ReadLoop, // EventToMsg, and send path — the only thing faked is the transport. @@ -166,10 +171,13 @@ type fakeConv struct { // goroutine + the test goroutine. closedIDs records CloseSession arguments. echoSelAsResolved bool createCount int - closedIDs []string - operations []string - closeErr error - mu sync.Mutex + // clearRequests records each successor handoff's source binding and opaque + // authority. It is guarded by mu because commands run off the test goroutine. + clearRequests []fakeClearRequest + closedIDs []string + operations []string + closeErr error + mu sync.Mutex // lastResolved records the resolved model the MOST RECENT CreateSession echoed // back, so GetSession can return it (modelling a real server whose GetSession // returns the same resolved model the create did). Without this, GetSession @@ -425,7 +433,16 @@ func (c *fakeConv) CreateSessionWithCarryover(ctx context.Context, sourceSession return c.CreateSession(ctx, sel, c.mode) } -func (c *fakeConv) ClearSession(ctx context.Context, _ string, selector *client.WorktreeSelector) (string, client.SessionSnapshot, error) { +func (c *fakeConv) ClearSession(ctx context.Context, sourceID string, selector *client.WorktreeSelector) (string, client.SessionSnapshot, error) { + var recordedSelector *client.WorktreeSelector + if selector != nil { + value := *selector + recordedSelector = &value + } + c.mu.Lock() + c.clearRequests = append(c.clearRequests, fakeClearRequest{sourceID: sourceID, selector: recordedSelector}) + c.mu.Unlock() + id, caps, resolved, err := c.CreateSession(ctx, client.ModelSelection{}, c.mode) c.mu.Lock() if n := len(c.operations); n > 0 { @@ -468,6 +485,21 @@ func (c *fakeConv) CloseSession(_ context.Context, id string) error { return c.closeErr } +// clearCalls returns copies of the recorded successor handoff authorities. +func (c *fakeConv) clearCalls() []fakeClearRequest { + c.mu.Lock() + defer c.mu.Unlock() + calls := make([]fakeClearRequest, len(c.clearRequests)) + for i, call := range c.clearRequests { + calls[i].sourceID = call.sourceID + if call.selector != nil { + selector := *call.selector + calls[i].selector = &selector + } + } + return calls +} + // closed returns a copy of the recorded CloseSession ids (test-goroutine read). func (c *fakeConv) closed() []string { c.mu.Lock() diff --git a/cmd/mecatui/ui/keymark_liveness_test.go b/cmd/mecatui/ui/keymark_liveness_test.go index 4298c3cead..7617eef018 100644 --- a/cmd/mecatui/ui/keymark_liveness_test.go +++ b/cmd/mecatui/ui/keymark_liveness_test.go @@ -706,19 +706,30 @@ func TestScheduleOverlayHintsReflectKeyOverride(t *testing.T) { } // TestWorktreesOverlayHintsReflectKeyOverride proves the worktrees overlay panel/confirm -// footers read the LIVE Choose/Close markings (issue #457). +// footers use the Choose/Close markings captured when the surface opens. func TestWorktreesOverlayHintsReflectKeyOverride(t *testing.T) { hk := liveHK() th := theme.New("aztec", theme.AztecPalette()) t.Run("panel", func(t *testing.T) { - st := worktreesState{view: worktreesPanel, filtered: []client.Worktree{{Selector: testWorktreeSelector("opaque"), Label: "p", Branch: "b"}}} - got := stripANSIstr(renderWorktreesOverlay(th, st, client.Capabilities{}, hk, 100, 30)) + st := worktreesState{deps: surfaceDeps{theme: th, keys: defaultKeys(), marks: hk}, identities: make(map[client.WorktreeSelector]string), worktrees: []client.Worktree{{Selector: testWorktreeSelector("opaque"), Label: "p", Branch: "b"}}} + st.syncFilter() + body, _ := st.Render(100, 30) + got := stripANSIstr(body) if !strings.Contains(got, "ctrl+f17: select ctrl+f16: close") { t.Errorf("worktrees panel hint should carry live select/close: %q", got) } if strings.Contains(got, "enter: select esc: close") { t.Errorf("worktrees panel hint still shows defaults: %q", got) } + st.confirm = &st.worktrees[0] + body, _ = st.Render(100, 30) + got = stripANSIstr(body) + if !strings.Contains(got, "ctrl+f17: start session ctrl+f16: back") { + t.Errorf("worktrees confirmation did not use captured markings: %q", got) + } + if strings.Contains(got, "enter: start session esc: back") { + t.Errorf("worktrees confirmation still shows defaults: %q", got) + } }) } diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 3d3036cfce..91c40ddea8 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -675,7 +675,6 @@ type Model struct { // overwrite root state or a reopened picker. modelCatalogRequestToken uint64 modelCatalog modelCatalog // root-owned inventory, statuses, defaults, and selection reconciliation - worktrees worktreesState // /worktrees overlay state (view==worktreesNone when closed) — issue #102 schedule scheduleState // /schedule overlay state (view==scheduleNone when closed) — issue #234 connect connectState // /connect saved-target picker (tombstone overlay) connectLoadGeneration uint64 // monotonic across overlay close/reopen; stale async loads are ignored diff --git a/cmd/mecatui/ui/prompt_input_test.go b/cmd/mecatui/ui/prompt_input_test.go index d801779232..337f7660a8 100644 --- a/cmd/mecatui/ui/prompt_input_test.go +++ b/cmd/mecatui/ui/prompt_input_test.go @@ -192,7 +192,7 @@ func TestSelectableMatchesAllBodyOwners(t *testing.T) { {"reflections", func(m *Model) { m.reflections.view = reflectionsList }, false}, {"dream", func(m *Model) { m.modal = &dreamState{view: dreamTargets} }, false}, {"effort", func(m *Model) { m.caps.ModelSelection = true; mm, _ := m.runEffort(); *m = mm.(Model) }, false}, - {"worktrees", func(m *Model) { m.worktrees.view = worktreesPanel }, false}, + {"worktrees", func(m *Model) { m.modal = &worktreesState{deps: m.surfaceDeps()} }, false}, {"schedule", func(m *Model) { m.schedule.view = schedulePanel }, false}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/cmd/mecatui/ui/selection.go b/cmd/mecatui/ui/selection.go index 07f6de20eb..5f12c4b030 100644 --- a/cmd/mecatui/ui/selection.go +++ b/cmd/mecatui/ui/selection.go @@ -112,7 +112,7 @@ func bodyOwnerOpen(m Model) bool { m.agentsInv.view != agentsInvNone || m.modal != nil || m.reflections.view != reflectionsNone || m.connect.open || m.executionPicker.open || - m.worktrees.view != worktreesNone || m.schedule.view != scheduleNone + m.schedule.view != scheduleNone } // selectable reports whether a left-click may START a selection right now. It is diff --git a/cmd/mecatui/ui/selection_test.go b/cmd/mecatui/ui/selection_test.go index 4099a6c85c..70ae1203ce 100644 --- a/cmd/mecatui/ui/selection_test.go +++ b/cmd/mecatui/ui/selection_test.go @@ -2086,7 +2086,7 @@ func TestSelectableGateBlocksAndClears(t *testing.T) { cases := []nonSelectableCase{ {"mcpOverlay", func(m *Model) { m.modal = &mcpState{view: mcpPanel} }}, {"modelsOverlay", func(m *Model) { m.modal = &modelsState{view: modelsPanel} }}, - {"worktreesOverlay", func(m *Model) { m.worktrees.view = worktreesPanel }}, + {"worktreesOverlay", func(m *Model) { m.modal = &worktreesState{deps: m.surfaceDeps()} }}, {"soulOverlay", func(m *Model) { m.modal = &soulState{view: soulPanel} }}, {"help", func(m *Model) { mm, _ := m.runHelp(); *m = mm.(Model) }}, {"awaitingApproval", func(m *Model) { m.phase = phaseAwaitingApproval }}, diff --git a/cmd/mecatui/ui/session_continuity_scenario5_test.go b/cmd/mecatui/ui/session_continuity_scenario5_test.go index 304d2d4789..defe791ed3 100644 --- a/cmd/mecatui/ui/session_continuity_scenario5_test.go +++ b/cmd/mecatui/ui/session_continuity_scenario5_test.go @@ -223,8 +223,8 @@ func driveSessionRebindJourney(t *testing.T, journey string, cb client.Clipboard return m, conv.forkedID case "worktree switch": conv.createCount = 1 - mm, cmd, handled := m.switchToWorktree(client.Worktree{Selector: testWorktreeSelector("opaque-feature"), Label: "feature", Branch: "feature"}) - if !handled || cmd == nil { + mm, cmd := m.switchToWorktree(client.Worktree{Selector: testWorktreeSelector("opaque-feature"), Label: "feature", Branch: "feature"}) + if cmd == nil { t.Fatal("worktree switch did not issue its create command") } m = mm.(Model) diff --git a/cmd/mecatui/ui/surface_arch_test.go b/cmd/mecatui/ui/surface_arch_test.go index 2b03839a1b..77fdb366e9 100644 --- a/cmd/mecatui/ui/surface_arch_test.go +++ b/cmd/mecatui/ui/surface_arch_test.go @@ -35,12 +35,33 @@ var surfaceFileHomes = map[string]bool{ "models_surface.go": true, "usermodel.go": true, "effort.go": true, + "worktrees.go": true, "dream.go": true, } // surfaceFileCount is the explicit homes the gate counts — another surface file // is an explicit decision here, not a silent drift. -const surfaceFileCount = 12 +const surfaceFileCount = 13 + +var worktreesSymbolHomes = map[string]string{ + "worktreesState": "worktrees.go", "worktreesResultMsg": "worktrees.go", + "worktreeSelectIntent": "worktrees.go", "openWorktrees": "worktrees.go", + "filterWorktrees": "worktrees.go", "worktreeRowText": "worktrees.go", +} + +func TestModelHasNoWorktreesStateField(t *testing.T) { + st := reflect.TypeOf(Model{}) + for i := 0; i < st.NumField(); i++ { + f := st.Field(i) + typ := f.Type + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + if f.Name == "worktrees" || typ.Name() == "worktreesState" { + t.Errorf("Model.%s retains worktrees open-lifetime state", f.Name) + } + } +} var effortSymbolHomes = map[string]string{ "effortState": "effort.go", "effortSelectIntent": "effort.go", "openEffort": "effort.go", @@ -117,6 +138,14 @@ func TestSurfaceSymbolsLiveInSurfaceFiles(t *testing.T) { if err != nil { t.Fatalf("parse %s: %v", file, err) } + if file != "worktrees.go" { + ast.Inspect(f, func(node ast.Node) bool { + if id, ok := node.(*ast.Ident); ok && id.Name == "worktreesState" { + t.Errorf("concrete worktrees state referenced outside its surface: %s", fset.Position(id.Pos())) + } + return true + }) + } if file != "effort.go" { ast.Inspect(f, func(node ast.Node) bool { if id, ok := node.(*ast.Ident); ok && id.Name == "effortState" { @@ -150,7 +179,8 @@ func TestSurfaceSymbolsLiveInSurfaceFiles(t *testing.T) { _, isUserModelSymbol := userModelSymbolHomes[n] _, isDreamSymbol := dreamSymbolHomes[n] _, isEffortSymbol := effortSymbolHomes[n] - if !surfaceToken.MatchString(n) && !isModelsSymbol && !isUserModelSymbol && !isDreamSymbol && !isEffortSymbol { + _, isWorktreesSymbol := worktreesSymbolHomes[n] + if !surfaceToken.MatchString(n) && !isModelsSymbol && !isUserModelSymbol && !isDreamSymbol && !isEffortSymbol && !isWorktreesSymbol { continue } if !surfaceFileHomes[file] { @@ -162,6 +192,9 @@ func TestSurfaceSymbolsLiveInSurfaceFiles(t *testing.T) { if want, ok := userModelSymbolHomes[n]; ok && file != want { t.Errorf("/memory declaration %q lives in %s, want %s", n, file, want) } + if want, ok := worktreesSymbolHomes[n]; ok && file != want { + t.Errorf("/worktrees declaration %q lives in %s, want %s", n, file, want) + } if want, ok := effortSymbolHomes[n]; ok && file != want { t.Errorf("/effort declaration %q lives in %s, want %s", n, file, want) } diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 9d7786d796..48d8491b11 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -488,8 +488,8 @@ func (m Model) dispatchNonInputMsg(msg tea.Msg) (tea.Model, tea.Cmd) { if mm, handled := m.updateSkillChangesMsg(msg); handled { return mm, nil } - // Inventory-overlay result/error msgs (agentsInv, usermodel, /worktrees, - // /schedule, /sessions) — grouped to keep update() flat as overlays accrue. + // Inventory-overlay result/error msgs (agentsInv, reflections, /schedule) + // remain grouped while surface-owned results route through the modal. if mm, cmd, handled := m.updateInventoryMsgs(msg); handled { return mm, cmd } @@ -508,7 +508,7 @@ func (m Model) dispatchNonInputMsg(msg tea.Msg) (tea.Model, tea.Cmd) { } // updateInventoryMsgs is the fall-through chain for the unmigrated inventory -// overlays (agentsInv, reflections, worktrees, schedule): +// overlays (agentsInv, reflections, execution, schedule): // each per-overlay helper returns handled=false for a non-matching msg, so at // most one consumes. Most carry no follow-up command; /schedule's // ScheduleActionMsg re-lists on success so the cmd is propagated. Surfaces @@ -524,9 +524,6 @@ func (m Model) updateInventoryMsgs(msg tea.Msg) (tea.Model, tea.Cmd, bool) { if mm, cmd, handled := m.updateExecutionMsg(msg); handled { return mm, cmd, true } - if mm, handled := m.updateWorktreesMsg(msg); handled { - return mm, nil, true - } if mm, cmd, handled := m.updateScheduleMsg(msg); handled { return mm, cmd, true } @@ -2362,8 +2359,7 @@ func (m Model) onPendingApprovalRecoveryKey(msg tea.KeyPressMsg) (tea.Model, tea func (m Model) clearAnySelection(msg tea.KeyPressMsg) (Model, bool) { if !key.Matches(msg, m.keys.Cancel) || (!m.sel.active && !m.prompt.HasSelection()) || m.modal != nil || m.team.view != teamNone || m.agentsInv.view != agentsInvNone || - m.reflections.view != reflectionsNone || - m.executionPicker.open || m.worktrees.view != worktreesNone { + m.reflections.view != reflectionsNone || m.executionPicker.open { return m, false } m = m.clearSelection() @@ -2426,7 +2422,6 @@ func (m Model) onOverlayKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { m.onReflectionsKey, m.onConnectKey, m.onExecutionKey, - m.onWorktreesKey, m.onScheduleKey, } for _, route := range overlays { @@ -2599,6 +2594,15 @@ func (m Model) applyToolcallsSurfaceIntent(intent surfaceIntent) bool { // tells the caller to skip common dispatch post-processing after a root-owned // effect takes over; handled=false never carries meaningful work. func (m Model) applySurfaceIntent(intent surfaceIntent) (model tea.Model, cmd tea.Cmd, stopSurfaceDispatch bool) { + if intent, ok := intent.(worktreeSelectIntent); ok { + if intent.sourceID != m.sessionID || m.phase != phaseIdle { + m.closeModal() + m.statusMsg = "worktree choice is stale; relist and try again" + return m, m.prompt.Focus(), true + } + mm, cmd := m.switchToWorktree(intent.worktree) + return mm, cmd, true + } if intent, ok := intent.(effortSelectIntent); ok { sel := m.effortSelection(intent.effort) m.closeModal() diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index 1ba39050d6..8d459073fa 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -110,8 +110,6 @@ func (m Model) renderBody() string { return renderReflectionsOverlay(m.deps.Theme, m.reflections, m.caps, m.helpKeyMarkings(), m.width, m.vp.Height()) case m.executionPicker.open: return m.renderExecutionPicker() - case m.worktrees.view != worktreesNone: - return renderWorktreesOverlay(m.deps.Theme, m.worktrees, m.caps, m.helpKeyMarkings(), m.width, m.vp.Height()) case m.schedule.view != scheduleNone: return renderScheduleOverlay(m.deps.Theme, m.schedule, m.caps, m.deps.Transcript != nil, m.helpKeyMarkings(), m.width, m.vp.Height()) case m.connect.open: diff --git a/cmd/mecatui/ui/worktrees.go b/cmd/mecatui/ui/worktrees.go index a94a8bff25..a8154d2e64 100644 --- a/cmd/mecatui/ui/worktrees.go +++ b/cmd/mecatui/ui/worktrees.go @@ -1,183 +1,307 @@ package ui import ( + "context" "errors" + "fmt" + "strconv" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" - "github.com/stacklok/mecatl/cmd/mecatui/theme" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" ) -// worktrees.go is the /worktrees overlay (issue #102) — the SECOND *selecting* -// overlay after /models. Unlike /models it has NO apply-on-next option: a -// workspace switch is ALWAYS a restart-now handoff (there is no "pendingNext -// workspace" concept — switching a live session's workspace is forbidden by -// design, so the only path is close-old → create-new-rooted-at-worktree). It -// mirrors the /models cursor+filter+Choose model, but the confirm step offers -// only restart-now / undo. +// worktreesState owns one picker lifetime. Only Model can bind a successor session. +type worktreesState struct { + deps surfaceDeps + lister client.WorktreeLister + sourceID string + cancel context.CancelFunc + request uint64 + loading bool + err error + worktrees []client.Worktree + filtered []client.Worktree + filter textinput.Model + list bounded.List + identities map[client.WorktreeSelector]string + confirm *client.Worktree + intent surfaceIntent + compact bool // view cache: refreshed from every Render offer + offer [2]int // view cache: last geometry, for reveal on resize +} -// worktreesView is the active /worktrees overlay (none = closed). Like /models it -// has a real cursor and an enter-to-confirm step; UNLIKE it there is no -// "switch-next" arm. -type worktreesView int +type worktreesResultMsg struct { + owner *worktreesState + request uint64 + result client.WorktreesMsg +} -const ( - worktreesNone worktreesView = iota // overlay closed - worktreesPanel // the flat, type-to-filter picker - worktreesConfirm // the post-Enter confirmation overlay (restart-now / undo) -) +type worktreeSelectIntent struct { + sourceID string + worktree client.Worktree +} -// worktreesState holds the /worktrees overlay state on the Model. Value-embedded -// so the Model stays a plain struct Update copies; the slices are replaced -// wholesale on each RPC result / filter recompute (never mutated in place). -type worktreesState struct { - view worktreesView - loading bool // the ListWorktrees RPC is in flight - err error // last ListWorktrees error, rendered distinctly - worktrees []client.Worktree // full list as relayed (path-sorted by git) - filtered []client.Worktree // subset matching filter.Value(); recomputed on each key - filter textinput.Model // the type-to-filter input; focused while the picker is open - cursor int // index into FILTERED (clamped to its bounds) - confirm client.Worktree // the candidate worktree when view==worktreesConfirm +func (worktreeSelectIntent) isSurfaceIntent() {} + +func (s *worktreesState) takeSurfaceIntent() surfaceIntent { + intent := s.intent + s.intent = nil + return intent } -// openWorktrees opens the picker and fires the ListWorktrees RPC. Only callable -// while idle and when a worktree lister is wired; returns the model unchanged -// otherwise. The result arrives as a client.WorktreesMsg handled in -// updateWorktreesMsg. func (m Model) openWorktrees() (tea.Model, tea.Cmd) { if m.phase != phaseIdle || m.deps.Worktrees == nil { return m, nil } - m.prompt.Blur() // overlay owns the keyboard while open - m.worktrees.view = worktreesPanel - m.worktrees.loading = true - m.worktrees.err = nil - m.worktrees.cursor = 0 ti := textinput.New() ti.Placeholder = "filter worktrees…" - ti.SetWidth(40) ti.Focus() - m.worktrees.filter = ti - m.worktrees.filtered = nil - return m, tea.Batch(client.ListWorktreesCmd(m.deps.Ctx, m.deps.Worktrees, m.sessionID), textinput.Blink) + s := &worktreesState{deps: (&m).surfaceDeps(), lister: m.deps.Worktrees, sourceID: m.sessionID, filter: ti, compact: true, identities: make(map[client.WorktreeSelector]string)} + m.modal = s + m.prompt.Blur() + return m, tea.Batch(s.listCmd(), textinput.Blink) } -// closeWorktrees dismisses the overlay and returns focus to the prompt input. -func (m Model) closeWorktrees() (tea.Model, tea.Cmd) { - m.worktrees.view = worktreesNone - m.worktrees.filter = textinput.Model{} - m.worktrees.filtered = nil - cmd := m.prompt.Focus() - return m, cmd +func (s *worktreesState) listCmd() tea.Cmd { + if s.cancel != nil { + s.cancel() + } + ctx, cancel := context.WithCancel(s.deps.ctx) + s.cancel = cancel + s.request++ + s.loading, s.err = true, nil + request, lister, sourceID := s.request, s.lister, s.sourceID + return func() tea.Msg { + worktrees, err := lister.List(ctx, sourceID) + return worktreesResultMsg{owner: s, request: request, result: client.WorktreesMsg{Worktrees: worktrees, Err: err}} + } } -// onWorktreesKey routes key presses while the picker is open. Mirrors onModelsKey: -// the filter input is FOCUSED, so nav/action keys are intercepted first and -// everything else feeds the input. esc is two-stage (clear filter, then close). -func (m Model) onWorktreesKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { - if m.worktrees.view == worktreesNone { - return m, nil, false +func (s *worktreesState) Close() { + if s.cancel != nil { + s.cancel() } - if m.worktrees.view == worktreesConfirm { - return m.onWorktreesConfirmKey(msg) + s.request++ + s.intent = nil + s.filter.Blur() +} + +func (s *worktreesState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { + if result, ok := msg.(worktreesResultMsg); ok { + if result.owner != s || result.request != s.request || !s.loading { + return nil, true, false + } + s.loading, s.err = false, result.result.Err + if s.err == nil { + s.worktrees = result.result.Worktrees + s.syncFilter() + } + return nil, true, false + } + var cmd tea.Cmd + s.filter, cmd = s.filter.Update(msg) + return cmd, cmd != nil, false +} + +func (s *worktreesState) syncFilter() { + s.filtered = filterWorktrees(s.worktrees, s.filter.Value()) + items := make([]bounded.ListItem, len(s.filtered)) + for i, wt := range s.filtered { + id, ok := s.identities[wt.Selector] + if !ok { + id = strconv.Itoa(len(s.identities) + 1) + s.identities[wt.Selector] = id + } + items[i] = bounded.ListItem{ID: id, Text: worktreeRowText(wt)} + } + s.list.SetItems(items) +} + +func (s *worktreesState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { + if s.compact { + return nil, true, key.Matches(msg, s.deps.keys.Close) + } + if s.confirm != nil { + switch { + case key.Matches(msg, s.deps.keys.Close): + s.confirm = nil + case key.Matches(msg, s.deps.keys.Choose): + if s.sourceID == "" || s.confirm.Selector.IsZero() { + s.confirm = nil + s.err = errors.New("worktree choice is stale; relist and try again") + } else { + s.intent = worktreeSelectIntent{sourceID: s.sourceID, worktree: *s.confirm} + } + } + return nil, true, false } switch { - case key.Matches(msg, m.keys.Close): - if m.worktrees.filter.Value() != "" { - m.worktrees.filter.SetValue("") - m = m.syncWorktreesFilter() - return m, nil, true + case key.Matches(msg, s.deps.keys.Close): + if s.filter.Value() == "" { + return nil, true, true } - mm, cmd := m.closeWorktrees() - return mm, cmd, true + s.filter.SetValue("") + s.syncFilter() case msg.String() == "up": - if m.worktrees.cursor > 0 { - m.worktrees.cursor-- - } - return m, nil, true + s.list.Move(bounded.LineUp) case msg.String() == "down": - if m.worktrees.cursor < len(m.worktrees.filtered)-1 { - m.worktrees.cursor++ + s.list.Move(bounded.LineDown) + case key.Matches(msg, s.deps.keys.ScrollTop): + s.list.Move(bounded.Top) + case key.Matches(msg, s.deps.keys.ScrollBottom): + s.list.Move(bounded.End) + case key.Matches(msg, s.deps.keys.Choose): + if !s.loading && s.err == nil && len(s.filtered) > 0 { + wt := s.filtered[s.list.Cursor()] + s.confirm = &wt } - return m, nil, true - case key.Matches(msg, m.keys.ScrollTop): - m.worktrees.cursor = 0 - return m, nil, true - case key.Matches(msg, m.keys.ScrollBottom): - m.worktrees.cursor = clampBounded(len(m.worktrees.filtered)-1, len(m.worktrees.filtered)) - return m, nil, true - case key.Matches(msg, m.keys.Choose): - return m.chooseWorktree(), nil, true + default: + var cmd tea.Cmd + s.filter, cmd = s.filter.Update(msg) + s.syncFilter() + return cmd, true, false } - var cmd tea.Cmd - m.worktrees.filter, cmd = m.worktrees.filter.Update(msg) - m = m.syncWorktreesFilter() - return m, cmd, true + return nil, true, false } -// syncWorktreesFilter recomputes the filtered slice from the filter input and -// clamps the cursor. -func (m Model) syncWorktreesFilter() Model { - m.worktrees.filtered = filterWorktrees(m.worktrees.worktrees, m.worktrees.filter.Value()) - if m.worktrees.cursor >= len(m.worktrees.filtered) { - m.worktrees.cursor = 0 +func (s *worktreesState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { + if !s.compact && s.confirm == nil && !s.loading && s.err == nil { + switch msg.Button { + case tea.MouseWheelUp: + s.list.Scroll(bounded.LineUp) + case tea.MouseWheelDown: + s.list.Scroll(bounded.LineDown) + } } - return m + return nil, true } -// chooseWorktree handles Enter on the cursor row: opens the confirmation overlay -// (worktreesConfirm) offering restart-now / undo. A cursor past the list end (or -// an empty list) is a no-op. -func (m Model) chooseWorktree() Model { - if m.worktrees.cursor < 0 || m.worktrees.cursor >= len(m.worktrees.filtered) { - return m +func (*worktreesState) modalMaxOuterWidth() int { return 128 } +func (s *worktreesState) modalFrame() bool { return !s.compact } + +func (s *worktreesState) Render(width, height int) (string, []ClickableRegion) { + offer := [2]int{width, height} + reveal := offer != s.offer || s.list.RevealPending() + s.offer = offer + s.compact = true + if width <= 0 || height <= 0 { + s.list.SetGeometry(0, 0, 1, bounded.Wrap) + return "", nil + } + if s.confirm != nil { + return s.renderConfirm(width, height), nil + } + th, hk := s.deps.theme, s.deps.marks + title := skillsTextLines(th.Style("askTitle"), "worktrees", width) + prefix := append(title, skillsTextLines(th.Style("muted"), "select a worktree to start a new session there", width)...) + s.filter.SetWidth(max(1, width-2)) + prefix = append(prefix, ansi.Cut(s.filter.View(), 0, width)) + footerText := hk.closeOnly + ": close" + if !s.loading && s.err == nil && len(s.filtered) > 0 && s.deps.keys.Choose.Enabled() { + footerText = hk.choose + ": select " + footerText + } + footer := skillsTextLines(th.Style("muted"), footerText, width) + rows := height - len(prefix) - len(footer) + s.list.SetGeometry(width, rows, 1, bounded.Wrap) + if !s.list.Valid() { + return renderSkillsCompact(th, hk, width), nil } - m.worktrees.confirm = m.worktrees.filtered[m.worktrees.cursor] - m.worktrees.view = worktreesConfirm - return m + state := s.stateLines(width) + if len(state) > rows { + return renderSkillsCompact(th, hk, width), nil + } + s.compact = false + lines := prefix + if state != nil { + lines = append(lines, state...) + } else { + view := s.list.ViewWithIndicators(rows, reveal) + if rows == 1 && (view.Above > 0 || view.Below > 0) { + lines[0] = ansi.Cut(th.Style("askTitle").Render(fmt.Sprintf("↑ %d ↓ %d more · worktrees", view.Above, view.Below)), 0, width) + } else if view.Above > 0 { + lines = append(lines, ansi.Cut(th.Style("muted").Render(fmt.Sprintf("↑ %d more", view.Above)), 0, width)) + } + for _, row := range view.Rows { + p := presentListRow(row, th.Style("spinner"), th.Style("muted")) + lines = append(lines, ansi.Cut(p.Style.Render(p.Text), 0, width)) + } + if rows > 1 && view.Below > 0 { + lines = append(lines, ansi.Cut(th.Style("muted").Render(fmt.Sprintf("↓ %d more", view.Below)), 0, width)) + } + } + lines = append(lines, footer...) + return strings.Join(lines, "\n"), nil } -// onWorktreesConfirmKey routes keys while the confirmation overlay is open. Two -// choices: -// - enter — RESTART NOW: close the old session and create a fresh one rooted at -// the chosen worktree (the only path — there is no apply-on-next for workspace). -// - esc — UNDO: return to the picker panel without switching. -// -// Any other key is swallowed. -func (m Model) onWorktreesConfirmKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { - chosen := m.worktrees.confirm +func (s *worktreesState) renderConfirm(width, height int) string { + th, hk := s.deps.theme, s.deps.marks + lines := skillsTextLines(th.Style("askTitle"), "start session in worktree", width) + lines = append(lines, skillsTextLines(th.Style("muted"), "a new session will start in:", width)...) + lines = append(lines, skillsTextLines(th.Style("spinner"), terminaltext.SanitizeSingleLine(s.confirm.Label), width)...) + if s.confirm.Branch != "" { + lines = append(lines, skillsTextLines(th.Style("muted"), "branch: "+terminaltext.SanitizeSingleLine(s.confirm.Branch), width)...) + } + footer := hk.closeOnly + ": back" + if s.deps.keys.Choose.Enabled() { + footer = hk.choose + ": start session " + footer + } + lines = append(lines, skillsTextLines(th.Style("muted"), footer, width)...) + if len(lines) > height { + return renderSkillsCompact(th, hk, width) + } + s.compact = false + return strings.Join(lines, "\n") +} + +func (s *worktreesState) stateLines(width int) []string { + th := s.deps.theme switch { - case key.Matches(msg, m.keys.Choose): // enter — restart now - return m.switchToWorktree(chosen) - case key.Matches(msg, m.keys.Close): // esc — undo - m.worktrees.view = worktreesPanel - m.worktrees.confirm = client.Worktree{} - return m, nil, true + case s.loading: + return skillsTextLines(th.Style("muted"), "loading…", width) + case s.err != nil: + return skillsTextLines(th.Style("errorText"), "could not list worktrees: "+terminaltext.Sanitize(s.err.Error()), width) + case len(s.filtered) == 0: + text := "no worktrees found" + if s.filter.Value() != "" { + text = "no matches — clear filter to see all" + } + return skillsTextLines(th.Style("muted"), text, width) + default: + return nil + } +} + +func worktreeRowText(w client.Worktree) string { + text := terminaltext.SanitizeSingleLine(w.Label) + if w.Branch != "" { + text += " (" + terminaltext.SanitizeSingleLine(w.Branch) + ")" + } else if w.Revision != "" { + text += " (" + shortSHA(terminaltext.SanitizeSingleLine(w.Revision)) + ")" } - return m, nil, true + return text } // switchToWorktree starts a create-first empty-history successor handoff using // only the opaque selector issued for the current session. The old session and // transcript stay selected until the server has created and returned the target. -func (m Model) switchToWorktree(wt client.Worktree) (tea.Model, tea.Cmd, bool) { +func (m Model) switchToWorktree(wt client.Worktree) (tea.Model, tea.Cmd) { if m.sessionID == "" || wt.Selector.IsZero() { - m.worktrees.view = worktreesPanel - m.worktrees.err = errors.New("worktree choice is stale; relist and try again") - return m, nil, true + m.statusMsg = "worktree choice is stale; relist and try again" + return m, nil } oldID := m.sessionID + m.closeModal() m.phase = phaseConnecting m.statusMsg = "starting a new session in the selected worktree…" - m.worktrees.view = worktreesNone m.refreshView() - return m, m.switchWorktreeCmd(oldID, wt), true + return m, m.switchWorktreeCmd(oldID, wt) } func (m Model) switchWorktreeCmd(oldID string, wt client.Worktree) tea.Cmd { @@ -207,30 +331,6 @@ type worktreeSwitchFailedMsg struct { err error } -// updateWorktreesMsg reduces a client.WorktreesMsg (the ListWorktrees RPC result): -// it stores the list, derives the filtered slice, clears loading, and keeps the -// overlay open. On error it records the error and clears loading (the panel -// renders an error line). Returns handled=false for any non-WorktreesMsg. -func (m Model) updateWorktreesMsg(msg tea.Msg) (tea.Model, bool) { - wm, ok := msg.(client.WorktreesMsg) - if !ok { - return m, false - } - m.worktrees.loading = false - if wm.Err != nil { - m.worktrees.err = wm.Err - m.worktrees.worktrees = nil - m.worktrees.filtered = nil - return m, true - } - m.worktrees.err = nil - m.worktrees.worktrees = wm.Worktrees - m = m.syncWorktreesFilter() - return m, true -} - -// filterWorktrees returns the worktrees whose Path/Branch/Head contains q -// (case-insensitive). An empty query returns the full list. func filterWorktrees(wts []client.Worktree, q string) []client.Worktree { if q == "" { return wts @@ -238,90 +338,15 @@ func filterWorktrees(wts []client.Worktree, q string) []client.Worktree { needle := strings.ToLower(q) out := make([]client.Worktree, 0, len(wts)) for _, w := range wts { - if strings.Contains(strings.ToLower(w.Label), needle) || - strings.Contains(strings.ToLower(w.Branch), needle) || - strings.Contains(strings.ToLower(w.Revision), needle) { + if strings.Contains(strings.ToLower(terminaltext.SanitizeSingleLine(w.Label)), needle) || + strings.Contains(strings.ToLower(terminaltext.SanitizeSingleLine(w.Branch)), needle) || + strings.Contains(strings.ToLower(terminaltext.SanitizeSingleLine(w.Revision)), needle) { out = append(out, w) } } return out } -// renderWorktreesOverlay draws the picker (or its post-Enter confirmation -// overlay). Mirrors renderModelsOverlay's centred-card shape. -func renderWorktreesOverlay(th theme.Theme, st worktreesState, caps client.Capabilities, hk helpKeys, width, height int) string { - switch st.view { - case worktreesConfirm: - return renderWorktreesConfirm(th, st, hk, width, height) - default: - return renderWorktreesPanel(th, st, caps, hk, width, height) - } -} - -// renderWorktreesPanel renders the worktree list card. -func renderWorktreesPanel(th theme.Theme, st worktreesState, _ client.Capabilities, hk helpKeys, width, _ int) string { - var b strings.Builder - b.WriteString(th.Style("title").Render("worktrees") + "\n") - b.WriteString(th.Style("muted").Render("select a worktree to start a new session there") + "\n\n") - if st.loading { - b.WriteString(th.Style("muted").Render("loading…")) - return b.String() - } - if st.err != nil { - b.WriteString(th.Style("errorText").Render("could not list worktrees: " + terminaltext.Sanitize(st.err.Error()))) - b.WriteString("\n" + th.Style("muted").Render(hk.closeOnly+": close")) - return b.String() - } - if len(st.filtered) == 0 { - if st.filter.Value() != "" { - b.WriteString(th.Style("muted").Render("no matches — clear filter to see all")) - } else { - b.WriteString(th.Style("muted").Render("no worktrees found")) - } - b.WriteString("\n" + th.Style("muted").Render(hk.closeOnly+": close")) - return b.String() - } - for i, w := range st.filtered { - marker := " " - if i == st.cursor { - marker = "▶ " - } - line := marker + terminaltext.Sanitize(w.Label) - if w.Branch != "" { - line += " (" + terminaltext.Sanitize(w.Branch) + ")" - } else if w.Revision != "" { - line += " (" + terminaltext.Sanitize(shortSHA(w.Revision)) + ")" - } - if i == st.cursor { - line = renderToolCardText(th.Style("accent"), line, width) - } else { - line = renderToolCardText(th.Style("toolArgs"), line, width) - } - b.WriteString(line + "\n") - } - // The Choose/Close chords read the LIVE keyMap markings (issue #457). - b.WriteString("\n" + th.Style("muted").Render(hk.choose+": select "+hk.closeOnly+": close")) - return b.String() -} - -// renderWorktreesConfirm renders the post-Enter confirmation card. -func renderWorktreesConfirm(th theme.Theme, st worktreesState, hk helpKeys, _, _ int) string { - w := st.confirm - var b strings.Builder - b.WriteString(th.Style("title").Render("start session in worktree") + "\n\n") - b.WriteString("a new session will start in:\n") - b.WriteString(th.Style("accent").Render(" "+terminaltext.Sanitize(w.Label)) + "\n") - if w.Branch != "" { - b.WriteString(th.Style("muted").Render(" branch: "+terminaltext.Sanitize(w.Branch)) + "\n") - } - b.WriteString("\n" + th.Style("muted").Render(hk.choose+": start session "+hk.closeOnly+": back")) - return b.String() -} - -// shortSHA returns a shortened commit SHA (first 8 chars) for display. func shortSHA(sha string) string { - if len(sha) > 8 { - return sha[:8] - } - return sha + return string([]rune(sha)[:min(8, len([]rune(sha)))]) } diff --git a/cmd/mecatui/ui/worktrees_surface_test.go b/cmd/mecatui/ui/worktrees_surface_test.go new file mode 100644 index 0000000000..3ce16bc020 --- /dev/null +++ b/cmd/mecatui/ui/worktrees_surface_test.go @@ -0,0 +1,403 @@ +package ui + +import ( + "errors" + "fmt" + "slices" + "strings" + "testing" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +func worktreeInventory(n int) []client.Worktree { + rows := make([]client.Worktree, n) + for i := range rows { + rows[i] = client.Worktree{Selector: testWorktreeSelector(fmt.Sprintf("opaque-%d", i)), Label: fmt.Sprintf("worktree-%02d", i), Branch: "topic"} + } + return rows +} + +func selectedWorktree(t *testing.T, s *worktreesState) client.Worktree { + t.Helper() + cursor := s.list.Cursor() + if cursor < 0 || cursor >= len(s.filtered) { + t.Fatalf("cursor %d outside %d filtered worktrees", cursor, len(s.filtered)) + } + return s.filtered[cursor] +} + +func openedWorktrees(t *testing.T, rows []client.Worktree) (Model, *worktreesState) { + t.Helper() + m := newWorktreesModel(t, &fakeConv{}, &fakeWorktreeLister{wts: rows}) + mm, cmd := m.openWorktrees() + m = feedWorktreesList(t, mm.(Model), cmd) + s := m.modal.(*worktreesState) + _ = m.View() + return m, s +} + +func worktreesBodyFits(t *testing.T, s *worktreesState, width, height int) string { + t.Helper() + body, hits := s.Render(width, height) + if width <= 0 || height <= 0 { + if body != "" || s.list.Valid() { + t.Fatal("zero offer retained body or usable list") + } + return body + } + if lipgloss.Width(body) > width || lipgloss.Height(body) > height || !utf8.ValidString(body) || len(hits) != 0 { + t.Fatalf("body does not fit %dx%d: %dx%d\n%s", width, height, lipgloss.Width(body), lipgloss.Height(body), body) + } + return stripANSIstr(body) +} + +func TestWorktreesSurface_Scenario1_ManyRowsAndShortOffers(t *testing.T) { + rows := worktreeInventory(80) + for _, width := range []int{1, 2, 3, 12, 32, 80, 124} { + for _, height := range []int{1, 2, 4, 5, 6, 9, 20} { + t.Run(fmt.Sprintf("%dx%d", width, height), func(t *testing.T) { + _, s := openedWorktrees(t, rows) + out := worktreesBodyFits(t, s, width, height) + if s.compact { + cursor := s.list.CursorID() + for _, msg := range []tea.KeyPressMsg{{Code: tea.KeyEnter}, {Code: tea.KeyDown}, {Code: 'j', Text: "j"}} { + s.HandleKey(msg) + } + if s.confirm != nil || s.filter.Value() != "" || s.list.CursorID() != cursor { + t.Fatal("compact fallback accepted typing/navigation/activation") + } + if _, handled, closed := s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}); !handled || !closed { + t.Fatal("compact fallback cannot close") + } + return + } + if !strings.Contains(out, "more") || strings.Contains(out, rows[79].Label) { + t.Fatalf("unbounded inventory or missing overflow:\n%s", out) + } + for _, navigation := range []struct { + msg tea.KeyPressMsg + want client.WorktreeSelector + }{ + {tea.KeyPressMsg{Code: tea.KeyEnd}, rows[79].Selector}, + {tea.KeyPressMsg{Code: tea.KeyUp}, rows[78].Selector}, + {tea.KeyPressMsg{Code: tea.KeyHome}, rows[0].Selector}, + {tea.KeyPressMsg{Code: tea.KeyDown}, rows[1].Selector}, + } { + s.HandleKey(navigation.msg) + out = worktreesBodyFits(t, s, width, height) + if got := selectedWorktree(t, s).Selector; got != navigation.want { + t.Fatalf("%s selected selector %q, want %q", navigation.msg.String(), got, navigation.want) + } + if !strings.Contains(out, "▶") { + t.Fatalf("navigation did not reveal selection:\n%s", out) + } + } + }) + } + } +} + +func TestWorktreesSurface_Scenario2_WrappingAndStableCard(t *testing.T) { + rows := worktreeInventory(30) + rows[4].Label = strings.Repeat("wide label 界 ❤️ ", 12) + m, s := openedWorktrees(t, rows) + m = effortOffer(m, 32, 16) + _ = m.renderBody() + bounds := m.metrics.outerBounds + for _, msg := range []tea.Msg{ + tea.KeyPressMsg{Code: tea.KeyEnd}, tea.KeyPressMsg{Code: tea.KeyHome}, + tea.MouseWheelMsg{Button: tea.MouseWheelDown}, tea.MouseWheelMsg{Button: tea.MouseWheelDown}, + tea.KeyPressMsg{Code: tea.KeyDown}, + } { + m = applyAll(m, msg) + body := m.renderBody() + got := m.metrics.outerBounds + if got.x1-got.x0 != bounds.x1-bounds.x0 || got.y1-got.y0 != bounds.y1-bounds.y0 || lipgloss.Height(body) > m.vp.Height() || lipgloss.Width(body) > m.width { + t.Fatalf("card geometry changed during %T: %+v -> %+v", msg, bounds, got) + } + } + for range len(rows) { + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyDown}) + body := m.renderBody() + got := m.metrics.outerBounds + if got.x1-got.x0 != bounds.x1-bounds.x0 || got.y1-got.y0 != bounds.y1-bounds.y0 || lipgloss.Height(body) > m.vp.Height() { + t.Fatalf("wrapped-row navigation changed fixed card geometry: %+v -> %+v", bounds, got) + } + } + s.list.SetCursor(4) + out := worktreesBodyFits(t, s, 32, 30) + view := s.list.View() + wrapped, markers := 0, 0 + for _, row := range view.Rows { + if row.Selected { + wrapped++ + if row.CursorMarker { + markers++ + } + } + } + if wrapped < 2 || markers != 1 || strings.Count(out, "▶") != 1 { + t.Fatalf("wrapped selection presentation: rows=%d markers=%d\n%s", wrapped, markers, out) + } +} + +func TestWorktreesSurface_Scenario3_FilterReorderAndRefreshIdentity(t *testing.T) { + rows := worktreeInventory(30) + _, s := openedWorktrees(t, rows) + worktreesBodyFits(t, s, 80, 10) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + selected := s.list.CursorID() + for range 8 { + s.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + } + worktreesBodyFits(t, s, 80, 10) + anchor := s.list.View().Rows[0].ID + refresh := s.listCmd() + s.lister.(*fakeWorktreeLister).wts = append([]client.Worktree{rows[29]}, rows[:29]...) + s.HandleMsg(refresh()) + worktreesBodyFits(t, s, 80, 10) + if s.list.CursorID() != selected || s.list.View().Rows[0].ID != anchor || selectedWorktree(t, s).Selector != rows[1].Selector { + t.Fatal("refresh/reorder lost the selected worktree or window identity") + } + s.filter.SetValue("worktree") + s.syncFilter() + worktreesBodyFits(t, s, 80, 10) + if s.list.CursorID() != selected || s.list.View().Rows[0].ID != anchor || selectedWorktree(t, s).Selector != rows[1].Selector { + t.Fatal("compatible filter lost the selected worktree or window identity") + } + s.filter.SetValue(rows[1].Label) + s.syncFilter() + if s.list.CursorID() != selected || len(s.filtered) != 1 || selectedWorktree(t, s).Selector != rows[1].Selector { + t.Fatal("filter selected by filtered index instead of the selected worktree identity") + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + if s.confirm == nil || s.confirm.Selector != rows[1].Selector { + t.Fatalf("filtered confirmation selector = %+v, want %q", s.confirm, rows[1].Selector) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + if s.filter.Value() != "" || s.list.CursorID() != selected { + t.Fatal("clear filter moved the selected worktree") + } + worktreesBodyFits(t, s, 80, 10) + for _, text := range []string{"j", "k", "g", "G"} { + s.HandleKey(tea.KeyPressMsg{Code: rune(text[0]), Text: text}) + } + if s.filter.Value() != "jkgG" { + t.Fatal("printable navigation aliases did not reach filter first") + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + if _, _, closed := s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}); !closed { + t.Fatal("second Escape did not close") + } +} + +func TestWorktreesSurface_Scenario4_WheelResizeAndLiveConversation(t *testing.T) { + rows := worktreeInventory(50) + m, s := openedWorktrees(t, rows) + m.conv.addUser("source") + m.conv.startAssistant() + m.conv.appendAssistant(strings.Repeat("underlying conversation\n\n", 100)) + m.refreshView() + m = effortOffer(m, 80, 10) + m.vp.SetYOffset(9) + m.conversationView.observe(m.vp) + _ = m.View() + before, selected := m.vp.YOffset(), s.list.CursorID() + for range 8 { + m = applyAll(m, tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + } + out := stripANSIstr(m.renderBody()) + offset := s.list.Offset() + if offset == 0 || strings.Contains(out, "▶") || s.list.CursorID() != selected || m.vp.YOffset() != before { + t.Fatal("wheel did not independently scroll list, or leaked into selection/conversation") + } + m = applyAll(m, client.AssistantDeltaMsg{Turn: 1, Text: "live update while picker open"}) + _ = m.renderBody() + if m.modal != s || s.list.Offset() != offset || s.list.CursorID() != selected || m.vp.YOffset() != before || !strings.Contains(m.conv.scrollback.SnapshotAt(1).Payload.(scrollback.AssistantCardSnapshot).Text, "live update") { + t.Fatal("live event interrupted modal or did not update conversation") + } + m = effortOffer(m, 70, 9) + out = stripANSIstr(m.renderBody()) + if !strings.Contains(out, "▶") || s.list.CursorID() != selected { + t.Fatal("resize did not reveal the selected worktree") + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if s.confirm == nil || s.confirm.Selector != rows[0].Selector { + t.Fatalf("wheel/resize confirmation selector = %+v, want %q", s.confirm, rows[0].Selector) + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + for _, geometry := range [][2]int{{0, 10}, {80, 0}, {1, 1}} { + m = applyAll(m, tea.WindowSizeMsg{Width: geometry[0], Height: geometry[1]}) + before := m.vp.YOffset() + m = applyAll(m, tea.MouseWheelMsg{Button: tea.MouseWheelDown}, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.modal != s || s.confirm != nil || m.vp.YOffset() != before || !s.compact { + t.Fatal("compact/zero geometry leaked input or activated stale rows") + } + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.modal != nil || !m.prompt.Focused() { + t.Fatal("compact close did not restore prompt focus") + } +} + +func TestWorktreesSurface_Scenario5_LoadingErrorEmptyAndUnsafeMetadata(t *testing.T) { + m := newWorktreesModel(t, &fakeConv{}, &fakeWorktreeLister{}) + mm, _ := m.openWorktrees() + s := mm.(Model).modal.(*worktreesState) + if out := worktreesBodyFits(t, s, 80, 20); !strings.Contains(out, "loading") || strings.Contains(out, ": select") { + t.Fatalf("loading state advertised selection:\n%s", out) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + if s.confirm != nil { + t.Fatal("loading state activated") + } + for _, result := range []client.WorktreesMsg{{Err: errors.New("offline\x1b\u202e")}, {}} { + s.loading = true + s.HandleMsg(worktreesResultMsg{owner: s, request: s.request, result: result}) + out := worktreesBodyFits(t, s, 32, 20) + if result.Err != nil && !strings.Contains(out, "could not list") || result.Err == nil && !strings.Contains(out, "no worktrees found") || strings.Contains(out, ": select") || strings.Contains(out, "\u202e") { + t.Fatalf("incorrect error/empty state:\n%s", out) + } + } + rows := worktreeInventory(2) + rows[0].Label = "safe\x1b\x07\u202ename\n\tlabel" + rows[0].Branch = "topic\u202e\r\n\t" + rows[1].Branch = "" + rows[1].Revision = "界界界界界界界界界" + _, s = openedWorktrees(t, rows) + out := worktreesBodyFits(t, s, 80, 20) + if !strings.Contains(out, "safenamelabel (topic)") || strings.Contains(out, "\u202e") || strings.Contains(out, "opaque-") || !strings.Contains(out, "界界界界界界界界") { + t.Fatalf("unsafe or damaged metadata:\n%s", out) + } + s.filter.SetValue("missing") + s.syncFilter() + if out := worktreesBodyFits(t, s, 32, 20); !strings.Contains(out, "no matches") { + t.Fatalf("filtered-empty state missing:\n%s", out) + } +} + +func TestWorktreesSurface_Scenario6_StaleResultsAndCapturedDependencies(t *testing.T) { + m, old := openedWorktrees(t, worktreeInventory(2)) + late := old.listCmd()() + requestCtx := old.lister.(*fakeWorktreeLister).ctx + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if requestCtx.Err() == nil { + t.Fatal("close did not cancel discovery") + } + m = applyAll(m, late) + if m.modal != nil { + t.Fatal("late result reopened closed surface") + } + mm, _ := m.openWorktrees() + m = mm.(Model) + current := m.modal.(*worktreesState) + m = applyAll(m, late) + if !current.loading || len(current.worktrees) != 0 { + t.Fatal("reopened picker accepted previous lifetime's response") + } + first := current.listCmd()() + secondCmd := current.listCmd() + m = applyAll(m, first) + if !current.loading { + t.Fatal("picker accepted previous request's response") + } + m.deps.Worktrees = &fakeWorktreeLister{err: errors.New("wrong dependency")} + m.sessionID = "other-session" + m = applyAll(m, secondCmd()) + if current.err != nil || current.sourceID != "source-session" || current.loading { + t.Fatal("open picker did not capture its lister/source dependency") + } + before := slices.Clone(current.worktrees) + m = applyAll(m, first, client.WorktreesMsg{Worktrees: worktreeInventory(9)}) + if !slices.Equal(current.worktrees, before) { + t.Fatal("uncorrelated result mutated surface inventory") + } +} + +func TestWorktreesSurface_Scenario7_ConfirmationAndExplicitRootHandoff(t *testing.T) { + rows := worktreeInventory(3) + m, s := openedWorktrees(t, rows) + conv := m.deps.Session.(*fakeConv) + m.conv.addUser("keep source until success") + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyDown}, tea.KeyPressMsg{Code: tea.KeyEnter}) + if s.confirm == nil || s.confirm.Selector != rows[1].Selector || m.phase != phaseIdle || conv.createCount != 0 { + t.Fatal("selection performed root effects instead of confirmation") + } + s.filter.SetValue("worktree") + s.syncFilter() + selected, offset := s.list.CursorID(), s.list.Offset() + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if s.confirm != nil || s.filter.Value() != "worktree" || s.list.CursorID() != selected || s.list.Offset() != offset { + t.Fatal("confirmation Escape lost picker state") + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + mm, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = mm.(Model) + if m.modal != nil || m.phase != phaseConnecting || m.sessionID != "source-session" || m.conv.isEmpty() || len(conv.closed()) != 0 || cmd == nil { + t.Fatal("explicit confirmation failed create-first root handoff") + } + m = feedCmd(t, m, cmd) + if calls := conv.clearCalls(); len(calls) != 1 || calls[0].sourceID != "source-session" || calls[0].selector == nil || *calls[0].selector != rows[1].Selector { + t.Fatalf("successor authority = %+v, want source-session and %q", calls, rows[1].Selector) + } + if m.sessionID == "source-session" || !m.conv.isEmpty() { + t.Fatal("server-confirmed successor was not adopted") + } + m, _ = openedWorktrees(t, rows) + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m.sessionID = "changed-source" + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.modal != nil || m.phase != phaseIdle || m.sessionID != "changed-source" || !strings.Contains(m.statusMsg, "stale") { + t.Fatal("root accepted a handoff for a different source binding") + } +} + +func TestWorktreesSurface_Scenario8_ConfirmationFitsAndResizeInvalidatesActivation(t *testing.T) { + rows := worktreeInventory(2) + rows[0].Label = strings.Repeat("界🧪 label\x1b\a\u202e ", 12) + rows[0].Branch = strings.Repeat("topic/é\r\n\t\u202e", 12) + m, s := openedWorktrees(t, rows) + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + for _, offer := range [][2]int{{80, 80}, {80, 8}, {32, 12}, {12, 25}, {3, 3}, {1, 1}, {0, 20}, {80, 0}} { + out := worktreesBodyFits(t, s, offer[0], offer[1]) + if strings.ContainsAny(out, "\x1b\a\r\t\u202e") { + t.Fatalf("confirmation leaked unsafe metadata at %dx%d:\n%s", offer[0], offer[1], out) + } + if offer == [2]int{80, 80} && (s.compact || !strings.Contains(out, "界") || !strings.Contains(out, "branch:")) { + t.Fatalf("large confirmation did not render sanitized Unicode metadata:\n%s", out) + } + if !s.compact && (!strings.Contains(out, "start") || !strings.Contains(out, "back")) { + t.Fatalf("fitted confirmation lost actions:\n%s", out) + } + if s.compact { + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + if s.intent != nil { + t.Fatal("compact confirmation emitted a handoff") + } + } + } + m = effortOffer(m, 80, 20) + _ = m.View() + m = effortOffer(m, 80, 1) + // Deliberately no View between resize and Enter. + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.modal != s || m.phase != phaseIdle || !s.compact || s.intent != nil { + t.Fatal("resized confirmation activated stale usable geometry") + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.modal != nil || !m.prompt.Focused() { + t.Fatal("compact confirmation cannot close") + } + m, s = openedWorktrees(t, []client.Worktree{{Label: "stale"}}) + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.modal != s || s.confirm != nil || s.err == nil || m.phase != phaseIdle { + t.Fatal("missing selector escaped surface validation") + } +} diff --git a/cmd/mecatui/ui/worktrees_test.go b/cmd/mecatui/ui/worktrees_test.go index 20b5137d8d..0ccf787a06 100644 --- a/cmd/mecatui/ui/worktrees_test.go +++ b/cmd/mecatui/ui/worktrees_test.go @@ -25,9 +25,11 @@ type fakeWorktreeLister struct { err error calls int sessionID string + ctx context.Context } -func (f *fakeWorktreeLister) List(_ context.Context, sessionID string) ([]client.Worktree, error) { +func (f *fakeWorktreeLister) List(ctx context.Context, sessionID string) ([]client.Worktree, error) { + f.ctx = ctx f.calls++ f.sessionID = sessionID if f.err != nil { @@ -48,12 +50,26 @@ func newWorktreesModel(t *testing.T, conv *fakeConv, fw *fakeWorktreeLister) Mod }) } +// feedWorktreesList reduces only discovery; widget blink timers remain owned by Tea. +func feedWorktreesList(t *testing.T, m Model, cmd tea.Cmd) Model { + t.Helper() + batch, ok := cmd().(tea.BatchMsg) + if !ok || len(batch) != 2 { + t.Fatal("open must batch discovery and filter blink") + } + msg := batch[0]() + if _, ok := msg.(worktreesResultMsg); !ok { + t.Fatalf("discovery returned %T", msg) + } + return applyAll(m, msg) +} + func TestWorktreesDiscoveryIsSessionScoped(t *testing.T) { fw := &fakeWorktreeLister{wts: []client.Worktree{{Selector: testWorktreeSelector("opaque-1"), Kind: "git", Label: "feature", Branch: "refs/heads/feature", Revision: "abcdef1"}}} m := newWorktreesModel(t, &fakeConv{}, fw) mm, cmd := m.openWorktrees() m = mm.(Model) - m = feedCmd(t, m, cmd) + m = feedWorktreesList(t, m, cmd) if fw.sessionID != "source-session" { t.Fatalf("ListWorktrees session = %q, want source-session", fw.sessionID) } @@ -67,9 +83,9 @@ func TestWorktreeSwitchUsesOpaqueSelectorAndClosesAfterBinding(t *testing.T) { conv := &fakeConv{} m := newWorktreesModel(t, conv, &fakeWorktreeLister{wts: []client.Worktree{wt}}) m.conv.addUser("source transcript") - mm, cmd, handled := m.switchToWorktree(wt) + mm, cmd := m.switchToWorktree(wt) m = mm.(Model) - if !handled || m.sessionID != "source-session" || m.conv.isEmpty() { + if m.sessionID != "source-session" || m.conv.isEmpty() { t.Fatal("switch destroyed source binding before successor creation") } if !strings.Contains(m.statusMsg, "starting a new session") || strings.Contains(m.statusMsg, "switching worktree") { @@ -80,6 +96,9 @@ func TestWorktreeSwitchUsesOpaqueSelectorAndClosesAfterBinding(t *testing.T) { if !ok { t.Fatalf("switch result = %T, want worktreeSwitchReadyMsg", msg) } + if calls := conv.clearCalls(); len(calls) != 1 || calls[0].sourceID != "source-session" || calls[0].selector == nil || *calls[0].selector != wt.Selector { + t.Fatalf("successor authority = %+v, want source-session and %q", calls, wt.Selector) + } if got := conv.closed(); len(got) != 0 { t.Fatalf("source closed before successor binding: %v", got) } @@ -99,7 +118,7 @@ func TestServerOwnedSessionPlacement_Scenario3_OwnershipRelistAndSafeSwitch(t *t conv := &fakeConv{createErr: errors.New("selector is stale")} m := newWorktreesModel(t, conv, &fakeWorktreeLister{wts: []client.Worktree{wt}}) m.conv.addUser("keep me") - mm, cmd, _ := m.switchToWorktree(wt) + mm, cmd := m.switchToWorktree(wt) m = mm.(Model) failed, ok := cmd().(worktreeSwitchFailedMsg) if !ok { @@ -118,8 +137,8 @@ func TestServerOwnedSessionPlacement_Scenario3_OwnershipRelistAndSafeSwitch(t *t m.deps.Worktrees = lister mm, listCmd := m.openWorktrees() m = mm.(Model) - m = feedCmd(t, m, listCmd) - if m.sessionID != "source-session" || m.worktrees.err == nil { + m = feedWorktreesList(t, m, listCmd) + if m.sessionID != "source-session" || m.modal.(*worktreesState).err == nil { t.Fatal("relist failure changed selected session or hid the error") } } @@ -136,7 +155,7 @@ func TestWorktreeCopyDescribesStartingANewSession(t *testing.T) { wt := client.Worktree{Selector: testWorktreeSelector("opaque-copy"), Label: "feature", Branch: "refs/heads/feature"} m := newWorktreesModel(t, &fakeConv{}, &fakeWorktreeLister{wts: []client.Worktree{wt}}) mm, cmd := m.openWorktrees() - m = feedCmd(t, mm.(Model), cmd) + m = feedWorktreesList(t, mm.(Model), cmd) panel := stripANSIstr(m.View().Content) for _, want := range []string{"select a worktree to start a new session there", "enter: select", "esc: close"} { if !strings.Contains(panel, want) { @@ -144,7 +163,7 @@ func TestWorktreeCopyDescribesStartingANewSession(t *testing.T) { } } - mm, _, handled := m.onWorktreesKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + mm, _, handled := m.dispatchSurfaceKey(tea.KeyPressMsg{Code: tea.KeyEnter}) if !handled { t.Fatal("enter was not handled") } diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index f7bd18db70..ec4f350b9d 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -32,6 +32,7 @@ bindings and dims features that the connected server does not provide. |Agents overlay controls|Select and inspect delegated work. See [Navigate the Agents overlay](#navigate-the-agents-overlay).| |Models picker controls|The mouse wheel scrolls the visible model list without moving its cursor. A primary click on a visible model row moves the cursor without switching models; press `enter` to activate the cursor row.| |Effort picker controls|The mouse wheel scrolls the list without changing the selected tier.| +|Worktrees picker controls|The mouse wheel scrolls the list without changing the selected worktree.| |`pgup` / `pgdn`|Scroll the conversation.| |`home` / `end`|Jump to the top or bottom; `end` resumes auto-follow.| |`/`|Open the slash-command palette.| From b0939dd30525bac94ee4a041ce791c0d05997970 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Thu, 15 Oct 2026 16:01:48 -0700 Subject: [PATCH 2/3] docs(mecatui): trim obvious interaction instructions from keybindings Remove routine picker, mouse, navigation, and duplicate editing guidance while retaining non-obvious shortcuts and platform caveats. Co-authored-by: Mecatl --- user-docs/mecatui/keybindings.md | 46 ++++++++------------------------ 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index ec4f350b9d..6405a6a651 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -18,26 +18,19 @@ bindings and dims features that the connected server does not provide. |`enter`|Send a prompt; while work is running, steer when the server supports it or queue a follow-up otherwise.| |`shift+enter`, `ctrl+j`, `ctrl+enter`, or `alt+enter`|Insert a newline. Your terminal decides which of these chords it can send; see [Newline chords and your terminal](#newline-chords-and-your-terminal).| |`↑`|With empty input, bring queued follow-ups back for editing.| -|`ctrl+u`|Clear the unsent draft, including staged attachments and large-paste placeholders (`ClearPrompt`; remappable).| +|`ctrl+u`|Clear the unsent draft and staged attachments (`ClearPrompt`; remappable).| |physical `esc` twice within 500ms|Clear an idle draft in terminals with enhanced key events. See [Clear a draft with Escape](#clear-a-draft-with-escape).| |`esc`|Clear an active selection first. While work is running, cancel directly and preserve the draft, queued follow-ups, and steer. While idle with a paused queue, clear that queue but preserve the draft.| |`ctrl+t`|Open `/toolcalls` for the current session; approval requests use it for details.| |`f9`|Reveal conversation details: reasoning, turn stats, changed files, permanent error details, benign guardrail notices, and individual settled tool-call summaries. Use `ctrl+t` for tool arguments and results.| |`shift+tab`|Cycle the current session permission mode: **default → plan → accept-edits → default**. In an MCP prompt argument form, it instead moves to the previous required field.| |`ctrl+g`|Select all prompt text.| -|`ctrl+a` / `ctrl+e`|Move to the start / end of the current prompt line.| -|`ctrl+p`|Move to the previous prompt line.| -|`ctrl+y`|Copy the active prompt or visible conversation selection; no selection is a no-op.| +|`ctrl+y`|Copy the active prompt or visible conversation selection.| |`f6` / `f7` / `f8`|Open Agents / Effort / MCP Prompts.| -|Agents overlay controls|Select and inspect delegated work. See [Navigate the Agents overlay](#navigate-the-agents-overlay).| -|Models picker controls|The mouse wheel scrolls the visible model list without moving its cursor. A primary click on a visible model row moves the cursor without switching models; press `enter` to activate the cursor row.| -|Effort picker controls|The mouse wheel scrolls the list without changing the selected tier.| -|Worktrees picker controls|The mouse wheel scrolls the list without changing the selected worktree.| -|`pgup` / `pgdn`|Scroll the conversation.| -|`home` / `end`|Jump to the top or bottom; `end` resumes auto-follow.| +|`end`|Return to the latest output and resume following new messages.| |`/`|Open the slash-command palette.| |`ctrl+c`|With text in the prompt, the first press clears the draft and staged media without quitting. With an empty prompt, press twice to quit.| -|`/quit` (or `/exit`)|Quit immediately and cancel an active run. `/quit` appears in the slash palette; `/exit` is a dispatch-only alias.| +|`/quit` or `/exit`|Quit immediately and cancel an active run.| |`ctrl+z`|Suspend to the shell; use `fg` to return.| Suspending does not stop an embedded server or active run. Cancel the run first @@ -56,20 +49,6 @@ run takes precedence and disarms the gesture. Another key or expiration of the requires enhanced key-event support. Use the remappable `ClearPrompt` (`ctrl+u`) action in other terminals. -### Navigate the Agents overlay - -At terminal heights of at least 24 rows, use the remappable `Up`, `Down`, -`ScrollU`, `ScrollD`, `JumpTop`, and `JumpEnd` actions to navigate Subagent and -Team rosters and focused Parallel groups. In a detail view, the same actions -scroll activity, tasks, or findings. The mouse wheel scrolls one line without -moving the cursor. Press `enter` to focus an item and `esc` to return or close. - -If the conversation area cannot fit a minimal card, a `vp short` line preserves -the active context and `esc` action. Below 24 rows, a compact line identifies -the active tab, child, group, member, Tasks, or Findings view. Only `esc` -remains active; wheel input is consumed without scrolling the hidden -conversation. - ## Newline chords and your terminal `Newline` answers to four chords because terminals differ in how much of a key @@ -148,7 +127,6 @@ In a permission modal, inspect the request before choosing: |`a`, `y`, or `enter`|Allow this call once.| |`w`|Allow this exact main-agent call for the current session when the option is offered.| |`d`, `n`, or `esc`|Deny.| -|`tab` or `←` / `→`|Move between buttons.| `Allow always` is session-scoped, applies only to the exact main-agent action, and never overrides configured policy. In ordinary tool approvals, the @@ -194,19 +172,17 @@ The prompt editor provides fixed editing keys. Only `SelectAll`, |Key|Editing action| |-|-| |`ctrl+a` / `ctrl+e`|Move to the start or end of the current line.| +|`ctrl+p`|Move to the previous prompt line.| |`ctrl+b` / `ctrl+f`|Move backward or forward by one character.| |`ctrl+w`|Delete the previous word.| |`ctrl+k`|Delete to the end of the line.| -Use `shift+arrow` for keyboard selection. On the alternate screen, drag over -prompt text for mouse selection. Starting a prompt selection clears a -conversation selection and vice versa. Releasing the mouse does not copy the -prompt; use `ctrl+y` or right-click to copy the active selection. Copying writes -only visible conversation text to the system clipboard and, on X11 and Wayland, -the primary selection. If a selected tool summary changes, `mecatui` can clear -the selection. Install `wl-clipboard` or `xclip` when your terminal does not -support the primary-selection mirror through OSC 52. With `--no-mouse`, the -terminal retains native mouse selection and keyboard selection remains available. +Copying with `ctrl+y` or right-click writes the selection to the system clipboard +and, on X11 and Wayland, the primary selection. Conversation selections include +only visible text. If a selected tool summary changes, `mecatui` can clear the +selection. Install `wl-clipboard` or `xclip` when your terminal does not support +the primary-selection mirror through OSC 52. With `--no-mouse`, the terminal +retains native mouse selection and keyboard selection remains available. Client-owned actions take precedence over textarea chords. For example, `ctrl+t` opens tool-call inspection and `ctrl+v` handles paste. `ctrl+g` selects all only From 833d6df8e4bc254924bf3b0bc0389322b05467e1 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Thu, 15 Oct 2026 16:37:39 -0700 Subject: [PATCH 3/3] refactor(mecatui): reuse bounded navigation and wheel mappings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve each caller’s supported moves, binding precedence, filter typing, and wheel guards while consolidating repeated input translation. Co-authored-by: Mecatl --- cmd/mecatui/ui/agents_inventory.go | 22 +-- cmd/mecatui/ui/bounded_navigation.go | 44 +++-- .../ui/bounded_navigation_precedence_test.go | 178 ++++++++++++++++++ cmd/mecatui/ui/bounded_navigation_test.go | 80 ++++++++ cmd/mecatui/ui/dream.go | 18 +- cmd/mecatui/ui/effort.go | 7 +- cmd/mecatui/ui/help.go | 25 +-- cmd/mecatui/ui/models_surface.go | 15 +- cmd/mecatui/ui/models_surface_test.go | 99 ++++++++++ cmd/mecatui/ui/skills.go | 17 +- .../skills_bounded_viewport_scenario_test.go | 37 ++++ cmd/mecatui/ui/soul.go | 25 +-- cmd/mecatui/ui/toolcalls.go | 30 +-- cmd/mecatui/ui/usermodel.go | 6 +- cmd/mecatui/ui/usermodel_bounded_more_test.go | 44 +++++ cmd/mecatui/ui/worktrees.go | 18 +- cmd/mecatui/ui/worktrees_surface_test.go | 125 ++++++++++++ 17 files changed, 634 insertions(+), 156 deletions(-) diff --git a/cmd/mecatui/ui/agents_inventory.go b/cmd/mecatui/ui/agents_inventory.go index 63472dcd0e..e36ce0d22b 100644 --- a/cmd/mecatui/ui/agents_inventory.go +++ b/cmd/mecatui/ui/agents_inventory.go @@ -125,19 +125,8 @@ func (m Model) onAgentsInvKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { if m.agentsInv.viewport == nil { return m, nil, true } - switch { - case key.Matches(msg, m.keys.Down): - m.agentsInv.viewport.Move(bounded.LineDown, len(rows)) - case key.Matches(msg, m.keys.Up): - m.agentsInv.viewport.Move(bounded.LineUp, len(rows)) - case key.Matches(msg, m.keys.ScrollD): - m.agentsInv.viewport.Move(bounded.PageDown, len(rows)) - case key.Matches(msg, m.keys.ScrollU): - m.agentsInv.viewport.Move(bounded.PageUp, len(rows)) - case key.Matches(msg, m.keys.ScrollBottom): - m.agentsInv.viewport.Move(bounded.End, len(rows)) - case key.Matches(msg, m.keys.ScrollTop): - m.agentsInv.viewport.Move(bounded.Top, len(rows)) + if move, ok := boundedNavigationMove(msg, m.keys, bounded.LineDown, bounded.LineUp, bounded.PageDown, bounded.PageUp, bounded.End, bounded.Top); ok { + m.agentsInv.viewport.Move(move, len(rows)) } return m, nil, true } @@ -153,11 +142,8 @@ func (m Model) onAgentsInvWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { if m.agentsInv.viewport == nil { return m, nil } - switch msg.Button { - case tea.MouseWheelUp: - m.agentsInv.viewport.Move(bounded.LineUp, len(rows)) - case tea.MouseWheelDown: - m.agentsInv.viewport.Move(bounded.LineDown, len(rows)) + if move, ok := boundedWheelMove(msg); ok { + m.agentsInv.viewport.Move(move, len(rows)) } return m, nil } diff --git a/cmd/mecatui/ui/bounded_navigation.go b/cmd/mecatui/ui/bounded_navigation.go index 156a6f610e..5fcb0cb015 100644 --- a/cmd/mecatui/ui/bounded_navigation.go +++ b/cmd/mecatui/ui/bounded_navigation.go @@ -7,20 +7,40 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" ) -func boundedNavigationMove(msg tea.KeyPressMsg, keys keyMap) (bounded.Move, bool) { - switch { - case key.Matches(msg, keys.Up): +// The optional order preserves each caller's supported moves and binding precedence. +func boundedNavigationMove(msg tea.KeyPressMsg, keys keyMap, order ...bounded.Move) (bounded.Move, bool) { + bindings := [...]key.Binding{ + bounded.LineUp: keys.Up, + bounded.LineDown: keys.Down, + bounded.PageUp: keys.ScrollU, + bounded.PageDown: keys.ScrollD, + bounded.Top: keys.ScrollTop, + bounded.End: keys.ScrollBottom, + } + if len(order) == 0 { + order = []bounded.Move{bounded.LineUp, bounded.LineDown, bounded.PageUp, bounded.PageDown, bounded.Top, bounded.End} + } + for _, move := range order { + if key.Matches(msg, bindings[move]) { + return move, true + } + } + return 0, false +} + +// Filterable pickers reserve printable Up/Down bindings for text input. +func boundedFilterNavigationMove(msg tea.KeyPressMsg, keys keyMap, order ...bounded.Move) (bounded.Move, bool) { + keys.Up = key.NewBinding(key.WithKeys("up")) + keys.Down = key.NewBinding(key.WithKeys("down")) + return boundedNavigationMove(msg, keys, order...) +} + +func boundedWheelMove(msg tea.MouseWheelMsg) (bounded.Move, bool) { + switch msg.Button { + case tea.MouseWheelUp: return bounded.LineUp, true - case key.Matches(msg, keys.Down): + case tea.MouseWheelDown: return bounded.LineDown, true - case key.Matches(msg, keys.ScrollU): - return bounded.PageUp, true - case key.Matches(msg, keys.ScrollD): - return bounded.PageDown, true - case key.Matches(msg, keys.ScrollTop): - return bounded.Top, true - case key.Matches(msg, keys.ScrollBottom): - return bounded.End, true default: return 0, false } diff --git a/cmd/mecatui/ui/bounded_navigation_precedence_test.go b/cmd/mecatui/ui/bounded_navigation_precedence_test.go index 43ec96848e..d40266a936 100644 --- a/cmd/mecatui/ui/bounded_navigation_precedence_test.go +++ b/cmd/mecatui/ui/bounded_navigation_precedence_test.go @@ -3,11 +3,189 @@ package ui import ( "testing" + "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" ) +func TestBoundedNavigationReaderHistoricalPrecedence(t *testing.T) { + for _, tc := range []struct { + name string + actions []string + help, soul, dream int + }{ + {"lines", []string{"Up", "Down"}, 11, 11, 11}, + {"page before line only in help", []string{"Down", "ScrollD"}, 15, 11, 11}, + {"pages", []string{"ScrollU", "ScrollD"}, 15, 15, 15}, + {"ends", []string{"ScrollTop", "ScrollBottom"}, 95, 95, 10}, + } { + t.Run(tc.name, func(t *testing.T) { + overrides := map[string][]string{} + for _, action := range tc.actions { + overrides[action] = []string{"z"} + } + deps := surfaceDeps{keys: applyKeyOverrides(defaultKeys(), overrides)} + viewport := func() *bounded.Viewport { + v := new(bounded.Viewport) + v.SetGeometry(40, 5, 0, bounded.Clip) + v.Move(bounded.PageDown, 100) + v.Move(bounded.PageDown, 100) + return v + } + h := &helpState{deps: deps, viewport: *viewport(), total: 100} + s := &soulState{deps: deps, viewport: viewport(), total: 100} + d := &dreamState{deps: deps, view: dreamReview, viewport: viewport(), readerRows: 100} + for _, reader := range []struct { + name string + handle func(tea.KeyPressMsg) (tea.Cmd, bool, bool) + v *bounded.Viewport + want int + }{ + {"help", h.HandleKey, &h.viewport, tc.help}, + {"soul", s.HandleKey, s.viewport, tc.soul}, + {"dream", d.HandleKey, d.viewport, tc.dream}, + } { + reader.handle(navigationTestKey("z")) + if got := reader.v.Offset(); got != reader.want { + t.Errorf("%s offset = %d, want %d", reader.name, got, reader.want) + } + } + }) + } +} + +func TestBoundedNavigationReaderCloseAndHelpPrecedeMovement(t *testing.T) { + deps := surfaceDeps{keys: applyKeyOverrides(defaultKeys(), map[string][]string{"Close": {"q"}, "Down": {"q"}, "Help": {"z"}, "ScrollD": {"z"}})} + v := new(bounded.Viewport) + v.SetGeometry(40, 5, 0, bounded.Clip) + h := &helpState{deps: deps, viewport: *v, total: 100} + s := &soulState{deps: deps, viewport: v, total: 100} + d := &dreamState{deps: deps, view: dreamReview, viewport: v, readerRows: 100} + if _, _, closed := h.HandleKey(navigationTestKey("z")); !closed || h.viewport.Offset() != 0 { + t.Fatal("Help must close before overlapping page movement") + } + if _, _, closed := h.HandleKey(navigationTestKey("q")); !closed || h.viewport.Offset() != 0 { + t.Fatal("Help Close must precede Down") + } + if _, _, closed := s.HandleKey(navigationTestKey("q")); !closed || v.Offset() != 0 { + t.Fatal("Soul Close must precede Down") + } + d.HandleKey(navigationTestKey("q")) + if d.view != dreamTargets || v.Offset() != 0 { + t.Fatal("Dream back action must precede Down") + } +} + +func TestBoundedNavigationToolcallsMovementBeforeChoose(t *testing.T) { + m := addToolcallsForTest(t, newToolcallsInspectorModel(t), 30) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + s.Render(80, 12) + s.list.SetCursor(10) + s.selected = 10 + s.deps.keys = applyKeyOverrides(s.deps.keys, map[string][]string{"Up": {"z"}, "Down": {"z"}, "Choose": {"z"}}) + s.HandleKey(navigationTestKey("z")) + if s.selected != 11 || s.detail { + t.Fatal("Toolcalls Down must precede Up and Choose") + } + s.deps.keys.Close = key.NewBinding(key.WithKeys("z")) + if _, _, closed := s.HandleKey(navigationTestKey("z")); !closed || s.selected != 11 { + t.Fatal("Toolcalls Close must precede navigation and Choose") + } +} + +func TestBoundedNavigationInventoryHistoricalPrecedence(t *testing.T) { + m := newAgentsInvModel(t, scrollAgents(60), client.Capabilities{Agents: true}) + opened, cmd := m.openAgentsInv() + m = feedCmd(t, opened.(Model), cmd) + m.keys = applyKeyOverrides(m.keys, map[string][]string{"Up": {"z"}, "Down": {"z"}, "ScrollD": {"z"}}) + updated, _, _ := m.onAgentsInvKey(navigationTestKey("z")) + m = updated.(Model) + if m.agentsInv.viewport.Offset() != 1 { + t.Fatal("inventory Down must precede Up and PageDown") + } + m.keys.Close = key.NewBinding(key.WithKeys("z")) + updated, _, _ = m.onAgentsInvKey(navigationTestKey("z")) + if updated.(Model).agentsInv.view != agentsInvNone { + t.Fatal("inventory Close must precede movement") + } +} + +func TestBoundedReaderWheelClassification(t *testing.T) { + h := &helpState{total: 100} + s := &soulState{viewport: new(bounded.Viewport), total: 100} + d := &dreamState{view: dreamReview, viewport: new(bounded.Viewport), readerRows: 100} + u := &userModelState{view: userModelDetail, viewport: new(bounded.Viewport), detailLines: make([]string, 100)} + for _, reader := range []struct { + name string + handle func(tea.MouseWheelMsg) (tea.Cmd, bool) + v *bounded.Viewport + }{ + {"help", h.HandleWheel, &h.viewport}, + {"soul", s.HandleWheel, s.viewport}, + {"dream", d.HandleWheel, d.viewport}, + {"memory", u.HandleWheel, u.viewport}, + } { + t.Run(reader.name, func(t *testing.T) { + reader.v.SetGeometry(40, 5, 0, bounded.Clip) + for _, step := range []struct { + button tea.MouseButton + want int + }{ + {tea.MouseWheelDown, 1}, {tea.MouseWheelLeft, 1}, {tea.MouseWheelRight, 1}, {tea.MouseNone, 1}, {tea.MouseWheelUp, 0}, + } { + reader.handle(tea.MouseWheelMsg{Button: step.button}) + if got := reader.v.Offset(); got != step.want { + t.Fatalf("button %v offset = %d, want %d", step.button, got, step.want) + } + } + }) + } + d.compact, u.compact = true, true + d.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + u.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + if d.viewport.Offset() != 0 || u.viewport.Offset() != 0 { + t.Fatal("compact readers accepted wheel movement") + } + d.compact, d.view = false, dreamConfirmApply + d.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + if d.viewport.Offset() != 0 { + t.Fatal("Dream confirmation accepted reader movement") + } +} + +func TestToolcallsWheelRetainsLegacyNonUpMovement(t *testing.T) { + m := addToolcallsForTest(t, newToolcallsInspectorModel(t), 30) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + s.selected = 0 + s.Render(80, 12) + selected := s.list.CursorID() + for _, detail := range []bool{false, true} { + s.detail = detail + s.window = new(bounded.Viewport) + s.window.SetGeometry(80, 5, 0, bounded.Clip) + s.lines, s.width = 100, 0 + for _, step := range []struct { + button tea.MouseButton + want int + }{ + {tea.MouseWheelLeft, 1}, {tea.MouseWheelRight, 2}, {tea.MouseNone, 3}, {tea.MouseWheelDown, 4}, {tea.MouseWheelUp, 3}, + } { + s.HandleWheel(tea.MouseWheelMsg{Button: step.button}) + got := s.list.Offset() + if detail { + got = s.window.Offset() + } + if got != step.want || s.list.CursorID() != selected { + t.Fatalf("detail=%t button=%v offset=%d, want %d and unchanged selection", detail, step.button, got, step.want) + } + } + } +} + func TestBoundedNavigationSessionsLocalActionsPrecedeMovement(t *testing.T) { m, _ := hitDispatchModel(t) m.phase = phaseIdle diff --git a/cmd/mecatui/ui/bounded_navigation_test.go b/cmd/mecatui/ui/bounded_navigation_test.go index 48f19b45ad..96964e5379 100644 --- a/cmd/mecatui/ui/bounded_navigation_test.go +++ b/cmd/mecatui/ui/bounded_navigation_test.go @@ -10,6 +10,8 @@ import ( ) func TestBoundedNavigationMove(t *testing.T) { + disabled := defaultKeys() + disabled.Down.SetEnabled(false) for _, tc := range []struct { name string keys keyMap @@ -30,6 +32,7 @@ func TestBoundedNavigationMove(t *testing.T) { {"remapped top", navigationTestKeys(), navigationTestKey("t"), bounded.Top, true}, {"remapped bottom", navigationTestKeys(), navigationTestKey("b"), bounded.End, true}, {"unmatched", defaultKeys(), navigationTestKey("x"), 0, false}, + {"disabled", disabled, tea.KeyPressMsg{Code: tea.KeyDown}, 0, false}, {"overlap keeps declared precedence", navigationOverlapTestKeys(), navigationTestKey("x"), bounded.LineUp, true}, } { t.Run(tc.name, func(t *testing.T) { @@ -41,6 +44,83 @@ func TestBoundedNavigationMove(t *testing.T) { } } +func TestBoundedNavigationOrderedSubset(t *testing.T) { + keys := navigationOverlapTestKeys() + keys.ScrollD = key.NewBinding(key.WithKeys("x")) + keys.ScrollBottom = key.NewBinding(key.WithKeys("x")) + for _, tc := range []struct { + name string + order []bounded.Move + msg tea.KeyPressMsg + want bounded.Move + ok bool + }{ + {"down first", []bounded.Move{bounded.LineDown, bounded.LineUp}, navigationTestKey("x"), bounded.LineDown, true}, + {"page first", []bounded.Move{bounded.PageDown, bounded.LineDown}, navigationTestKey("x"), bounded.PageDown, true}, + {"end only", []bounded.Move{bounded.End}, navigationTestKey("x"), bounded.End, true}, + {"excluded page", []bounded.Move{bounded.LineUp, bounded.LineDown}, navigationTestKey("p"), 0, false}, + {"excluded top", []bounded.Move{bounded.PageDown, bounded.PageUp}, navigationTestKey("t"), 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got, ok := boundedNavigationMove(tc.msg, keys, tc.order...); got != tc.want || ok != tc.ok { + t.Fatalf("move = (%v, %t), want (%v, %t)", got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestBoundedFilterNavigationMove(t *testing.T) { + keys := navigationTestKeys() + keys.Up.SetEnabled(false) + keys.Down.SetEnabled(false) + keys.ScrollTop.SetEnabled(false) + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + want bounded.Move + ok bool + }{ + {"fixed up", tea.KeyPressMsg{Code: tea.KeyUp}, bounded.LineUp, true}, + {"fixed down", tea.KeyPressMsg{Code: tea.KeyDown}, bounded.LineDown, true}, + {"remapped up is text", navigationTestKey("u"), 0, false}, + {"remapped down is text", navigationTestKey("d"), 0, false}, + {"j is text", navigationTestKey("j"), 0, false}, + {"k is text", navigationTestKey("k"), 0, false}, + {"page up", navigationTestKey("p"), bounded.PageUp, true}, + {"page down", navigationTestKey("n"), bounded.PageDown, true}, + {"disabled top", navigationTestKey("t"), 0, false}, + {"end", navigationTestKey("b"), bounded.End, true}, + {"old page", tea.KeyPressMsg{Code: tea.KeyPgDown}, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got, ok := boundedFilterNavigationMove(tc.msg, keys); got != tc.want || ok != tc.ok { + t.Fatalf("move = (%v, %t), want (%v, %t)", got, ok, tc.want, tc.ok) + } + }) + } + if keys.Up.Enabled() || keys.Down.Enabled() || keys.Up.Keys()[0] != "u" || keys.Down.Keys()[0] != "d" { + t.Fatal("filter navigation mutated shared bindings") + } +} + +func TestBoundedWheelMove(t *testing.T) { + for _, tc := range []struct { + button tea.MouseButton + want bounded.Move + ok bool + }{ + {tea.MouseWheelUp, bounded.LineUp, true}, + {tea.MouseWheelDown, bounded.LineDown, true}, + {tea.MouseWheelLeft, 0, false}, + {tea.MouseWheelRight, 0, false}, + {tea.MouseNone, 0, false}, + } { + if got, ok := boundedWheelMove(tea.MouseWheelMsg{Button: tc.button}); got != tc.want || ok != tc.ok { + t.Errorf("button %v = (%v, %t), want (%v, %t)", tc.button, got, ok, tc.want, tc.ok) + } + } +} + func navigationTestKeys() keyMap { return applyKeyOverrides(defaultKeys(), map[string][]string{ "Up": {"u"}, "Down": {"d"}, "ScrollU": {"p"}, "ScrollD": {"n"}, "ScrollTop": {"t"}, "ScrollBottom": {"b"}, diff --git a/cmd/mecatui/ui/dream.go b/cmd/mecatui/ui/dream.go index bad1ec4b17..e20f49902c 100644 --- a/cmd/mecatui/ui/dream.go +++ b/cmd/mecatui/ui/dream.go @@ -196,16 +196,8 @@ func (s *dreamState) HandleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, handled bool, } func (s *dreamState) moveKey(msg tea.KeyPressMsg) bool { - move := bounded.LineDown - switch { - case key.Matches(msg, s.deps.keys.Down): - case key.Matches(msg, s.deps.keys.Up): - move = bounded.LineUp - case key.Matches(msg, s.deps.keys.ScrollD): - move = bounded.PageDown - case key.Matches(msg, s.deps.keys.ScrollU): - move = bounded.PageUp - default: + move, ok := boundedNavigationMove(msg, s.deps.keys, bounded.LineDown, bounded.LineUp, bounded.PageDown, bounded.PageUp) + if !ok { return false } s.moveViewport(move) @@ -222,10 +214,8 @@ func (s *dreamState) moveViewport(move bounded.Move) { } func (s *dreamState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { - move := bounded.LineDown - if msg.Mouse().Button == tea.MouseWheelUp { - move = bounded.LineUp - } else if msg.Mouse().Button != tea.MouseWheelDown { + move, ok := boundedWheelMove(msg) + if !ok { return nil, true } if s.view == dreamTargets && !s.compact && s.list != nil { diff --git a/cmd/mecatui/ui/effort.go b/cmd/mecatui/ui/effort.go index 72aa99bcc0..c30c6d8194 100644 --- a/cmd/mecatui/ui/effort.go +++ b/cmd/mecatui/ui/effort.go @@ -111,11 +111,8 @@ func (s *effortState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { func (s *effortState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { if !s.compact && s.list.Valid() { - switch msg.Button { - case tea.MouseWheelUp: - s.list.Scroll(bounded.LineUp) - case tea.MouseWheelDown: - s.list.Scroll(bounded.LineDown) + if move, ok := boundedWheelMove(msg); ok { + s.list.Scroll(move) } } return nil, true diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index 9d203dd66b..78ae43e854 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -77,21 +77,11 @@ func (s *helpState) Render(width, height int) (string, []ClickableRegion) { } func (s *helpState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { - switch { - case key.Matches(msg, s.deps.keys.Help), key.Matches(msg, s.deps.keys.Close): + if key.Matches(msg, s.deps.keys.Help) || key.Matches(msg, s.deps.keys.Close) { return nil, true, true - case key.Matches(msg, s.deps.keys.ScrollD): - s.viewport.Move(bounded.PageDown, s.total) - case key.Matches(msg, s.deps.keys.ScrollU): - s.viewport.Move(bounded.PageUp, s.total) - case key.Matches(msg, s.deps.keys.Down): - s.viewport.Move(bounded.LineDown, s.total) - case key.Matches(msg, s.deps.keys.Up): - s.viewport.Move(bounded.LineUp, s.total) - case key.Matches(msg, s.deps.keys.ScrollBottom): - s.viewport.Move(bounded.End, s.total) - case key.Matches(msg, s.deps.keys.ScrollTop): - s.viewport.Move(bounded.Top, s.total) + } + if move, ok := boundedNavigationMove(msg, s.deps.keys, bounded.PageDown, bounded.PageUp, bounded.LineDown, bounded.LineUp, bounded.End, bounded.Top); ok { + s.viewport.Move(move, s.total) } return nil, true, false } @@ -99,11 +89,8 @@ func (s *helpState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { func (*helpState) HandleMsg(tea.Msg) (tea.Cmd, bool, bool) { return nil, false, false } func (s *helpState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { - switch msg.Button { - case tea.MouseWheelUp: - s.viewport.Move(bounded.LineUp, s.total) - case tea.MouseWheelDown: - s.viewport.Move(bounded.LineDown, s.total) + if move, ok := boundedWheelMove(msg); ok { + s.viewport.Move(move, s.total) } return nil, true } diff --git a/cmd/mecatui/ui/models_surface.go b/cmd/mecatui/ui/models_surface.go index cdbe80a4d5..6025695084 100644 --- a/cmd/mecatui/ui/models_surface.go +++ b/cmd/mecatui/ui/models_surface.go @@ -129,6 +129,7 @@ func (s *modelsState) Render(width, height int) (string, []ClickableRegion) { } func (s *modelsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { + move, navigation := boundedFilterNavigationMove(msg, s.deps.keys) switch { case key.Matches(msg, s.deps.keys.Close) && s.rowBudget == 0: return nil, true, true @@ -141,18 +142,8 @@ func (s *modelsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { return nil, true, false } return nil, true, true - case msg.String() == keyMenuUp: - s.moveCursor(bounded.LineUp) - case msg.String() == keyMenuDown: - s.moveCursor(bounded.LineDown) - case key.Matches(msg, s.deps.keys.ScrollU): - s.moveCursor(bounded.PageUp) - case key.Matches(msg, s.deps.keys.ScrollD): - s.moveCursor(bounded.PageDown) - case key.Matches(msg, s.deps.keys.ScrollTop): - s.moveCursor(bounded.Top) - case key.Matches(msg, s.deps.keys.ScrollBottom): - s.moveCursor(bounded.End) + case navigation: + s.moveCursor(move) case key.Matches(msg, s.deps.keys.SetGlobalDefault): if chosen, ok := s.chosen(); ok { s.intent = modelsGlobalDefaultIntent{client.ModelSelection{ProviderID: chosen.ProviderID, ModelID: chosen.ID}, modelLabel(chosen)} diff --git a/cmd/mecatui/ui/models_surface_test.go b/cmd/mecatui/ui/models_surface_test.go index 3d0bb86f5d..bc3e038cc8 100644 --- a/cmd/mecatui/ui/models_surface_test.go +++ b/cmd/mecatui/ui/models_surface_test.go @@ -13,6 +13,105 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" ) +func TestModelsNavigationThroughUpdate(t *testing.T) { + for _, disabled := range []bool{false, true} { + t.Run(fmt.Sprintf("disabled=%t", disabled), func(t *testing.T) { + m := newModelsModel(t, sampleModels(), &fakeStore{}, modelsCaps(), client.ModelSelection{}) + m.keys = applyKeyOverrides(m.keys, map[string][]string{ + "Up": {"u"}, "Down": {"d"}, "ScrollTop": {"x"}, + "Choose": {"x", "q"}, "SetGlobalDefault": {"x", "q"}, "Close": {"q"}, + }) + m.keys.Up.SetEnabled(!disabled) + m.keys.Down.SetEnabled(!disabled) + mm, cmd := m.runModels() + m = feedCmd(t, mm.(Model), cmd) + s := modelsSurface(t, m) + active, globalDefault := m.modelCatalog.active, m.modelCatalog.globalDefault + m.prompt.Rewrite("draft") + for _, step := range []struct { + msg tea.KeyPressMsg + cursor int + filter string + closed bool + }{ + {tea.KeyPressMsg{Code: tea.KeyDown}, 1, "", false}, + {tea.KeyPressMsg{Code: tea.KeyUp}, 0, "", false}, + {tea.KeyPressMsg{Code: tea.KeyDown}, 1, "", false}, + {navigationTestKey("x"), 0, "", false}, // navigation precedes both actions + {navigationTestKey("j"), 0, "j", false}, + {navigationTestKey("k"), 0, "jk", false}, + {navigationTestKey("q"), 0, "", false}, // Close clears before either action + {navigationTestKey("q"), 0, "", true}, + } { + updated, _ := m.Update(step.msg) + m = updated.(Model) + if s.list.Cursor() != step.cursor || s.filter.Value() != step.filter || (m.modal == nil) != step.closed { + t.Fatalf("%q: cursor=%d filter=%q modal=%T", step.msg.String(), s.list.Cursor(), s.filter.Value(), m.modal) + } + if m.prompt.Value() != "draft" || m.phase != phaseIdle || m.modelCatalog.active != active || m.modelCatalog.globalDefault != globalDefault || s.intent != nil { + t.Fatalf("%q leaked input or triggered a model action", step.msg.String()) + } + } + }) + } +} + +func TestModelsFilterNavigationPreservesPagingAndPriority(t *testing.T) { + models := make([]client.ModelInfo, 40) + for i := range models { + models[i] = client.ModelInfo{ProviderID: "provider", ID: fmt.Sprintf("jk-model-%02d", i)} + } + m := newModelsModel(t, &fakeModels{models: models}, &fakeStore{}, modelsCaps(), client.ModelSelection{}) + mm, cmd := m.runModels() + m = feedCmd(t, mm.(Model), cmd) + s := modelsSurface(t, m) + s.deps.keys = navigationTestKeys() + s.deps.keys.Up.SetEnabled(false) + s.deps.keys.Down.SetEnabled(false) + s.Render(80, 24) + for _, text := range []string{"j", "k"} { + s.HandleKey(navigationTestKey(text)) + } + if s.filter.Value() != "jk" || len(s.filtered) != 40 { + t.Fatal("j/k did not remain filter input") + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + if s.list.Cursor() != 1 { + t.Fatal("physical Down lost to disabled/remapped binding") + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyUp}) + if s.list.Cursor() != 0 { + t.Fatal("physical Up lost to disabled/remapped binding") + } + s.HandleKey(navigationTestKey("n")) + if s.list.Cursor() <= 1 { + t.Fatal("Models must retain configurable paging") + } + s.HandleKey(navigationTestKey("p")) + if s.list.Cursor() != 0 { + t.Fatal("remapped PageUp did not return to first row") + } + s.HandleKey(navigationTestKey("b")) + if s.list.Cursor() != 39 { + t.Fatal("remapped End did not select final row") + } + s.HandleKey(navigationTestKey("t")) + if s.list.Cursor() != 0 { + t.Fatal("remapped Top did not select first row") + } + s.deps.keys = applyKeyOverrides(s.deps.keys, map[string][]string{"ScrollU": {"z"}, "ScrollD": {"z"}, "Choose": {"z"}, "SetGlobalDefault": {"z"}}) + s.list.SetCursor(20) + s.HandleKey(navigationTestKey("z")) + if s.list.Cursor() >= 20 || s.intent != nil { + t.Fatal("Models PageUp must precede PageDown, Choose and SetGlobalDefault") + } + s.deps.keys = applyKeyOverrides(s.deps.keys, map[string][]string{"Close": {"z"}}) + selected := s.list.CursorID() + if _, _, closed := s.HandleKey(navigationTestKey("z")); closed || s.filter.Value() != "" || s.list.CursorID() != selected { + t.Fatal("Models Close/filter clear must precede navigation") + } +} + func TestModelsSurfaceCapturesInputAndLateCatalogDoesNotReopen(t *testing.T) { m := newModelsModel(t, sampleModels(), &fakeStore{}, modelsCaps(), client.ModelSelection{}) mm, cmd := m.runModels() diff --git a/cmd/mecatui/ui/skills.go b/cmd/mecatui/ui/skills.go index fc5792a32f..05cbba5a51 100644 --- a/cmd/mecatui/ui/skills.go +++ b/cmd/mecatui/ui/skills.go @@ -381,22 +381,7 @@ func (s *skillsState) HandleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, handled bool, } func (s *skillsState) navigationMove(msg tea.KeyPressMsg) (bounded.Move, bool) { - switch { - case key.Matches(msg, s.deps.keys.Down): - return bounded.LineDown, true - case key.Matches(msg, s.deps.keys.Up): - return bounded.LineUp, true - case key.Matches(msg, s.deps.keys.ScrollD): - return bounded.PageDown, true - case key.Matches(msg, s.deps.keys.ScrollU): - return bounded.PageUp, true - case key.Matches(msg, s.deps.keys.ScrollBottom): - return bounded.End, true - case key.Matches(msg, s.deps.keys.ScrollTop): - return bounded.Top, true - default: - return 0, false - } + return boundedNavigationMove(msg, s.deps.keys, bounded.LineDown, bounded.LineUp, bounded.PageDown, bounded.PageUp, bounded.End, bounded.Top) } func (s *skillsState) moveLearned(msg tea.KeyPressMsg) { diff --git a/cmd/mecatui/ui/skills_bounded_viewport_scenario_test.go b/cmd/mecatui/ui/skills_bounded_viewport_scenario_test.go index ed52cefdcf..969cc6ce5f 100644 --- a/cmd/mecatui/ui/skills_bounded_viewport_scenario_test.go +++ b/cmd/mecatui/ui/skills_bounded_viewport_scenario_test.go @@ -458,6 +458,43 @@ func TestMecatuiSkillsInventoryBoundedViewport_Scenario3_OpenCloseAndFilterLifec } } +func TestSkillsNavigationHistoricalOverlapAndPrintableGuard(t *testing.T) { + st := skillsScenarioState(testTheme(), 30, 20) + st.Render(70, 18) + st.deps.keys = applyKeyOverrides(defaultKeys(), map[string][]string{"Up": {"z"}, "Down": {"z"}, "ScrollD": {"z"}}) + st.HandleKey(navigationTestKey("z")) + if st.viewport.Offset() != 1 || st.filter.Value() != "" { + t.Fatal("external remapped printable Down must precede Up and PageDown") + } + st.HandleKey(tea.KeyPressMsg{Code: tea.KeyTab}) + st.HandleKey(navigationTestKey("z")) + if st.cursor != 1 { + t.Fatal("learned Down must precede Up and PageDown") + } + st.deps.keys = applyKeyOverrides(defaultKeys(), map[string][]string{"Down": {"j"}, "Up": {"k"}}) + st.HandleKey(navigationTestKey("j")) + if st.cursor != 2 { + t.Fatal("learned j must remain navigation") + } + st.HandleKey(navigationTestKey("k")) + if st.cursor != 1 { + t.Fatal("learned k must remain navigation") + } + st.HandleKey(tea.KeyPressMsg{Code: tea.KeyTab}) + st.HandleKey(navigationTestKey("j")) + st.HandleKey(navigationTestKey("k")) + if st.filter.Value() != "jk" || st.cursor != 1 { + t.Fatal("external j/k must remain filter input, not navigation") + } + st.deps.keys = applyKeyOverrides(st.deps.keys, map[string][]string{"Close": {"z"}, "Down": {"z"}}) + if _, _, closed := st.HandleKey(navigationTestKey("z")); closed || st.filter.Value() != "" { + t.Fatal("Skills Close/filter clear must precede Down") + } + if _, _, closed := st.HandleKey(navigationTestKey("z")); !closed { + t.Fatal("Skills Close must precede Down after filter is clear") + } +} + func TestMecatuiSkillsInventoryBoundedViewport_Scenario3_RemappablePhysicalLineAndPagingNavigation(t *testing.T) { th := theme.New("aztec", theme.AztecPalette()) st := skillsScenarioState(th, 30, 20) diff --git a/cmd/mecatui/ui/soul.go b/cmd/mecatui/ui/soul.go index e7250641db..762d1c187c 100644 --- a/cmd/mecatui/ui/soul.go +++ b/cmd/mecatui/ui/soul.go @@ -140,34 +140,17 @@ func (s *soulState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { return nil, true, true } if s.viewport != nil { - move := bounded.LineDown - switch { - case key.Matches(msg, s.deps.keys.Down): - case key.Matches(msg, s.deps.keys.Up): - move = bounded.LineUp - case key.Matches(msg, s.deps.keys.ScrollD): - move = bounded.PageDown - case key.Matches(msg, s.deps.keys.ScrollU): - move = bounded.PageUp - case key.Matches(msg, s.deps.keys.ScrollBottom): - move = bounded.End - case key.Matches(msg, s.deps.keys.ScrollTop): - move = bounded.Top - default: - return nil, true, false + if move, ok := boundedNavigationMove(msg, s.deps.keys, bounded.LineDown, bounded.LineUp, bounded.PageDown, bounded.PageUp, bounded.End, bounded.Top); ok { + s.viewport.Move(move, s.total) } - s.viewport.Move(move, s.total) } return nil, true, false } func (s *soulState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { if s.viewport != nil { - switch msg.Mouse().Button { - case tea.MouseWheelUp: - s.viewport.Move(bounded.LineUp, s.total) - case tea.MouseWheelDown: - s.viewport.Move(bounded.LineDown, s.total) + if move, ok := boundedWheelMove(msg); ok { + s.viewport.Move(move, s.total) } } return nil, true diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index fe1de2bff6..79a961f0be 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -918,21 +918,9 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if s.compact || len(s.entries) == 0 { return nil, true, false } - move := bounded.LineDown - switch { - case bubbleskey.Matches(msg, s.deps.keys.Down): - case bubbleskey.Matches(msg, s.deps.keys.Up): - move = bounded.LineUp - case bubbleskey.Matches(msg, s.deps.keys.ScrollD): - move = bounded.PageDown - case bubbleskey.Matches(msg, s.deps.keys.ScrollU): - move = bounded.PageUp - case bubbleskey.Matches(msg, s.deps.keys.ScrollBottom): - move = bounded.End - case bubbleskey.Matches(msg, s.deps.keys.ScrollTop): - move = bounded.Top - case bubbleskey.Matches(msg, s.deps.keys.Choose): - if !s.detail { + move, navigation := boundedNavigationMove(msg, s.deps.keys, bounded.LineDown, bounded.LineUp, bounded.PageDown, bounded.PageUp, bounded.End, bounded.Top) + if !navigation { + if bubbleskey.Matches(msg, s.deps.keys.Choose) && !s.detail { s.detail = true s.intent = toolcallsDetailIntent{blockID: s.entries[s.selected].blockID} s.window = new(bounded.Viewport) @@ -941,8 +929,6 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { s.follow = false } return nil, true, false - default: - return nil, true, false } if s.detail { if s.window != nil { @@ -963,11 +949,11 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { } func (s *toolcallsState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { + move := bounded.LineDown + if msg.Mouse().Button == tea.MouseWheelUp { + move = bounded.LineUp + } if s.detail && s.window != nil { - move := bounded.LineDown - if msg.Mouse().Button == tea.MouseWheelUp { - move = bounded.LineUp - } s.window.Move(move, s.lines) s.follow = s.window.Offset() >= max(0, s.lines-s.window.Height()) if !s.follow { @@ -978,7 +964,7 @@ func (s *toolcallsState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { if s.list == nil { return nil, true } - s.list.Scroll(map[bool]bounded.Move{true: bounded.LineUp, false: bounded.LineDown}[msg.Mouse().Button == tea.MouseWheelUp]) + s.list.Scroll(move) s.listFollow = s.selected == len(s.entries)-1 && s.list.View().Below == 0 return nil, true } diff --git a/cmd/mecatui/ui/usermodel.go b/cmd/mecatui/ui/usermodel.go index 5e27b6fb87..3892ffaf28 100644 --- a/cmd/mecatui/ui/usermodel.go +++ b/cmd/mecatui/ui/usermodel.go @@ -108,10 +108,8 @@ func (s *userModelState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { if s.compact { return nil, true } - move := bounded.LineDown - if msg.Button == tea.MouseWheelUp { - move = bounded.LineUp - } else if msg.Button != tea.MouseWheelDown { + move, ok := boundedWheelMove(msg) + if !ok { return nil, true } if s.view == userModelPanel && s.list != nil { diff --git a/cmd/mecatui/ui/usermodel_bounded_more_test.go b/cmd/mecatui/ui/usermodel_bounded_more_test.go index 6e3e3ba696..bdf86cf7a9 100644 --- a/cmd/mecatui/ui/usermodel_bounded_more_test.go +++ b/cmd/mecatui/ui/usermodel_bounded_more_test.go @@ -45,6 +45,50 @@ func checkSavedMemoryCard(t *testing.T, m Model, w, h int) { t.Fatalf("body width=%d offered=%d: %q", got, w, rendered) } } +func TestSavedMemoryWheelThroughUpdate(t *testing.T) { + fum := sampleUserModel() + fum.model.Detail = &client.UserModelDetail{Current: client.UserModelRevision{Key: "name", Value: strings.Repeat("reader line\n", 100)}} + m := newUserModelModel(t, fum, client.Capabilities{UserModel: true}) + m.conv.addUser("question") + m.conv.appendAssistant(strings.Repeat("conversation line\n", 100)) + m.refreshView() + m, s := openedUserModel(t, m) + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = feedCmd(t, updated.(Model), cmd) + if s.view != userModelDetail { + t.Fatal("Choose did not open the reader") + } + selected, listOffset, conversationOffset := s.list.CursorID(), s.list.Offset(), m.vp.YOffset() + for _, step := range []struct { + button tea.MouseButton + offset int + }{ + {tea.MouseWheelDown, 1}, {tea.MouseWheelLeft, 1}, {tea.MouseWheelRight, 1}, {tea.MouseNone, 1}, {tea.MouseWheelUp, 0}, + } { + // No View between input events: root preparation must supply reader geometry. + updated, _ = m.Update(tea.MouseWheelMsg{Button: step.button}) + m = updated.(Model) + if s.viewport.Offset() != step.offset || s.list.CursorID() != selected || s.list.Offset() != listOffset || m.vp.YOffset() != conversationOffset || m.modal != s { + t.Fatalf("button %v escaped reader ownership or moved the wrong window", step.button) + } + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = updated.(Model) + for _, size := range [][2]int{{0, 30}, {100, 0}, {2, 18}} { + updated, _ = m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + m = updated.(Model) + listOffset, conversationOffset = s.list.Offset(), m.vp.YOffset() + generation := s.generation + for _, msg := range []tea.Msg{tea.MouseWheelMsg{Button: tea.MouseWheelDown}, tea.KeyPressMsg{Code: tea.KeyEnter}} { + updated, _ = m.Update(msg) + m = updated.(Model) + if !s.compact || m.modal != s || s.view != userModelPanel || s.list.CursorID() != selected || s.list.Offset() != listOffset || m.vp.YOffset() != conversationOffset || s.generation != generation || s.requestKey != "" || s.loading { + t.Fatalf("size %v: %T scrolled or activated unusable geometry", size, msg) + } + } + } +} + func TestMecatuiSavedMemoryBoundedBrowser_Scenario1_AllStatesFit(t *testing.T) { m, s := savedMemoryOpened(t, savedMemoryEntries(30)) for _, state := range []struct { diff --git a/cmd/mecatui/ui/worktrees.go b/cmd/mecatui/ui/worktrees.go index a8154d2e64..e67cb108fa 100644 --- a/cmd/mecatui/ui/worktrees.go +++ b/cmd/mecatui/ui/worktrees.go @@ -142,6 +142,7 @@ func (s *worktreesState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { } return nil, true, false } + move, navigation := boundedFilterNavigationMove(msg, s.deps.keys, bounded.LineUp, bounded.LineDown, bounded.Top, bounded.End) switch { case key.Matches(msg, s.deps.keys.Close): if s.filter.Value() == "" { @@ -149,14 +150,8 @@ func (s *worktreesState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { } s.filter.SetValue("") s.syncFilter() - case msg.String() == "up": - s.list.Move(bounded.LineUp) - case msg.String() == "down": - s.list.Move(bounded.LineDown) - case key.Matches(msg, s.deps.keys.ScrollTop): - s.list.Move(bounded.Top) - case key.Matches(msg, s.deps.keys.ScrollBottom): - s.list.Move(bounded.End) + case navigation: + s.list.Move(move) case key.Matches(msg, s.deps.keys.Choose): if !s.loading && s.err == nil && len(s.filtered) > 0 { wt := s.filtered[s.list.Cursor()] @@ -173,11 +168,8 @@ func (s *worktreesState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { func (s *worktreesState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { if !s.compact && s.confirm == nil && !s.loading && s.err == nil { - switch msg.Button { - case tea.MouseWheelUp: - s.list.Scroll(bounded.LineUp) - case tea.MouseWheelDown: - s.list.Scroll(bounded.LineDown) + if move, ok := boundedWheelMove(msg); ok { + s.list.Scroll(move) } } return nil, true diff --git a/cmd/mecatui/ui/worktrees_surface_test.go b/cmd/mecatui/ui/worktrees_surface_test.go index 3ce16bc020..e66dbb09d1 100644 --- a/cmd/mecatui/ui/worktrees_surface_test.go +++ b/cmd/mecatui/ui/worktrees_surface_test.go @@ -57,6 +57,131 @@ func worktreesBodyFits(t *testing.T, s *worktreesState, width, height int) strin return stripANSIstr(body) } +func TestWorktreesNavigationThroughUpdate(t *testing.T) { + for _, disabled := range []bool{false, true} { + t.Run(fmt.Sprintf("disabled=%t", disabled), func(t *testing.T) { + m, s := openedWorktrees(t, worktreeInventory(4)) + m.keys = applyKeyOverrides(m.keys, map[string][]string{ + "Up": {"u"}, "Down": {"d"}, "ScrollTop": {"x"}, "Choose": {"x", "q"}, "Close": {"q"}, + }) + m.keys.Up.SetEnabled(!disabled) + m.keys.Down.SetEnabled(!disabled) + s.deps = (&m).surfaceDeps() + m.prompt.Rewrite("draft") + for _, step := range []struct { + msg tea.KeyPressMsg + cursor int + filter string + closed bool + }{ + {tea.KeyPressMsg{Code: tea.KeyDown}, 1, "", false}, + {tea.KeyPressMsg{Code: tea.KeyUp}, 0, "", false}, + {tea.KeyPressMsg{Code: tea.KeyDown}, 1, "", false}, + {navigationTestKey("x"), 0, "", false}, // navigation precedes Choose + {navigationTestKey("j"), 0, "j", false}, + {navigationTestKey("k"), 0, "jk", false}, + {navigationTestKey("q"), 0, "", false}, // Close clears before Choose + {navigationTestKey("q"), 0, "", true}, + } { + updated, _ := m.Update(step.msg) + m = updated.(Model) + if s.list.Cursor() != step.cursor || s.filter.Value() != step.filter || (m.modal == nil) != step.closed { + t.Fatalf("%q: cursor=%d filter=%q modal=%T", step.msg.String(), s.list.Cursor(), s.filter.Value(), m.modal) + } + if m.prompt.Value() != "draft" || m.phase != phaseIdle || s.confirm != nil || s.intent != nil { + t.Fatalf("%q leaked input or activated a worktree", step.msg.String()) + } + } + }) + } +} + +func TestWorktreesFilterNavigationDoesNotPage(t *testing.T) { + _, s := openedWorktrees(t, worktreeInventory(40)) + s.deps.keys = navigationTestKeys() + s.deps.keys.Up.SetEnabled(false) + s.deps.keys.Down.SetEnabled(false) + s.Render(80, 12) + s.list.SetCursor(20) + for _, msg := range []tea.KeyPressMsg{{Code: tea.KeyPgDown}, {Code: tea.KeyPgUp}} { + s.HandleKey(msg) + if s.list.Cursor() != 20 || s.filter.Value() != "" { + t.Fatal("Worktrees unexpectedly accepted page navigation") + } + } + for _, text := range []string{"n", "p"} { + s.HandleKey(navigationTestKey(text)) + } + if s.filter.Value() != "np" { + t.Fatalf("remapped page keys must remain text, got %q", s.filter.Value()) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + s.Render(80, 12) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + if s.list.Cursor() != 1 { + t.Fatal("fixed Down lost to disabled/remapped binding") + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyUp}) + if s.list.Cursor() != 0 { + t.Fatal("fixed Up lost to disabled/remapped binding") + } + s.HandleKey(navigationTestKey("b")) + if s.list.Cursor() != 39 { + t.Fatal("configurable End no longer navigates") + } + s.HandleKey(navigationTestKey("t")) + if s.list.Cursor() != 0 { + t.Fatal("configurable Top no longer navigates") + } + s.deps.keys.ScrollBottom.SetEnabled(false) + s.HandleKey(navigationTestKey("b")) + if s.filter.Value() != "b" { + t.Fatal("disabled End must remain text") + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + s.deps.keys = applyKeyOverrides(s.deps.keys, map[string][]string{"ScrollTop": {"z"}, "ScrollBottom": {"z"}, "Choose": {"z"}}) + s.list.SetCursor(20) + s.HandleKey(navigationTestKey("z")) + if s.list.Cursor() != 0 || s.confirm != nil { + t.Fatal("Worktrees Top must precede End and Choose") + } +} + +func TestWorktreesWheelClassificationAndGuards(t *testing.T) { + for _, guard := range []string{"none", "compact", "confirmation", "loading", "error"} { + t.Run(guard, func(t *testing.T) { + _, s := openedWorktrees(t, worktreeInventory(40)) + s.Render(80, 12) + selected, offset := s.list.CursorID(), s.list.Offset() + switch guard { + case "compact": + s.compact = true + case "confirmation": + wt := selectedWorktree(t, s) + s.confirm = &wt + case "loading": + s.loading = true + case "error": + s.err = errors.New("test error") + } + for _, button := range []tea.MouseButton{tea.MouseWheelLeft, tea.MouseWheelRight, tea.MouseNone} { + s.HandleWheel(tea.MouseWheelMsg{Button: button}) + if s.list.CursorID() != selected || s.list.Offset() != offset { + t.Fatal("nonvertical wheel changed selection/window") + } + } + s.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + if s.list.CursorID() != selected || (s.list.Offset() > offset) != (guard == "none") { + t.Fatal("wheel must scroll only the unguarded window, never selection") + } + s.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + if s.list.Offset() != offset || s.list.CursorID() != selected { + t.Fatal("WheelUp did not restore window/retain selection") + } + }) + } +} + func TestWorktreesSurface_Scenario1_ManyRowsAndShortOffers(t *testing.T) { rows := worktreeInventory(80) for _, width := range []int{1, 2, 3, 12, 32, 80, 124} {