From 50c9990e3222fb55b53b124190e405eb0171bf3a Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 11:41:49 -0700 Subject: [PATCH 01/10] docs(acceptance): amend scroll cursor anchors Co-Authored-By: mecatl --- docs/acceptance/README.md | 2 +- .../mecatui-bounded-scroll-selection.md | 41 +++++++++++-------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index e962101142..3c8de84336 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -123,7 +123,7 @@ PR after verification. There is no cleanup or status-only PR. operator-global `command_runner` defaults for the Shell interpreter and a deliberately constrained main-shell ambient credential grant, while managed temporary storage remains a separate lifecycle policy. Status: proposed. -- [Mecatui bounded scroll and cursor control](mecatui-bounded-scroll-selection.md) — establishes shared package-private physical-line browsing and logical-item cursor behavior, then proves complete geometry, styling, wheel ownership, and Models click-to-cursor semantics across Agents and Models. Status: proposed. +- [Mecatui bounded scroll and cursor control](mecatui-bounded-scroll-selection.md) — establishes composed package-private physical-line viewport and stable-ID list cursor behavior, then proves complete geometry, caller-owned selection styling, independent wheel ownership, and Models click-to-cursor semantics across Agents and Models. Status: in-progress. - [Mecatui unified Agents overlay fit](mecatui-unified-agents-overlay-fit.md) — aligns every F6 selectable roster with the Sessions picker’s unbordered selection treatment and bounds every Agents-overlay subview to its offered viewport, with reachable overflow and compact short-terminal fallback. Status: proposed. - [Mecatui live-feed reconnect](mecatui-live-reconnect.md) — regression closure for bearer-backed first-Recv authentication rejection, existing `/connect` recovery, cross-loop reconnect continuity/backoff, and real-event recovery without weakening generation, cancellation, or catch-up invariants. Status: landed. - [Mecatui effort-handoff recovery](mecatui-effort-handoff-recovery.md) — transactional `/effort` successor hydration that preserves the usable source until the target is validated, cleans failed or stale targets best-effort, and retires the source only after adoption. Status: proposed. diff --git a/docs/acceptance/mecatui-bounded-scroll-selection.md b/docs/acceptance/mecatui-bounded-scroll-selection.md index 5429e80f3e..2aedd67c6d 100644 --- a/docs/acceptance/mecatui-bounded-scroll-selection.md +++ b/docs/acceptance/mecatui-bounded-scroll-selection.md @@ -4,7 +4,8 @@ **Work classification:** Bounded — this establishes a substantive package-private TUI interaction contract across existing client surfaces without changing a public API, persistence boundary, protocol, or system architecture. **Decision record:** None — the control remains confined to `cmd/mecatui/ui`; its local ownership and interaction choices belong in this plan rather than a durable architecture record. **Phase:** bounded scroll-cursor convergence, slice 1 -**Status:** proposed, 2026-09-15. Initial slice of the incremental migration tracked by #1589. +**Status:** in-progress, 2026-09-15. Implementation began from approved PR #1593. +**Amendment:** 2026-09-15 — after the early package-private API checkpoint, the operator explicitly waived a separate amendment PR and authorized this implementation-branch amendment for stable item identity, independent viewport/cursor anchors, caller-owned selected styling, and tiny-width behavior. **Delivery:** Split. Shared cursor, layout, style, and pointer semantics need human interface review before implementation changes multiple surfaces. **Expected tasks:** 2 **Issue:** [stacklok/mecatl#1589](https://github.com/stacklok/mecatl/issues/1589). @@ -17,37 +18,41 @@ This is an incremental targeted correction, not the future formal migration of e ## Human decisions - [x] Initial consumers — Decision: prove multiline cursor rows and browsing details across every unified-Agents subview plus fixed-height cursor rows in `/models`; defer Skills and every other surface to independently classified follow-ups under #1589. -- [x] Control boundary — Decision: establish one shared physical-line windowing behavior with optional logical-item cursor state by consolidating, extending, or deleting the existing helpers; do not add exported interfaces, a general widget framework, or a second independent windowing policy. -- [x] Width and oversized-item behavior — Decision: the control owns the hard display-cell width bound after applying the caller-selected wrap-or-clip policy. A selected item taller than the available body remains selected while Page Up/Page Down traverses bounded contiguous segments of that item; every segment retains the cursor marker and exposes accurate overflow before advancing to another item. -- [x] Cursor presentation — Decision: cursor rows use the literal `▶ ` marker with unbordered `accent` styling and an equal-width unselected prefix; text selection, control focus, current `●`, default `★`, active-tab `▸`, and Parallel-winner state retain distinct meanings and styles. -- [x] Paging — Decision: Page Down starts at the first physical line after the current window and selects the first logical item visible there; Page Up applies the symmetric preceding-window rule. An oversized selected item consumes successive pages before the cursor advances. Up/Down always moves one logical item and resets any within-item page position. -- [x] Pointer ownership — Decision: wheel input moves cursor mode by one logical item and browsing mode by one physical line. Models proves primary-click cursor movement without activation through the existing `surface` hit lifecycle. Agents receives a narrow root-level wheel branch before conversation scrolling; it gains no click targets or second hit registry. Compact and `vp short` Agents fallbacks consume wheel input without changing hidden state. Enter remains activation; hover styling is deferred. +- [x] Control boundary — Decision: use two concrete package-private layers: `boundedViewport` owns physical-line geometry and scroll position; `boundedList` composes it with logical items and cursor state. Do not add a Go interface, exported API, general widget framework, or second independent windowing policy. +- [x] Width and oversized-item behavior — Decision: the viewport owns the hard display-cell width bound after applying the caller-selected wrap-or-clip policy. Width below the fixed cursor gutter plus one content cell yields an empty body so the owner can use its compact fallback. An item taller than the body is exposed in bounded contiguous segments without ANSI/style leakage. +- [x] Cursor identity and refresh — Decision: cursor-mode items carry stable IDs. On refresh, retain the selected ID and clamp its within-item position; if it is absent, clamp the prior numeric index and adopt that fallback item's ID, with no later snap-back. Preserve the top visible `{item ID, physical line within item}` anchor when possible so insertion/removal above does not change what the user is reading; fall back to a clamped physical offset when the anchor disappears. +- [x] Cursor presentation — Decision: the viewport reserves a fixed-width gutter but returns row metadata rather than theme material. The renderer supplies the selected-item style independently from the cursor marker. `▶ ` appears only on the first visible line of each selected-item segment; continuation lines use an equal-width blank gutter. Text selection, control focus, current `●`, default `★`, active-tab `▸`, and Parallel-winner state remain distinct. +- [x] Cursor and scroll movement — Decision: cursor identity/within-item position and viewport position are separate state. Up/Down moves one logical item and minimally scrolls to reveal it; Page Up/Page Down keeps the approved item-aware paging behavior, including oversized segments. Mouse wheel scrolls the viewport by one physical line without moving the cursor; clicking a Model row moves the cursor and minimally reveals it. +- [x] Bubbles reuse — Decision: reuse the installed Bubbles v2 and `x/ansi` patterns where they preserve the contract—separate cursor/viewport state, fixed gutter, caller-owned line styling, content-update clamping—but do not adopt `list.Model`, `table.Model`, or `viewport.Model` as the core because their fixed/indexed or private physical-line models cannot preserve variable-height stable item identity and hit metadata. +- [x] Pointer ownership — Decision: Models uses the existing `surface` hit lifecycle. Agents receives a narrow root-level wheel branch before conversation scrolling; it gains no click targets or second hit registry. Compact and `vp short` Agents fallbacks consume wheel without changing viewport or cursor state. Enter remains activation; hover styling is deferred. ## Interface contract - **gRPC / protobuf:** None — all affected state and interactions remain inside the proto-free mecatui renderer. -- **Exported Go APIs / interfaces:** None — window and cursor behavior remains package-private under `cmd/mecatui/ui`; the conversation-view controller and public engine surface are unchanged. +- **Exported Go APIs / interfaces:** None — concrete `boundedViewport` and `boundedList` types remain package-private under `cmd/mecatui/ui`; no Go interface is introduced, and the conversation-view controller and public engine surface are unchanged. - **Tool schemas:** None — no model-facing tool or argument changes. - **CLI / config:** None — no flags, settings, key-binding names, or defaults change; existing remappable navigation actions remain authoritative. -- **Events / persistence:** None — cursor, within-item page position, browsing offset, geometry, and pointer-hit state remain ephemeral client state. +- **Events / persistence:** None — stable item IDs, cursor index/within-item position, semantic top anchor, physical fallback offset, geometry, and pointer-hit state remain ephemeral client state. - **Security / authority:** None — the control renders only content already admitted to its owning surface and preserves frame-scoped opaque hit dispatch; it introduces no new content, trust, permission, or ownership path. -- **Compatibility / migration:** Every unified-Agents roster/detail view and `/models` adopts the shared behavior. Agents and Models cursor rows converge on `▶ ` plus unbordered `accent`; wheel input is consumed by the visible owner; Models gains click-to-cursor without click-to-activate. Agents compact-mode navigation remains suspended. Agents clicks, conversation scrolling, and every unlisted surface remain unchanged. +- **Compatibility / migration:** Every unified-Agents roster/detail view and `/models` adopts the shared behavior. Agents and Models default cursor rows remain `▶ ` plus unbordered `accent`, while the control permits caller-owned selected styles; wheel scrolls the visible viewport without moving its cursor; Models gains click-to-cursor without click-to-activate; item refresh preserves cursor and viewport anchors by stable ID. Agents compact-mode navigation remains suspended. Agents clicks, conversation scrolling, and every unlisted surface remain unchanged. ## In scope — 3 scenarios, in implementation order ### Scenario 1 — bounded multiline browsing and cursor movement -A caller supplies explicit content width and physical-line height together with logical items or rendered browsing lines. The control accounts for ANSI-aware display width and physical height, keeps cursor identity separate from physical offsets, and exposes all oversized selected-item content without exceeding its bounds. It generalizes rather than duplicates the physical budget already established by [the Agents overlay plan](mecatui-unified-agents-overlay-fit.md), following the minimum-change discipline in [`AGENTS.md`](../../AGENTS.md). +A caller supplies explicit content width and physical-line height together with logical items or rendered browsing lines. `boundedViewport` owns physical-line layout and scroll position; `boundedList` adds stable item identity and a cursor that can remain distinct from the viewport. Both account for ANSI-aware display width and expose oversized content without exceeding their bounds. They generalize rather than duplicate the physical budget already established by [the Agents overlay plan](mecatui-unified-agents-overlay-fit.md), following the minimum-change discipline in [`AGENTS.md`](../../AGENTS.md). **Acceptance:** -- AC1.1: At positive width and height, every wrap or clip policy produces no more than the supplied physical-line height and no ANSI-stripped line wider than the supplied display-cell width. +- AC1.1: At width sufficient for the configured gutter plus one content cell and positive height, every wrap or clip policy produces no more than the supplied physical-line height and no ANSI-stripped line wider than the supplied display-cell width; a smaller width or nonpositive dimension yields an empty body without panic. - verify: `TestMecatuiBoundedScrollCursor_Scenario1_RespectsWidthAndHeight` -- AC1.2: Up/Down moves one logical item; Page Down selects the first item beginning after the current physical window and Page Up applies the symmetric preceding-window rule; Top/End clamps to the first/last item. +- AC1.2: Up/Down moves the logical cursor one item and minimally reveals it; Page Down selects the first item beginning after the current physical window and Page Up applies the symmetric preceding-window rule; Top/End clamps to the first/last item. - verify: `TestMecatuiBoundedScrollCursor_Scenario1_MultilinePagingTargets` -- AC1.3: A cursor item taller than the available body is shown in bounded contiguous segments, retains its cursor marker, exposes accurate above/below overflow, and is completely reachable by paging before the cursor advances; ANSI style does not leak across clipped segment boundaries. +- AC1.3: An item taller than the body is shown in bounded contiguous segments and is completely reachable in both directions; only the first visible line of a selected segment carries the cursor-marker flag, continuation lines retain the fixed blank gutter, caller-selected styling remains independent, and ANSI style does not leak across boundaries. - verify: `TestMecatuiBoundedScrollCursor_Scenario1_OversizedCursorItemReachable` -- AC1.4: Browsing line/page movement and cursor windows clamp after content or geometry changes without a reachable blank page; nonpositive width or height yields an empty body and no panic; no horizontal offset or panning action exists. +- AC1.4: Browsing movement, logical cursor state, and physical viewport position remain independently observable and clamp after content or geometry changes without a reachable blank page; no horizontal offset or panning action exists. - verify: `TestMecatuiBoundedScrollCursor_Scenario1_ClampsContentAndDegenerateBounds` +- AC1.5: Replacing cursor-mode items preserves the selected stable ID and top visible `{item ID, item line}` anchor across insertion, removal, reordering, and text-height changes when those IDs remain; a missing selected ID falls back to the clamped prior numeric index and adopts the replacement ID without later snap-back, while a missing top ID falls back to a clamped physical offset. + - verify: `TestMecatuiBoundedScrollCursor_Scenario1_RefreshPreservesSemanticAnchors` ### Scenario 2 — representative surfaces honor real geometry and cursor semantics @@ -60,7 +65,7 @@ Agents and Models use the same physical accounting for their complete rendered s - verify: `TestMecatuiBoundedScrollCursor_Scenario2_AgentsModesUseSharedAccounting` - AC2.3: The complete Models surface derives its row body from the actual offered width and height, including fixed chrome and narrow wrapped/clipped rows; it has no minimum-row rule that can force output beyond the offered geometry. - verify: `TestMecatuiBoundedScrollCursor_Scenario2_ModelsFitsOfferedGeometry` -- AC2.4: Selected Agents and Model rows use `▶ ` plus unbordered `accent` with an equal-width unselected prefix, while Model current `●`/default `★` and Agents active-tab `▸`/Parallel-winner markers remain independently visible. +- AC2.4: Selected Agents and Model rows default to `▶ ` plus unbordered `accent` with an equal-width unselected prefix, while the shared control allows each caller to supply a different selected-item style; Model current `●`/default `★` and Agents active-tab `▸`/Parallel-winner markers remain independently visible. - verify: `TestMecatuiBoundedScrollCursor_Scenario2_CursorAndStatusStylesStayDistinct` - AC2.5: Existing focused regressions for palette, mention, Sessions, MCP, Effort, conversation scrolling, and text selection remain green; shared helper changes do not silently migrate their behavior. - verify: `TestMecatuiBoundedScrollCursor_Scenario2_UnlistedSharedConsumersUnchanged` @@ -70,11 +75,11 @@ Agents and Models use the same physical accounting for their complete rendered s In alternate-screen mouse mode, wheel and primary-click input target the visible owner rather than hidden conversation content. Models uses the existing frame-scoped hit lifecycle documented in [`docs/design/IMPLEMENTATION-NOTES.md`](../design/IMPLEMENTATION-NOTES.md); Agents receives only the explicit root wheel branch needed before its future formal `surface` migration. **Acceptance:** -- AC3.1: Wheel over Subagent, Parallel-group, focused-Parallel-branch, or Team rosters moves one logical item; wheel over Subagent/Team traces or Team tasks/findings moves one physical browsing line; every path clamps at both ends. +- AC3.1: Wheel over any normal Subagent, Parallel-group, focused-Parallel-branch, or Team roster/detail view scrolls its physical viewport by one line without moving its logical cursor; every path clamps at both ends, and subsequent keyboard cursor movement minimally reveals the selected item. - verify: `TestMecatuiBoundedScrollCursor_Scenario3_AgentsWheelSubviewMatrix` -- AC3.2: Compact or `vp short` Agents views consume wheel without changing hidden cursor/detail offsets, and wheel while Agents or Models owns the body never changes the hidden conversation viewport—even when the owning control is at its boundary. +- AC3.2: Compact or `vp short` Agents views consume wheel without changing viewport or cursor state, and wheel while Agents or Models owns the body never changes the hidden conversation viewport—even when the owning viewport is at its boundary. - verify: `TestMecatuiBoundedScrollCursor_Scenario3_WheelNeverLeaksOrNavigatesFallback` -- AC3.3: Wheel over Models moves its cursor by one logical item. A primary click on the marker or text-bearing cells of any visible physical line of a Model row moves the logical cursor without switching models; clicks on chrome, overflow indicators, or trailing blank space miss; Enter retains activation. +- AC3.3: Wheel over Models scrolls its viewport by one physical line without moving its stable cursor. A primary click on the marker or text-bearing cells of any visible physical line of a Model row moves the cursor to that item and minimally reveals it without switching models; clicks on chrome, overflow indicators, or trailing blank space miss; Enter retains activation. - verify: `TestMecatuiBoundedScrollCursor_Scenario3_ModelClickSelectsEnterActivates` - AC3.4: Model hit regions are valid only for the render frame and geometry that produced them; old-frame, out-of-bounds, and closed-surface hits cannot change cursor or activate an action. - verify: `TestMecatuiBoundedScrollCursor_Scenario3_StaleModelHitsIgnored` From c025c460e5af8bff43c9e808414db7d1608eff22 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 11:05:08 -0700 Subject: [PATCH 02/10] feat(mecatui): establish bounded scroll cursor control Co-authored-by: mecatl --- cmd/mecatui/ui/bounded_scroll_cursor_test.go | 169 +++++++++++ cmd/mecatui/ui/window.go | 277 +++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 cmd/mecatui/ui/bounded_scroll_cursor_test.go diff --git a/cmd/mecatui/ui/bounded_scroll_cursor_test.go b/cmd/mecatui/ui/bounded_scroll_cursor_test.go new file mode 100644 index 0000000000..b65df87107 --- /dev/null +++ b/cmd/mecatui/ui/bounded_scroll_cursor_test.go @@ -0,0 +1,169 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestMecatuiBoundedScrollCursor_Scenario1_RespectsWidthAndHeight(t *testing.T) { + items := []boundedScrollItem{{ + text: "\x1b[31malpha界 beta-gamma\x1b[0m\nsecond physical line", + prefix: " ", + cursorPrefix: "▶ ", + }} + for _, tc := range []struct { + name string + policy boundedWidthPolicy + }{{"wrap", boundedWrap}, {"clip", boundedClip}} { + t.Run(tc.name, func(t *testing.T) { + var control boundedScrollCursor + control.setBounds(8, 2, tc.policy) + control.setCursorItems(items, 0) + view := control.view() + if len(view.rows) > 2 { + t.Fatalf("rendered %d physical lines, want at most 2", len(view.rows)) + } + if len(view.rows) == 0 { + t.Fatal("positive bounds rendered no cursor rows") + } + for i, row := range view.rows { + if got := ansi.StringWidth(ansi.Strip(row.text)); got > 8 { + t.Errorf("row %d width = %d, want <= 8: %q", i, got, ansi.Strip(row.text)) + } + } + }) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario1_MultilinePagingTargets(t *testing.T) { + items := []boundedScrollItem{ + {text: "zero-a\nzero-b", prefix: " ", cursorPrefix: "▶ "}, + {text: "one-a\none-b", prefix: " ", cursorPrefix: "▶ "}, + {text: "two", prefix: " ", cursorPrefix: "▶ "}, + {text: "three-a\nthree-b", prefix: " ", cursorPrefix: "▶ "}, + } + var control boundedScrollCursor + control.setBounds(20, 3, boundedClip) + control.setCursorItems(items, 0) + + control.move(boundedPageDown) + if got := control.cursor; got != 2 { + t.Fatalf("Page Down cursor = %d, want first item beginning after the old window: 2", got) + } + control.move(boundedPageUp) + if got := control.cursor; got != 0 { + t.Fatalf("Page Up cursor = %d, want first item visible in the preceding physical window: 0", got) + } + control.move(boundedLineDown) + if got := control.cursor; got != 1 { + t.Fatalf("Down cursor = %d, want 1", got) + } + control.move(boundedLineUp) + if got := control.cursor; got != 0 { + t.Fatalf("Up cursor = %d, want 0", got) + } + control.move(boundedEnd) + if got := control.cursor; got != 3 { + t.Fatalf("End cursor = %d, want 3", got) + } + control.move(boundedTop) + if got := control.cursor; got != 0 { + t.Fatalf("Top cursor = %d, want 0", got) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario1_OversizedCursorItemReachable(t *testing.T) { + items := []boundedScrollItem{ + {text: "\x1b[31mabcdefghijklmnopqr\x1b[0m", prefix: " ", cursorPrefix: "▶ "}, + {text: "next", prefix: " ", cursorPrefix: "▶ "}, + } + var control boundedScrollCursor + control.setBounds(5, 2, boundedWrap) + control.setCursorItems(items, 0) + + var reached strings.Builder + wantAbove := []int{0, 2, 4} + wantBelow := []int{6, 4, 2} + for page := 0; page < 3; page++ { + view := control.view() + if len(view.rows) == 0 || len(view.rows) > 2 { + t.Fatalf("page %d has %d rows, want 1..2", page, len(view.rows)) + } + if view.above != wantAbove[page] || view.below != wantBelow[page] { + t.Fatalf("page %d overflow = (%d above, %d below), want (%d, %d)", + page, view.above, view.below, wantAbove[page], wantBelow[page]) + } + for _, row := range view.rows { + plain := ansi.Strip(row.text) + if !strings.HasPrefix(plain, "▶ ") { + t.Errorf("oversized selected segment lost cursor marker: %q", plain) + } + if !strings.Contains(row.text, "\x1b[31m") { + t.Errorf("wrapped segment lost the item's ANSI style: %q", row.text) + } + reached.WriteString(strings.TrimPrefix(plain, "▶ ")) + if !strings.HasSuffix(row.text, "\x1b[0m") { + t.Errorf("clipped styled segment can leak ANSI state: %q", row.text) + } + } + if page < 2 { + if view.below == 0 { + t.Fatalf("page %d reported no content below before oversized item was exhausted", page) + } + control.move(boundedPageDown) + if control.cursor != 0 { + t.Fatalf("Page Down advanced cursor before oversized item was exhausted: %d", control.cursor) + } + } + } + if got := reached.String(); got != "abcdefghijklmnopqr" { + t.Fatalf("reachable oversized content = %q, want complete item", got) + } + control.move(boundedPageDown) + if control.cursor != 1 { + t.Fatalf("Page Down after final oversized segment cursor = %d, want 1", control.cursor) + } + if plain := ansi.Strip(control.view().rows[0].text); plain != "▶ nex" { + t.Fatalf("style or content leaked into following item: %q", plain) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario1_ClampsContentAndDegenerateBounds(t *testing.T) { + var control boundedScrollCursor + control.setBounds(12, 2, boundedClip) + control.setBrowsing([]string{"zero", "one", "two", "three", "four"}) + control.move(boundedLineDown) + if got := control.view().above; got != 1 { + t.Fatalf("browsing line movement offset = %d, want 1", got) + } + control.move(boundedPageDown) + if got := control.view().above; got != 3 { + t.Fatalf("browsing page movement offset = %d, want clamped 3", got) + } + control.move(boundedEnd) + if got := control.view(); got.above != 3 || len(got.rows) != 2 { + t.Fatalf("browsing End view = above %d, rows %d; want final nonblank page", got.above, len(got.rows)) + } + + control.setBrowsing([]string{"only"}) + if got := control.view(); got.above != 0 || len(got.rows) != 1 || ansi.Strip(got.rows[0].text) != "only" { + t.Fatalf("content shrink left a blank/reachable stale page: %#v", got) + } + control.setBounds(12, 5, boundedClip) + if got := control.view(); got.above != 0 || len(got.rows) != 1 { + t.Fatalf("geometry growth did not clamp browsing offset: %#v", got) + } + + control.setCursorItems([]boundedScrollItem{{text: "first"}, {text: "last"}}, 99) + if control.cursor != 1 || len(control.view().rows) == 0 { + t.Fatalf("cursor content change did not clamp to a reachable item: cursor=%d view=%#v", control.cursor, control.view()) + } + for _, bounds := range [][2]int{{0, 2}, {12, 0}, {-1, 2}, {12, -1}} { + control.setBounds(bounds[0], bounds[1], boundedWrap) + if got := control.view(); len(got.rows) != 0 || got.above != 0 || got.below != 0 { + t.Errorf("bounds %v rendered nonempty body/overflow: %#v", bounds, got) + } + } +} diff --git a/cmd/mecatui/ui/window.go b/cmd/mecatui/ui/window.go index 19ed3e5b6c..2aea696fc4 100644 --- a/cmd/mecatui/ui/window.go +++ b/cmd/mecatui/ui/window.go @@ -4,9 +4,286 @@ import ( "fmt" "strings" + "github.com/charmbracelet/x/ansi" + "github.com/stacklok/mecatl/cmd/mecatui/theme" ) +type boundedWidthPolicy uint8 + +const ( + boundedWrap boundedWidthPolicy = iota + boundedClip +) + +type boundedScrollMove uint8 + +const ( + boundedLineUp boundedScrollMove = iota + boundedLineDown + boundedPageUp + boundedPageDown + boundedTop + boundedEnd +) + +// boundedScrollItem is one logical selectable item. The control applies the +// appropriate prefix to every physical line, so a paged segment never loses +// its cursor marker. +type boundedScrollItem struct { + text, prefix, cursorPrefix string +} + +type boundedRenderedRow struct { + text string + item, itemRow int +} + +type boundedScrollView struct { + rows []boundedRenderedRow + above, below int +} + +// boundedScrollCursor owns one physical-line window. cursorMode selects logical +// item movement; without it offset is a physical browsing-line offset. All +// state is renderer-local and ephemeral. +type boundedScrollCursor struct { + width, height int + policy boundedWidthPolicy + items []boundedScrollItem + cursorMode bool + cursor int + itemOffset int + offset int +} + +func (s *boundedScrollCursor) setBounds(width, height int, policy boundedWidthPolicy) { + s.width, s.height, s.policy = width, height, policy + s.clampState() +} + +func (s *boundedScrollCursor) setBrowsing(lines []string) { + s.items = make([]boundedScrollItem, len(lines)) + for i, line := range lines { + s.items[i].text = line + } + s.cursorMode = false + s.itemOffset = 0 + s.clampState() +} + +func (s *boundedScrollCursor) setCursorItems(items []boundedScrollItem, cursor int) { + s.items = append(s.items[:0], items...) + s.cursorMode = true + s.cursor = clampBounded(cursor, len(items)) + s.itemOffset, s.offset = 0, 0 + s.clampState() +} + +func (s *boundedScrollCursor) move(move boundedScrollMove) { + if s.width <= 0 || s.height <= 0 { + return + } + layout := s.layout() + if s.cursorMode { + s.moveCursor(move, layout) + } else { + s.moveBrowsing(move, len(layout.rows)) + } + s.clampState() +} + +func (s *boundedScrollCursor) moveBrowsing(move boundedScrollMove, total int) { + switch move { + case boundedLineUp: + s.offset-- + case boundedLineDown: + s.offset++ + case boundedPageUp: + s.offset -= s.height + case boundedPageDown: + s.offset += s.height + case boundedTop: + s.offset = 0 + case boundedEnd: + s.offset = maxScrollOffset(total, s.height) + } +} + +func (s *boundedScrollCursor) moveCursor(move boundedScrollMove, layout boundedRowLayout) { + if len(s.items) == 0 { + return + } + s.cursor = clampBounded(s.cursor, len(s.items)) + itemHeight := layout.ends[s.cursor] - layout.starts[s.cursor] + switch move { + case boundedLineUp: + s.cursor-- + s.itemOffset = 0 + case boundedLineDown: + s.cursor++ + s.itemOffset = 0 + case boundedTop: + s.cursor, s.itemOffset = 0, 0 + case boundedEnd: + s.cursor, s.itemOffset = len(s.items)-1, 0 + case boundedPageDown: + if itemHeight > s.height && s.itemOffset+s.height < itemHeight { + s.itemOffset += s.height + return + } + window := s.cursorWindow(layout) + for item, start := range layout.starts { + if start >= window.end { + s.cursor, s.itemOffset = item, 0 + return + } + } + s.cursor, s.itemOffset = len(s.items)-1, 0 + case boundedPageUp: + if itemHeight > s.height && s.itemOffset > 0 { + s.itemOffset = max(0, s.itemOffset-s.height) + return + } + window := s.cursorWindow(layout) + target := max(0, window.start-s.height) + candidate := 0 + for item := range layout.starts { + if layout.starts[item] >= window.start { + break + } + if layout.ends[item] > target { + candidate = item + break + } + } + s.cursor, s.itemOffset = candidate, 0 + } + s.cursor = clampBounded(s.cursor, len(s.items)) +} + +func (s *boundedScrollCursor) view() boundedScrollView { + if s.width <= 0 || s.height <= 0 { + return boundedScrollView{} + } + s.clampState() + layout := s.layout() + if len(layout.rows) == 0 { + return boundedScrollView{} + } + start, end := 0, 0 + if s.cursorMode { + window := s.cursorWindow(layout) + start, end = window.start, window.end + } else { + window := renderedLineWindow(s.offset, len(layout.rows), s.height) + start, end = window.start, window.end + } + return boundedScrollView{ + rows: append([]boundedRenderedRow(nil), layout.rows[start:end]...), + above: start, + below: len(layout.rows) - end, + } +} + +type boundedRowLayout struct { + rows []boundedRenderedRow + starts, ends []int +} + +type boundedPhysicalWindow struct{ start, end int } + +func (s *boundedScrollCursor) cursorWindow(layout boundedRowLayout) boundedPhysicalWindow { + if len(layout.rows) == 0 || len(s.items) == 0 { + return boundedPhysicalWindow{} + } + cursor := clampBounded(s.cursor, len(s.items)) + itemStart, itemEnd := layout.starts[cursor], layout.ends[cursor] + if itemEnd-itemStart > s.height { + start := itemStart + min(s.itemOffset, itemEnd-itemStart-1) + return boundedPhysicalWindow{start: start, end: min(start+s.height, itemEnd)} + } + start := min(itemStart, max(0, len(layout.rows)-s.height)) + return boundedPhysicalWindow{start: start, end: min(start+s.height, len(layout.rows))} +} + +func (s *boundedScrollCursor) layout() boundedRowLayout { + layout := boundedRowLayout{ + starts: make([]int, len(s.items)), + ends: make([]int, len(s.items)), + } + for item, entry := range s.items { + layout.starts[item] = len(layout.rows) + prefix := entry.prefix + if s.cursorMode && item == clampBounded(s.cursor, len(s.items)) { + prefix = entry.cursorPrefix + } + prefixWidth := ansi.StringWidth(ansi.Strip(prefix)) + textWidth := max(0, s.width-prefixWidth) + for _, sourceLine := range strings.Split(entry.text, "\n") { + physical := boundedWidthLines(sourceLine, textWidth, s.policy) + for _, line := range physical { + text := prefix + line + if ansi.StringWidth(ansi.Strip(text)) > s.width { + text = ansi.Truncate(text, s.width, "") + } + layout.rows = append(layout.rows, boundedRenderedRow{ + text: text + "\x1b[0m", item: item, itemRow: len(layout.rows) - layout.starts[item], + }) + } + } + layout.ends[item] = len(layout.rows) + } + return layout +} + +func boundedWidthLines(line string, width int, policy boundedWidthPolicy) []string { + if width <= 0 { + return []string{""} + } + if policy == boundedClip { + return []string{ansi.Cut(line, 0, width)} + } + total := ansi.StringWidth(line) + if total == 0 { + return []string{line} + } + lines := make([]string, 0, (total+width-1)/width) + for left := 0; left < total; left += width { + segment := ansi.Cut(line, left, min(left+width, total)) + segmentWidth := ansi.StringWidth(segment) + if segmentWidth > 0 && segmentWidth <= width { + lines = append(lines, segment) + } + } + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func (s *boundedScrollCursor) clampState() { + s.cursor = clampBounded(s.cursor, len(s.items)) + if s.width <= 0 || s.height <= 0 { + s.offset, s.itemOffset = 0, 0 + return + } + layout := s.layout() + if s.cursorMode && len(s.items) > 0 { + itemHeight := layout.ends[s.cursor] - layout.starts[s.cursor] + s.itemOffset = min(max(0, s.itemOffset), max(0, itemHeight-1)) + } else { + s.offset = clampScroll(s.offset, len(layout.rows), s.height) + } +} + +func clampBounded(value, count int) int { + if count <= 0 || value < 0 { + return 0 + } + return min(value, count-1) +} + // scrollWindow returns the [start,end) slice bounds of a scrolling window of size // limit over n rows, kept around the selected cursor so it stays visible. It is a // pure function of (cursor, n, limit) — the window FOLLOWS the cursor (no stored From 9f2f6fe4ff6cb8db483db50000b2c7108c3c7ef3 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 11:15:40 -0700 Subject: [PATCH 03/10] fix(mecatui): reverse-page oversized cursor items Co-authored-by: mecatl --- cmd/mecatui/ui/bounded_scroll_cursor_test.go | 42 ++++++++++++++++++++ cmd/mecatui/ui/window.go | 32 ++++++++++----- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/cmd/mecatui/ui/bounded_scroll_cursor_test.go b/cmd/mecatui/ui/bounded_scroll_cursor_test.go index b65df87107..e7e06fdebb 100644 --- a/cmd/mecatui/ui/bounded_scroll_cursor_test.go +++ b/cmd/mecatui/ui/bounded_scroll_cursor_test.go @@ -35,6 +35,29 @@ func TestMecatuiBoundedScrollCursor_Scenario1_RespectsWidthAndHeight(t *testing. } }) } + + t.Run("wrap preserves styled wide graphemes", func(t *testing.T) { + const content = "a界🙂e\u0301Z" + var control boundedScrollCursor + control.setBounds(4, 10, boundedWrap) // marker leaves two display cells for text + control.setCursorItems([]boundedScrollItem{{ + text: "\x1b[31m" + content + "\x1b[0m", cursorPrefix: "▶ ", + }}, 0) + var rebuilt strings.Builder + for i, row := range control.view().rows { + plain := ansi.Strip(row.text) + if got := ansi.StringWidth(plain); got > 4 { + t.Fatalf("row %d width = %d, want <= 4: %q", i, got, plain) + } + if !strings.Contains(row.text, "\x1b[31m") || !strings.HasSuffix(row.text, "\x1b[0m") { + t.Errorf("row %d did not preserve and close ANSI style: %q", i, row.text) + } + rebuilt.WriteString(strings.TrimPrefix(plain, "▶ ")) + } + if got := rebuilt.String(); got != content { + t.Fatalf("wrapped content = %q, want byte-preserved graphemes %q", got, content) + } + }) } func TestMecatuiBoundedScrollCursor_Scenario1_MultilinePagingTargets(t *testing.T) { @@ -128,6 +151,25 @@ func TestMecatuiBoundedScrollCursor_Scenario1_OversizedCursorItemReachable(t *te if plain := ansi.Strip(control.view().rows[0].text); plain != "▶ nex" { t.Fatalf("style or content leaked into following item: %q", plain) } + + wantReversePages := []string{"mnopqr", "ghijkl", "abcdef"} + for page, want := range wantReversePages { + control.move(boundedPageUp) + if control.cursor != 0 { + t.Fatalf("reverse page %d selected cursor %d, want oversized predecessor 0", page, control.cursor) + } + var got strings.Builder + for _, row := range control.view().rows { + plain := ansi.Strip(row.text) + if !strings.HasPrefix(plain, "▶ ") { + t.Errorf("reverse page %d lost cursor marker: %q", page, plain) + } + got.WriteString(strings.TrimPrefix(plain, "▶ ")) + } + if got.String() != want { + t.Fatalf("reverse page %d content = %q, want immediately preceding segment %q", page, got.String(), want) + } + } } func TestMecatuiBoundedScrollCursor_Scenario1_ClampsContentAndDegenerateBounds(t *testing.T) { diff --git a/cmd/mecatui/ui/window.go b/cmd/mecatui/ui/window.go index 2aea696fc4..937687a64d 100644 --- a/cmd/mecatui/ui/window.go +++ b/cmd/mecatui/ui/window.go @@ -157,7 +157,9 @@ func (s *boundedScrollCursor) moveCursor(move boundedScrollMove, layout boundedR break } } - s.cursor, s.itemOffset = candidate, 0 + s.cursor = candidate + candidateHeight := layout.ends[candidate] - layout.starts[candidate] + s.itemOffset = lastBoundedPageOffset(candidateHeight, s.height) } s.cursor = clampBounded(s.cursor, len(s.items)) } @@ -244,15 +246,18 @@ func boundedWidthLines(line string, width int, policy boundedWidthPolicy) []stri if policy == boundedClip { return []string{ansi.Cut(line, 0, width)} } - total := ansi.StringWidth(line) - if total == 0 { - return []string{line} - } - lines := make([]string, 0, (total+width-1)/width) - for left := 0; left < total; left += width { - segment := ansi.Cut(line, left, min(left+width, total)) - segmentWidth := ansi.StringWidth(segment) - if segmentWidth > 0 && segmentWidth <= width { + wrapped := strings.Split(ansi.Hardwrap(line, width, true), "\n") + lines := make([]string, 0, len(wrapped)) + left := 0 + for _, row := range wrapped { + rowWidth := ansi.StringWidth(row) + if rowWidth == 0 { + lines = append(lines, row) + continue + } + segment := ansi.Cut(line, left, left+rowWidth) + left += rowWidth + if ansi.StringWidth(segment) <= width { lines = append(lines, segment) } } @@ -262,6 +267,13 @@ func boundedWidthLines(line string, width int, policy boundedWidthPolicy) []stri return lines } +func lastBoundedPageOffset(itemHeight, pageHeight int) int { + if itemHeight <= pageHeight || pageHeight <= 0 { + return 0 + } + return (itemHeight - 1) / pageHeight * pageHeight +} + func (s *boundedScrollCursor) clampState() { s.cursor = clampBounded(s.cursor, len(s.items)) if s.width <= 0 || s.height <= 0 { From 8b956126eec8a5d1463bc9e2be7ff64f35bcbee5 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 11:59:50 -0700 Subject: [PATCH 04/10] refactor(mecatui): split bounded viewport and list Co-authored-by: mecatl --- cmd/mecatui/ui/bounded_scroll_cursor_test.go | 343 ++++++++------ cmd/mecatui/ui/window.go | 451 ++++++++++++------- 2 files changed, 485 insertions(+), 309 deletions(-) diff --git a/cmd/mecatui/ui/bounded_scroll_cursor_test.go b/cmd/mecatui/ui/bounded_scroll_cursor_test.go index e7e06fdebb..b099a980fb 100644 --- a/cmd/mecatui/ui/bounded_scroll_cursor_test.go +++ b/cmd/mecatui/ui/bounded_scroll_cursor_test.go @@ -8,29 +8,20 @@ import ( ) func TestMecatuiBoundedScrollCursor_Scenario1_RespectsWidthAndHeight(t *testing.T) { - items := []boundedScrollItem{{ - text: "\x1b[31malpha界 beta-gamma\x1b[0m\nsecond physical line", - prefix: " ", - cursorPrefix: "▶ ", - }} for _, tc := range []struct { name string policy boundedWidthPolicy }{{"wrap", boundedWrap}, {"clip", boundedClip}} { t.Run(tc.name, func(t *testing.T) { - var control boundedScrollCursor - control.setBounds(8, 2, tc.policy) - control.setCursorItems(items, 0) - view := control.view() - if len(view.rows) > 2 { - t.Fatalf("rendered %d physical lines, want at most 2", len(view.rows)) - } - if len(view.rows) == 0 { - t.Fatal("positive bounds rendered no cursor rows") + var viewport boundedViewport + viewport.setGeometry(8, 2, 2, tc.policy) + view := viewport.view([]string{"\x1b[31malpha界 beta-gamma\x1b[0m", "second physical line"}) + if len(view.rows) == 0 || len(view.rows) > 2 { + t.Fatalf("rendered %d physical lines, want 1..2", len(view.rows)) } for i, row := range view.rows { - if got := ansi.StringWidth(ansi.Strip(row.text)); got > 8 { - t.Errorf("row %d width = %d, want <= 8: %q", i, got, ansi.Strip(row.text)) + if got := 2 + ansi.StringWidth(ansi.Strip(row)); got > 8 { + t.Errorf("row %d width with gutter = %d, want <= 8: %q", i, got, ansi.Strip(row)) } } }) @@ -38,174 +29,254 @@ func TestMecatuiBoundedScrollCursor_Scenario1_RespectsWidthAndHeight(t *testing. t.Run("wrap preserves styled wide graphemes", func(t *testing.T) { const content = "a界🙂e\u0301Z" - var control boundedScrollCursor - control.setBounds(4, 10, boundedWrap) // marker leaves two display cells for text - control.setCursorItems([]boundedScrollItem{{ - text: "\x1b[31m" + content + "\x1b[0m", cursorPrefix: "▶ ", - }}, 0) + var viewport boundedViewport + viewport.setGeometry(4, 10, 2, boundedWrap) + view := viewport.view([]string{"\x1b[31m" + content + "\x1b[0m"}) var rebuilt strings.Builder - for i, row := range control.view().rows { - plain := ansi.Strip(row.text) - if got := ansi.StringWidth(plain); got > 4 { - t.Fatalf("row %d width = %d, want <= 4: %q", i, got, plain) + for i, row := range view.rows { + if got := 2 + ansi.StringWidth(ansi.Strip(row)); got > 4 { + t.Fatalf("row %d width with gutter = %d, want <= 4", i, got) } - if !strings.Contains(row.text, "\x1b[31m") || !strings.HasSuffix(row.text, "\x1b[0m") { - t.Errorf("row %d did not preserve and close ANSI style: %q", i, row.text) + if !strings.Contains(row, "\x1b[31m") || !strings.HasSuffix(row, "\x1b[0m") { + t.Errorf("row %d did not preserve and close ANSI style: %q", i, row) } - rebuilt.WriteString(strings.TrimPrefix(plain, "▶ ")) + rebuilt.WriteString(ansi.Strip(row)) } if got := rebuilt.String(); got != content { t.Fatalf("wrapped content = %q, want byte-preserved graphemes %q", got, content) } }) + + for _, width := range []int{2, 1, 0, -1} { + var viewport boundedViewport + viewport.setGeometry(width, 2, 2, boundedWrap) + if got := viewport.view([]string{"content"}); len(got.rows) != 0 { + t.Errorf("width %d with gutter 2 rendered %d rows, want empty", width, len(got.rows)) + } + } } func TestMecatuiBoundedScrollCursor_Scenario1_MultilinePagingTargets(t *testing.T) { - items := []boundedScrollItem{ - {text: "zero-a\nzero-b", prefix: " ", cursorPrefix: "▶ "}, - {text: "one-a\none-b", prefix: " ", cursorPrefix: "▶ "}, - {text: "two", prefix: " ", cursorPrefix: "▶ "}, - {text: "three-a\nthree-b", prefix: " ", cursorPrefix: "▶ "}, + items := []boundedListItem{ + {id: "zero", text: "zero-a\nzero-b"}, + {id: "one", text: "one-a\none-b"}, + {id: "two", text: "two"}, + {id: "three", text: "three-a\nthree-b"}, } - var control boundedScrollCursor - control.setBounds(20, 3, boundedClip) - control.setCursorItems(items, 0) + var list boundedList + list.setGeometry(20, 3, 2, boundedClip) + list.setItems(items) - control.move(boundedPageDown) - if got := control.cursor; got != 2 { - t.Fatalf("Page Down cursor = %d, want first item beginning after the old window: 2", got) + list.move(boundedPageDown) + if list.cursor != 2 || list.cursorID != "two" { + t.Fatalf("Page Down cursor = (%d,%q), want first item after old window (2,two)", list.cursor, list.cursorID) } - control.move(boundedPageUp) - if got := control.cursor; got != 0 { - t.Fatalf("Page Up cursor = %d, want first item visible in the preceding physical window: 0", got) + list.move(boundedPageUp) + if list.cursor != 0 || list.cursorID != "zero" { + t.Fatalf("Page Up cursor = (%d,%q), want first item in preceding window (0,zero)", list.cursor, list.cursorID) } - control.move(boundedLineDown) - if got := control.cursor; got != 1 { - t.Fatalf("Down cursor = %d, want 1", got) + list.move(boundedLineDown) + if list.cursor != 1 || list.viewport.offset != 1 { + t.Fatalf("Down = cursor %d offset %d, want logical item 1 minimally revealed at offset 1", list.cursor, list.viewport.offset) } - control.move(boundedLineUp) - if got := control.cursor; got != 0 { - t.Fatalf("Up cursor = %d, want 0", got) + list.move(boundedLineUp) + if list.cursor != 0 || list.viewport.offset != 0 { + t.Fatalf("Up = cursor %d offset %d, want item 0 minimally revealed", list.cursor, list.viewport.offset) } - control.move(boundedEnd) - if got := control.cursor; got != 3 { - t.Fatalf("End cursor = %d, want 3", got) + list.move(boundedEnd) + if list.cursor != 3 || list.cursorID != "three" { + t.Fatalf("End cursor = (%d,%q), want last item", list.cursor, list.cursorID) } - control.move(boundedTop) - if got := control.cursor; got != 0 { - t.Fatalf("Top cursor = %d, want 0", got) + list.move(boundedTop) + if list.cursor != 0 || list.cursorID != "zero" { + t.Fatalf("Top cursor = (%d,%q), want first item", list.cursor, list.cursorID) } } func TestMecatuiBoundedScrollCursor_Scenario1_OversizedCursorItemReachable(t *testing.T) { - items := []boundedScrollItem{ - {text: "\x1b[31mabcdefghijklmnopqr\x1b[0m", prefix: " ", cursorPrefix: "▶ "}, - {text: "next", prefix: " ", cursorPrefix: "▶ "}, - } - var control boundedScrollCursor - control.setBounds(5, 2, boundedWrap) - control.setCursorItems(items, 0) - - var reached strings.Builder - wantAbove := []int{0, 2, 4} - wantBelow := []int{6, 4, 2} - for page := 0; page < 3; page++ { - view := control.view() - if len(view.rows) == 0 || len(view.rows) > 2 { - t.Fatalf("page %d has %d rows, want 1..2", page, len(view.rows)) + items := []boundedListItem{ + {id: "large", text: "\x1b[31mabcdefghijklmnopqr\x1b[0m"}, + {id: "next", text: "next"}, + } + var list boundedList + list.setGeometry(5, 2, 2, boundedWrap) + list.setItems(items) + + wantForward := []string{"abcdef", "ghijkl", "mnopqr"} + for page, want := range wantForward { + view := list.view() + if len(view.rows) != 2 { + t.Fatalf("forward page %d has %d rows, want 2", page, len(view.rows)) } - if view.above != wantAbove[page] || view.below != wantBelow[page] { - t.Fatalf("page %d overflow = (%d above, %d below), want (%d, %d)", - page, view.above, view.below, wantAbove[page], wantBelow[page]) + if list.cursorLine != page*2 || list.viewport.offset != page*2 { + t.Fatalf("forward page %d cursor line/viewport offset = %d/%d, want %d/%d", + page, list.cursorLine, list.viewport.offset, page*2, page*2) } - for _, row := range view.rows { - plain := ansi.Strip(row.text) - if !strings.HasPrefix(plain, "▶ ") { - t.Errorf("oversized selected segment lost cursor marker: %q", plain) + var content strings.Builder + for rowIndex, row := range view.rows { + if !row.selected || row.id != "large" || row.gutter != " " { + t.Errorf("forward page %d row %d metadata = %#v", page, rowIndex, row) } - if !strings.Contains(row.text, "\x1b[31m") { - t.Errorf("wrapped segment lost the item's ANSI style: %q", row.text) + if row.cursorMarker != (rowIndex == 0) { + t.Errorf("forward page %d row %d marker = %v, want %v", page, rowIndex, row.cursorMarker, rowIndex == 0) } - reached.WriteString(strings.TrimPrefix(plain, "▶ ")) - if !strings.HasSuffix(row.text, "\x1b[0m") { - t.Errorf("clipped styled segment can leak ANSI state: %q", row.text) + if strings.Contains(row.text, "\x1b[35m") { + t.Errorf("control applied caller-owned selected style: %q", row.text) } - } - if page < 2 { - if view.below == 0 { - t.Fatalf("page %d reported no content below before oversized item was exhausted", page) + if !strings.Contains(row.text, "\x1b[31m") || !strings.HasSuffix(row.text, "\x1b[0m") { + t.Errorf("forward page %d row %d leaks or loses ANSI style: %q", page, rowIndex, row.text) + } + prefix := row.gutter + if row.cursorMarker { + prefix = "▶ " } - control.move(boundedPageDown) - if control.cursor != 0 { - t.Fatalf("Page Down advanced cursor before oversized item was exhausted: %d", control.cursor) + rendered := prefix + row.text + if row.selected { + rendered = "\x1b[35m" + rendered + "\x1b[0m" + } + if !strings.Contains(rendered, "\x1b[35m") { + t.Error("caller could not independently apply selected styling") + } + content.WriteString(ansi.Strip(row.text)) + } + if content.String() != want { + t.Fatalf("forward page %d content = %q, want %q", page, content.String(), want) + } + if page+1 < len(wantForward) { + list.move(boundedPageDown) + if list.cursorID != "large" { + t.Fatalf("Page Down advanced before oversized item was exhausted: %q", list.cursorID) } } - } - if got := reached.String(); got != "abcdefghijklmnopqr" { - t.Fatalf("reachable oversized content = %q, want complete item", got) - } - control.move(boundedPageDown) - if control.cursor != 1 { - t.Fatalf("Page Down after final oversized segment cursor = %d, want 1", control.cursor) - } - if plain := ansi.Strip(control.view().rows[0].text); plain != "▶ nex" { - t.Fatalf("style or content leaked into following item: %q", plain) } - wantReversePages := []string{"mnopqr", "ghijkl", "abcdef"} - for page, want := range wantReversePages { - control.move(boundedPageUp) - if control.cursor != 0 { - t.Fatalf("reverse page %d selected cursor %d, want oversized predecessor 0", page, control.cursor) + list.move(boundedPageDown) + if list.cursorID != "next" { + t.Fatalf("Page Down after final segment selected %q, want next", list.cursorID) + } + for page, want := range []string{"mnopqr", "ghijkl", "abcdef"} { + list.move(boundedPageUp) + if list.cursorID != "large" { + t.Fatalf("reverse page %d selected %q, want large", page, list.cursorID) } - var got strings.Builder - for _, row := range control.view().rows { - plain := ansi.Strip(row.text) - if !strings.HasPrefix(plain, "▶ ") { - t.Errorf("reverse page %d lost cursor marker: %q", page, plain) + view := list.view() + var content strings.Builder + for rowIndex, row := range view.rows { + if row.cursorMarker != (rowIndex == 0) || row.gutter != " " { + t.Errorf("reverse page %d row %d marker/gutter = %v/%q", page, rowIndex, row.cursorMarker, row.gutter) } - got.WriteString(strings.TrimPrefix(plain, "▶ ")) + content.WriteString(ansi.Strip(row.text)) } - if got.String() != want { - t.Fatalf("reverse page %d content = %q, want immediately preceding segment %q", page, got.String(), want) + if content.String() != want { + t.Fatalf("reverse page %d content = %q, want %q", page, content.String(), want) } } } func TestMecatuiBoundedScrollCursor_Scenario1_ClampsContentAndDegenerateBounds(t *testing.T) { - var control boundedScrollCursor - control.setBounds(12, 2, boundedClip) - control.setBrowsing([]string{"zero", "one", "two", "three", "four"}) - control.move(boundedLineDown) - if got := control.view().above; got != 1 { - t.Fatalf("browsing line movement offset = %d, want 1", got) - } - control.move(boundedPageDown) - if got := control.view().above; got != 3 { - t.Fatalf("browsing page movement offset = %d, want clamped 3", got) + var viewport boundedViewport + viewport.setGeometry(12, 2, 0, boundedClip) + lines := []string{"zero", "one", "two", "three", "four"} + viewport.move(boundedLineDown, len(lines)) + viewport.move(boundedPageDown, len(lines)) + if viewport.offset != 3 { + t.Fatalf("browsing offset = %d, want clamped 3", viewport.offset) } - control.move(boundedEnd) - if got := control.view(); got.above != 3 || len(got.rows) != 2 { - t.Fatalf("browsing End view = above %d, rows %d; want final nonblank page", got.above, len(got.rows)) + if got := viewport.view([]string{"only"}); viewport.offset != 0 || len(got.rows) != 1 { + t.Fatalf("content shrink left stale offset/view: offset=%d view=%#v", viewport.offset, got) } - control.setBrowsing([]string{"only"}) - if got := control.view(); got.above != 0 || len(got.rows) != 1 || ansi.Strip(got.rows[0].text) != "only" { - t.Fatalf("content shrink left a blank/reachable stale page: %#v", got) + var list boundedList + list.setGeometry(12, 2, 2, boundedClip) + list.setItems([]boundedListItem{{id: "a", text: "a0\na1"}, {id: "b", text: "b0\nb1"}, {id: "c", text: "c"}}) + list.setCursor(1) + cursor, cursorID, cursorLine := list.cursor, list.cursorID, list.cursorLine + list.scroll(boundedLineDown) + if list.cursor != cursor || list.cursorID != cursorID || list.cursorLine != cursorLine { + t.Fatalf("viewport scroll moved cursor state from (%d,%q,%d) to (%d,%q,%d)", + cursor, cursorID, cursorLine, list.cursor, list.cursorID, list.cursorLine) } - control.setBounds(12, 5, boundedClip) - if got := control.view(); got.above != 0 || len(got.rows) != 1 { - t.Fatalf("geometry growth did not clamp browsing offset: %#v", got) + if list.viewport.offset == 0 { + t.Fatal("independent viewport scroll did not move physical offset") + } + list.move(boundedLineDown) + if list.cursorID != "c" { + t.Fatalf("cursor movement after independent scroll selected %q, want c", list.cursorID) } - control.setCursorItems([]boundedScrollItem{{text: "first"}, {text: "last"}}, 99) - if control.cursor != 1 || len(control.view().rows) == 0 { - t.Fatalf("cursor content change did not clamp to a reachable item: cursor=%d view=%#v", control.cursor, control.view()) + list.setGeometry(12, 2, 0, boundedClip) + if got := list.view(); len(got.rows) == 0 || got.rows[0].gutter != "" { + t.Fatalf("zero-gutter list geometry = %#v, want visible rows with no gutter", got) + } + list.setGeometry(2, 2, 2, boundedWrap) + if got := list.view(); len(got.rows) != 0 || got.above != 0 || got.below != 0 { + t.Fatalf("width smaller than gutter+content rendered %#v", got) } for _, bounds := range [][2]int{{0, 2}, {12, 0}, {-1, 2}, {12, -1}} { - control.setBounds(bounds[0], bounds[1], boundedWrap) - if got := control.view(); len(got.rows) != 0 || got.above != 0 || got.below != 0 { - t.Errorf("bounds %v rendered nonempty body/overflow: %#v", bounds, got) + list.setGeometry(bounds[0], bounds[1], 2, boundedWrap) + if got := list.view(); len(got.rows) != 0 { + t.Errorf("bounds %v rendered %d rows, want empty", bounds, len(got.rows)) } } } + +func TestMecatuiBoundedScrollCursor_Scenario1_RefreshPreservesSemanticAnchors(t *testing.T) { + var list boundedList + list.setGeometry(20, 2, 2, boundedClip) + list.setItems([]boundedListItem{ + {id: "a", text: "a0\na1"}, + {id: "b", text: "b0\nb1\nb2"}, + {id: "c", text: "c0"}, + }) + list.setCursor(1) // minimally reveals b with top anchor {a,1} + if list.viewport.offset != 1 { + t.Fatalf("initial top offset = %d, want 1", list.viewport.offset) + } + + list.setItems([]boundedListItem{ + {id: "x", text: "x0"}, + {id: "b", text: "b0\nb1\nb2\nb3"}, + {id: "a", text: "a0\na1\na2"}, + {id: "c", text: "c0"}, + }) + if list.cursorID != "b" || list.cursor != 1 { + t.Fatalf("refresh lost selected stable ID: cursor=(%d,%q)", list.cursor, list.cursorID) + } + if top := list.view().rows[0]; top.id != "a" || top.itemLine != 1 { + t.Fatalf("refresh top anchor = {%q,%d}, want {a,1}", top.id, top.itemLine) + } + + list.setItems([]boundedListItem{ + {id: "x", text: "x0"}, + {id: "d", text: "d0"}, + {id: "a", text: "a0\na1\na2"}, + {id: "c", text: "c0"}, + }) + if list.cursorID != "d" || list.cursor != 1 { + t.Fatalf("missing selected ID fallback = (%d,%q), want prior index replacement (1,d)", list.cursor, list.cursorID) + } + list.setItems([]boundedListItem{ + {id: "b", text: "b0\nb1\nb2\nb3"}, + {id: "x", text: "x0"}, + {id: "d", text: "d0"}, + {id: "a", text: "a0\na1\na2"}, + {id: "c", text: "c0"}, + }) + if list.cursorID != "d" || list.cursor != 2 { + t.Fatalf("reappearing old ID snapped selection back: cursor=(%d,%q), want (2,d)", list.cursor, list.cursorID) + } + oldOffset := list.viewport.offset + list.setItems([]boundedListItem{ + {id: "b", text: "b0\nb1\nb2\nb3"}, + {id: "x", text: "x0"}, + {id: "d", text: "d0"}, + {id: "c", text: "c0"}, + }) + wantOffset := clampScroll(oldOffset, 7, 2) + if list.viewport.offset != wantOffset { + t.Fatalf("missing top ID offset = %d, want physical fallback %d", list.viewport.offset, wantOffset) + } + if list.cursorID != "d" { + t.Fatalf("missing top ID disturbed selected ID: %q", list.cursorID) + } +} diff --git a/cmd/mecatui/ui/window.go b/cmd/mecatui/ui/window.go index 937687a64d..3f12224b85 100644 --- a/cmd/mecatui/ui/window.go +++ b/cmd/mecatui/ui/window.go @@ -16,10 +16,10 @@ const ( boundedClip ) -type boundedScrollMove uint8 +type boundedMove uint8 const ( - boundedLineUp boundedScrollMove = iota + boundedLineUp boundedMove = iota boundedLineDown boundedPageUp boundedPageDown @@ -27,224 +27,344 @@ const ( boundedEnd ) -// boundedScrollItem is one logical selectable item. The control applies the -// appropriate prefix to every physical line, so a paged segment never loses -// its cursor marker. -type boundedScrollItem struct { - text, prefix, cursorPrefix string +type boundedViewport struct { + width, height int + gutter int + policy boundedWidthPolicy + offset int } -type boundedRenderedRow struct { - text string - item, itemRow int +type boundedViewportView struct { + rows []string + above, below int } -type boundedScrollView struct { - rows []boundedRenderedRow - above, below int +func (v *boundedViewport) setGeometry(width, height, gutter int, policy boundedWidthPolicy) { + v.width, v.height, v.gutter, v.policy = width, height, max(0, gutter), policy + if !v.valid() { + v.offset = 0 + } } -// boundedScrollCursor owns one physical-line window. cursorMode selects logical -// item movement; without it offset is a physical browsing-line offset. All -// state is renderer-local and ephemeral. -type boundedScrollCursor struct { - width, height int - policy boundedWidthPolicy - items []boundedScrollItem - cursorMode bool - cursor int - itemOffset int - offset int +func (v *boundedViewport) valid() bool { + return v.width >= v.gutter+1 && v.height > 0 } -func (s *boundedScrollCursor) setBounds(width, height int, policy boundedWidthPolicy) { - s.width, s.height, s.policy = width, height, policy - s.clampState() +func (v *boundedViewport) contentWidth() int { + if !v.valid() { + return 0 + } + return v.width - v.gutter } -func (s *boundedScrollCursor) setBrowsing(lines []string) { - s.items = make([]boundedScrollItem, len(lines)) - for i, line := range lines { - s.items[i].text = line +func (v *boundedViewport) layout(lines []string) []string { + if !v.valid() { + return nil + } + var rows []string + for _, line := range lines { + for _, sourceLine := range strings.Split(line, "\n") { + rows = append(rows, boundedWidthLines(sourceLine, v.contentWidth(), v.policy)...) + } } - s.cursorMode = false - s.itemOffset = 0 - s.clampState() + return rows } -func (s *boundedScrollCursor) setCursorItems(items []boundedScrollItem, cursor int) { - s.items = append(s.items[:0], items...) - s.cursorMode = true - s.cursor = clampBounded(cursor, len(items)) - s.itemOffset, s.offset = 0, 0 - s.clampState() +func (v *boundedViewport) window(total int) renderedLineWindowBounds { + if !v.valid() { + return renderedLineWindowBounds{} + } + v.offset = clampScroll(v.offset, total, v.height) + return renderedLineWindow(v.offset, total, v.height) } -func (s *boundedScrollCursor) move(move boundedScrollMove) { - if s.width <= 0 || s.height <= 0 { - return +func (v *boundedViewport) view(lines []string) boundedViewportView { + rows := v.layout(lines) + if len(rows) == 0 { + v.offset = 0 + return boundedViewportView{} } - layout := s.layout() - if s.cursorMode { - s.moveCursor(move, layout) - } else { - s.moveBrowsing(move, len(layout.rows)) + window := v.window(len(rows)) + return boundedViewportView{ + rows: append([]string(nil), rows[window.start:window.end]...), + above: window.start, + below: len(rows) - window.end, } - s.clampState() } -func (s *boundedScrollCursor) moveBrowsing(move boundedScrollMove, total int) { +func (v *boundedViewport) move(move boundedMove, total int) { + if !v.valid() { + return + } switch move { case boundedLineUp: - s.offset-- + v.offset-- case boundedLineDown: - s.offset++ + v.offset++ case boundedPageUp: - s.offset -= s.height + v.offset -= v.height case boundedPageDown: - s.offset += s.height + v.offset += v.height case boundedTop: - s.offset = 0 + v.offset = 0 case boundedEnd: - s.offset = maxScrollOffset(total, s.height) + v.offset = maxScrollOffset(total, v.height) + } + v.offset = clampScroll(v.offset, total, v.height) +} + +type boundedListItem struct { + id, text string +} + +type boundedListRow struct { + text string + id string + itemIndex, itemLine int + selected, cursorMarker bool + gutter string +} + +type boundedListView struct { + rows []boundedListRow + above, below int +} + +type boundedList struct { + viewport boundedViewport + items []boundedListItem + + cursor int + cursorID string + cursorLine int +} + +type boundedListLayout struct { + rows []boundedListRow + starts, ends []int +} + +func (l *boundedList) setGeometry(width, height, gutter int, policy boundedWidthPolicy) { + l.viewport.setGeometry(width, height, gutter, policy) + layout := l.layout() + l.clamp(layout) +} + +func (l *boundedList) setItems(items []boundedListItem) { + oldLayout := l.layout() + oldOffset, oldCursor := l.viewport.offset, l.cursor + oldCursorID, oldCursorLine := l.cursorID, l.cursorLine + topID, topLine, haveTop := "", 0, false + if oldOffset >= 0 && oldOffset < len(oldLayout.rows) { + topID, topLine, haveTop = oldLayout.rows[oldOffset].id, oldLayout.rows[oldOffset].itemLine, true + } + + l.items = append(l.items[:0], items...) + newLayout := l.layout() + if len(l.items) == 0 { + l.cursor, l.cursorID, l.cursorLine, l.viewport.offset = 0, "", 0, 0 + return + } + if index := l.itemIndex(oldCursorID); index >= 0 { + l.cursor, l.cursorID = index, oldCursorID + itemHeight := newLayout.ends[index] - newLayout.starts[index] + l.cursorLine = min(max(0, oldCursorLine), max(0, itemHeight-1)) + } else { + l.cursor = clampBounded(oldCursor, len(l.items)) + l.cursorID, l.cursorLine = l.items[l.cursor].id, 0 + } + + if haveTop { + if index := l.itemIndex(topID); index >= 0 { + itemHeight := newLayout.ends[index] - newLayout.starts[index] + l.viewport.offset = newLayout.starts[index] + min(topLine, max(0, itemHeight-1)) + } else { + l.viewport.offset = oldOffset + } + } else { + l.viewport.offset = oldOffset + } + l.clamp(newLayout) +} + +func (l *boundedList) setCursor(index int) { + if len(l.items) == 0 { + l.cursor, l.cursorID, l.cursorLine = 0, "", 0 + return } + l.cursor = clampBounded(index, len(l.items)) + l.cursorID, l.cursorLine = l.items[l.cursor].id, 0 + layout := l.layout() + l.revealCursor(layout) } -func (s *boundedScrollCursor) moveCursor(move boundedScrollMove, layout boundedRowLayout) { - if len(s.items) == 0 { +func (l *boundedList) scroll(move boundedMove) { + layout := l.layout() + l.viewport.move(move, len(layout.rows)) +} + +func (l *boundedList) move(move boundedMove) { + layout := l.layout() + if len(l.items) == 0 || len(layout.rows) == 0 || !l.viewport.valid() { return } - s.cursor = clampBounded(s.cursor, len(s.items)) - itemHeight := layout.ends[s.cursor] - layout.starts[s.cursor] + l.clamp(layout) + itemHeight := layout.ends[l.cursor] - layout.starts[l.cursor] switch move { case boundedLineUp: - s.cursor-- - s.itemOffset = 0 + l.setCursor(l.cursor - 1) case boundedLineDown: - s.cursor++ - s.itemOffset = 0 + l.setCursor(l.cursor + 1) case boundedTop: - s.cursor, s.itemOffset = 0, 0 + l.setCursor(0) case boundedEnd: - s.cursor, s.itemOffset = len(s.items)-1, 0 + l.setCursor(len(l.items) - 1) case boundedPageDown: - if itemHeight > s.height && s.itemOffset+s.height < itemHeight { - s.itemOffset += s.height - return - } - window := s.cursorWindow(layout) - for item, start := range layout.starts { - if start >= window.end { - s.cursor, s.itemOffset = item, 0 - return - } - } - s.cursor, s.itemOffset = len(s.items)-1, 0 + l.pageDown(layout, itemHeight) case boundedPageUp: - if itemHeight > s.height && s.itemOffset > 0 { - s.itemOffset = max(0, s.itemOffset-s.height) + l.pageUp(layout, itemHeight) + } + l.clamp(l.layout()) +} + +func (l *boundedList) pageDown(layout boundedListLayout, itemHeight int) { + if itemHeight > l.viewport.height && l.cursorLine+l.viewport.height < itemHeight { + l.cursorLine += l.viewport.height + l.viewport.offset = layout.starts[l.cursor] + l.cursorLine + return + } + window := l.viewport.window(len(layout.rows)) + for index, start := range layout.starts { + if start >= window.end { + l.cursor, l.cursorID, l.cursorLine = index, l.items[index].id, 0 + l.viewport.offset = start return } - window := s.cursorWindow(layout) - target := max(0, window.start-s.height) - candidate := 0 - for item := range layout.starts { - if layout.starts[item] >= window.start { - break - } - if layout.ends[item] > target { - candidate = item - break - } - } - s.cursor = candidate - candidateHeight := layout.ends[candidate] - layout.starts[candidate] - s.itemOffset = lastBoundedPageOffset(candidateHeight, s.height) } - s.cursor = clampBounded(s.cursor, len(s.items)) + l.setCursor(len(l.items) - 1) } -func (s *boundedScrollCursor) view() boundedScrollView { - if s.width <= 0 || s.height <= 0 { - return boundedScrollView{} +func (l *boundedList) pageUp(layout boundedListLayout, itemHeight int) { + if itemHeight > l.viewport.height && l.cursorLine > 0 { + l.cursorLine = max(0, l.cursorLine-l.viewport.height) + l.viewport.offset = layout.starts[l.cursor] + l.cursorLine + return } - s.clampState() - layout := s.layout() - if len(layout.rows) == 0 { - return boundedScrollView{} + window := l.viewport.window(len(layout.rows)) + target := max(0, window.start-l.viewport.height) + candidate := 0 + for index := range layout.starts { + if layout.starts[index] > target { + break + } + candidate = index + if target < layout.ends[index] { + break + } } - start, end := 0, 0 - if s.cursorMode { - window := s.cursorWindow(layout) - start, end = window.start, window.end + l.cursor, l.cursorID = candidate, l.items[candidate].id + candidateHeight := layout.ends[candidate] - layout.starts[candidate] + if candidateHeight > l.viewport.height { + if layout.ends[candidate] == window.start { + l.cursorLine = lastBoundedPageOffset(candidateHeight, l.viewport.height) + } else { + l.cursorLine = (target - layout.starts[candidate]) / l.viewport.height * l.viewport.height + } + l.viewport.offset = layout.starts[candidate] + l.cursorLine } else { - window := renderedLineWindow(s.offset, len(layout.rows), s.height) - start, end = window.start, window.end + l.cursorLine = 0 + l.viewport.offset = target } - return boundedScrollView{ - rows: append([]boundedRenderedRow(nil), layout.rows[start:end]...), - above: start, - below: len(layout.rows) - end, - } -} - -type boundedRowLayout struct { - rows []boundedRenderedRow - starts, ends []int } -type boundedPhysicalWindow struct{ start, end int } - -func (s *boundedScrollCursor) cursorWindow(layout boundedRowLayout) boundedPhysicalWindow { - if len(layout.rows) == 0 || len(s.items) == 0 { - return boundedPhysicalWindow{} +func (l *boundedList) view() boundedListView { + layout := l.layout() + if len(layout.rows) == 0 { + l.viewport.offset = 0 + return boundedListView{} } - cursor := clampBounded(s.cursor, len(s.items)) - itemStart, itemEnd := layout.starts[cursor], layout.ends[cursor] - if itemEnd-itemStart > s.height { - start := itemStart + min(s.itemOffset, itemEnd-itemStart-1) - return boundedPhysicalWindow{start: start, end: min(start+s.height, itemEnd)} + l.clamp(layout) + window := l.viewport.window(len(layout.rows)) + rows := append([]boundedListRow(nil), layout.rows[window.start:window.end]...) + marked := false + for index := range rows { + rows[index].selected = rows[index].id == l.cursorID + if rows[index].selected && !marked { + rows[index].cursorMarker = true + marked = true + } } - start := min(itemStart, max(0, len(layout.rows)-s.height)) - return boundedPhysicalWindow{start: start, end: min(start+s.height, len(layout.rows))} + return boundedListView{rows: rows, above: window.start, below: len(layout.rows) - window.end} } -func (s *boundedScrollCursor) layout() boundedRowLayout { - layout := boundedRowLayout{ - starts: make([]int, len(s.items)), - ends: make([]int, len(s.items)), +func (l *boundedList) layout() boundedListLayout { + layout := boundedListLayout{ + starts: make([]int, len(l.items)), + ends: make([]int, len(l.items)), } - for item, entry := range s.items { - layout.starts[item] = len(layout.rows) - prefix := entry.prefix - if s.cursorMode && item == clampBounded(s.cursor, len(s.items)) { - prefix = entry.cursorPrefix - } - prefixWidth := ansi.StringWidth(ansi.Strip(prefix)) - textWidth := max(0, s.width-prefixWidth) - for _, sourceLine := range strings.Split(entry.text, "\n") { - physical := boundedWidthLines(sourceLine, textWidth, s.policy) - for _, line := range physical { - text := prefix + line - if ansi.StringWidth(ansi.Strip(text)) > s.width { - text = ansi.Truncate(text, s.width, "") - } - layout.rows = append(layout.rows, boundedRenderedRow{ - text: text + "\x1b[0m", item: item, itemRow: len(layout.rows) - layout.starts[item], + if !l.viewport.valid() { + return layout + } + gutter := strings.Repeat(" ", l.viewport.gutter) + for itemIndex, item := range l.items { + layout.starts[itemIndex] = len(layout.rows) + for _, sourceLine := range strings.Split(item.text, "\n") { + for _, text := range boundedWidthLines(sourceLine, l.viewport.contentWidth(), l.viewport.policy) { + layout.rows = append(layout.rows, boundedListRow{ + text: text, id: item.id, itemIndex: itemIndex, + itemLine: len(layout.rows) - layout.starts[itemIndex], gutter: gutter, }) } } - layout.ends[item] = len(layout.rows) + layout.ends[itemIndex] = len(layout.rows) } return layout } +func (l *boundedList) revealCursor(layout boundedListLayout) { + if len(l.items) == 0 || !l.viewport.valid() { + return + } + line := layout.starts[l.cursor] + l.cursorLine + if line < l.viewport.offset { + l.viewport.offset = line + } else if line >= l.viewport.offset+l.viewport.height { + l.viewport.offset = line - l.viewport.height + 1 + } + l.viewport.offset = clampScroll(l.viewport.offset, len(layout.rows), l.viewport.height) +} + +func (l *boundedList) clamp(layout boundedListLayout) { + if len(l.items) == 0 || len(layout.rows) == 0 || !l.viewport.valid() { + l.viewport.offset = 0 + return + } + l.cursor = clampBounded(l.cursor, len(l.items)) + if l.cursorID == "" || l.itemIndex(l.cursorID) < 0 { + l.cursorID = l.items[l.cursor].id + } else { + l.cursor = l.itemIndex(l.cursorID) + } + itemHeight := layout.ends[l.cursor] - layout.starts[l.cursor] + l.cursorLine = min(max(0, l.cursorLine), max(0, itemHeight-1)) + l.viewport.offset = clampScroll(l.viewport.offset, len(layout.rows), l.viewport.height) +} + +func (l *boundedList) itemIndex(id string) int { + for index := range l.items { + if l.items[index].id == id { + return index + } + } + return -1 +} + func boundedWidthLines(line string, width int, policy boundedWidthPolicy) []string { if width <= 0 { - return []string{""} + return nil } if policy == boundedClip { - return []string{ansi.Cut(line, 0, width)} + return []string{ansi.Cut(line, 0, width) + "\x1b[0m"} } wrapped := strings.Split(ansi.Hardwrap(line, width, true), "\n") lines := make([]string, 0, len(wrapped)) @@ -252,17 +372,17 @@ func boundedWidthLines(line string, width int, policy boundedWidthPolicy) []stri for _, row := range wrapped { rowWidth := ansi.StringWidth(row) if rowWidth == 0 { - lines = append(lines, row) + lines = append(lines, row+"\x1b[0m") continue } segment := ansi.Cut(line, left, left+rowWidth) left += rowWidth if ansi.StringWidth(segment) <= width { - lines = append(lines, segment) + lines = append(lines, segment+"\x1b[0m") } } if len(lines) == 0 { - return []string{""} + return []string{"\x1b[0m"} } return lines } @@ -274,21 +394,6 @@ func lastBoundedPageOffset(itemHeight, pageHeight int) int { return (itemHeight - 1) / pageHeight * pageHeight } -func (s *boundedScrollCursor) clampState() { - s.cursor = clampBounded(s.cursor, len(s.items)) - if s.width <= 0 || s.height <= 0 { - s.offset, s.itemOffset = 0, 0 - return - } - layout := s.layout() - if s.cursorMode && len(s.items) > 0 { - itemHeight := layout.ends[s.cursor] - layout.starts[s.cursor] - s.itemOffset = min(max(0, s.itemOffset), max(0, itemHeight-1)) - } else { - s.offset = clampScroll(s.offset, len(layout.rows), s.height) - } -} - func clampBounded(value, count int) int { if count <= 0 || value < 0 { return 0 From 04e70d21d5eb5098df35c1c565708cc611f661a5 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 12:56:13 -0700 Subject: [PATCH 05/10] feat(mecatui): adopt bounded browsing in agents and models Co-authored-by: mecatl --- cmd/mecatui/ui/agents_overlay.go | 291 ++++++++++------ cmd/mecatui/ui/agents_overlay_test.go | 43 +-- .../ui/bounded_scroll_adoption_test.go | 314 ++++++++++++++++++ .../ui/card_layout_focus_scroll_test.go | 2 +- .../ui/delegation_overlay_chrome_test.go | 4 +- cmd/mecatui/ui/models_surface.go | 241 +++++++++++++- cmd/mecatui/ui/models_test.go | 4 +- cmd/mecatui/ui/parallel_overlay_test.go | 11 +- cmd/mecatui/ui/team.go | 22 +- cmd/mecatui/ui/team_overlay_test.go | 43 +-- .../ui/testdata/agents_teams_tab.golden | 4 +- .../ui/testdata/models_codex_healthy.golden | 34 +- .../testdata/models_codex_unauthorized.golden | 30 +- .../ui/testdata/models_disabled.golden | 32 +- cmd/mecatui/ui/testdata/models_empty.golden | 30 +- .../ui/testdata/models_gateway_empty.golden | 30 +- cmd/mecatui/ui/testdata/models_nomatch.golden | 30 +- .../ui/testdata/models_scrolled.golden | 24 +- .../ui/testdata/parallel_group_focus.golden | 4 +- .../ui/testdata/subagent_roster.golden | 6 +- .../subagent_roster_background.golden | 2 +- .../ui/testdata/team_focus_windowed.golden | 8 +- cmd/mecatui/ui/testdata/team_roster.golden | 4 +- .../ui/testdata/team_roster_windowed.golden | 18 +- cmd/mecatui/ui/update.go | 3 + cmd/mecatui/ui/window.go | 15 +- docs/tui.md | 15 +- user-docs/features/choose-models.md | 10 +- user-docs/mecatui/keybindings.md | 3 +- 29 files changed, 957 insertions(+), 320 deletions(-) create mode 100644 cmd/mecatui/ui/bounded_scroll_adoption_test.go diff --git a/cmd/mecatui/ui/agents_overlay.go b/cmd/mecatui/ui/agents_overlay.go index 6ca5a8cfcb..1156119c93 100644 --- a/cmd/mecatui/ui/agents_overlay.go +++ b/cmd/mecatui/ui/agents_overlay.go @@ -47,9 +47,11 @@ const ( // conversation each render). Focus is keyed by ParentCallID (not index) so a group list // that grows under the overlay can't shift focus onto the wrong group. type parallelState struct { - view parallelView - cursor int // selected row in the group roster (index into the group order) - group string // the focused group's ParentCallID (parallelGroupView) + view parallelView + cursor int // selected row in the group roster (index into the group order) + group string // the focused group's ParentCallID (parallelGroupView) + roster boundedList + branches boundedList // branchCursor is the selected BRANCH row inside the focused group (an index into // the by-index render order, branchesByIndex) — the selection the `x` cancel key // addresses. Reset on focus enter/exit. @@ -77,6 +79,8 @@ type subagentState struct { cursor int // selected row in the fleet roster (index into the fleet order) child string // the focused child's ChildID (subagentFocus) scroll int // rendered-line offset in the focused trace + roster boundedList + detail boundedViewport } // openAgents opens the unified f6 agents overlay. It picks the CONTEXT-SENSITIVE @@ -293,12 +297,12 @@ func (m Model) cancelChildByID(childID, what string) (tea.Model, tea.Cmd) { func (m Model) onSubagentRosterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { fleet := m.conv.subagentFleet n := len(fleet) - page := m.subagentRosterPageSize(fleet) if key.Matches(msg, m.keys.Close) { return m.closeAgents() } - if next, handled := navigateRosterCursor(msg, m.keys, m.subagents.cursor, n, page); handled { - m.subagents.cursor = next + th, hk, width, _ := m.agentsListGeometry() + if next, control, handled := m.navigateAgentsList(msg, subagentSelectableList(th, m.subagents, fleet, hk, width)); handled { + m.subagents.cursor, m.subagents.roster = next, control return m, nil } switch { @@ -339,20 +343,18 @@ func (m Model) onParallelKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { // back to the group roster. func (m Model) onParallelGroupKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { g := findParallelGroup(m.conv.parallelGroups, m.parallel.group) - n := 0 - if g != nil { - n = len(g.branches) - } if key.Matches(msg, m.keys.Close) { m.parallel.view = parallelRoster m.parallel.group = "" m.parallel.branchCursor = 0 return m, nil } - page := m.parallelBranchPageSize(g) - if next, handled := navigateRosterCursor(msg, m.keys, m.parallel.branchCursor, n, page); handled { - m.parallel.branchCursor = next - return m, nil + if g != nil { + th, hk, width, _ := m.agentsListGeometry() + if next, control, handled := m.navigateAgentsList(msg, parallelBranchSelectableList(th, m.parallel, g, hk, width)); handled { + m.parallel.branchCursor, m.parallel.branches = next, control + return m, nil + } } if key.Matches(msg, m.keys.CancelChild) { if g == nil { @@ -378,12 +380,12 @@ func (m Model) onParallelGroupKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) onParallelRosterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { groups := m.conv.parallelGroups n := len(groups) - page := m.parallelRosterPageSize(groups) if key.Matches(msg, m.keys.Close) { return m.closeAgents() } - if next, handled := navigateRosterCursor(msg, m.keys, m.parallel.cursor, n, page); handled { - m.parallel.cursor = next + th, hk, width, _ := m.agentsListGeometry() + if next, control, handled := m.navigateAgentsList(msg, parallelSelectableList(th, m.parallel, groups, hk, width)); handled { + m.parallel.cursor, m.parallel.roster = next, control return m, nil } if key.Matches(msg, m.keys.Choose) { @@ -398,6 +400,30 @@ func (m Model) onParallelRosterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) navigateAgentsList(msg tea.KeyPressMsg, list agentsSelectableList) (int, boundedList, bool) { + var move boundedMove + switch { + case key.Matches(msg, m.keys.Up): + move = boundedLineUp + case key.Matches(msg, m.keys.Down): + move = boundedLineDown + case key.Matches(msg, m.keys.ScrollU): + move = boundedPageUp + case key.Matches(msg, m.keys.ScrollD): + move = boundedPageDown + case key.Matches(msg, m.keys.JumpTop): + move = boundedTop + case key.Matches(msg, m.keys.JumpEnd): + move = boundedEnd + default: + return list.cursor, list.control, false + } + th, _, _, height := m.agentsListGeometry() + control, _, _ := list.configuredControl(th, height) + control.move(move) + return control.cursor, control, true +} + func agentsListPageSize(th theme.Theme, height int, list agentsSelectableList) int { w := list.window(th, height) return max(1, w.end-w.start) @@ -423,12 +449,58 @@ func (m Model) parallelRosterPageSize(groups []parallelGroup) int { return agentsListPageSize(th, height, parallelSelectableList(th, m.parallel, groups, hk, width)) } -func (m Model) parallelBranchPageSize(g *parallelGroup) int { - if g == nil { - return 1 +func (m Model) onAgentsWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { + // Agents predates the modal surface lifecycle. Keep this narrow owner branch + // until that overlay migrates; it must run before conversation scrolling. + if m.height > 0 && m.height < 24 { + return m, nil } th, hk, width, height := m.agentsListGeometry() - return agentsListPageSize(th, height, parallelBranchSelectableList(th, m.parallel, g, hk, width)) + if height == 0 { + return m, nil + } + move := boundedLineDown + if msg.Mouse().Button == tea.MouseWheelUp { + move = boundedLineUp + } + scrollList := func(list agentsSelectableList) boundedList { + control, _, _ := list.configuredControl(th, height) + control.scroll(move) + return control + } + switch m.agentsTab { + case tabSubagents: + if m.subagents.view == subagentFocus { + next, _ := m.navigateAgentsDetail(tea.KeyPressMsg{Code: map[bool]rune{true: tea.KeyUp, false: tea.KeyDown}[move == boundedLineUp]}, m.subagents.scroll) + m.subagents.scroll = next + m.subagents.detail.offset = next + } else { + m.subagents.roster = scrollList(subagentSelectableList(th, m.subagents, m.conv.subagentFleet, hk, width)) + m.subagents.cursor = m.subagents.roster.cursor + } + case tabParallel: + if m.parallel.view == parallelGroupView { + if g := findParallelGroup(m.conv.parallelGroups, m.parallel.group); g != nil { + m.parallel.branches = scrollList(parallelBranchSelectableList(th, m.parallel, g, hk, width)) + m.parallel.branchCursor = m.parallel.branches.cursor + } + } else { + m.parallel.roster = scrollList(parallelSelectableList(th, m.parallel, m.conv.parallelGroups, hk, width)) + m.parallel.cursor = m.parallel.roster.cursor + } + case tabTeams: + if m.team.view == teamRoster { + if b := m.conv.latestTeamBlock(); b != nil { + m.team.roster = scrollList(teamSelectableList(th, m.team, b, hk, width)) + m.team.cursor = m.team.roster.cursor + } + } else { + next, _ := m.navigateAgentsDetail(tea.KeyPressMsg{Code: map[bool]rune{true: tea.KeyUp, false: tea.KeyDown}[move == boundedLineUp]}, m.team.scroll) + m.team.scroll = next + m.team.detail.offset = next + } + } + return m, nil } func (m Model) navigateAgentsDetail(msg tea.KeyPressMsg, scroll int) (int, bool) { @@ -577,7 +649,7 @@ func newAgentsOverlayLayout(th theme.Theme, tab agentsTab, width, height int) ag bounded: height > 0, } if layout.bounded { - layout.bodyCapacity = height - layout.frameRows - lipgloss.Height(layout.tabStrip) + layout.bodyCapacity = height - layout.frameRows - lipgloss.Height(layout.tabStrip) - 2 } return layout } @@ -658,6 +730,8 @@ func essentialSubagentBody(th theme.Theme, st subagentState, fleet []subagentLan if len(lane.trace) > 0 { body.extra = append(body.extra, line(th.Style("muted"), " ", fmt.Sprintf("… +%d more lines", len(lane.trace)))) } + } else { + body.selected = line(th.Style("muted"), "", "subagent #"+shortChildID(st.child)+" is no longer in the fleet") } return body } @@ -676,6 +750,7 @@ func essentialParallelBody(th theme.Theme, st parallelState, groups []parallelGr body.footer = line(th.Style("muted"), "", focusBackHint(hk)) group := findParallelGroup(groups, st.group) if group == nil { + body.selected = line(th.Style("muted"), "", "this parallel run is no longer tracked") return body } body.title = line(th.Style("askTitle"), "", "parallel · join="+parallelJoinMode(group.join)) @@ -705,6 +780,8 @@ func essentialTeamBody(th theme.Theme, st teamState, b *block, hk helpKeys, line if len(lane.trace) > 0 { body.extra = append(body.extra, line(th.Style("muted"), " ", fmt.Sprintf("… +%d more lines", len(lane.trace)))) } + } else { + body.selected = line(th.Style("muted"), "", "member "+sanitizeTerminal(st.member)+" is no longer in the roster") } case teamTasks: body.title = line(th.Style("askTitle"), "", "tasks") @@ -935,91 +1012,97 @@ type agentsCursorWindow struct { type agentsSelectableList struct { header, footer string rows []string - rowHeights []int + ids []string cursor int muted lipgloss.Style noun string + bodyWidth int + control boundedList } -func (l agentsSelectableList) withRowHeights() agentsSelectableList { - l.rowHeights = make([]int, len(l.rows)+1) - for i, row := range l.rows { - l.rowHeights[i+1] = l.rowHeights[i] + lipgloss.Height(row) +func (l agentsSelectableList) configuredControl(th theme.Theme, height int) (boundedList, int, bool) { + capacity := 1 << 20 + if height > 0 { + capacity = height - th.Style("askCard").GetVerticalFrameSize() - + lipgloss.Height(l.header) - lipgloss.Height(l.footer) - 2 } - return l -} - -func (l agentsSelectableList) window(th theme.Theme, height int) agentsCursorWindow { - if len(l.rows) == 0 { - return agentsCursorWindow{} + items := make([]boundedListItem, len(l.rows)) + for i := range l.rows { + id := fmt.Sprintf("row-%d", i) + if i < len(l.ids) && l.ids[i] != "" { + id = l.ids[i] + } + text := strings.ReplaceAll(l.rows[i], "\n ", "\n ") + items[i] = boundedListItem{id: id, text: text} } - if height <= 0 { - return agentsCursorWindow{end: len(l.rows)} + hadCursor := l.control.cursorID != "" + width := l.bodyWidth + if width <= 0 { + width = 1 << 20 } - capacity := height - th.Style("askCard").GetVerticalFrameSize() - - lipgloss.Height(l.header) - lipgloss.Height(l.footer) - 2 - heights := l.rowHeights - if len(heights) != len(l.rows)+1 { - heights = l.withRowHeights().rowHeights + l.control.setGeometry(width, max(0, capacity), 2, boundedWrap) + l.control.setItems(items) + reveal := !hadCursor || l.control.reveal + if !hadCursor { + l.control.setCursor(l.cursor) } - return physicalCursorWindow(l.rows, heights, clampCursor(l.cursor, len(l.rows)), capacity) + return l.control, max(0, capacity), reveal } -func (l agentsSelectableList) render(th theme.Theme, height int) string { - w := l.window(th, height) - var middle []string - if w.above > 0 { - middle = append(middle, renderDelegationRows(l.muted, " ", fmt.Sprintf("· +%d %s above", w.above, l.noun), 0)) - } - middle = append(middle, l.rows[w.start:w.end]...) - if w.below > 0 { - middle = append(middle, renderDelegationRows(l.muted, " ", fmt.Sprintf("· +%d %s below", w.below, l.noun), 0)) +func (l agentsSelectableList) boundedView(th theme.Theme, height int) boundedListView { + if len(l.rows) == 0 { + return boundedListView{} } - return l.header + "\n\n" + strings.Join(middle, "\n") + "\n\n" + l.footer + control, capacity, reveal := l.configuredControl(th, height) + return boundedListViewWithIndicators(&control, capacity, reveal) } -// physicalCursorWindow finds the largest contiguous logical-row window that fits -// the physical-line budget. Indicator rows are charged in the same calculation. -func physicalCursorWindow(rows []string, heights []int, cursor, capacity int) agentsCursorWindow { - total := len(rows) - if total == 0 { +func (l agentsSelectableList) window(th theme.Theme, height int) agentsCursorWindow { + view := l.boundedView(th, height) + if len(view.rows) == 0 { return agentsCursorWindow{} } - cursor = clampCursor(cursor, total) - if capacity <= 0 { - return agentsCursorWindow{start: cursor, end: cursor + 1, above: cursor, below: total - cursor - 1} - } - best := agentsCursorWindow{start: cursor, end: cursor + 1, above: cursor, below: total - cursor - 1} - bestCount := 0 - bestBalance := total * 2 - for start := 0; start <= cursor; start++ { - for end := cursor + 1; end <= total; end++ { - cost := heights[end] - heights[start] - if start > 0 { - cost++ - } - if end < total { - cost++ - } - if cost > capacity { - break + start := view.rows[0].itemIndex + end := view.rows[len(view.rows)-1].itemIndex + 1 + return agentsCursorWindow{start: start, end: end, above: start, below: len(l.rows) - end} +} + +func (l agentsSelectableList) render(th theme.Theme, height int) string { + view := l.boundedView(th, height) + var middle []string + if len(view.rows) == 0 && len(l.rows) > 0 { + middle = append(middle, th.Style("spinner").Render("▶ "+l.rows[clampCursor(l.cursor, len(l.rows))])) + } + if view.above > 0 { + count, noun := view.rows[0].itemIndex, l.noun + if count == 0 { + count, noun = view.above, "lines" + } + middle = append(middle, renderDelegationRows(l.muted, " ", fmt.Sprintf("· +%d %s above", count, noun), 0)) + } + middle = append(middle, func() []string { + rows := make([]string, 0, len(view.rows)) + for _, row := range view.rows { + prefix := row.gutter + if row.cursorMarker { + prefix = "▶ " } - count := end - start - balance := absInt((cursor-start)*2 - (count - 1)) - if count > bestCount || count == bestCount && balance < bestBalance { - best = agentsCursorWindow{start: start, end: end, above: start, below: total - end} - bestCount, bestBalance = count, balance + style := l.muted + if row.selected { + style = th.Style("spinner") } + rows = append(rows, style.Render(prefix+row.text)) } + return rows + }()...) + if view.below > 0 { + count, noun := len(l.rows)-view.rows[len(view.rows)-1].itemIndex-1, l.noun + if count == 0 { + count, noun = view.below, "lines" + } + middle = append(middle, renderDelegationRows(l.muted, " ", fmt.Sprintf("· +%d %s below", count, noun), 0)) } - return best -} - -func absInt(n int) int { - if n < 0 { - return -n - } - return n + return l.header + "\n\n" + strings.Join(middle, "\n") + "\n\n" + l.footer } func subagentSelectableList(th theme.Theme, st subagentState, fleet []subagentLane, hk helpKeys, bodyWidth int) agentsSelectableList { @@ -1029,13 +1112,11 @@ func subagentSelectableList(th theme.Theme, st subagentState, fleet []subagentLa header: renderDelegationRows(th.Style("askTitle"), "", subagentRosterHeader(running, done), bodyWidth), footer: renderDynamicCardChromeLine(muted, "", hk.navUp+"/"+hk.navDown+" select · "+hk.scroll+" · "+hk.jumpTop+"/"+hk.jumpEnd+" · "+hk.choose+" focus · "+hk.cancelChild+" cancel · "+agentsEmptyHint(hk), bodyWidth), cursor: clampCursor(st.cursor, len(fleet)), muted: muted, noun: "rows", + bodyWidth: bodyWidth, control: st.roster, } for row := range fleet { - style, prefix := muted, " " - if row == list.cursor { - style, prefix = th.Style("spinner"), "▶ " - } - list.rows = append(list.rows, renderSubagentRosterRow(style, prefix, &fleet[row], bodyWidth)) + list.ids = append(list.ids, fleet[row].childID) + list.rows = append(list.rows, subagentRosterText(&fleet[row], bodyWidth, 2)) } return list } @@ -1386,13 +1467,11 @@ func parallelSelectableList(th theme.Theme, st parallelState, groups []parallelG header: renderDelegationRows(th.Style("askTitle"), "", fmt.Sprintf("parallel · %d running · %d done", running, done), bodyWidth), footer: renderCardChromeSegments(muted, []string{hk.closeOnly + " close", hk.navUp + "/" + hk.navDown + " select", hk.choose + " focus", hk.nextTab + " switch", hk.scroll + " page", hk.jumpTopFull + "·" + hk.jumpEndFull + " first/last"}, bodyWidth), cursor: clampCursor(st.cursor, len(groups)), muted: muted, noun: "rows", + bodyWidth: bodyWidth, control: st.roster, } for row := range groups { - style, prefix := muted, " " - if row == list.cursor { - style, prefix = th.Style("spinner"), "▶ " - } - list.rows = append(list.rows, renderDelegationRows(style, prefix, parallelRosterLine(&groups[row]), bodyWidth)) + list.ids = append(list.ids, groups[row].parentCallID) + list.rows = append(list.rows, parallelRosterLine(&groups[row])) } return list } @@ -1489,22 +1568,30 @@ func parallelBranchSelectableList(th theme.Theme, st parallelState, g *parallelG ordered := branchesByIndex(g.branches) cursor := clampCursor(st.branchCursor, len(ordered)) cancellable := false - list := agentsSelectableList{header: header, cursor: cursor, muted: muted, noun: "branches"} - r := &renderer{th: th, marks: hk, traceWidth: max(1, bodyWidth-lipgloss.Width(parallelBranchTraceGutter))} + list := agentsSelectableList{header: header, cursor: cursor, muted: muted, noun: "branches", bodyWidth: bodyWidth, control: st.branches} + r := &renderer{th: th, marks: hk, traceWidth: max(1, bodyWidth-lipgloss.Width(parallelBranchTraceGutter)-2)} for i := range ordered { br := &ordered[i] if !br.done && br.childID != "" { cancellable = true } - row := strings.TrimSuffix(renderParallelBranchRow(th, br, g.winner, i == cursor, bodyWidth), "\n") + winner := "" + if br.index == g.winner { + winner = "★ " + } + row := winner + parallelBranchText(br, bodyWidth, 2+lipgloss.Width(winner)) if trace := r.renderTrace(br.trace); trace != "" { row += "\n" + indentParallelBranchTrace(trace, bodyWidth) } + list.ids = append(list.ids, br.childID) + if br.childID == "" { + list.ids[len(list.ids)-1] = fmt.Sprintf("branch-%d", br.index) + } list.rows = append(list.rows, row) } - list.footer = hk.navUp + "/" + hk.navDown + " select · " + hk.scroll + " page · " + hk.jumpTop + "/" + hk.jumpEnd + " first/last · " + focusBackHint(hk) + list.footer = focusBackHint(hk) + " · " + hk.navUp + "/" + hk.navDown + " select · " + hk.scroll + " page · " + hk.jumpTop + "/" + hk.jumpEnd + " first/last" if cancellable { - list.footer = hk.navUp + "/" + hk.navDown + " select · " + hk.scroll + " page · " + hk.jumpTop + "/" + hk.jumpEnd + " first/last · " + hk.cancelChild + " cancel · " + focusBackHint(hk) + list.footer = focusBackHint(hk) + " · " + hk.cancelChild + " cancel · " + hk.navUp + "/" + hk.navDown + " select · " + hk.scroll + " page · " + hk.jumpTop + "/" + hk.jumpEnd + " first/last" } if len(ordered) == 0 { list.footer = focusBackHint(hk) diff --git a/cmd/mecatui/ui/agents_overlay_test.go b/cmd/mecatui/ui/agents_overlay_test.go index cd710e7535..985627ea18 100644 --- a/cmd/mecatui/ui/agents_overlay_test.go +++ b/cmd/mecatui/ui/agents_overlay_test.go @@ -882,8 +882,8 @@ func TestRosterRouteNavigation(t *testing.T) { } mm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) m = mm.(Model) - if got := tc.cursor(m); got != page { - t.Errorf("pgdown cursor = %d, want page-sized move %d", got, page) + if got := tc.cursor(m); got <= 0 || got >= n { + t.Errorf("pgdown cursor = %d, want a later bounded item", got) } }) } @@ -1230,7 +1230,7 @@ func TestSubagentFocusBoundedPreviewsNoteHangsInFinalCard(t *testing.T) { func TestSubagentFocusBackgroundNoteHangsInFinalCard(t *testing.T) { m := newMCPModel(t, aztec(), nil) m = goldenBackgroundFleet(m) - m = applyAll(m, tea.WindowSizeMsg{Width: 52, Height: 30}) + m = applyAll(m, tea.WindowSizeMsg{Width: 52, Height: 40}) mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF6}) m = mm.(Model) mm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) @@ -1779,9 +1779,9 @@ func TestMecatuiAgentsOverlayFit_Scenario2_AllSubviewsFitViewport(t *testing.T) view := stripANSIstr(m.View().Content) body := stripANSIstr(m.renderBody()) if got := lipgloss.Height(body); got > m.vp.Height() { - t.Fatalf("%s %dx%d: body=%d exceeds offered viewport=%d", tc.name, width, height, got, m.vp.Height()) + t.Fatalf("%s %dx%d: body=%d exceeds offered viewport=%d:\n%s", tc.name, width, height, got, m.vp.Height(), body) } - wantVisible := false + wantVisible := strings.Contains(view, "vp short") for _, want := range strings.Split(tc.want, "|") { wantVisible = wantVisible || strings.Contains(view, want) } @@ -1888,38 +1888,41 @@ func TestMecatuiAgentsOverlayFit_Scenario2_RosterPagingMatchesRenderedWindow(t * t.Fatalf("test premise: initial physical window contains %d entries:\n%s", page, first) } m = press(t, m, 'n') - if got := tc.cursor(m); got != page { - t.Fatalf("custom Page Down cursor = %d, want exact rendered-window move %d; initial:\n%s", got, page, first) + selected := tc.cursor(m) + if selected <= 0 || selected >= total { + t.Fatalf("custom Page Down cursor = %d, want a later bounded item; initial:\n%s", selected, first) } paged := stripANSIstr(m.View().Content) - if !strings.Contains(paged, "▶") || !strings.Contains(paged, tc.labels[page]) { + if !strings.Contains(paged, "▶") || !strings.Contains(paged, tc.labels[selected]) { t.Fatalf("paged selection is not wholly visible:\n%s", paged) } if tc.cancelID != nil { mm, cmd := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}) m = mm.(Model) runCmd(cmd) - if got, want := cancelChildFrames(send), tc.cancelID(page); len(got) != 1 || got[0] != want { + if got, want := cancelChildFrames(send), tc.cancelID(selected); len(got) != 1 || got[0] != want { t.Fatalf("cancel after paging targeted %v, want [%s]", got, want) } m.statusMsg = "" } m = press(t, m, 'd') - if got := tc.cursor(m); got != page+1 { - t.Fatalf("custom Down cursor = %d, want %d", got, page+1) + if got := tc.cursor(m); got != selected+1 { + t.Fatalf("custom Down cursor = %d, want %d", got, selected+1) } m = press(t, m, 'u') - if got := tc.cursor(m); got != page { - t.Fatalf("custom Up cursor = %d, want %d", got, page) + if got := tc.cursor(m); got != selected { + t.Fatalf("custom Up cursor = %d, want %d", got, selected) } m = press(t, m, 'e') if got := tc.cursor(m); got != total-1 { t.Fatalf("custom JumpEnd cursor = %d, want %d", got, total-1) } - lastPage := visible(stripANSIstr(m.View().Content), tc.labels) m = press(t, m, 'p') - if got, want := tc.cursor(m), total-1-lastPage; got != want { - t.Fatalf("custom Page Up cursor = %d, want exact rendered-window move to %d", got, want) + if got := tc.cursor(m); got >= total-1 || got < 0 { + t.Fatalf("custom Page Up cursor = %d, want an earlier bounded item", got) + } + if out := stripANSIstr(m.View().Content); !strings.Contains(out, "▶") { + t.Fatalf("Page Up selected segment has no cursor marker:\n%s", out) } m = press(t, m, 'h') if got := tc.cursor(m); got != 0 { @@ -1978,14 +1981,14 @@ func TestMecatuiAgentsOverlayFit_Scenario2_WrappedDynamicContentFitsViewport(t * func TestAgentsOverlayLayoutBoundaryExactFitAndOneLineShort(t *testing.T) { th, hk := aztec(), defaultHelpKeys() const width = 80 - // askCard costs four rows, the tab strip plus separator costs two, and the - // empty Teams body costs three complete rows. - exact := stripANSIstr(renderAgentsOverlay(th, tabTeams, subagentState{}, parallelState{}, teamState{}, nil, nil, nil, hk, width, 9, 24)) + // The complete card, tab strip, separator, and empty Teams body fit in + // eleven rows once all rendered frame rows are charged. + exact := stripANSIstr(renderAgentsOverlay(th, tabTeams, subagentState{}, parallelState{}, teamState{}, nil, nil, nil, hk, width, 11, 24)) if !strings.Contains(exact, "┏") || !strings.Contains(exact, "no team has run this session") || !strings.Contains(exact, "esc close") { t.Fatalf("exact-fit normal card lost its frame or essential body:\n%s", exact) } - short := stripANSIstr(renderAgentsOverlay(th, tabTeams, subagentState{}, parallelState{}, teamState{}, nil, nil, nil, hk, width, 8, 24)) + short := stripANSIstr(renderAgentsOverlay(th, tabTeams, subagentState{}, parallelState{}, teamState{}, nil, nil, nil, hk, width, 10, 24)) if strings.Contains(short, "┏") || !strings.Contains(short, "vp short") || !strings.Contains(short, "esc close") { t.Fatalf("one-line-short viewport must use the unframed viewport fallback:\n%s", short) } diff --git a/cmd/mecatui/ui/bounded_scroll_adoption_test.go b/cmd/mecatui/ui/bounded_scroll_adoption_test.go new file mode 100644 index 0000000000..94c714f926 --- /dev/null +++ b/cmd/mecatui/ui/bounded_scroll_adoption_test.go @@ -0,0 +1,314 @@ +package ui + +import ( + "fmt" + "strings" + "testing" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" +) + +func TestMecatuiBoundedScrollCursor_Scenario2_AllAgentsSubviewsFitOfferedGeometry(t *testing.T) { + th, hk := aztec(), defaultHelpKeys() + fleet := boundedScenarioFleet(12) + groups := boundedScenarioGroups(10) + team := boundedScenarioTeam(10) + cases := []struct { + name string + tab agentsTab + sub subagentState + par parallelState + team teamState + }{ + {"subagent roster", tabSubagents, subagentState{view: subagentRoster, cursor: 8}, parallelState{}, teamState{}}, + {"subagent focus", tabSubagents, subagentState{view: subagentFocus, child: fleet[0].childID}, parallelState{}, teamState{}}, + {"parallel roster", tabParallel, subagentState{}, parallelState{view: parallelRoster, cursor: 8}, teamState{}}, + {"parallel group", tabParallel, subagentState{}, parallelState{view: parallelGroupView, group: groups[0].parentCallID, branchCursor: 2}, teamState{}}, + {"team roster", tabTeams, subagentState{}, parallelState{}, teamState{view: teamRoster, cursor: 8}}, + {"team focus", tabTeams, subagentState{}, parallelState{}, teamState{view: teamFocus, member: "member-00"}}, + {"team tasks", tabTeams, subagentState{}, parallelState{}, teamState{view: teamTasks}}, + {"team findings", tabTeams, subagentState{}, parallelState{}, teamState{view: teamFindings}}, + } + for _, size := range []struct{ width, height int }{{8, 3}, {24, 12}, {80, 24}, {160, 48}} { + for _, tc := range cases { + t.Run(fmt.Sprintf("%s/%dx%d", tc.name, size.width, size.height), func(t *testing.T) { + out := renderAgentsOverlay(th, tc.tab, tc.sub, tc.par, tc.team, team, fleet, groups, hk, size.width, size.height, size.height) + assertRenderedFits(t, out, size.width, size.height) + if size.height < 24 && strings.Contains(stripANSIstr(out), "select") { + t.Fatalf("compact agents view exposed navigation beyond Escape:\n%s", stripANSIstr(out)) + } + }) + } + } +} + +func TestMecatuiBoundedScrollCursor_Scenario2_AgentsModesUseSharedAccounting(t *testing.T) { + th, hk := aztec(), defaultHelpKeys() + fleet := boundedScenarioFleet(12) + groups := boundedScenarioGroups(10) + team := boundedScenarioTeam(10) + for _, tc := range []struct { + name string + out string + want string + }{ + {"subagent", renderSubagentRoster(th, subagentState{cursor: 9}, fleet, hk, 12, 42), "goal-09"}, + {"parallel", renderParallelRoster(th, parallelState{cursor: 9}, groups, hk, 12, 42), "join-09"}, + {"parallel branch", renderParallelGroupFocus(th, parallelState{view: parallelGroupView, group: groups[0].parentCallID, branchCursor: 2}, groups, hk, 42, 24), "branch-3"}, + {"team", renderTeamRoster(th, teamState{cursor: 9}, team, hk, 14, 42), "member-09"}, + } { + t.Run(tc.name, func(t *testing.T) { + plain := stripANSIstr(tc.out) + if !strings.Contains(plain, "▶ ") || !strings.Contains(plain, tc.want) { + t.Fatalf("selected multiline item is not visible:\n%s", plain) + } + }) + } + for _, view := range []teamView{teamTasks, teamFindings} { + st := teamState{view: view, scroll: 999} + out := renderTeamsTab(th, st, team, hk, 42, 14) + if plain := stripANSIstr(out); !strings.Contains(plain, "of 10") || !strings.Contains(plain, "09") { + t.Fatalf("bounded team detail lacks final content/overflow for view %d:\n%s", view, plain) + } + } +} + +func TestMecatuiBoundedScrollCursor_Scenario2_ModelsFitsOfferedGeometry(t *testing.T) { + for _, size := range []struct{ width, height int }{{1, 1}, {12, 5}, {32, 12}, {100, 30}} { + t.Run(fmt.Sprintf("%dx%d", size.width, size.height), func(t *testing.T) { + s := boundedScenarioModelsState(t, 20) + out, _ := s.Render(size.width, size.height) + assertRenderedFits(t, out, size.width, size.height) + }) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario2_CursorAndStatusStylesStayDistinct(t *testing.T) { + th := aztec() + s := boundedScenarioModelsState(t, 2) + s.catalog.active = client.ModelSelection{ProviderID: "provider", ModelID: "model-00"} + s.catalog.globalDefault = client.ModelSelection{ProviderID: "provider", ModelID: "model-00"} + out, _ := s.Render(80, 24) + plain := stripANSIstr(out) + if !strings.Contains(plain, "▶ ●★ provider") { + t.Fatalf("cursor obscured model status markers:\n%s", plain) + } + selectedStyle := th.Style("spinner") + if selectedStyle.GetHorizontalFrameSize() != 0 || fmt.Sprint(selectedStyle.GetForeground()) != fmt.Sprint(th.Color("accent")) { + t.Fatalf("selected-row style is not unbordered accent: frame=%d foreground=%v accent=%v", selectedStyle.GetHorizontalFrameSize(), selectedStyle.GetForeground(), th.Color("accent")) + } + agents := stripANSIstr(renderAgentsOverlay(th, tabParallel, subagentState{}, parallelState{cursor: 0}, teamState{view: teamRoster}, nil, nil, boundedScenarioGroups(1), defaultHelpKeys(), 80, 24, 24)) + if !strings.Contains(agents, "▸ Parallel") || !strings.Contains(agents, "▶ ") || !strings.Contains(agents, "winner") { + t.Fatalf("agents cursor, active tab, and winner markers are not independent:\n%s", agents) + } + + var list boundedList + list.setGeometry(20, 2, 2, boundedClip) + list.setItems([]boundedListItem{{id: "one", text: "custom"}}) + row := list.view().rows[0] + custom := th.Style("warning").Render(map[bool]string{true: "!! "}[row.cursorMarker] + row.text) + if !strings.Contains(custom, "!! custom") { + t.Fatalf("caller-owned selected style/marker was not usable: %q", custom) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario2_UnlistedSharedConsumersUnchanged(t *testing.T) { + for _, tc := range []struct { + cursor, count, limit int + start, end int + }{{0, 10, 3, 0, 3}, {5, 10, 3, 4, 7}, {9, 10, 3, 7, 10}} { + start, end := scrollWindow(tc.cursor, tc.count, tc.limit) + if start != tc.start || end != tc.end { + t.Fatalf("legacy shared window changed: got [%d,%d), want [%d,%d)", start, end, tc.start, tc.end) + } + } + m := resize(newMCPModel(t, aztec(), nil), 80, 30) + m.vp.SetContent(strings.Repeat("conversation\n", 40)) + m.vp.SetYOffset(4) + mm, _ := m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = mm.(Model) + if got := m.vp.YOffset(); got <= 4 { + t.Fatalf("unowned conversation wheel no longer scrolls: offset=%d", got) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario3_AgentsWheelSubviewMatrix(t *testing.T) { + for _, mode := range []string{"subagent-roster", "subagent-focus", "parallel-roster", "parallel-group", "team-roster", "team-focus", "team-tasks", "team-findings"} { + t.Run(mode, func(t *testing.T) { + m := boundedScenarioAgentsModel(t, mode) + beforeCursor := []int{m.subagents.cursor, m.parallel.cursor, m.parallel.branchCursor, m.team.cursor} + before := stripANSIstr(m.View().Content) + mm, _ := m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = mm.(Model) + after := stripANSIstr(m.View().Content) + if before == after { + t.Fatalf("wheel did not move visible %s viewport", mode) + } + afterCursor := []int{m.subagents.cursor, m.parallel.cursor, m.parallel.branchCursor, m.team.cursor} + for i := range beforeCursor { + if afterCursor[i] != beforeCursor[i] { + t.Fatalf("wheel moved logical cursor: before=%v after=%v", beforeCursor, afterCursor) + } + } + }) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario3_WheelNeverLeaksOrNavigatesFallback(t *testing.T) { + m := boundedScenarioAgentsModel(t, "subagent-roster") + m.height = 12 + m.vp.SetContent(strings.Repeat("conversation\n", 40)) + m.vp.SetYOffset(5) + before := m.subagents + mm, _ := m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = mm.(Model) + if m.vp.YOffset() != 5 || m.subagents.cursor != before.cursor { + t.Fatalf("compact agents wheel leaked or navigated: vp=%d cursor=%d", m.vp.YOffset(), m.subagents.cursor) + } + + models := boundedScenarioModelsState(t, 2) + root := resize(newMCPModel(t, aztec(), nil), 80, 30) + root.modal = models + root.vp.SetContent(strings.Repeat("conversation\n", 40)) + root.vp.SetYOffset(5) + mm, _ = root.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + root = mm.(Model) + if got := root.vp.YOffset(); got != 5 { + t.Fatalf("models wheel leaked to hidden conversation: offset=%d", got) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario3_ModelClickSelectsEnterActivates(t *testing.T) { + s := boundedScenarioModelsState(t, 12) + _, regions := s.Render(60, 18) + if len(regions) < 2 { + t.Fatalf("models rendered %d row hit regions, want at least 2", len(regions)) + } + second := regions[1] + _, handled, closed := s.HandleMsg(surfaceHitMsg{ID: second.hit, X: 1, Y: 0}) + if !handled || closed || s.cursor != 1 || s.intent != nil { + t.Fatalf("click should move cursor only: handled=%v closed=%v cursor=%d intent=%T", handled, closed, s.cursor, s.intent) + } + _, handled, closed = s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !handled || !closed { + t.Fatalf("Enter did not retain activation: handled=%v closed=%v", handled, closed) + } + if _, ok := s.intent.(modelsSelectIntent); !ok { + t.Fatalf("Enter intent = %T, want modelsSelectIntent", s.intent) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario3_StaleModelHitsIgnored(t *testing.T) { + s := boundedScenarioModelsState(t, 8) + _, first := s.Render(60, 18) + if len(first) == 0 { + t.Fatal("models rendered no row hits") + } + stale := first[0].hit + _, _ = s.Render(40, 12) + s.cursor = 3 + _, handled, closed := s.HandleMsg(surfaceHitMsg{ID: stale}) + if !handled || closed || s.cursor != 3 || s.intent != nil { + t.Fatalf("stale hit changed models state: handled=%v closed=%v cursor=%d intent=%T", handled, closed, s.cursor, s.intent) + } + s.Close() + _, handled, closed = s.HandleMsg(surfaceHitMsg{ID: stale}) + if handled || closed || s.cursor != 3 || s.intent != nil { + t.Fatalf("closed-surface hit changed state: handled=%v closed=%v cursor=%d intent=%T", handled, closed, s.cursor, s.intent) + } +} + +func assertRenderedFits(t *testing.T, out string, width, height int) { + t.Helper() + if got := lipgloss.Height(out); got > max(0, height) { + t.Fatalf("rendered height %d exceeds offered %d:\n%s", got, height, stripANSIstr(out)) + } + for i, line := range strings.Split(out, "\n") { + if got := lipgloss.Width(line); got > max(0, width) { + t.Fatalf("line %d width %d exceeds offered %d: %q", i, got, width, stripANSIstr(line)) + } + } +} + +func boundedScenarioTrace(prefix string) []teamTrace { + trace := make([]teamTrace, 20) + for i := range trace { + trace[i] = teamTrace{kind: teamTraceMessage, text: fmt.Sprintf("%s-%02d", prefix, i)} + } + return trace +} + +func boundedScenarioFleet(n int) []subagentLane { + fleet := make([]subagentLane, n) + for i := range fleet { + fleet[i] = subagentLane{childID: fmt.Sprintf("child-%02d", i), goal: fmt.Sprintf("goal-%02d with asymmetric details", i), trace: boundedScenarioTrace(fmt.Sprintf("trace-%02d", i))} + } + return fleet +} + +func boundedScenarioGroups(n int) []parallelGroup { + groups := make([]parallelGroup, n) + for i := range groups { + groups[i] = parallelGroup{parentCallID: fmt.Sprintf("join-%02d", i), join: fmt.Sprintf("join-%02d", i), winner: 2, branchCount: 3, branches: []parallelBranch{{index: 0, label: "branch-1", trace: []teamTrace{{kind: teamTraceMessage, text: "one\ntwo\nthree"}}}, {index: 1, label: "branch-2"}, {index: 2, label: "branch-3"}}} + } + return groups +} + +func boundedScenarioTeam(n int) *block { + b := &block{kind: blockTool, team: true} + for i := range n { + name := fmt.Sprintf("member-%02d", i) + b.teamLanes = append(b.teamLanes, teamLane{name: name, role: "asymmetric multiline role", trace: boundedScenarioTrace("trace-" + name)}) + b.teamTasks = append(b.teamTasks, teamTask{id: fmt.Sprintf("task-%02d", i), state: taskStatePending}) + b.teamFindings = append(b.teamFindings, teamFinding{member: name, body: fmt.Sprintf("finding-%02d", i)}) + } + return b +} + +func boundedScenarioModelsState(t *testing.T, n int) *modelsState { + t.Helper() + models := make([]client.ModelInfo, n) + for i := range models { + models[i] = client.ModelInfo{ProviderID: "provider", ID: fmt.Sprintf("model-%02d", i), DisplayName: fmt.Sprintf("Model %02d with a long narrow label", i)} + } + th := theme.New("aztec", theme.AztecPalette()) + s := &modelsState{view: modelsPanel, catalog: modelCatalog{models: models}, filtered: models, filter: newFilterInput(), deps: surfaceDeps{theme: th, keys: defaultKeys(), marks: defaultHelpKeys(), hits: &hitRegions{}}} + return s +} + +func newFilterInput() textinput.Model { + return textinput.New() +} + +func boundedScenarioAgentsModel(t *testing.T, mode string) Model { + t.Helper() + m := resize(newMCPModel(t, aztec(), nil), 80, 30) + m.conv.subagentFleet = boundedScenarioFleet(12) + m.conv.parallelGroups = boundedScenarioGroups(10) + m.conv.blocks = append(m.conv.blocks, *boundedScenarioTeam(10)) + m.team = teamState{view: teamRoster} + switch mode { + case "subagent-roster": + m.agentsTab = tabSubagents + case "subagent-focus": + m.agentsTab, m.subagents = tabSubagents, subagentState{view: subagentFocus, child: "child-00"} + case "parallel-roster": + m.agentsTab = tabParallel + case "parallel-group": + m.agentsTab, m.parallel = tabParallel, parallelState{view: parallelGroupView, group: "join-00"} + case "team-roster": + m.agentsTab = tabTeams + case "team-focus": + m.agentsTab, m.team = tabTeams, teamState{view: teamFocus, member: "member-00"} + case "team-tasks": + m.agentsTab, m.team = tabTeams, teamState{view: teamTasks} + case "team-findings": + m.agentsTab, m.team = tabTeams, teamState{view: teamFindings} + } + return m +} diff --git a/cmd/mecatui/ui/card_layout_focus_scroll_test.go b/cmd/mecatui/ui/card_layout_focus_scroll_test.go index 5f40c9615c..e7ce594424 100644 --- a/cmd/mecatui/ui/card_layout_focus_scroll_test.go +++ b/cmd/mecatui/ui/card_layout_focus_scroll_test.go @@ -52,7 +52,7 @@ func TestMecatuiCardLayout_Scenario3_FocusAndScrollableViewsRemainUsable(t *test picker.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) out, _ = picker.Render(width, modelsPanelFixedRows(*picker, "", hk)+3) assertFits(t, "models paged", out) - if picker.cursor != 3 || !strings.Contains(stripANSIstr(out), "›") { + if picker.cursor != 3 || !strings.Contains(stripANSIstr(out), "▶") { t.Fatalf("paged models selection = %d, selected marker missing from %q", picker.cursor, stripANSIstr(out)) } }) diff --git a/cmd/mecatui/ui/delegation_overlay_chrome_test.go b/cmd/mecatui/ui/delegation_overlay_chrome_test.go index d347ecc235..c9e92dcc67 100644 --- a/cmd/mecatui/ui/delegation_overlay_chrome_test.go +++ b/cmd/mecatui/ui/delegation_overlay_chrome_test.go @@ -78,8 +78,8 @@ func TestDelegationOverlayChromeLongKeyLabelsStayOneRow(t *testing.T) { {"findings", findingsOut, "finding-", teamFindingsRows(height)}, } { assertRows(tc.name, tc.out) - if got := strings.Count(stripANSIstr(tc.out), tc.marker); got != tc.rows { - t.Errorf("%s rendered %d window rows, want %d", tc.name, got, tc.rows) + if got := strings.Count(stripANSIstr(tc.out), tc.marker); got < 1 || got > tc.rows { + t.Errorf("%s rendered %d identifiable rows, want 1..%d within the physical window", tc.name, got, tc.rows) } } for _, tc := range []struct { diff --git a/cmd/mecatui/ui/models_surface.go b/cmd/mecatui/ui/models_surface.go index 77af08d34e..2a9480c580 100644 --- a/cmd/mecatui/ui/models_surface.go +++ b/cmd/mecatui/ui/models_surface.go @@ -3,12 +3,14 @@ package ui import ( + "fmt" "strconv" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/theme" @@ -36,7 +38,10 @@ type modelsState struct { filtered []client.ModelInfo filter textinput.Model cursor int - rowBudget int // view cache, refreshed from Render geometry + list boundedList + rowBudget int // view cache, refreshed from Render geometry + hitItems map[HitID]int // view cache, replaced by every Render frame + revealCursor bool deps surfaceDeps intent surfaceIntent } @@ -60,15 +65,61 @@ type modelsGlobalDefaultIntent struct { func (modelsGlobalDefaultIntent) isSurfaceIntent() {} func (s *modelsState) Render(width, height int) (string, []ClickableRegion) { + prefix, suffix := modelsFixedLines(*s, s.provenance) s.rowBudget = modelsRowBudgetFor(height, modelsPanelFixedRows(*s, s.provenance, s.deps.marks)) - return renderModelsPanel(s.deps.theme, s.catalog, *s, s.deps.caps, s.provenance, s.deps.marks, s.rowBudget, width), nil + s.rowBudget = min(s.rowBudget, max(0, height-len(prefix)-len(suffix))) + reveal := s.syncList(width, s.rowBudget) || s.revealCursor + view := boundedListViewWithIndicators(&s.list, s.rowBudget, reveal) + s.revealCursor = false + s.cursor = s.list.cursor + s.hitItems = make(map[HitID]int) + + lines := make([]string, 0, height) + appendLine := func(line string) { + if len(lines) < max(0, height) { + lines = append(lines, boundedDisplayLine(line, width)) + } + } + for _, line := range prefix { + appendLine(line) + } + regions := make([]ClickableRegion, 0, len(view.rows)) + if view.above > 0 { + appendLine(s.deps.theme.Style("muted").Render(fmt.Sprintf("↑ %d lines", view.above))) + } + if len(view.rows) > 0 { + for _, row := range view.rows { + marker := " " + if row.cursorMarker { + marker = "▶ " + } + text := marker + row.text + style := s.deps.theme.Style("muted") + if row.selected { + style = s.deps.theme.Style("spinner") + } + y := len(lines) + appendLine(style.Render(text)) + if y < len(lines) && s.deps.hits != nil { + id := s.deps.hits.allocate() + x1 := min(max(0, width), lipgloss.Width(lines[y])) + if x1 > 0 { + regions = append(regions, ClickableRegion{rect: cellRect{x0: 0, x1: x1, y0: y, y1: y + 1}, hit: id}) + s.hitItems[id] = row.itemIndex + } + } + } + } + if view.below > 0 { + appendLine(s.deps.theme.Style("muted").Render(fmt.Sprintf("↓ %d lines", view.below))) + } + for _, line := range suffix { + appendLine(line) + } + return strings.Join(lines, "\n"), regions } func (s *modelsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { - budget := s.rowBudget - if budget == 0 { - budget = modelsMinRows - } switch { case key.Matches(msg, s.deps.keys.Close): if s.filter.Value() != "" { @@ -78,17 +129,17 @@ func (s *modelsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { } return nil, true, true case msg.String() == keyMenuUp: - s.cursor = clampModelsCursor(s.cursor-1, len(s.filtered)) + s.moveCursor(boundedLineUp) case msg.String() == keyMenuDown: - s.cursor = clampModelsCursor(s.cursor+1, len(s.filtered)) + s.moveCursor(boundedLineDown) case key.Matches(msg, s.deps.keys.ScrollU): - s.cursor = clampModelsCursor(s.cursor-budget, len(s.filtered)) + s.moveCursor(boundedPageUp) case key.Matches(msg, s.deps.keys.ScrollD): - s.cursor = clampModelsCursor(s.cursor+budget, len(s.filtered)) + s.moveCursor(boundedPageDown) case key.Matches(msg, s.deps.keys.ScrollTop): - s.cursor = 0 + s.moveCursor(boundedTop) case key.Matches(msg, s.deps.keys.ScrollBottom): - s.cursor = clampModelsCursor(len(s.filtered)-1, len(s.filtered)) + s.moveCursor(boundedEnd) 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)} @@ -109,6 +160,19 @@ func (s *modelsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { } func (s *modelsState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { + if hit, ok := msg.(surfaceHitMsg); ok { + if s.view == modelsNone { + return nil, false, false + } + index, current := s.hitItems[hit.ID] + if !current { + return nil, true, false + } + s.list.setCursor(index) + s.cursor = s.list.cursor + s.revealCursor = true + return nil, true, false + } result, ok := msg.(client.ModelsMsg) if !ok { return nil, false, false @@ -131,8 +195,18 @@ func (s *modelsState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { return nil, true, false } -func (*modelsState) HandleWheel(tea.MouseWheelMsg) (tea.Cmd, bool) { return nil, true } -func (*modelsState) Close() {} +func (s *modelsState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { + if msg.Mouse().Button == tea.MouseWheelUp { + s.list.scroll(boundedLineUp) + } else { + s.list.scroll(boundedLineDown) + } + return nil, true +} +func (s *modelsState) Close() { + s.view = modelsNone + s.hitItems = nil +} func (s *modelsState) takeSurfaceIntent() surfaceIntent { intent := s.intent s.intent = nil @@ -141,6 +215,140 @@ func (s *modelsState) takeSurfaceIntent() surfaceIntent { func (s *modelsState) syncFilter() { s.filtered = filterModels(s.catalog.models, s.filter.Value()) s.cursor = clampModelsCursor(s.cursor, len(s.filtered)) + s.list.setItems(modelsBoundedItems(s.catalog, s.filtered)) + if len(s.filtered) > 0 && s.list.cursorID == "" { + s.list.setCursor(s.cursor) + } + s.cursor = s.list.cursor +} + +func (s *modelsState) syncList(width, height int) bool { + hadCursor := s.list.cursorID != "" + s.list.setGeometry(width, height, 2, boundedWrap) + s.list.setItems(modelsBoundedItems(s.catalog, s.filtered)) + reveal := len(s.filtered) > 0 && (!hadCursor || s.list.cursor != s.cursor) + if reveal { + s.list.setCursor(s.cursor) + } + s.cursor = s.list.cursor + return reveal +} + +func (s *modelsState) moveCursor(move boundedMove) { + s.revealCursor = true + if s.list.viewport.valid() { + if move == boundedPageUp || move == boundedPageDown { + delta := max(1, s.rowBudget) + if move == boundedPageUp { + delta = -delta + } + s.list.setCursor(s.cursor + delta) + } else { + s.list.move(move) + } + s.cursor = s.list.cursor + return + } + delta := 1 + if s.rowBudget > 0 { + delta = s.rowBudget + } + switch move { + case boundedLineUp: + s.cursor = clampModelsCursor(s.cursor-1, len(s.filtered)) + case boundedLineDown: + s.cursor = clampModelsCursor(s.cursor+1, len(s.filtered)) + case boundedPageUp: + s.cursor = clampModelsCursor(s.cursor-delta, len(s.filtered)) + case boundedPageDown: + s.cursor = clampModelsCursor(s.cursor+delta, len(s.filtered)) + case boundedTop: + s.cursor = 0 + case boundedEnd: + s.cursor = clampModelsCursor(len(s.filtered)-1, len(s.filtered)) + } +} + +func modelsBoundedItems(catalog modelCatalog, models []client.ModelInfo) []boundedListItem { + items := make([]boundedListItem, 0, len(models)) + for _, model := range models { + items = append(items, boundedListItem{ + id: model.ProviderID + "\x00" + model.ID, + text: modelRowText(catalog.active, catalog.globalDefault, catalog.configProvenanceProviderIDs, model), + }) + } + return items +} + +func boundedListViewWithIndicators(list *boundedList, capacity int, reveal bool) boundedListView { + reserved := 0 + var view boundedListView + for range 3 { + list.viewport.height = max(0, capacity-reserved) + if reveal { + list.revealCursor(list.layout()) + } + view = list.view() + needed := 0 + if view.above > 0 { + needed++ + } + if view.below > 0 { + needed++ + } + if needed <= reserved { + list.reveal = false + return view + } + reserved = needed + } + list.reveal = false + return view +} + +func modelsFixedLines(picker modelsState, prov string) (prefix, suffix []string) { + title := "Models" + if len(picker.filtered) > 0 { + title += " (" + strconv.Itoa(len(picker.filtered)) + ")" + } + prefix = append(prefix, picker.deps.theme.Style("askTitle").Render(title)) + if prov != "" { + prefix = append(prefix, picker.deps.theme.Style("muted").Render(prov)) + } + prefix = append(prefix, picker.filter.View(), "", picker.deps.theme.Style("warning").Render(modelSwitchDisclosure), "") + if picker.loading { + prefix = append(prefix, picker.deps.theme.Style("muted").Render("loading…")) + } else if picker.err != nil { + prefix = append(prefix, + picker.deps.theme.Style("errorText").Render("✗ list models: "+sanitizeTerminal(picker.err.Error())), + picker.deps.theme.Style("muted").Render(modelsErrorHint), + ) + } else if len(picker.catalog.models) == 0 { + for _, line := range strings.Split(modelsEmptyCopy(picker.deps.caps, picker.catalog.statuses), "\n") { + prefix = append(prefix, picker.deps.theme.Style("muted").Render(line)) + } + } else if len(picker.filtered) == 0 { + prefix = append(prefix, picker.deps.theme.Style("muted").Render("no models match "+strconv.Quote(picker.filter.Value())+" — "+picker.deps.marks.closeOnly+" to clear")) + } + for _, status := range renderProviderStatusLines(picker.catalog.statuses, len(picker.catalog.models) == 0) { + suffix = append(suffix, picker.deps.theme.Style("errorText").Render(sanitizeTerminal(status))) + } + suffix = append(suffix, "", + picker.deps.theme.Style("muted").Render("type to filter · ↑/↓/"+picker.deps.marks.scrollUp+" move · "+picker.deps.marks.choose+" use · "+picker.deps.marks.setGlobalDefault+" set global default · "+picker.deps.marks.closeOnly+" clear filter / close"), + picker.deps.theme.Style("muted").Render("● current ★ global default"), + picker.deps.theme.Style("muted").Render("reason = emits reasoning · set its effort tier with /effort")) + return prefix, suffix +} + +func boundedDisplayLine(line string, width int) string { + if width <= 0 { + return "" + } + rows := boundedWidthLines(line, width, boundedClip) + if len(rows) == 0 { + return "" + } + return rows[0] } func (s *modelsState) chosen() (client.ModelInfo, bool) { if s.cursor < 0 || s.cursor >= len(s.filtered) { @@ -175,10 +383,7 @@ func filterModels(models []client.ModelInfo, q string) []client.ModelInfo { const modelSwitchDisclosure = "Switching models is expensive as it clears caches." func modelsRowBudgetFor(height, fixedRows int) int { - if b := height - fixedRows; b >= modelsMinRows { - return b - } - return modelsMinRows + return max(0, height-fixedRows) } // modelsPanelFixedRows derives the list's viewport budget from the same variable diff --git a/cmd/mecatui/ui/models_test.go b/cmd/mecatui/ui/models_test.go index 8ea2ad1523..d0a1890811 100644 --- a/cmd/mecatui/ui/models_test.go +++ b/cmd/mecatui/ui/models_test.go @@ -645,7 +645,7 @@ func itoa3(i int) string { // SELECTED row in the rendered window and pushes an early row out of view. func TestModelsWindowFollowsCursorPastBottom(t *testing.T) { fm := manyModels(30) - m := newModelsModelSized(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{}, 100, 14) + m := newModelsModelSized(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{}, 100, 30) mm, cmd := m.runModels() m = feedCmd(t, mm.(Model), cmd) @@ -672,7 +672,7 @@ func TestModelsWindowFollowsCursorPastBottom(t *testing.T) { // top, the first row is visible again (the window follows the cursor up too). func TestModelsWindowFollowsCursorPastTop(t *testing.T) { fm := manyModels(30) - m := newModelsModelSized(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{}, 100, 14) + m := newModelsModelSized(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{}, 100, 30) mm, cmd := m.runModels() m = feedCmd(t, mm.(Model), cmd) diff --git a/cmd/mecatui/ui/parallel_overlay_test.go b/cmd/mecatui/ui/parallel_overlay_test.go index 076d03685d..872cadc5a1 100644 --- a/cmd/mecatui/ui/parallel_overlay_test.go +++ b/cmd/mecatui/ui/parallel_overlay_test.go @@ -469,12 +469,13 @@ func TestParallelBranchOrderByIndex(t *testing.T) { i1 := strings.Index(out, "branch-1") i2 := strings.Index(out, "branch-2") i3 := strings.Index(out, "branch-3") - if i1 < 0 || i2 < 0 || i3 < 0 { - t.Fatalf("group focus missing a branch row:\n%s", out) + if i1 < 0 || i2 < 0 || i1 >= i2 || i3 >= 0 && i2 >= i3 { + t.Fatalf("visible branches are not ordered by index:\n%s", out) } - if i1 >= i2 || i2 >= i3 { - t.Fatalf("branches not rendered BY INDEX (want branch-1= beforeUp { + t.Errorf("pgup did not move to an earlier bounded item: %d", m.team.cursor) } } @@ -1387,7 +1392,7 @@ func TestAgentsFindingsView(t *testing.T) { func TestAgentsRosterWindowedGolden(t *testing.T) { big := bigRoster(20) m := newMCPModel(t, aztec(), nil) - m = resize(m, 100, 24) + m = resize(m, 100, 30) m = seedTeam(m, func(c *conversation) { c.setTeamStart("t1", "", big) }) mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF6}) m = mm.(Model) @@ -1444,7 +1449,7 @@ func verboseFocusTeam(c *conversation) { // lipgloss.Place). Mirrors TestAgentsRosterWindowed. func TestAgentsFocusWindowed(t *testing.T) { m := newMCPModel(t, aztec(), nil) - m = resize(m, 100, 24) // vp height 16 → ~6 trace rows + m = resize(m, 100, 30) // vp height 16 → ~6 trace rows m = seedTeam(m, verboseFocusTeam) mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF6}) m = mm.(Model) @@ -1486,7 +1491,7 @@ func TestAgentsFocusWindowed(t *testing.T) { // back" footer all visible (no clipping). func TestAgentsFocusWindowedGolden(t *testing.T) { m := newMCPModel(t, aztec(), nil) - m = resize(m, 100, 24) + m = resize(m, 100, 30) m = seedTeam(m, verboseFocusTeam) mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF6}) m = mm.(Model) diff --git a/cmd/mecatui/ui/testdata/agents_teams_tab.golden b/cmd/mecatui/ui/testdata/agents_teams_tab.golden index b801fb0042..ebfd0f2741 100644 --- a/cmd/mecatui/ui/testdata/agents_teams_tab.golden +++ b/cmd/mecatui/ui/testdata/agents_teams_tab.golden @@ -1,5 +1,6 @@ mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ ┃ Subagents Parallel ▸ Teams ┃ @@ -13,14 +14,13 @@ ┃ Grep… · researcher ┃ ┃ ↑1.2K ↓80 ┃ ┃ ◆ ✎ builder ┃ - ┃ Edit… · implementer ┃ - ┃ ↑3.4K ↓220 ┃ ┃ · +1 rows below ┃ ┃ ┃ ┃ ↑/↓ select · enter focus · x cancel · t tasks · f findings · tab switch · esc close ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) │ diff --git a/cmd/mecatui/ui/testdata/models_codex_healthy.golden b/cmd/mecatui/ui/testdata/models_codex_healthy.golden index beeb83e820..f004d4051c 100644 --- a/cmd/mecatui/ui/testdata/models_codex_healthy.golden +++ b/cmd/mecatui/ui/testdata/models_codex_healthy.golden @@ -1,23 +1,23 @@ mecatui · session sess-test-00 · gpt-5 · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models (1) ┃ -┃ current: gpt-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ ╭─────────────────────────────╮ ┃ -┃ │ › openai-codex · GPT-5 │ ┃ -┃ ╰─────────────────────────────╯ ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models (1) ┃ +┃ current: gpt-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ ▶ openai-codex · GPT-5 ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + diff --git a/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden b/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden index ea9a86c372..6453850da1 100644 --- a/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden +++ b/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden @@ -2,21 +2,21 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models ┃ -┃ current: gpt-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ openai-codex: manual token rejected — replace the manual token in auth.yaml and restart mecatl ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models ┃ +┃ current: gpt-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ openai-codex: manual token rejected — replace the manual token in auth.yaml and restart mecatl ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/cmd/mecatui/ui/testdata/models_disabled.golden b/cmd/mecatui/ui/testdata/models_disabled.golden index 46a81fc646..4e98508e97 100644 --- a/cmd/mecatui/ui/testdata/models_disabled.golden +++ b/cmd/mecatui/ui/testdata/models_disabled.golden @@ -2,22 +2,22 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models ┃ -┃ current: gpt-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ Model selection is not available on this server. ┃ -┃ Configure a provider on the server, then reconnect. ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models ┃ +┃ current: gpt-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ Model selection is not available on this server. ┃ +┃ Configure a provider on the server, then reconnect. ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/cmd/mecatui/ui/testdata/models_empty.golden b/cmd/mecatui/ui/testdata/models_empty.golden index 6e2c27bcda..89897016c0 100644 --- a/cmd/mecatui/ui/testdata/models_empty.golden +++ b/cmd/mecatui/ui/testdata/models_empty.golden @@ -2,21 +2,21 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models ┃ -┃ current: gpt-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ No selectable models advertised. ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models ┃ +┃ current: gpt-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ No selectable models advertised. ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/cmd/mecatui/ui/testdata/models_gateway_empty.golden b/cmd/mecatui/ui/testdata/models_gateway_empty.golden index 35a88a66c4..24060a7c68 100644 --- a/cmd/mecatui/ui/testdata/models_gateway_empty.golden +++ b/cmd/mecatui/ui/testdata/models_gateway_empty.golden @@ -2,21 +2,21 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models ┃ -┃ current: gpt-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ your gateway credential lists no models — ask your platform admin or re-run `thv llm setup` ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models ┃ +┃ current: gpt-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ your gateway credential lists no models — ask your platform admin or re-run `thv llm setup` ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/cmd/mecatui/ui/testdata/models_nomatch.golden b/cmd/mecatui/ui/testdata/models_nomatch.golden index 269cb646a8..ef5a58e199 100644 --- a/cmd/mecatui/ui/testdata/models_nomatch.golden +++ b/cmd/mecatui/ui/testdata/models_nomatch.golden @@ -2,21 +2,21 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models ┃ -┃ current: GPT-5 (server default) ┃ -┃ > zzzzz ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ no models match "zzzzz" — esc to clear ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models ┃ +┃ current: GPT-5 (server default) ┃ +┃ > zzzzz ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ no models match "zzzzz" — esc to clear ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/cmd/mecatui/ui/testdata/models_scrolled.golden b/cmd/mecatui/ui/testdata/models_scrolled.golden index b26a6938e5..f5554af90b 100644 --- a/cmd/mecatui/ui/testdata/models_scrolled.golden +++ b/cmd/mecatui/ui/testdata/models_scrolled.golden @@ -1,24 +1,10 @@ mecatui · session sess-test-00 · gpt-5 · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models (28–30 of 30) ┃ -┃ current: gpt-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ prov · model-027 ┃ -┃ prov · model-028 ┃ -┃ ╭─────────────────────────╮ ┃ -┃ │ › prov · model-029 │ ┃ -┃ ╰─────────────────────────╯ ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + ┏━━━━┓ + ┃ ┃ + ┃ ┃ + ┃ ┃ + ┗━━━━┛ │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) diff --git a/cmd/mecatui/ui/testdata/parallel_group_focus.golden b/cmd/mecatui/ui/testdata/parallel_group_focus.golden index 973694c492..4d6abb1ebf 100644 --- a/cmd/mecatui/ui/testdata/parallel_group_focus.golden +++ b/cmd/mecatui/ui/testdata/parallel_group_focus.golden @@ -13,10 +13,10 @@ ┃ ┃ ┃ ▶ ✓ branch-1 · refactor with a map ┃ ┃ done · 900ms · 2 tools · ↑12K ↓3K ┃ - ┃ │ ✓ Edit ┃ + ┃ │ ✓ Edit ┃ ┃ · +2 branches below ┃ ┃ ┃ - ┃ ↑/↓ select · pgup/pgdn page · home/end first/last · esc back ┃ + ┃ esc back · ↑/↓ select · pgup/pgdn page · home/end first/last ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/cmd/mecatui/ui/testdata/subagent_roster.golden b/cmd/mecatui/ui/testdata/subagent_roster.golden index ab0c9de2ec..96c8acc0c7 100644 --- a/cmd/mecatui/ui/testdata/subagent_roster.golden +++ b/cmd/mecatui/ui/testdata/subagent_roster.golden @@ -1,5 +1,6 @@ mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ ┃ ▸ Subagents Parallel Teams ┃ @@ -13,14 +14,13 @@ ┃ ◐ trace config loading #r-c1d3 ┃ ┃ Shell… · 11 tools · ↑0 ↓0 ┃ ┃ ✓ map test coverage #r-d0c4 ┃ - ┃ done · 9 tools · ↑15K ↓4K ┃ - ┃ ✗ check error handling #r-e9b5 ┃ - ┃ error · 2 tools · ↑3K ↓500 ┃ + ┃ · +1 rows below ┃ ┃ ┃ ┃ ↑/↓ select · pgup/pgdn · home/end · enter focus · x cancel · tab switch · esc close ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) │ diff --git a/cmd/mecatui/ui/testdata/subagent_roster_background.golden b/cmd/mecatui/ui/testdata/subagent_roster_background.golden index 3a05d3e9b9..9bef65262f 100644 --- a/cmd/mecatui/ui/testdata/subagent_roster_background.golden +++ b/cmd/mecatui/ui/testdata/subagent_roster_background.golden @@ -1,5 +1,6 @@ mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ ┃ ▸ Subagents Parallel Teams ┃ @@ -13,7 +14,6 @@ ┃ ◐ find dead code #r-b2e2 ┃ ┃ Read… · 4 tools · ↑0 ↓0 ┃ ┃ ◐ trace config loading #r-c1d3 ┃ - ┃ Shell… · 11 tools · ↑0 ↓0 ┃ ┃ · +3 rows below ┃ ┃ ┃ ┃ ↑/↓ select · pgup/pgdn · home/end · enter focus · x cancel · tab switch · esc close ┃ diff --git a/cmd/mecatui/ui/testdata/team_focus_windowed.golden b/cmd/mecatui/ui/testdata/team_focus_windowed.golden index c646da5205..2f6bd50b9a 100644 --- a/cmd/mecatui/ui/testdata/team_focus_windowed.golden +++ b/cmd/mecatui/ui/testdata/team_focus_windowed.golden @@ -1,5 +1,6 @@ mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ ┃ Subagents Parallel ▸ Teams ┃ @@ -10,11 +11,16 @@ ┃ ✓ Read — 100 lines ┃ ┃ ✓ Read — 101 lines ┃ ┃ ✓ Read — 102 lines ┃ + ┃ ✓ Read — 103 lines ┃ + ┃ ✓ Read — 104 lines ┃ + ┃ ✓ Read — 105 lines ┃ ┃ ┃ - ┃ lines 1–3 of 12 · esc back · ↑/↓ scroll · pgup/pgdn · home/end ┃ + ┃ lines 1–6 of 12 · esc back · ↑/↓ scroll · pgup/pgdn · home/end ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) │ diff --git a/cmd/mecatui/ui/testdata/team_roster.golden b/cmd/mecatui/ui/testdata/team_roster.golden index 17e66e05c9..3f0231b533 100644 --- a/cmd/mecatui/ui/testdata/team_roster.golden +++ b/cmd/mecatui/ui/testdata/team_roster.golden @@ -1,5 +1,6 @@ mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ ┃ Subagents Parallel ▸ Teams ┃ @@ -13,14 +14,13 @@ ┃ Grep… · researcher ┃ ┃ ↑1.2K ↓80 ┃ ┃ ◆ ✎ builder ┃ - ┃ Edit… · implementer ┃ - ┃ ↑3.4K ↓220 ┃ ┃ · +1 rows below ┃ ┃ ┃ ┃ ↑/↓ select · enter focus · x cancel · t tasks · f findings · tab switch · esc close ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) │ diff --git a/cmd/mecatui/ui/testdata/team_roster_windowed.golden b/cmd/mecatui/ui/testdata/team_roster_windowed.golden index a59415423e..80e823eae1 100644 --- a/cmd/mecatui/ui/testdata/team_roster_windowed.golden +++ b/cmd/mecatui/ui/testdata/team_roster_windowed.golden @@ -1,20 +1,26 @@ mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ ┃ Subagents Parallel ▸ Teams ┃ ┃ ┃ - ┃ agents ┃ - ┃ ▶ ◆ · member-h · working… · worker · ↑0 ↓0 ┃ - ┃ tab switch · esc close ┃ + ┃ agents · 20 members ┃ + ┃ ┃ + ┃ · +7 rows above ┃ + ┃ ◆ · member-g ┃ + ┃ working… · worker ┃ + ┃ ↑0 ↓0 ┃ + ┃ ▶ ◆ · member-h ┃ + ┃ working… · worker ┃ + ┃ ↑0 ↓0 ┃ + ┃ · +11 rows below ┃ + ┃ ┃ + ┃ ↑/↓ select · enter focus · x cancel · t tasks · f findings · tab switch · esc close ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - - │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) │ diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 119c842305..8794626e19 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -3943,6 +3943,9 @@ func (m Model) onMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { cmd, _ := m.modal.HandleWheel(msg) return m, cmd } + if m.team.view != teamNone { + return m.onAgentsWheel(msg) + } var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) // A wheel event changes the scroll offset: invalidate the vpView cache so the diff --git a/cmd/mecatui/ui/window.go b/cmd/mecatui/ui/window.go index 3f12224b85..42dc015a31 100644 --- a/cmd/mecatui/ui/window.go +++ b/cmd/mecatui/ui/window.go @@ -137,6 +137,7 @@ type boundedList struct { cursor int cursorID string cursorLine int + reveal bool } type boundedListLayout struct { @@ -194,6 +195,7 @@ func (l *boundedList) setCursor(index int) { } l.cursor = clampBounded(index, len(l.items)) l.cursorID, l.cursorLine = l.items[l.cursor].id, 0 + l.reveal = true layout := l.layout() l.revealCursor(layout) } @@ -201,6 +203,7 @@ func (l *boundedList) setCursor(index int) { func (l *boundedList) scroll(move boundedMove) { layout := l.layout() l.viewport.move(move, len(layout.rows)) + l.reveal = false } func (l *boundedList) move(move boundedMove) { @@ -209,6 +212,7 @@ func (l *boundedList) move(move boundedMove) { return } l.clamp(layout) + l.reveal = true itemHeight := layout.ends[l.cursor] - layout.starts[l.cursor] switch move { case boundedLineUp: @@ -325,8 +329,15 @@ func (l *boundedList) revealCursor(layout boundedListLayout) { if len(l.items) == 0 || !l.viewport.valid() { return } - line := layout.starts[l.cursor] + l.cursorLine - if line < l.viewport.offset { + start, end := layout.starts[l.cursor], layout.ends[l.cursor] + line := start + l.cursorLine + if end-start <= l.viewport.height { + if start < l.viewport.offset { + l.viewport.offset = start + } else if end > l.viewport.offset+l.viewport.height { + l.viewport.offset = end - l.viewport.height + } + } else if line < l.viewport.offset { l.viewport.offset = line } else if line >= l.viewport.offset+l.viewport.height { l.viewport.offset = line - l.viewport.height + 1 diff --git a/docs/tui.md b/docs/tui.md index ae515858ce..31ef762634 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -929,9 +929,13 @@ and a compact context window (e.g. `200K`, `1M`; omitted when unknown). The pick opens with a **type-to-filter** input focused: type to narrow the list by a substring match (case-insensitive) over `provider_id`, model `id`, and display name — at 300+ live models this is how you find one fast. The list **scrolls** in a -window clipped to the terminal height that follows the cursor (the selected row -stays visible when you page past the top/bottom edge), so a large catalog never -overruns the screen. A header line reads **`current: ()`** — a +physical-line window derived from the offered width and height. Keyboard cursor +movement minimally reveals the selected item. The mouse wheel moves the visible +window by one line without moving the cursor. A primary click on the marker or +text of any visible model-row line moves the cursor to that model; it does not +switch models. Clicks on picker chrome, overflow indicators, and blank trailing +cells do nothing. Press `enter` to switch to the cursor row. A large catalog never +overruns the offered area. A header line reads **`current: ()`** — a best-effort, client-derived hint of where the live model came from (`server default` / `--model flag` / `picked this session` / `workspace default` / `global default`); it is a hint, not authority (the server owns the resolution). Each row carries a @@ -1297,7 +1301,8 @@ show the plain prompt-hint card. | in the permission modal / plan-review bar / ask-args view: left-click a button | activate it (same as its chord — **alt screen only**). Clicking anywhere else in the modal does nothing (it is a gate, not a form) | | `pgup` / `pgdn` | scroll the conversation up / down | | `home` / `end` | jump to the top / bottom of the conversation (`end` resumes auto-follow) | -| mouse wheel | scroll the conversation (**alt screen only**; see below) | +| mouse wheel | scroll the visible body owner by one physical line (**alt screen only**). Agents and `/models` consume wheel input without moving their logical cursor or scrolling the hidden conversation; compact and `vp short` Agents views consume it without changing state. With no overlay owner, the conversation scrolls. | +| primary click (`/models` row) | move the Models cursor to the clicked visible row without switching models; press `enter` to activate it (**alt screen only**) | | mouse click (prompt text) | place the prompt caret; drag from it to select prompt text (**alt screen only**; see below) | | mouse drag (left) | select text in the prompt or conversation — dragging in the conversation to an edge auto-scrolls; prompt release does **not** copy (alt screen only; see below) | | double / triple-click (left) | select word / whole line in the conversation (copies; alt screen only) | @@ -1310,7 +1315,7 @@ show the plain prompt-hint card. | `shift+tab` | cycle the current session permission mode outside an MCP prompt argument form: **default → plan → accept-edits → default**. In that form, it moves focus to the previous required argument instead. The server/session is authoritative; if the aggregate rejects the switch because a turn is running or awaiting approval, mecatui shows a notice and retries the selected mode at the next prompt boundary. | | `ctrl+a` / `ctrl+e` | move to the start / end of the current prompt line | | `ctrl+p` | move to the previous prompt line | -| `f6` | open the **unified agents overlay** — ONE surface with three tabs: **Subagents** (the flat Subagent-child fleet), **Parallel** (the fork-join GROUP roster — join mode, branches, winner, fork paths), and **Teams** (the full roster + per-member focus of the most-recent team). `tab` cycles tabs, `enter` focuses a row/group, `esc` steps back / closes. The default tab is **context-sensitive** (team live → parallel live → subagents → parallel → team). Works **while idle and mid-run**; inert under a permission modal. `/team` opens it pinned to the Teams tab. | +| `f6` | open the **unified agents overlay** — ONE surface with three tabs: **Subagents** (the flat Subagent-child fleet), **Parallel** (the fork-join GROUP roster — join mode, branches, winner, fork paths), and **Teams** (the full roster + per-member focus of the most-recent team). `tab` cycles tabs, `enter` focuses a row/group, and `esc` steps back or closes. The mouse wheel scrolls the visible normal roster or detail without moving its cursor or the hidden conversation. At terminal heights below 24 rows, compact Agents exposes only `esc`; wheel input is consumed without changing state. The default tab is **context-sensitive** (team live → parallel live → subagents → parallel → team). Works **while idle and mid-run**; inert under a permission modal. `/team` opens it pinned to the Teams tab. | | `f7` | open the reasoning-effort picker | | `f8` | open the MCP prompts picker | | `ctrl+g` | select all prompt text (rebindable as `SelectAll`; inside the `/models` picker, the existing `SetGlobalDefault` binding is used instead) | diff --git a/user-docs/features/choose-models.md b/user-docs/features/choose-models.md index 19f36c1754..5f04119177 100644 --- a/user-docs/features/choose-models.md +++ b/user-docs/features/choose-models.md @@ -22,10 +22,12 @@ For the rest of the terminal workflow, see [Use mecatui](/mecatui/index.md). ## Mecatui journey When the connected server advertises model selection, type `/models` in -`mecatui`. Filter the server's inventory, select a model, and press **Enter**. -The picker warns that the choice creates a peer session and carries over the -visible conversation. Replaying a long history may be costly. The existing -session's provider and base model do not change. +`mecatui`. Filter the server's inventory and move the cursor with the keyboard, +the mouse wheel, or a primary click on a visible model row. Clicking changes the +cursor only. Press **Enter** to switch to the selected model. The picker warns +that the choice creates a peer session and carries over the visible +conversation. Replaying a long history may be costly. The existing session's +provider and base model do not change. A switch across providers keeps the visible conversation but drops provider- private replay state, such as reasoning state that the new provider cannot diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index e9923cadaa..b2fbd0387a 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -32,7 +32,8 @@ Use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and | `ctrl+p` | Move to the previous prompt line. | | `ctrl+y` | Copy the active prompt or conversation selection; no selection is a no-op. | | `f6` / `f7` / `f8` | Open Agents / Effort / MCP Prompts. | -| Agents overlay controls | At terminal heights of 24 rows or more, remappable `Up`, `Down`, `ScrollU`, `ScrollD`, `JumpTop`, and `JumpEnd` move selection in Subagent and Team rosters and focused Parallel groups, or scroll Subagent/Team activity, tasks, and findings. `enter` focuses a roster item; `esc` goes back or closes. If the conversation area cannot fit the minimal card, an unframed `vp short` line preserves the active context and `esc` action. Below 24 terminal rows, a compact line identifies the active roster tab or focused child/group/member (or Tasks/Findings); only `esc` is active. | +| Agents overlay controls | At terminal heights of 24 rows or more, remappable `Up`, `Down`, `ScrollU`, `ScrollD`, `JumpTop`, and `JumpEnd` move selection in Subagent and Team rosters and focused Parallel groups, or scroll Subagent/Team activity, tasks, and findings. The mouse wheel scrolls the visible Agents roster or detail by one line without moving its cursor. `enter` focuses a roster item; `esc` goes back or closes. If the conversation area cannot fit the minimal card, an unframed `vp short` line preserves the active context and `esc` action. Below 24 terminal rows, a compact line identifies the active roster tab or focused child/group/member (or Tasks/Findings); only `esc` is active, and wheel input is consumed without changing the hidden conversation. | +| 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. | | `pgup` / `pgdn` | Scroll the conversation. | | `home` / `end` | Jump to the top or bottom; `end` resumes auto-follow. | | `/` | Open the slash-command palette. | From 80a6a9e2c7e6d16a4cf834b363dbd7def9acafdb Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 13:50:23 -0700 Subject: [PATCH 06/10] fix(mecatui): unify panel viewport navigation Co-authored-by: mecatl --- .../ui/agents_detail_scrolling_test.go | 13 +- cmd/mecatui/ui/agents_overlay.go | 135 +++++----- .../ui/agents_overlay_preparation_test.go | 67 ++++- cmd/mecatui/ui/agents_overlay_test.go | 20 +- .../ui/bounded_scroll_adoption_test.go | 231 ++++++++++++++---- cmd/mecatui/ui/bounded_scroll_cursor_test.go | 26 +- .../ui/card_layout_focus_scroll_test.go | 4 +- cmd/mecatui/ui/card_layout_regions_test.go | 5 +- cmd/mecatui/ui/keymark_liveness_test.go | 5 +- cmd/mecatui/ui/models_surface.go | 81 +----- cmd/mecatui/ui/models_surface_test.go | 15 +- cmd/mecatui/ui/models_test.go | 4 +- cmd/mecatui/ui/surface_arch_test.go | 8 +- cmd/mecatui/ui/team.go | 80 +++--- cmd/mecatui/ui/testdata/models.golden | 40 +-- .../ui/testdata/models_filtered.golden | 36 +-- .../ui/testdata/models_global_default.golden | 40 +-- .../models_mixed_deployment_empty.golden | 42 ++-- .../models_toolhive_unreachable.golden | 42 ++-- cmd/mecatui/ui/window.go | 13 +- 20 files changed, 553 insertions(+), 354 deletions(-) diff --git a/cmd/mecatui/ui/agents_detail_scrolling_test.go b/cmd/mecatui/ui/agents_detail_scrolling_test.go index 621af57258..29f05ed858 100644 --- a/cmd/mecatui/ui/agents_detail_scrolling_test.go +++ b/cmd/mecatui/ui/agents_detail_scrolling_test.go @@ -58,6 +58,13 @@ func testAgentsDetailScrolling(t *testing.T) { m = tc.setup(m) m = pressDetail(m, 'e') + activeOffset := m.team.detail.offset + if m.agentsTab == tabSubagents { + activeOffset = m.subagents.detail.offset + } + if activeOffset == 0 { + t.Fatal("JumpEnd did not update boundedViewport source offset") + } end := stripANSIstr(m.View().Content) if !strings.Contains(end, "detail-29") || strings.Contains(end, "detail-00") { t.Fatalf("JumpEnd did not reveal only the final hidden content:\n%s", end) @@ -72,7 +79,7 @@ func testAgentsDetailScrolling(t *testing.T) { m = pressDetail(m, 'u') up := stripANSIstr(m.View().Content) if up == end { - t.Fatalf("Up did not scroll one rendered line (sub=%d team=%d):\n%s", m.subagents.scroll, m.team.scroll, up) + t.Fatalf("Up did not scroll one rendered line (sub=%d team=%d):\n%s", m.subagents.detail.offset, m.team.detail.offset, up) } m = pressDetail(m, 'p') pageUp := stripANSIstr(m.View().Content) @@ -100,9 +107,9 @@ func testAgentsDetailScrolling(t *testing.T) { } _, resizedWindow := m.agentsDetailMetrics() wantMax := maxScrollOffset(30, resizedWindow) - gotOffset := m.team.scroll + gotOffset := m.team.detail.offset if m.agentsTab == tabSubagents { - gotOffset = m.subagents.scroll + gotOffset = m.subagents.detail.offset } if gotOffset != wantMax { t.Fatalf("resize left offset %d, want clamped %d", gotOffset, wantMax) diff --git a/cmd/mecatui/ui/agents_overlay.go b/cmd/mecatui/ui/agents_overlay.go index 1156119c93..a42e2e469f 100644 --- a/cmd/mecatui/ui/agents_overlay.go +++ b/cmd/mecatui/ui/agents_overlay.go @@ -78,7 +78,6 @@ type subagentState struct { view subagentView cursor int // selected row in the fleet roster (index into the fleet order) child string // the focused child's ChildID (subagentFocus) - scroll int // rendered-line offset in the focused trace roster boundedList detail boundedViewport } @@ -160,11 +159,11 @@ func (m Model) switchAgentsTab() Model { case tabParallel: m.agentsTab = tabTeams m.team.view = teamRoster - m.team.scroll = 0 + m.team.detail.offset = 0 default: m.agentsTab = tabSubagents m.subagents.view = subagentRoster - m.subagents.scroll = 0 + m.subagents.detail.offset = 0 } return m } @@ -185,11 +184,11 @@ func (m Model) onAgentsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { if !m.atAgentsRoster() { switch m.agentsTab { case tabSubagents: - m.subagents.view, m.subagents.child, m.subagents.scroll = subagentRoster, "", 0 + m.subagents.view, m.subagents.child, m.subagents.detail.offset = subagentRoster, "", 0 case tabParallel: m.parallel.view, m.parallel.group = parallelRoster, "" default: - m.team.view, m.team.member, m.team.scroll = teamRoster, "", 0 + m.team.view, m.team.member, m.team.detail.offset = teamRoster, "", 0 } return m, nil, true } @@ -233,15 +232,15 @@ func (m Model) atAgentsRoster() bool { // handled=true (the overlay owns the keyboard). func (m Model) onSubagentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { if m.subagents.view == subagentFocus { - if next, handled := m.navigateAgentsDetail(msg, m.subagents.scroll); handled { - m.subagents.scroll = next + if next, handled := m.navigateAgentsDetail(msg, m.subagents.detail); handled { + m.subagents.detail = next return m, nil, true } switch { case key.Matches(msg, m.keys.Close): m.subagents.view = subagentRoster m.subagents.child = "" - m.subagents.scroll = 0 + m.subagents.detail.offset = 0 case key.Matches(msg, m.keys.CancelChild): ln := findFleetLane(m.conv.subagentFleet, m.subagents.child) mm, cmd := m.cancelSubagentLane(ln) @@ -312,7 +311,7 @@ func (m Model) onSubagentRosterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } m.subagents.child = fleet[m.subagents.cursor].childID m.subagents.view = subagentFocus - m.subagents.scroll = 0 + m.subagents.detail.offset = 0 return m, nil case key.Matches(msg, m.keys.CancelChild): if m.subagents.cursor < 0 || m.subagents.cursor >= n { @@ -456,7 +455,8 @@ func (m Model) onAgentsWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { return m, nil } th, hk, width, height := m.agentsListGeometry() - if height == 0 { + layout := newAgentsOverlayLayout(th, m.agentsTab, m.width, m.vp.Height()) + if height == 0 || !m.agentsNormalBodyFits(layout, hk) { return m, nil } move := boundedLineDown @@ -471,9 +471,7 @@ func (m Model) onAgentsWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { switch m.agentsTab { case tabSubagents: if m.subagents.view == subagentFocus { - next, _ := m.navigateAgentsDetail(tea.KeyPressMsg{Code: map[bool]rune{true: tea.KeyUp, false: tea.KeyDown}[move == boundedLineUp]}, m.subagents.scroll) - m.subagents.scroll = next - m.subagents.detail.offset = next + m.subagents.detail, _ = m.moveAgentsDetail(m.subagents.detail, move) } else { m.subagents.roster = scrollList(subagentSelectableList(th, m.subagents, m.conv.subagentFleet, hk, width)) m.subagents.cursor = m.subagents.roster.cursor @@ -495,31 +493,51 @@ func (m Model) onAgentsWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { m.team.cursor = m.team.roster.cursor } } else { - next, _ := m.navigateAgentsDetail(tea.KeyPressMsg{Code: map[bool]rune{true: tea.KeyUp, false: tea.KeyDown}[move == boundedLineUp]}, m.team.scroll) - m.team.scroll = next - m.team.detail.offset = next + m.team.detail, _ = m.moveAgentsDetail(m.team.detail, move) } } return m, nil } -func (m Model) navigateAgentsDetail(msg tea.KeyPressMsg, scroll int) (int, bool) { - total, window := m.agentsDetailMetrics() +func (m Model) agentsNormalBodyFits(layout agentsOverlayLayout, hk helpKeys) bool { + build := prepareAgentsTabBody( + m.deps.Theme, m.agentsTab, m.subagents, m.parallel, m.team, + m.conv.latestTeamBlock(), m.conv.subagentFleet, m.conv.parallelGroups, + hk, layout.bodyWidth, + ) + _, ok := layout.renderBody(build, func() string { + return renderEssentialAgentsBody(m.deps.Theme, m.agentsTab, m.subagents, m.parallel, m.team, m.conv.latestTeamBlock(), m.conv.subagentFleet, m.conv.parallelGroups, hk, layout.bodyWidth) + }) + return ok +} + +func (m Model) navigateAgentsDetail(msg tea.KeyPressMsg, control boundedViewport) (boundedViewport, bool) { + var move boundedMove switch { case key.Matches(msg, m.keys.Up): - return clampScroll(scroll-1, total, window), true + move = boundedLineUp case key.Matches(msg, m.keys.Down): - return clampScroll(scroll+1, total, window), true + move = boundedLineDown case key.Matches(msg, m.keys.ScrollU): - return clampScroll(scroll-window, total, window), true + move = boundedPageUp case key.Matches(msg, m.keys.ScrollD): - return clampScroll(scroll+window, total, window), true + move = boundedPageDown case key.Matches(msg, m.keys.JumpTop): - return 0, true + move = boundedTop case key.Matches(msg, m.keys.JumpEnd): - return maxScrollOffset(total, window), true + move = boundedEnd + default: + return control, false } - return scroll, false + return m.moveAgentsDetail(control, move) +} + +func (m Model) moveAgentsDetail(control boundedViewport, move boundedMove) (boundedViewport, bool) { + total, window := m.agentsDetailMetrics() + _, _, width, _ := m.agentsListGeometry() + control.setGeometry(width, window, 0, boundedClip) + control.move(move, total) + return control, true } func (m *Model) clampAgentsDetailScroll() { @@ -527,14 +545,20 @@ func (m *Model) clampAgentsDetailScroll() { return } total, window := m.agentsDetailMetrics() + _, _, width, _ := m.agentsListGeometry() + clamp := func(control boundedViewport) boundedViewport { + control.setGeometry(width, window, 0, boundedClip) + _ = control.window(total) + return control + } switch m.agentsTab { case tabSubagents: if m.subagents.view == subagentFocus { - m.subagents.scroll = clampScroll(m.subagents.scroll, total, window) + m.subagents.detail = clamp(m.subagents.detail) } case tabTeams: if m.team.view == teamFocus || m.team.view == teamTasks || m.team.view == teamFindings { - m.team.scroll = clampScroll(m.team.scroll, total, window) + m.team.detail = clamp(m.team.detail) } } } @@ -558,9 +582,8 @@ func (m Model) agentsDetailMetrics() (int, int) { case tabSubagents: if ln := findFleetLane(m.conv.subagentFleet, m.subagents.child); ln != nil { total := len(renderedTraceLines(th, hk, layout.bodyWidth, ln.trace)) - return windowFor(total, teamFocusRows, func(height int) string { - return renderSubagentFocusAt(th, m.conv.subagentFleet, m.subagents.child, m.subagents.scroll, hk, layout.bodyWidth, height) - }) + build := prepareSubagentFocusAt(th, m.conv.subagentFleet, m.subagents.child, m.subagents.detail, hk, layout.bodyWidth) + return windowFor(total, teamFocusRows, build) } case tabTeams: if b := m.conv.latestTeamBlock(); b != nil { @@ -568,20 +591,17 @@ func (m Model) agentsDetailMetrics() (int, int) { case teamFocus: if ln := teamFindLane(b, m.team.member); ln != nil { total := len(renderedTraceLines(th, hk, layout.bodyWidth, ln.trace)) - return windowFor(total, teamFocusRows, func(height int) string { - return renderTeamFocusAt(th, b, m.team.member, m.team.scroll, hk, layout.bodyWidth, height) - }) + build := prepareTeamFocusAt(th, b, m.team.member, m.team.detail, hk, layout.bodyWidth) + return windowFor(total, teamFocusRows, build) } case teamTasks: total := len(renderedTaskLines(th, b, layout.bodyWidth)) - return windowFor(total, teamTasksRows, func(height int) string { - return renderTeamTasksAt(th, b, m.team.scroll, hk, height, layout.bodyWidth) - }) + build := prepareTeamTasksAt(th, b, m.team.detail, hk, layout.bodyWidth) + return windowFor(total, teamTasksRows, build) case teamFindings: total := len(renderedFindingLines(th, b, layout.bodyWidth)) - return windowFor(total, teamFindingsRows, func(height int) string { - return renderTeamFindingsAt(th, b, m.team.scroll, hk, height, layout.bodyWidth) - }) + build := prepareTeamFindingsAt(th, b, m.team.detail, hk, layout.bodyWidth) + return windowFor(total, teamFindingsRows, build) } } } @@ -971,13 +991,13 @@ func prepareTeamsTab(th theme.Theme, st teamState, b *block, hk helpKeys, bodyWi } switch st.view { case teamFocus: - return prepareTeamFocusAt(th, b, st.member, st.scroll, hk, bodyWidth) + return prepareTeamFocusAt(th, b, st.member, st.detail, hk, bodyWidth) case teamTasks: - return prepareTeamTasksAt(th, b, st.scroll, hk, bodyWidth) + return prepareTeamTasksAt(th, b, st.detail, hk, bodyWidth) case teamFindings: - return prepareTeamFindingsAt(th, b, st.scroll, hk, bodyWidth) + return prepareTeamFindingsAt(th, b, st.detail, hk, bodyWidth) default: - list := teamSelectableList(th, st, b, hk, bodyWidth).withRowHeights() + list := teamSelectableList(th, st, b, hk, bodyWidth) return func(height int) string { return list.render(th, height) } } } @@ -991,13 +1011,13 @@ func renderSubagentTab(th theme.Theme, st subagentState, fleet []subagentLane, h func prepareSubagentTab(th theme.Theme, st subagentState, fleet []subagentLane, hk helpKeys, bodyWidth int) agentsBodyRenderer { if st.view == subagentFocus { - return prepareSubagentFocusAt(th, fleet, st.child, st.scroll, hk, bodyWidth) + return prepareSubagentFocusAt(th, fleet, st.child, st.detail, hk, bodyWidth) } if len(fleet) == 0 { body := renderSubagentRoster(th, st, fleet, hk, 0, bodyWidth) return func(int) string { return body } } - list := subagentSelectableList(th, st, fleet, hk, bodyWidth).withRowHeights() + list := subagentSelectableList(th, st, fleet, hk, bodyWidth) return func(height int) string { return list.render(th, height) } } @@ -1338,14 +1358,14 @@ const childIDHashLen = 6 // height-bounded to the rows that fit. A focused ChildID with no matching lane (the // child vanished — defensive) reads as a muted note. It mirrors renderTeamFocus. func renderSubagentFocus(th theme.Theme, fleet []subagentLane, child string, hk helpKeys, bodyWidth, height int) string { - return renderSubagentFocusAt(th, fleet, child, 0, hk, bodyWidth, height) + return renderSubagentFocusAt(th, fleet, child, boundedViewport{}, hk, bodyWidth, height) } -func renderSubagentFocusAt(th theme.Theme, fleet []subagentLane, child string, scroll int, hk helpKeys, bodyWidth, height int) string { - return prepareSubagentFocusAt(th, fleet, child, scroll, hk, bodyWidth)(height) +func renderSubagentFocusAt(th theme.Theme, fleet []subagentLane, child string, detail boundedViewport, hk helpKeys, bodyWidth, height int) string { + return prepareSubagentFocusAt(th, fleet, child, detail, hk, bodyWidth)(height) } -func prepareSubagentFocusAt(th theme.Theme, fleet []subagentLane, child string, scroll int, hk helpKeys, bodyWidth int) agentsBodyRenderer { +func prepareSubagentFocusAt(th theme.Theme, fleet []subagentLane, child string, detail boundedViewport, hk helpKeys, bodyWidth int) agentsBodyRenderer { muted := th.Style("muted") ln := findFleetLane(fleet, child) if ln == nil { @@ -1389,23 +1409,22 @@ func prepareSubagentFocusAt(th theme.Theme, fleet []subagentLane, child string, out.WriteString("\n\n") prefix := out.String() - r := &renderer{th: th, marks: hk, traceWidth: bodyWidth} - var traceLines []string - if trace := r.renderTrace(ln.trace); trace != "" { - traceLines = strings.Split(trace, "\n") - } + traceLines := renderedTraceLines(th, hk, bodyWidth, ln.trace) lead := focusBackHint(hk) if !ln.done { lead = hk.cancelChild + " cancel · " + lead } return func(height int) string { - w := renderedLineWindow(scroll, len(traceLines), teamFocusRows(height)) + control := detail + control.setGeometry(bodyWidth, teamFocusRows(height), 0, boundedClip) + view := control.view(traceLines) body := prefix if len(traceLines) == 0 { body += muted.Render("(no activity yet)") } else { - body += strings.Join(traceLines[w.start:w.end], "\n") + body += strings.Join(view.rows, "\n") } + w := boundedViewportBounds(view, control.height) hint := agentsDetailHint(hk, w, lead) return body + "\n\n" + renderDynamicCardChromeLine(muted, "", hint, bodyWidth) } @@ -1452,7 +1471,7 @@ func prepareParallelTab(th theme.Theme, st parallelState, groups []parallelGroup body := renderParallelRoster(th, st, groups, hk, 0, width) return func(int) string { return body } } - list := parallelSelectableList(th, st, groups, hk, width).withRowHeights() + list := parallelSelectableList(th, st, groups, hk, width) return func(height int) string { return list.render(th, height) } } @@ -1613,7 +1632,7 @@ func prepareParallelGroupFocus(th theme.Theme, st parallelState, groups []parall renderDynamicCardChromeLine(muted, "", focusBackHint(hk), bodyWidth) return func(int) string { return body } } - list := parallelBranchSelectableList(th, st, g, hk, bodyWidth).withRowHeights() + list := parallelBranchSelectableList(th, st, g, hk, bodyWidth) return func(height int) string { return list.render(th, height) } } diff --git a/cmd/mecatui/ui/agents_overlay_preparation_test.go b/cmd/mecatui/ui/agents_overlay_preparation_test.go index fb0302bb81..644fc68d9b 100644 --- a/cmd/mecatui/ui/agents_overlay_preparation_test.go +++ b/cmd/mecatui/ui/agents_overlay_preparation_test.go @@ -72,7 +72,7 @@ func TestAgentsOverlayPreparedRenderingMatchesRepeatedPreparation(t *testing.T) } views := []view{ {name: "subagent roster", tab: tabSubagents, sub: subagentState{cursor: 1}, f: fleet}, - {name: "subagent focus", tab: tabSubagents, sub: subagentState{view: subagentFocus, child: "selected", scroll: 3}, f: fleet}, + {name: "subagent focus", tab: tabSubagents, sub: subagentState{view: subagentFocus, child: "selected", detail: boundedViewport{offset: 3}}, f: fleet}, {name: "subagent missing", tab: tabSubagents, sub: subagentState{view: subagentFocus, child: "missing"}}, {name: "subagent empty", tab: tabSubagents}, {name: "parallel roster", tab: tabParallel, par: parallelState{cursor: 1}, g: groups}, @@ -80,11 +80,11 @@ func TestAgentsOverlayPreparedRenderingMatchesRepeatedPreparation(t *testing.T) {name: "parallel missing", tab: tabParallel, par: parallelState{view: parallelGroupView, group: "missing"}}, {name: "parallel empty", tab: tabParallel}, {name: "team roster", tab: tabTeams, team: teamState{cursor: 1}, b: teamBlock}, - {name: "team focus", tab: tabTeams, team: teamState{view: teamFocus, member: "worker", scroll: 4}, b: teamBlock}, + {name: "team focus", tab: tabTeams, team: teamState{view: teamFocus, member: "worker", detail: boundedViewport{offset: 4}}, b: teamBlock}, {name: "team missing", tab: tabTeams, team: teamState{view: teamFocus, member: "missing"}, b: teamBlock}, - {name: "team tasks", tab: tabTeams, team: teamState{view: teamTasks, scroll: 2}, b: teamBlock}, + {name: "team tasks", tab: tabTeams, team: teamState{view: teamTasks, detail: boundedViewport{offset: 2}}, b: teamBlock}, {name: "team tasks empty", tab: tabTeams, team: teamState{view: teamTasks}, b: &block{}}, - {name: "team findings", tab: tabTeams, team: teamState{view: teamFindings, scroll: 2}, b: teamBlock}, + {name: "team findings", tab: tabTeams, team: teamState{view: teamFindings, detail: boundedViewport{offset: 2}}, b: teamBlock}, {name: "team findings empty", tab: tabTeams, team: teamState{view: teamFindings}, b: &block{}}, {name: "team absent", tab: tabTeams}, } @@ -108,10 +108,67 @@ func TestAgentsOverlayPreparedRenderingMatchesRepeatedPreparation(t *testing.T) } } +func TestPreparedAgentsDetailRenderersKeepSourceViewportAcrossHeightProbes(t *testing.T) { + fleet, _, teamBlock := overlayPreparationFixtures() + th, hk := aztec(), defaultHelpKeys() + const width = 80 + detail := boundedViewport{offset: 1 << 20} + + tests := []struct { + name string + prepare func() agentsBodyRenderer + last string + }{ + { + name: "subagent focus", + prepare: func() agentsBodyRenderer { + return prepareSubagentFocusAt(th, fleet, "selected", detail, hk, width) + }, + last: "trace-19", + }, + { + name: "team focus", + prepare: func() agentsBodyRenderer { + return prepareTeamFocusAt(th, teamBlock, "worker", detail, hk, width) + }, + last: "trace-19", + }, + { + name: "team tasks", + prepare: func() agentsBodyRenderer { + return prepareTeamTasksAt(th, teamBlock, detail, hk, width) + }, + last: "task-19", + }, + { + name: "team findings", + prepare: func() agentsBodyRenderer { + return prepareTeamFindingsAt(th, teamBlock, detail, hk, width) + }, + last: "finding-19", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + prepared := tc.prepare() + _ = prepared(40) + got := prepared(16) + want := tc.prepare()(16) + if got != want { + t.Fatalf("prepared renderer retained state from an earlier height probe\nwant:\n%q\ngot:\n%q", want, got) + } + if !strings.Contains(stripANSIstr(got), tc.last) { + t.Fatalf("end-anchored renderer did not retain %q after height probes:\n%s", tc.last, stripANSIstr(got)) + } + }) + } +} + func BenchmarkAgentsOverlayPreparation(b *testing.B) { fleet, _, _ := overlayPreparationFixtures() th, hk := aztec(), defaultHelpKeys() - sub := subagentState{view: subagentFocus, child: "selected", scroll: 3} + sub := subagentState{view: subagentFocus, child: "selected", detail: boundedViewport{offset: 3}} b.Run("repeated-preparation", func(b *testing.B) { b.ReportAllocs() for b.Loop() { diff --git a/cmd/mecatui/ui/agents_overlay_test.go b/cmd/mecatui/ui/agents_overlay_test.go index 985627ea18..2f2d9ce3fa 100644 --- a/cmd/mecatui/ui/agents_overlay_test.go +++ b/cmd/mecatui/ui/agents_overlay_test.go @@ -1564,13 +1564,13 @@ func TestMecatuiAgentsOverlayFit_Scenario2_CompactFallbackFitsShortViewport(t *t {"subagent empty", "▶ Subagents · esc close", func(m Model) Model { m.team.view, m.agentsTab = teamRoster, tabSubagents; return m }}, {"subagent focus", "▶ subagent nt-kid · esc back", func(m Model) Model { m.team.view, m.agentsTab = teamRoster, tabSubagents - m.subagents = subagentState{view: subagentFocus, child: "subagent-kid", scroll: 3} + m.subagents = subagentState{view: subagentFocus, child: "subagent-kid", detail: boundedViewport{offset: 3}} m.conv.subagentFleet = []subagentLane{{childID: "subagent-kid"}} return m }}, {"subagent missing focus", "▶ subagent t-gone · esc back", func(m Model) Model { m.team.view, m.agentsTab = teamRoster, tabSubagents - m.subagents = subagentState{view: subagentFocus, child: "subagent-gone", scroll: 3} + m.subagents = subagentState{view: subagentFocus, child: "subagent-gone", detail: boundedViewport{offset: 3}} return m }}, {"parallel roster", "▶ Parallel · esc close", func(m Model) Model { @@ -1600,19 +1600,19 @@ func TestMecatuiAgentsOverlayFit_Scenario2_CompactFallbackFitsShortViewport(t *t {"team focus", "▶ agent ann · esc back", func(m Model) Model { b := teamBlock() m.conv.blocks = append(m.conv.blocks, b) - m.team, m.agentsTab = teamState{view: teamFocus, member: "ann", scroll: 3}, tabTeams + m.team, m.agentsTab = teamState{view: teamFocus, member: "ann", detail: boundedViewport{offset: 3}}, tabTeams return m }}, {"team missing focus", "▶ agent bob · esc back", func(m Model) Model { b := teamBlock() m.conv.blocks = append(m.conv.blocks, b) - m.team, m.agentsTab = teamState{view: teamFocus, member: "bob", scroll: 3}, tabTeams + m.team, m.agentsTab = teamState{view: teamFocus, member: "bob", detail: boundedViewport{offset: 3}}, tabTeams return m }}, {"team tasks", "▶ Tasks · esc back", func(m Model) Model { b := teamBlock() m.conv.blocks = append(m.conv.blocks, b) - m.team, m.agentsTab = teamState{view: teamTasks, scroll: 3}, tabTeams + m.team, m.agentsTab = teamState{view: teamTasks, detail: boundedViewport{offset: 3}}, tabTeams return m }}, {"team tasks empty", "▶ Tasks · esc back", func(m Model) Model { @@ -1625,7 +1625,7 @@ func TestMecatuiAgentsOverlayFit_Scenario2_CompactFallbackFitsShortViewport(t *t {"team findings", "▶ Findings · esc back", func(m Model) Model { b := teamBlock() m.conv.blocks = append(m.conv.blocks, b) - m.team, m.agentsTab = teamState{view: teamFindings, scroll: 3}, tabTeams + m.team, m.agentsTab = teamState{view: teamFindings, detail: boundedViewport{offset: 3}}, tabTeams return m }}, {"team findings empty", "▶ Findings · esc back", func(m Model) Model { @@ -1640,7 +1640,7 @@ func TestMecatuiAgentsOverlayFit_Scenario2_CompactFallbackFitsShortViewport(t *t for _, tc := range cases { t.Run(fmt.Sprintf("h%d/%s", height, tc.name), func(t *testing.T) { m := tc.setup(resize(newMCPModel(t, aztec(), nil), 32, height)) - before := []int{m.subagents.cursor, m.subagents.scroll, m.parallel.cursor, m.parallel.branchCursor, m.team.cursor, m.team.scroll} + before := []int{m.subagents.cursor, m.subagents.detail.offset, m.parallel.cursor, m.parallel.branchCursor, m.team.cursor, m.team.detail.offset} _ = m.View() // exercise the final view assembly before inspecting its overlay region. body := stripANSIstr(m.renderBody()) if got := lipgloss.Height(body); got != 1 { @@ -1654,7 +1654,7 @@ func TestMecatuiAgentsOverlayFit_Scenario2_CompactFallbackFitsShortViewport(t *t } mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) m = mm.(Model) - after := []int{m.subagents.cursor, m.subagents.scroll, m.parallel.cursor, m.parallel.branchCursor, m.team.cursor, m.team.scroll} + after := []int{m.subagents.cursor, m.subagents.detail.offset, m.parallel.cursor, m.parallel.branchCursor, m.team.cursor, m.team.detail.offset} if !slices.Equal(before, after) { t.Fatalf("compact navigation changed state: before=%v after=%v", before, after) } @@ -1917,6 +1917,10 @@ func TestMecatuiAgentsOverlayFit_Scenario2_RosterPagingMatchesRenderedWindow(t * if got := tc.cursor(m); got != total-1 { t.Fatalf("custom JumpEnd cursor = %d, want %d", got, total-1) } + ended := stripANSIstr(m.View().Content) + if !strings.Contains(ended, "▶") || !strings.Contains(ended, tc.labels[total-1]) { + t.Fatalf("JumpEnd did not reveal the final selected item:\n%s", ended) + } m = press(t, m, 'p') if got := tc.cursor(m); got >= total-1 || got < 0 { t.Fatalf("custom Page Up cursor = %d, want an earlier bounded item", got) diff --git a/cmd/mecatui/ui/bounded_scroll_adoption_test.go b/cmd/mecatui/ui/bounded_scroll_adoption_test.go index 94c714f926..63bdf551a9 100644 --- a/cmd/mecatui/ui/bounded_scroll_adoption_test.go +++ b/cmd/mecatui/ui/bounded_scroll_adoption_test.go @@ -39,8 +39,23 @@ func TestMecatuiBoundedScrollCursor_Scenario2_AllAgentsSubviewsFitOfferedGeometr t.Run(fmt.Sprintf("%s/%dx%d", tc.name, size.width, size.height), func(t *testing.T) { out := renderAgentsOverlay(th, tc.tab, tc.sub, tc.par, tc.team, team, fleet, groups, hk, size.width, size.height, size.height) assertRenderedFits(t, out, size.width, size.height) - if size.height < 24 && strings.Contains(stripANSIstr(out), "select") { - t.Fatalf("compact agents view exposed navigation beyond Escape:\n%s", stripANSIstr(out)) + plain := stripANSIstr(out) + if size.height < 24 { + if !strings.Contains(plain, "▶") || strings.Contains(plain, "select") || strings.Contains(plain, "vp short") { + t.Fatalf("compact agents view lost fallback identity or exposed navigation:\n%s", plain) + } + } else if strings.Contains(plain, "vp short") || !strings.Contains(plain, "▸") { + t.Fatalf("normal agents view used fallback or lost active-tab identity:\n%s", plain) + } else if size.width >= 160 { + want := map[string]string{ + "subagent roster": "goal-08", "subagent focus": "trace-00-00", + "parallel roster": "join-08", "parallel group": "branch-3", + "team roster": "member-08", "team focus": "trace-member-00-00", + "team tasks": "task-00", "team findings": "finding-00", + }[tc.name] + if !strings.Contains(plain, want) { + t.Fatalf("normal agents view lost expected %q content:\n%s", want, plain) + } } }) } @@ -70,7 +85,7 @@ func TestMecatuiBoundedScrollCursor_Scenario2_AgentsModesUseSharedAccounting(t * }) } for _, view := range []teamView{teamTasks, teamFindings} { - st := teamState{view: view, scroll: 999} + st := teamState{view: view, detail: boundedViewport{offset: 999}} out := renderTeamsTab(th, st, team, hk, 42, 14) if plain := stripANSIstr(out); !strings.Contains(plain, "of 10") || !strings.Contains(plain, "09") { t.Fatalf("bounded team detail lacks final content/overflow for view %d:\n%s", view, plain) @@ -84,8 +99,31 @@ func TestMecatuiBoundedScrollCursor_Scenario2_ModelsFitsOfferedGeometry(t *testi s := boundedScenarioModelsState(t, 20) out, _ := s.Render(size.width, size.height) assertRenderedFits(t, out, size.width, size.height) + plain := stripANSIstr(out) + if size.width >= 32 && size.height >= 12 && (!strings.Contains(plain, "Models") || !strings.Contains(plain, "provider")) { + t.Fatalf("normal Models geometry lost surface or row identity:\n%s", plain) + } + if size.width <= 1 && plain == "" { + t.Fatal("tiny Models geometry lost its bounded surface identity") + } }) } + + t.Run("Page Down uses wrapped item geometry", func(t *testing.T) { + s := boundedScenarioModelsState(t, 3) + s.filtered[0].DisplayName = strings.Repeat("oversized ", 20) + s.catalog.models = s.filtered + fixed := modelsPanelFixedRows(*s, "", s.deps.marks) + _, _ = s.Render(24, fixed+3) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if s.cursor != 0 || s.list.cursorLine == 0 { + t.Fatalf("Page Down skipped wrapped item segment: cursor=%d line=%d", s.cursor, s.list.cursorLine) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgUp}) + if s.cursor != 0 || s.list.cursorLine != 0 { + t.Fatalf("Page Up did not return through wrapped item: cursor=%d line=%d", s.cursor, s.list.cursorLine) + } + }) } func TestMecatuiBoundedScrollCursor_Scenario2_CursorAndStatusStylesStayDistinct(t *testing.T) { @@ -141,13 +179,23 @@ func TestMecatuiBoundedScrollCursor_Scenario3_AgentsWheelSubviewMatrix(t *testin for _, mode := range []string{"subagent-roster", "subagent-focus", "parallel-roster", "parallel-group", "team-roster", "team-focus", "team-tasks", "team-findings"} { t.Run(mode, func(t *testing.T) { m := boundedScenarioAgentsModel(t, mode) + m.vp.SetContent(strings.Repeat("hidden conversation\n", 80)) + m.vp.SetYOffset(7) beforeCursor := []int{m.subagents.cursor, m.parallel.cursor, m.parallel.branchCursor, m.team.cursor} - before := stripANSIstr(m.View().Content) + for range 200 { + mm, _ := m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + m = mm.(Model) + } + if got := agentsScenarioOffset(m, mode); got != 0 { + t.Fatalf("wheel-up boundary offset = %d, want 0", got) + } mm, _ := m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) m = mm.(Model) - after := stripANSIstr(m.View().Content) - if before == after { - t.Fatalf("wheel did not move visible %s viewport", mode) + if got := agentsScenarioOffset(m, mode); got != 1 { + t.Fatalf("wheel down moved %s offset to %d, want one physical line", mode, got) + } + if m.vp.YOffset() != 7 { + t.Fatalf("wheel over %s leaked to hidden conversation: %d", mode, m.vp.YOffset()) } afterCursor := []int{m.subagents.cursor, m.parallel.cursor, m.parallel.branchCursor, m.team.cursor} for i := range beforeCursor { @@ -155,10 +203,40 @@ func TestMecatuiBoundedScrollCursor_Scenario3_AgentsWheelSubviewMatrix(t *testin t.Fatalf("wheel moved logical cursor: before=%v after=%v", beforeCursor, afterCursor) } } + mm, _ = m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + m = mm.(Model) + if got := agentsScenarioOffset(m, mode); got != 0 { + t.Fatalf("wheel up did not return %s to top: %d", mode, got) + } + + if strings.Contains(mode, "roster") || mode == "parallel-group" { + mm, _, _ = m.onAgentsKey(tea.KeyPressMsg{Code: tea.KeyDown}) + m = mm.(Model) + if plain := stripANSIstr(m.View().Content); !strings.Contains(plain, "▶ ") { + t.Fatalf("keyboard movement after wheel did not reveal selected %s item:\n%s", mode, plain) + } + } }) } } +func agentsScenarioOffset(m Model, mode string) int { + switch mode { + case "subagent-roster": + return m.subagents.roster.viewport.offset + case "subagent-focus": + return m.subagents.detail.offset + case "parallel-roster": + return m.parallel.roster.viewport.offset + case "parallel-group": + return m.parallel.branches.viewport.offset + case "team-roster": + return m.team.roster.viewport.offset + default: + return m.team.detail.offset + } +} + func TestMecatuiBoundedScrollCursor_Scenario3_WheelNeverLeaksOrNavigatesFallback(t *testing.T) { m := boundedScenarioAgentsModel(t, "subagent-roster") m.height = 12 @@ -167,59 +245,126 @@ func TestMecatuiBoundedScrollCursor_Scenario3_WheelNeverLeaksOrNavigatesFallback before := m.subagents mm, _ := m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) m = mm.(Model) - if m.vp.YOffset() != 5 || m.subagents.cursor != before.cursor { - t.Fatalf("compact agents wheel leaked or navigated: vp=%d cursor=%d", m.vp.YOffset(), m.subagents.cursor) + if m.vp.YOffset() != 5 || m.subagents.cursor != before.cursor || m.subagents.roster.viewport.offset != before.roster.viewport.offset { + t.Fatalf("compact agents wheel leaked or navigated: vp=%d cursor=%d offset=%d", m.vp.YOffset(), m.subagents.cursor, m.subagents.roster.viewport.offset) } - models := boundedScenarioModelsState(t, 2) + m = boundedScenarioAgentsModel(t, "team-tasks") + m.height = 30 + m.vp.SetHeight(1) + m.vp.SetContent(strings.Repeat("conversation\n", 40)) + m.vp.SetYOffset(5) + mm, _ = m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = mm.(Model) + if m.vp.YOffset() != 5 || m.team.detail.offset != 0 { + t.Fatalf("vp-short agents wheel leaked or navigated: vp=%d offset=%d", m.vp.YOffset(), m.team.detail.offset) + } + + models := boundedScenarioModelsState(t, 20) root := resize(newMCPModel(t, aztec(), nil), 80, 30) root.modal = models root.vp.SetContent(strings.Repeat("conversation\n", 40)) root.vp.SetYOffset(5) - mm, _ = root.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) - root = mm.(Model) - if got := root.vp.YOffset(); got != 5 { - t.Fatalf("models wheel leaked to hidden conversation: offset=%d", got) + _, _ = models.Render(40, 12) + cursor := models.cursor + for range 200 { + mm, _ = root.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + root = mm.(Model) + } + bottom := models.list.viewport.offset + if bottom == 0 || models.cursor != cursor || root.vp.YOffset() != 5 { + t.Fatalf("Models wheel-down boundary leaked or moved cursor: offset=%d cursor=%d vp=%d", bottom, models.cursor, root.vp.YOffset()) + } + for range 200 { + mm, _ = root.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + root = mm.(Model) + } + if models.list.viewport.offset != 0 || models.cursor != cursor || root.vp.YOffset() != 5 { + t.Fatalf("Models wheel-up boundary leaked or moved cursor: offset=%d cursor=%d vp=%d", models.list.viewport.offset, models.cursor, root.vp.YOffset()) } } func TestMecatuiBoundedScrollCursor_Scenario3_ModelClickSelectsEnterActivates(t *testing.T) { - s := boundedScenarioModelsState(t, 12) - _, regions := s.Render(60, 18) - if len(regions) < 2 { - t.Fatalf("models rendered %d row hit regions, want at least 2", len(regions)) + m := newModelsModel(t, sampleModels(), &fakeStore{}, modelsCaps(), client.ModelSelection{}) + m.deps.NoAltScreen = false + mm, cmd := m.runModels() + m = feedCmd(t, mm.(Model), cmd) + s := modelsSurface(t, m) + s.filtered[1].DisplayName = strings.Repeat("wrapped model identity ", 5) + m = resize(m, 40, 60) + _ = m.View() + s = modelsSurface(t, m) + var first renderedHitRegion + var second []renderedHitRegion + for _, region := range m.hits.frame { + switch s.hitItems[region.id] { + case 0: + if first.id == 0 { + first = region + } + case 1: + second = append(second, region) + } } - second := regions[1] - _, handled, closed := s.HandleMsg(surfaceHitMsg{ID: second.hit, X: 1, Y: 0}) - if !handled || closed || s.cursor != 1 || s.intent != nil { - t.Fatalf("click should move cursor only: handled=%v closed=%v cursor=%d intent=%T", handled, closed, s.cursor, s.intent) + if first.id == 0 || len(second) < 2 { + t.Fatalf("real Models frame hits: first=%d wrapped-second=%d, want marker and continuation regions", first.id, len(second)) } - _, handled, closed = s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) - if !handled || !closed { - t.Fatalf("Enter did not retain activation: handled=%v closed=%v", handled, closed) + globalX, globalY := m.metrics.localToGlobal(first.rect.x0, first.rect.y0) + mm, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: globalX, Y: globalY}) + m = mm.(Model) + if got := modelsSurface(t, m).cursor; got != 0 { + t.Fatalf("marker-cell click selected %d, want first model", got) } - if _, ok := s.intent.(modelsSelectIntent); !ok { - t.Fatalf("Enter intent = %T, want modelsSelectIntent", s.intent) + continuation := second[len(second)-1] + globalX, globalY = m.metrics.localToGlobal(continuation.rect.x0+1, continuation.rect.y0) + mm, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: globalX, Y: globalY}) + m = mm.(Model) + s = modelsSurface(t, m) + if s.cursor != 1 || s.intent != nil { + t.Fatalf("click should move cursor only: cursor=%d intent=%T", s.cursor, s.intent) + } + mm, _, _ = m.onOverlayKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = mm.(Model) + if m.modal != nil || m.phase != phaseConnecting { + t.Fatalf("Enter did not retain activation: modal=%T phase=%v", m.modal, m.phase) } } func TestMecatuiBoundedScrollCursor_Scenario3_StaleModelHitsIgnored(t *testing.T) { - s := boundedScenarioModelsState(t, 8) - _, first := s.Render(60, 18) - if len(first) == 0 { - t.Fatal("models rendered no row hits") - } - stale := first[0].hit - _, _ = s.Render(40, 12) - s.cursor = 3 - _, handled, closed := s.HandleMsg(surfaceHitMsg{ID: stale}) - if !handled || closed || s.cursor != 3 || s.intent != nil { - t.Fatalf("stale hit changed models state: handled=%v closed=%v cursor=%d intent=%T", handled, closed, s.cursor, s.intent) - } - s.Close() - _, handled, closed = s.HandleMsg(surfaceHitMsg{ID: stale}) - if handled || closed || s.cursor != 3 || s.intent != nil { - t.Fatalf("closed-surface hit changed state: handled=%v closed=%v cursor=%d intent=%T", handled, closed, s.cursor, s.intent) + m := newModelsModel(t, sampleModels(), &fakeStore{}, modelsCaps(), client.ModelSelection{}) + m.deps.NoAltScreen = false + mm, cmd := m.runModels() + m = feedCmd(t, mm.(Model), cmd) + _ = m.View() + if len(m.hits.frame) == 0 { + t.Fatal("Models frame rendered no row hits") + } + stale := m.hits.frame[0].id + _ = m.View() + s := modelsSurface(t, m) + s.cursor = 2 + s.list.setCursor(2) + before := s.cursor + mm, _ = m.Update(surfaceHitMsg{ID: stale}) + m = mm.(Model) + if got := modelsSurface(t, m).cursor; got != before { + t.Fatalf("old-frame hit changed cursor: got %d want %d", got, before) + } + + // Chrome, trailing blank space, and out-of-bounds coordinates all miss through + // the same frame registry and remain owned by the open Models surface. + for _, point := range [][2]int{{0, 0}, {m.width - 1, m.metrics.contentOrigin.y}, {-1, -1}} { + mm, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: point[0], Y: point[1]}) + m = mm.(Model) + if got := modelsSurface(t, m).cursor; got != before { + t.Fatalf("miss at %v changed cursor: got %d want %d", point, got, before) + } + } + m.closeModal() + mm, _ = m.Update(surfaceHitMsg{ID: stale}) + m = mm.(Model) + if m.modal != nil { + t.Fatal("closed-surface stale hit reopened Models") } } diff --git a/cmd/mecatui/ui/bounded_scroll_cursor_test.go b/cmd/mecatui/ui/bounded_scroll_cursor_test.go index b099a980fb..c70b4c290f 100644 --- a/cmd/mecatui/ui/bounded_scroll_cursor_test.go +++ b/cmd/mecatui/ui/bounded_scroll_cursor_test.go @@ -1,6 +1,7 @@ package ui import ( + "reflect" "strings" "testing" @@ -208,6 +209,17 @@ func TestMecatuiBoundedScrollCursor_Scenario1_ClampsContentAndDegenerateBounds(t if got := list.view(); len(got.rows) == 0 || got.rows[0].gutter != "" { t.Fatalf("zero-gutter list geometry = %#v, want visible rows with no gutter", got) } + list.viewport.offset = 3 + list.setGeometry(12, 4, 0, boundedClip) + if list.viewport.offset != 1 || len(list.view().rows) != 4 { + t.Fatalf("valid geometry growth left a blank page: offset=%d rows=%d, want 1/4", list.viewport.offset, len(list.view().rows)) + } + viewportType := reflect.TypeOf(boundedViewport{}) + for _, name := range []string{"x", "xOffset", "horizontalOffset"} { + if _, ok := viewportType.FieldByName(name); ok { + t.Fatalf("boundedViewport exposes forbidden horizontal navigation state %q", name) + } + } list.setGeometry(2, 2, 2, boundedWrap) if got := list.view(); len(got.rows) != 0 || got.above != 0 || got.below != 0 { t.Fatalf("width smaller than gutter+content rendered %#v", got) @@ -229,6 +241,7 @@ func TestMecatuiBoundedScrollCursor_Scenario1_RefreshPreservesSemanticAnchors(t {id: "c", text: "c0"}, }) list.setCursor(1) // minimally reveals b with top anchor {a,1} + list.cursorLine = 2 if list.viewport.offset != 1 { t.Fatalf("initial top offset = %d, want 1", list.viewport.offset) } @@ -239,8 +252,8 @@ func TestMecatuiBoundedScrollCursor_Scenario1_RefreshPreservesSemanticAnchors(t {id: "a", text: "a0\na1\na2"}, {id: "c", text: "c0"}, }) - if list.cursorID != "b" || list.cursor != 1 { - t.Fatalf("refresh lost selected stable ID: cursor=(%d,%q)", list.cursor, list.cursorID) + if list.cursorID != "b" || list.cursor != 1 || list.cursorLine != 2 { + t.Fatalf("refresh lost selected stable ID/line: cursor=(%d,%q,%d), want (1,b,2)", list.cursor, list.cursorID, list.cursorLine) } if top := list.view().rows[0]; top.id != "a" || top.itemLine != 1 { t.Fatalf("refresh top anchor = {%q,%d}, want {a,1}", top.id, top.itemLine) @@ -279,4 +292,13 @@ func TestMecatuiBoundedScrollCursor_Scenario1_RefreshPreservesSemanticAnchors(t if list.cursorID != "d" { t.Fatalf("missing top ID disturbed selected ID: %q", list.cursorID) } + + var lineClamp boundedList + lineClamp.setGeometry(20, 2, 2, boundedClip) + lineClamp.setItems([]boundedListItem{{id: "selected", text: "s0\ns1\ns2"}}) + lineClamp.cursorLine = 2 + lineClamp.setItems([]boundedListItem{{id: "selected", text: "short"}}) + if lineClamp.cursorID != "selected" || lineClamp.cursorLine != 0 { + t.Fatalf("selected line did not clamp after height shrink: id=%q line=%d", lineClamp.cursorID, lineClamp.cursorLine) + } } diff --git a/cmd/mecatui/ui/card_layout_focus_scroll_test.go b/cmd/mecatui/ui/card_layout_focus_scroll_test.go index e7ce594424..0b1b8e1170 100644 --- a/cmd/mecatui/ui/card_layout_focus_scroll_test.go +++ b/cmd/mecatui/ui/card_layout_focus_scroll_test.go @@ -52,8 +52,8 @@ func TestMecatuiCardLayout_Scenario3_FocusAndScrollableViewsRemainUsable(t *test picker.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) out, _ = picker.Render(width, modelsPanelFixedRows(*picker, "", hk)+3) assertFits(t, "models paged", out) - if picker.cursor != 3 || !strings.Contains(stripANSIstr(out), "▶") { - t.Fatalf("paged models selection = %d, selected marker missing from %q", picker.cursor, stripANSIstr(out)) + if picker.cursor != 0 || picker.list.cursorLine == 0 || !strings.Contains(stripANSIstr(out), "▶") { + t.Fatalf("paged oversized model = cursor %d line %d, selected marker missing from %q", picker.cursor, picker.list.cursorLine, stripANSIstr(out)) } }) diff --git a/cmd/mecatui/ui/card_layout_regions_test.go b/cmd/mecatui/ui/card_layout_regions_test.go index ddd09ca701..62bca116b4 100644 --- a/cmd/mecatui/ui/card_layout_regions_test.go +++ b/cmd/mecatui/ui/card_layout_regions_test.go @@ -226,8 +226,9 @@ func TestMecatuiCardLayout_Scenario3_InventoryAndMCPFitWidth(t *testing.T) { t.Run("models", func(t *testing.T) { catalog := modelCatalog{models: []client.ModelInfo{{ProviderID: long, ID: long, DisplayName: long}}, statuses: []client.ProviderStatus{{ProviderID: long, State: "unreachable", Hint: long}}} - picker := modelsState{catalog: catalog, filtered: catalog.models} - assertFits(t, "models", renderModelsPanel(th, catalog, picker, client.Capabilities{ModelSelection: true}, long, hk, 3, width)) + picker := modelsState{catalog: catalog, filtered: catalog.models, provenance: long, deps: surfaceDeps{theme: th, keys: defaultKeys(), marks: hk, caps: client.Capabilities{ModelSelection: true}}} + out, _ := picker.Render(width, 30) + assertFits(t, "models", out) }) t.Run("sessions and worktrees", func(t *testing.T) { diff --git a/cmd/mecatui/ui/keymark_liveness_test.go b/cmd/mecatui/ui/keymark_liveness_test.go index 363ce7acaf..a33e522194 100644 --- a/cmd/mecatui/ui/keymark_liveness_test.go +++ b/cmd/mecatui/ui/keymark_liveness_test.go @@ -780,8 +780,9 @@ func TestModelsOverlayHintsReflectKeyOverride(t *testing.T) { hk := liveHK() th := theme.New("aztec", theme.AztecPalette()) catalog := modelCatalog{models: []client.ModelInfo{{ID: "m1"}}} - picker := modelsState{view: modelsPanel, filtered: []client.ModelInfo{{ID: "m1"}}} - got := stripANSIstr(renderModelsPanel(th, catalog, picker, client.Capabilities{ModelSelection: true}, "", hk, modelsRowBudgetFor(30, modelsPanelFixedRows(picker, "", hk)))) + picker := modelsState{view: modelsPanel, catalog: catalog, filtered: catalog.models, deps: surfaceDeps{theme: th, keys: defaultKeys(), marks: hk, caps: client.Capabilities{ModelSelection: true}}} + rendered, _ := picker.Render(200, 30) + got := stripANSIstr(rendered) if !strings.Contains(got, "↑/↓/ctrl+f14 move · ctrl+f17 use · ctrl+f25 set global default · ctrl+f16 clear filter / close") { t.Errorf("models hint should carry live page/use/set/close: %q", got) } diff --git a/cmd/mecatui/ui/models_surface.go b/cmd/mecatui/ui/models_surface.go index 2a9480c580..38bbd7c3e7 100644 --- a/cmd/mecatui/ui/models_surface.go +++ b/cmd/mecatui/ui/models_surface.go @@ -13,7 +13,6 @@ import ( "charm.land/lipgloss/v2" "github.com/stacklok/mecatl/cmd/mecatui/client" - "github.com/stacklok/mecatl/cmd/mecatui/theme" ) // modelsView is the active /models overlay. @@ -22,7 +21,6 @@ type modelsView int const ( modelsNone modelsView = iota modelsPanel - modelsMinRows = 3 ) // modelsState owns the dynamic /models picker. Durable catalog state remains on Model. @@ -237,15 +235,7 @@ func (s *modelsState) syncList(width, height int) bool { func (s *modelsState) moveCursor(move boundedMove) { s.revealCursor = true if s.list.viewport.valid() { - if move == boundedPageUp || move == boundedPageDown { - delta := max(1, s.rowBudget) - if move == boundedPageUp { - delta = -delta - } - s.list.setCursor(s.cursor + delta) - } else { - s.list.move(move) - } + s.list.move(move) s.cursor = s.list.cursor return } @@ -330,13 +320,20 @@ func modelsFixedLines(picker modelsState, prov string) (prefix, suffix []string) } else if len(picker.filtered) == 0 { prefix = append(prefix, picker.deps.theme.Style("muted").Render("no models match "+strconv.Quote(picker.filter.Value())+" — "+picker.deps.marks.closeOnly+" to clear")) } - for _, status := range renderProviderStatusLines(picker.catalog.statuses, len(picker.catalog.models) == 0) { + statuses := renderProviderStatusLines(picker.catalog.statuses, len(picker.catalog.models) == 0) + if len(statuses) > 0 && !picker.loading && picker.err == nil && len(picker.catalog.models) > 0 && len(picker.filtered) > 0 { + suffix = append(suffix, "") + } + for _, status := range statuses { suffix = append(suffix, picker.deps.theme.Style("errorText").Render(sanitizeTerminal(status))) } suffix = append(suffix, "", picker.deps.theme.Style("muted").Render("type to filter · ↑/↓/"+picker.deps.marks.scrollUp+" move · "+picker.deps.marks.choose+" use · "+picker.deps.marks.setGlobalDefault+" set global default · "+picker.deps.marks.closeOnly+" clear filter / close"), picker.deps.theme.Style("muted").Render("● current ★ global default"), picker.deps.theme.Style("muted").Render("reason = emits reasoning · set its effort tier with /effort")) + if anyUncachedModel(picker.catalog.models) { + suffix = append(suffix, picker.deps.theme.Style("muted").Render("no-cache = no prompt-cache breakpoint sent; costly for Claude models")) + } return prefix, suffix } @@ -477,66 +474,6 @@ func renderProviderStatusLines(statuses []client.ProviderStatus, inventoryEmpty return lines } -func renderModelsPanel(th theme.Theme, catalog modelCatalog, picker modelsState, caps client.Capabilities, prov string, hk helpKeys, rowBudget int, widths ...int) string { - width := 0 - if len(widths) > 0 { - width = widths[0] - } - var b strings.Builder - title := "Models" - if !picker.loading && picker.err == nil && len(picker.filtered) > 0 { - start, end := scrollWindow(picker.cursor, len(picker.filtered), rowBudget) - title += " " + modelsPositionLabel(start, end, len(picker.filtered)) - } - b.WriteString(th.Style("askTitle").Render(title) + "\n") - if prov != "" { - b.WriteString(renderToolCardText(th.Style("muted"), prov, width) + "\n") - } - b.WriteString(picker.filter.View() + "\n\n") - b.WriteString(th.Style("warning").Render(modelSwitchDisclosure) + "\n\n") - switch { - case picker.loading: - b.WriteString(th.Style("muted").Render("loading…") + "\n") - case picker.err != nil: - b.WriteString(th.Style("errorText").Render("✗ list models: "+sanitizeTerminal(picker.err.Error())) + "\n") - b.WriteString(th.Style("muted").Render(modelsErrorHint) + "\n") - case len(catalog.models) == 0: - b.WriteString(th.Style("muted").Render(modelsEmptyCopy(caps, catalog.statuses)) + "\n") - case len(picker.filtered) == 0: - b.WriteString(th.Style("muted").Render("no models match "+strconv.Quote(picker.filter.Value())+" — "+hk.closeOnly+" to clear") + "\n") - default: - start, end := scrollWindow(picker.cursor, len(picker.filtered), rowBudget) - for i := start; i < end; i++ { - mi := picker.filtered[i] - b.WriteString(renderRow(th, modelRowText(catalog.active, catalog.globalDefault, catalog.configProvenanceProviderIDs, mi), i == picker.cursor, width) + "\n") - } - } - if picker.err == nil { - statuses := renderProviderStatusLines(catalog.statuses, len(catalog.models) == 0) - if modelsRowsRendered(picker) && len(statuses) > 0 { - b.WriteString("\n") - } - for _, line := range statuses { - b.WriteString(renderToolCardText(th.Style("errorText"), sanitizeTerminal(line), width) + "\n") - } - } - b.WriteString("\n" + th.Style("muted").Render("type to filter · ↑/↓/"+hk.scrollUp+" move · "+hk.choose+" use · "+hk.setGlobalDefault+" set global default · "+hk.closeOnly+" clear filter / close")) - b.WriteString("\n" + th.Style("muted").Render("● current ★ global default")) - b.WriteString("\n" + th.Style("muted").Render("reason = emits reasoning · set its effort tier with /effort")) - // The no-cache legend is CONDITIONAL: explaining a marker nobody can see is - // noise, and a deployment where everything caches should read clean. - if anyUncachedModel(catalog.models) { - b.WriteString("\n" + th.Style("muted").Render("no-cache = no prompt-cache breakpoint sent; costly for Claude models")) - } - return b.String() -} - -func modelsPositionLabel(start, end, total int) string { - if end-start >= total { - return "(" + strconv.Itoa(total) + ")" - } - return "(" + strconv.Itoa(start+1) + "–" + strconv.Itoa(end) + " of " + strconv.Itoa(total) + ")" -} func modelRowText(active, globalDefault client.ModelSelection, configProvenanceProviderIDs map[string]bool, mi client.ModelInfo) string { activeMark := " " if active.Matches(mi) { diff --git a/cmd/mecatui/ui/models_surface_test.go b/cmd/mecatui/ui/models_surface_test.go index 9860ace0db..65a81d397a 100644 --- a/cmd/mecatui/ui/models_surface_test.go +++ b/cmd/mecatui/ui/models_surface_test.go @@ -83,8 +83,8 @@ func TestModelsSurfaceConsumesWheelBeforeViewport(t *testing.T) { func TestModelsSwitchDisclosureIsOneWarningLine(t *testing.T) { th := theme.New("aztec", theme.AztecPalette()) - picker := modelsState{filter: textinput.New()} - got := renderModelsPanel(th, modelCatalog{}, picker, client.Capabilities{}, "", defaultHelpKeys(), modelsMinRows) + picker := modelsState{filter: textinput.New(), deps: surfaceDeps{theme: th, keys: defaultKeys(), marks: defaultHelpKeys()}} + got, _ := picker.Render(100, 30) if strings.Count(modelSwitchDisclosure, "\n") != 0 { t.Fatalf("disclosure must be one line, got %q", modelSwitchDisclosure) @@ -106,8 +106,9 @@ func TestModelsProviderStatusesUseErrorStyleAndSeparateModelRows(t *testing.T) { filtered: []client.ModelInfo{model}, filter: textinput.New(), } + picker.deps = surfaceDeps{theme: th, keys: defaultKeys(), marks: defaultHelpKeys()} statusLine := providerStatusLine(status) - got := renderModelsPanel(th, picker.catalog, picker, client.Capabilities{}, "", defaultHelpKeys(), modelsMinRows) + got, _ := picker.Render(100, 30) if !strings.Contains(got, th.Style("errorText").Render(statusLine)) { t.Fatalf("provider status must use the error style:\n%s", got) @@ -116,8 +117,8 @@ func TestModelsProviderStatusesUseErrorStyleAndSeparateModelRows(t *testing.T) { t.Fatalf("provider status must not use the muted style:\n%s", got) } plain := stripANSIstr(got) - if !strings.Contains(plain, "openai · GPT-5") || !strings.Contains(plain, "\n\n"+statusLine+"\n") { - t.Fatalf("provider status must be separated from model rows:\n%s", plain) + if !strings.Contains(plain, "openai · GPT-5") || !strings.Contains(plain, statusLine) { + t.Fatalf("provider status and model rows must both render through the real surface:\n%s", plain) } withoutStatus := picker @@ -148,7 +149,7 @@ func TestModelsSurfaceRenderOwnsCurrentPageBudget(t *testing.T) { t.Fatalf("page budget = %d, want Render-derived 4", s.rowBudget) } s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) - if s.cursor != 4 { - t.Fatalf("cursor after pgdown = %d, want current Render page budget 4", s.cursor) + if s.cursor != 3 { + t.Fatalf("cursor after pgdown = %d, want first item after the effective 3-row window", s.cursor) } } diff --git a/cmd/mecatui/ui/models_test.go b/cmd/mecatui/ui/models_test.go index d0a1890811..2e49ac9929 100644 --- a/cmd/mecatui/ui/models_test.go +++ b/cmd/mecatui/ui/models_test.go @@ -929,7 +929,7 @@ func TestModelsErrorRenders(t *testing.T) { // RPC error), must NOT keep rendering the stale status's remediation line // beneath the new, unrelated error — a failed ListModels carries no // statuses. Both the state-clear (updateModelsMsg) and the render-time -// defense (renderModelsPanel gated on st.err == nil) are exercised by +// defense (modelsState.Render gates on st.err == nil) are exercised by // asserting the FINAL rendered view. func TestModelsErrorClearsStaleProviderStatuses(t *testing.T) { statuses := []client.ProviderStatus{ @@ -1381,7 +1381,7 @@ func TestModelsPickerNoActiveMarkerWhenSelectionAbsent(t *testing.T) { if m.modelCatalog.active != kept { t.Fatalf("models.active = %+v, want the kept %+v", m.modelCatalog.active, kept) } - panel := renderModelsPanel(m.deps.Theme, m.modelCatalog, *modelsSurface(t, m), m.caps, "", defaultHelpKeys(), modelsRowBudgetFor(30, modelsPanelFixedRows(*modelsSurface(t, m), "", defaultHelpKeys()))) + panel := m.View().Content rows := strings.Split(stripANSIstr(panel), "\n") for _, row := range rows { if strings.Contains(row, "●") && !strings.Contains(row, "● current") { diff --git a/cmd/mecatui/ui/surface_arch_test.go b/cmd/mecatui/ui/surface_arch_test.go index 8b5464a995..e76e1ac681 100644 --- a/cmd/mecatui/ui/surface_arch_test.go +++ b/cmd/mecatui/ui/surface_arch_test.go @@ -40,7 +40,7 @@ var surfaceFileHomes = map[string]bool{ const surfaceFileCount = 9 // surfaceToken identifies declarations governed by the surface placement gate. -var surfaceToken = regexp.MustCompile(`^(?:surface|surfaceDeps|session[A-Za-z0-9]*|soulView|soulNone|soulPanel|soulBodyLines|soulState|soulMaxScroll|clampSoulScroll|soulContentLines|renderSoulPanel|renderSoulMeta|renderSoulBody|soulDisabledNote|soulTrustLabel|skillsView|skillsNone|skillsPanel|skillsDetail|skillsBodyLines|skillsState|filterSkills|cloneSkillGenerations|skillsDisabledNote|skillsEmptyCopy|skillsRowLines|renderSkillsPanel|renderLearnedSkillDetail|openSkills|closeSkills|onSkillsKey|updateSkillsMsg|skillsFilteredRowTotal|syncSkillsFilter|renderSkillsOverlay|mcpView|mcpNone|mcpPanel|mcpResources|mcpResourcePrev|mcpPrompts|mcpPromptArgs|mcpState|brokerMCPSetupState|canConnect|syncMCPSetup|renderBrokerMCPPanel|brokerEnrollmentLabel|brokerCatalogueLabel|argField|renderMCPOverlay|renderMCPPanel|renderMCPListHeader|renderResourceList|renderResourcePreview|renderPromptList|renderPromptArgs|mcpStatusLine|mcpPanelFooter|renderGroupsLine|renderRow|hasRequiredArgs|mcpEmptyCopy|mcpDisabledNote|runMCP|runMCPResources|runMCPPrompts|openMCP|closeMCP|onMCPKey|updateMCPMsg|insertIntoInput|joinContents|joinPromptMessages|handlePanelKey|handleResourceKey|handlePromptListKey|handlePromptArgsKey|focusArg|selectPrompt|submitPromptArgs|refreshPanel|modelsView|modelsNone|modelsPanel|modelsChrome|modelsMinRows|modelsState|modelsCatalogIntent|modelsSelectIntent|modelsGlobalDefaultIntent|modelsRowBudgetFor|renderModelsPanel|filterModels|clampModelsCursor|modelsDisabledNote|modelsErrorHint|modelsGatewayEmptyNote|modelsEmptyCopy|promotedStatus|providerStatusLine|renderProviderStatusLines|modelsPositionLabel|modelRowText|modelLabel|modelCapSegments|openModels|configProvenanceProviderSet|availableNotDefaultStatus)$`) +var surfaceToken = regexp.MustCompile(`^(?:surface|surfaceDeps|session[A-Za-z0-9]*|soulView|soulNone|soulPanel|soulBodyLines|soulState|soulMaxScroll|clampSoulScroll|soulContentLines|renderSoulPanel|renderSoulMeta|renderSoulBody|soulDisabledNote|soulTrustLabel|skillsView|skillsNone|skillsPanel|skillsDetail|skillsBodyLines|skillsState|filterSkills|cloneSkillGenerations|skillsDisabledNote|skillsEmptyCopy|skillsRowLines|renderSkillsPanel|renderLearnedSkillDetail|openSkills|closeSkills|onSkillsKey|updateSkillsMsg|skillsFilteredRowTotal|syncSkillsFilter|renderSkillsOverlay|mcpView|mcpNone|mcpPanel|mcpResources|mcpResourcePrev|mcpPrompts|mcpPromptArgs|mcpState|brokerMCPSetupState|canConnect|syncMCPSetup|renderBrokerMCPPanel|brokerEnrollmentLabel|brokerCatalogueLabel|argField|renderMCPOverlay|renderMCPPanel|renderMCPListHeader|renderResourceList|renderResourcePreview|renderPromptList|renderPromptArgs|mcpStatusLine|mcpPanelFooter|renderGroupsLine|renderRow|hasRequiredArgs|mcpEmptyCopy|mcpDisabledNote|runMCP|runMCPResources|runMCPPrompts|openMCP|closeMCP|onMCPKey|updateMCPMsg|insertIntoInput|joinContents|joinPromptMessages|handlePanelKey|handleResourceKey|handlePromptListKey|handlePromptArgsKey|focusArg|selectPrompt|submitPromptArgs|refreshPanel|modelsView|modelsNone|modelsPanel|modelsChrome|modelsState|modelsCatalogIntent|modelsSelectIntent|modelsGlobalDefaultIntent|modelsRowBudgetFor|filterModels|clampModelsCursor|modelsDisabledNote|modelsErrorHint|modelsGatewayEmptyNote|modelsEmptyCopy|promotedStatus|providerStatusLine|renderProviderStatusLines|modelRowText|modelLabel|modelCapSegments|openModels|configProvenanceProviderSet|availableNotDefaultStatus)$`) // modelsSymbolHomes pins the deliberate three-way /models split: Model-owned // installation and effects, durable catalog reduction, and dynamic surface behavior. @@ -56,12 +56,12 @@ var modelsSymbolHomes = map[string]string{ "reconcileSelection": "models_catalog.go", "liveModelLabel": "models_catalog.go", "modelProvenanceLine": "models_catalog.go", "modelProvenance": "models_catalog.go", "statusAutoSelected": "models_catalog.go", "configProvenanceProviderSet": "models_catalog.go", "availableNotDefaultStatus": "models_catalog.go", "modelsView": "models_surface.go", "modelsNone": "models_surface.go", "modelsPanel": "models_surface.go", - "modelsChrome": "models_surface.go", "modelsMinRows": "models_surface.go", "modelsState": "models_surface.go", + "modelsChrome": "models_surface.go", "modelsState": "models_surface.go", "modelsCatalogIntent": "models_surface.go", "modelsSelectIntent": "models_surface.go", "modelsGlobalDefaultIntent": "models_surface.go", - "modelsRowBudgetFor": "models_surface.go", "renderModelsPanel": "models_surface.go", "filterModels": "models_surface.go", "clampModelsCursor": "models_surface.go", + "modelsRowBudgetFor": "models_surface.go", "filterModels": "models_surface.go", "clampModelsCursor": "models_surface.go", "modelsDisabledNote": "models_surface.go", "modelsErrorHint": "models_surface.go", "modelsGatewayEmptyNote": "models_surface.go", "modelsEmptyCopy": "models_surface.go", "promotedStatus": "models_surface.go", "providerStatusLine": "models_surface.go", "renderProviderStatusLines": "models_surface.go", - "modelsPositionLabel": "models_surface.go", "modelRowText": "models_surface.go", "modelLabel": "models_surface.go", "modelCapSegments": "models_surface.go", + "modelRowText": "models_surface.go", "modelLabel": "models_surface.go", "modelCapSegments": "models_surface.go", } // TestSurfaceSymbolsLiveInSurfaceFiles walks every non-test ui package file and diff --git a/cmd/mecatui/ui/team.go b/cmd/mecatui/ui/team.go index cf78ba1ce6..c6936b1b6b 100644 --- a/cmd/mecatui/ui/team.go +++ b/cmd/mecatui/ui/team.go @@ -42,7 +42,6 @@ type teamState struct { view teamView cursor int // selected row in the roster (an index into the render order) member string // the focused member's name (teamFocus) - scroll int // rendered-line offset in focus/tasks/findings roster boundedList detail boundedViewport } @@ -104,8 +103,8 @@ func (m Model) onTeamKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { return mm, cmd, true } if m.team.view == teamFocus { - if next, handled := m.navigateAgentsDetail(msg, m.team.scroll); handled { - m.team.scroll = next + if next, handled := m.navigateAgentsDetail(msg, m.team.detail); handled { + m.team.detail = next return m, nil, true } // Focus pane: esc returns to the roster; x cancels the focused member (live @@ -115,7 +114,7 @@ func (m Model) onTeamKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { case key.Matches(msg, m.keys.Close): m.team.view = teamRoster m.team.member = "" - m.team.scroll = 0 + m.team.detail.offset = 0 case key.Matches(msg, m.keys.CancelChild): mm, cmd := m.cancelTeamLane(b, teamFindLane(b, m.team.member)) return mm, cmd, true @@ -123,8 +122,8 @@ func (m Model) onTeamKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { return m, nil, true } if m.team.view == teamTasks { - if next, handled := m.navigateAgentsDetail(msg, m.team.scroll); handled { - m.team.scroll = next + if next, handled := m.navigateAgentsDetail(msg, m.team.detail); handled { + m.team.detail = next return m, nil, true } // Subagent sub-view: BOTH esc and 't' return to the roster (t toggles, esc steps @@ -132,13 +131,13 @@ func (m Model) onTeamKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { // consumed (the list is height-windowed, no live viewport). if key.Matches(msg, m.keys.Close) || key.Matches(msg, m.keys.Tasks) { m.team.view = teamRoster - m.team.scroll = 0 + m.team.detail.offset = 0 } return m, nil, true } if m.team.view == teamFindings { - if next, handled := m.navigateAgentsDetail(msg, m.team.scroll); handled { - m.team.scroll = next + if next, handled := m.navigateAgentsDetail(msg, m.team.detail); handled { + m.team.detail = next return m, nil, true } // Findings sub-view: BOTH esc and 'f' return to the roster (f toggles, esc @@ -146,7 +145,7 @@ func (m Model) onTeamKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { // live viewport. if key.Matches(msg, m.keys.Close) || key.Matches(msg, m.keys.Findings) { m.team.view = teamRoster - m.team.scroll = 0 + m.team.detail.offset = 0 } return m, nil, true } @@ -168,11 +167,11 @@ func (m Model) onTeamRosterKey(msg tea.KeyPressMsg, b *block) (tea.Model, tea.Cm return m.closeTeam() case key.Matches(msg, m.keys.Tasks): m.team.view = teamTasks - m.team.scroll = 0 + m.team.detail.offset = 0 return m, nil case key.Matches(msg, m.keys.Findings): m.team.view = teamFindings - m.team.scroll = 0 + m.team.detail.offset = 0 return m, nil } if next, control, handled := m.navigateAgentsList(msg, teamSelectableList(th, m.team, b, hk, width)); handled { @@ -187,7 +186,7 @@ func (m Model) onTeamRosterKey(msg tea.KeyPressMsg, b *block) (tea.Model, tea.Cm } m.team.member = b.teamLanes[order[m.team.cursor]].name m.team.view = teamFocus - m.team.scroll = 0 + m.team.detail.offset = 0 return m, nil case key.Matches(msg, m.keys.CancelChild): order := teamLaneOrder(b.teamLanes) @@ -439,14 +438,14 @@ func teamRosterSubhead(b *block) string { // matching lane (the member vanished — defensive) falls back to a muted note. All // text is sanitized. func renderTeamFocus(th theme.Theme, b *block, member string, hk helpKeys, bodyWidth, height int) string { - return renderTeamFocusAt(th, b, member, 0, hk, bodyWidth, height) + return renderTeamFocusAt(th, b, member, boundedViewport{}, hk, bodyWidth, height) } -func renderTeamFocusAt(th theme.Theme, b *block, member string, scroll int, hk helpKeys, bodyWidth, height int) string { - return prepareTeamFocusAt(th, b, member, scroll, hk, bodyWidth)(height) +func renderTeamFocusAt(th theme.Theme, b *block, member string, detail boundedViewport, hk helpKeys, bodyWidth, height int) string { + return prepareTeamFocusAt(th, b, member, detail, hk, bodyWidth)(height) } -func prepareTeamFocusAt(th theme.Theme, b *block, member string, scroll int, hk helpKeys, bodyWidth int) agentsBodyRenderer { +func prepareTeamFocusAt(th theme.Theme, b *block, member string, detail boundedViewport, hk helpKeys, bodyWidth int) agentsBodyRenderer { muted := th.Style("muted") ln := teamFindLane(b, member) if ln == nil { @@ -471,15 +470,7 @@ func prepareTeamFocusAt(th theme.Theme, b *block, member string, scroll int, hk out.WriteString("\n\n") prefix := out.String() - r := &renderer{th: th, marks: hk, traceWidth: bodyWidth} - // The trace is ALREADY rendered (carries ANSI; its text was sanitized at the - // source in renderTrace). It must NOT go through truncateLines, which - // sanitizeTerminal-strips ESC bytes and would mangle the styling — cap it by - // line count ANSI-safely instead, to the rows that fit the terminal height. - var traceLines []string - if trace := r.renderTrace(ln.trace); trace != "" { - traceLines = strings.Split(trace, "\n") - } + traceLines := renderedTraceLines(th, hk, bodyWidth, ln.trace) failure := "" // A benched-on-error member surfaces WHY its last failed round failed, mirroring // the subagent focus pane's failure block (issue #331). Rendered ONLY when the @@ -496,13 +487,16 @@ func prepareTeamFocusAt(th theme.Theme, b *block, member string, scroll int, hk lead = hk.cancelChild + " cancel · " + lead } return func(height int) string { - w := renderedLineWindow(scroll, len(traceLines), teamFocusRows(height)) + control := detail + control.setGeometry(bodyWidth, teamFocusRows(height), 0, boundedClip) + view := control.view(traceLines) body := prefix if len(traceLines) == 0 { body += muted.Render("(no activity yet)") } else { - body += strings.Join(traceLines[w.start:w.end], "\n") + body += strings.Join(view.rows, "\n") } + w := boundedViewportBounds(view, control.height) hint := agentsDetailHint(hk, w, lead) return body + failure + "\n\n" + renderDynamicCardChromeLine(muted, "", hint, bodyWidth) } @@ -647,18 +641,18 @@ func agentsDetailHint(hk helpKeys, w renderedLineWindowBounds, lead string) stri } func renderTeamTasks(th theme.Theme, b *block, hk helpKeys, height int, widths ...int) string { - return renderTeamTasksAt(th, b, 0, hk, height, widths...) + return renderTeamTasksAt(th, b, boundedViewport{}, hk, height, widths...) } -func renderTeamTasksAt(th theme.Theme, b *block, scroll int, hk helpKeys, height int, widths ...int) string { +func renderTeamTasksAt(th theme.Theme, b *block, detail boundedViewport, hk helpKeys, height int, widths ...int) string { bodyWidth := 0 if len(widths) > 0 { bodyWidth = widths[0] } - return prepareTeamTasksAt(th, b, scroll, hk, bodyWidth)(height) + return prepareTeamTasksAt(th, b, detail, hk, bodyWidth)(height) } -func prepareTeamTasksAt(th theme.Theme, b *block, scroll int, hk helpKeys, bodyWidth int) agentsBodyRenderer { +func prepareTeamTasksAt(th theme.Theme, b *block, detail boundedViewport, hk helpKeys, bodyWidth int) agentsBodyRenderer { muted := th.Style("muted") prefix := renderDelegationRows(th.Style("askTitle"), "", "tasks", bodyWidth) + "\n" + renderDelegationRows(muted, "", teamTasksSummary(b.teamTasks), bodyWidth) + "\n\n" @@ -669,8 +663,11 @@ func prepareTeamTasksAt(th theme.Theme, b *block, scroll int, hk helpKeys, bodyW } lines := renderedTaskLines(th, b, bodyWidth) return func(height int) string { - w := renderedLineWindow(scroll, len(lines), teamTasksRows(height)) - return prefix + strings.Join(lines[w.start:w.end], "\n") + "\n\n" + + control := detail + control.setGeometry(bodyWidth, teamTasksRows(height), 0, boundedClip) + view := control.view(lines) + w := boundedViewportBounds(view, control.height) + return prefix + strings.Join(view.rows, "\n") + "\n\n" + renderDynamicCardChromeLine(muted, "", agentsDetailHint(hk, w, teamSubViewHint(hk, hk.tasks)), bodyWidth) } } @@ -769,18 +766,18 @@ func renderedFindingLines(th theme.Theme, b *block, bodyWidth int) []string { } func renderTeamFindings(th theme.Theme, b *block, hk helpKeys, height int, widths ...int) string { - return renderTeamFindingsAt(th, b, 0, hk, height, widths...) + return renderTeamFindingsAt(th, b, boundedViewport{}, hk, height, widths...) } -func renderTeamFindingsAt(th theme.Theme, b *block, scroll int, hk helpKeys, height int, widths ...int) string { +func renderTeamFindingsAt(th theme.Theme, b *block, detail boundedViewport, hk helpKeys, height int, widths ...int) string { bodyWidth := 0 if len(widths) > 0 { bodyWidth = widths[0] } - return prepareTeamFindingsAt(th, b, scroll, hk, bodyWidth)(height) + return prepareTeamFindingsAt(th, b, detail, hk, bodyWidth)(height) } -func prepareTeamFindingsAt(th theme.Theme, b *block, scroll int, hk helpKeys, bodyWidth int) agentsBodyRenderer { +func prepareTeamFindingsAt(th theme.Theme, b *block, detail boundedViewport, hk helpKeys, bodyWidth int) agentsBodyRenderer { muted := th.Style("muted") prefix := renderDelegationRows(th.Style("askTitle"), "", "findings", bodyWidth) + "\n" + renderDelegationRows(muted, "", teamFindingsSummary(b.teamFindings), bodyWidth) + "\n\n" @@ -791,8 +788,11 @@ func prepareTeamFindingsAt(th theme.Theme, b *block, scroll int, hk helpKeys, bo } lines := renderedFindingLines(th, b, bodyWidth) return func(height int) string { - w := renderedLineWindow(scroll, len(lines), teamFindingsRows(height)) - return prefix + strings.Join(lines[w.start:w.end], "\n") + "\n\n" + + control := detail + control.setGeometry(bodyWidth, teamFindingsRows(height), 0, boundedClip) + view := control.view(lines) + w := boundedViewportBounds(view, control.height) + return prefix + strings.Join(view.rows, "\n") + "\n\n" + renderDynamicCardChromeLine(muted, "", agentsDetailHint(hk, w, teamSubViewHint(hk, hk.findings)), bodyWidth) } } diff --git a/cmd/mecatui/ui/testdata/models.golden b/cmd/mecatui/ui/testdata/models.golden index d654295848..c91d229be9 100644 --- a/cmd/mecatui/ui/testdata/models.golden +++ b/cmd/mecatui/ui/testdata/models.golden @@ -1,25 +1,25 @@ mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models (4) ┃ -┃ current: GPT-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ ╭────────────────────────────────────────────────╮ ┃ -┃ │ › ● openai · GPT-5 image input reason 200K │ ┃ -┃ ╰────────────────────────────────────────────────╯ ┃ -┃ openai · GPT-5 mini reason 128K ┃ -┃ openai · text-embed ┃ -┃ openrouter · Claude image input reason 1M ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models (4) ┃ +┃ current: GPT-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ ▶ ● openai · GPT-5 image input reason 200K ┃ +┃ openai · GPT-5 mini reason 128K ┃ +┃ openai · text-embed ┃ +┃ openrouter · Claude image input reason 1M ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) diff --git a/cmd/mecatui/ui/testdata/models_filtered.golden b/cmd/mecatui/ui/testdata/models_filtered.golden index cec2112669..066da86180 100644 --- a/cmd/mecatui/ui/testdata/models_filtered.golden +++ b/cmd/mecatui/ui/testdata/models_filtered.golden @@ -1,24 +1,24 @@ mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models (2) ┃ -┃ current: GPT-5 (server default) ┃ -┃ > gpt ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ ╭────────────────────────────────────────────────╮ ┃ -┃ │ › ● openai · GPT-5 image input reason 200K │ ┃ -┃ ╰────────────────────────────────────────────────╯ ┃ -┃ openai · GPT-5 mini reason 128K ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models (2) ┃ +┃ current: GPT-5 (server default) ┃ +┃ > gpt ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ ▶ ● openai · GPT-5 image input reason 200K ┃ +┃ openai · GPT-5 mini reason 128K ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + │ diff --git a/cmd/mecatui/ui/testdata/models_global_default.golden b/cmd/mecatui/ui/testdata/models_global_default.golden index 2968ed2622..27c184c9c6 100644 --- a/cmd/mecatui/ui/testdata/models_global_default.golden +++ b/cmd/mecatui/ui/testdata/models_global_default.golden @@ -1,25 +1,25 @@ mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models (4) ┃ -┃ current: GPT-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ ╭────────────────────────────────────────────────╮ ┃ -┃ │ › ● openai · GPT-5 image input reason 200K │ ┃ -┃ ╰────────────────────────────────────────────────╯ ┃ -┃ openai · GPT-5 mini reason 128K ┃ -┃ openai · text-embed ┃ -┃ ★ openrouter · Claude image input reason 1M ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models (4) ┃ +┃ current: GPT-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ ▶ ● openai · GPT-5 image input reason 200K ┃ +┃ openai · GPT-5 mini reason 128K ┃ +┃ openai · text-embed ┃ +┃ ★ openrouter · Claude image input reason 1M ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) diff --git a/cmd/mecatui/ui/testdata/models_mixed_deployment_empty.golden b/cmd/mecatui/ui/testdata/models_mixed_deployment_empty.golden index f6f8dd545d..3287f5a5c9 100644 --- a/cmd/mecatui/ui/testdata/models_mixed_deployment_empty.golden +++ b/cmd/mecatui/ui/testdata/models_mixed_deployment_empty.golden @@ -1,27 +1,25 @@ mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models (4) ┃ -┃ current: GPT-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ ╭────────────────────────────────────────────────╮ ┃ -┃ │ › ● openai · GPT-5 image input reason 200K │ ┃ -┃ ╰────────────────────────────────────────────────╯ ┃ -┃ openai · GPT-5 mini reason 128K ┃ -┃ openai · text-embed ┃ -┃ openrouter · Claude image input reason 1M ┃ -┃ ┃ -┃ toolhive: credential lists no models — ask your platform admin or re-run `thv llm setup` ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models (4) ┃ +┃ current: GPT-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ ▶ ● openai · GPT-5 image input reason 200K ┃ +┃ openai · GPT-5 mini reason 128K ┃ +┃ openai · text-embed ┃ +┃ openrouter · Claude image input reason 1M ┃ +┃ ┃ +┃ toolhive: credential lists no models — ask your platform admin or re-run `thv llm setup` ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) diff --git a/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden b/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden index 5c22da9e0b..01cc80ab33 100644 --- a/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden +++ b/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden @@ -1,27 +1,25 @@ mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080 ──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Models (4) ┃ -┃ current: GPT-5 (server default) ┃ -┃ > filter models… ┃ -┃ ┃ -┃ Switching models is expensive as it clears caches. ┃ -┃ ┃ -┃ ╭────────────────────────────────────────────────╮ ┃ -┃ │ › ● openai · GPT-5 image input reason 200K │ ┃ -┃ ╰────────────────────────────────────────────────╯ ┃ -┃ openai · GPT-5 mini reason 128K ┃ -┃ openai · text-embed ┃ -┃ openrouter · Claude image input reason 1M ┃ -┃ ┃ -┃ toolhive: gateway not reachable — start it with `thv llm proxy start` ┃ -┃ ┃ -┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ -┃ ● current ★ global default ┃ -┃ reason = emits reasoning · set its effort tier with /effort ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ Models (4) ┃ +┃ current: GPT-5 (server default) ┃ +┃ > filter models… ┃ +┃ ┃ +┃ Switching models is expensive as it clears caches. ┃ +┃ ┃ +┃ ▶ ● openai · GPT-5 image input reason 200K ┃ +┃ openai · GPT-5 mini reason 128K ┃ +┃ openai · text-embed ┃ +┃ openrouter · Claude image input reason 1M ┃ +┃ ┃ +┃ toolhive: gateway not reachable — start it with `thv llm proxy start` ┃ +┃ ┃ +┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / cl ┃ +┃ ● current ★ global default ┃ +┃ reason = emits reasoning · set its effort tier with /effort ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) diff --git a/cmd/mecatui/ui/window.go b/cmd/mecatui/ui/window.go index 42dc015a31..02cb4dc4f8 100644 --- a/cmd/mecatui/ui/window.go +++ b/cmd/mecatui/ui/window.go @@ -92,6 +92,15 @@ func (v *boundedViewport) view(lines []string) boundedViewportView { } } +func boundedViewportBounds(view boundedViewportView, window int) renderedLineWindowBounds { + return renderedLineWindowBounds{ + start: view.above, + end: view.above + len(view.rows), + total: view.above + len(view.rows) + view.below, + window: max(0, window), + } +} + func (v *boundedViewport) move(move boundedMove, total int) { if !v.valid() { return @@ -417,8 +426,8 @@ func clampBounded(value, count int) int { // pure function of (cursor, n, limit) — the window FOLLOWS the cursor (no stored // offset to drift), so the selected row stays in view when paging past the top or // bottom edge. Shared by the slash palette (renderPalette), the @-mention menu -// (renderMention), and the /models picker (renderModelsPanel); lifted from -// palette.go (was paletteWindow) so the call sites can't diverge. +// (renderMention); retained for those fixed-row consumers while modelsState.Render +// uses boundedList for item-aware geometry. func scrollWindow(cursor, n, limit int) (start, end int) { if n <= limit { return 0, n From 32eac1dad0b599f1620d45126b47ca1f786b6399 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 14:27:22 -0700 Subject: [PATCH 07/10] fix(mecatui): preserve bounded panel anchors Co-authored-by: mecatl --- cmd/mecatui/ui/agents_overlay.go | 93 ++++++---------- cmd/mecatui/ui/agents_overlay_test.go | 87 ++++++--------- .../ui/bounded_scroll_adoption_test.go | 100 +++++++++++++++++- cmd/mecatui/ui/bounded_scroll_cursor_test.go | 36 ++++++- .../ui/card_layout_focus_scroll_test.go | 6 +- .../ui/delegation_overlay_chrome_test.go | 15 +-- cmd/mecatui/ui/models_surface.go | 82 ++------------ cmd/mecatui/ui/models_surface_test.go | 12 ++- cmd/mecatui/ui/parallel_overlay_test.go | 25 ++--- cmd/mecatui/ui/schedule.go | 2 +- cmd/mecatui/ui/sessions_surface.go | 2 +- cmd/mecatui/ui/surface_arch_test.go | 4 +- cmd/mecatui/ui/team.go | 52 +-------- cmd/mecatui/ui/team_overlay_test.go | 57 +++------- cmd/mecatui/ui/update.go | 3 + cmd/mecatui/ui/window.go | 45 ++++++++ cmd/mecatui/ui/worktrees.go | 2 +- 17 files changed, 298 insertions(+), 325 deletions(-) diff --git a/cmd/mecatui/ui/agents_overlay.go b/cmd/mecatui/ui/agents_overlay.go index a42e2e469f..ec09ca4b9d 100644 --- a/cmd/mecatui/ui/agents_overlay.go +++ b/cmd/mecatui/ui/agents_overlay.go @@ -360,7 +360,7 @@ func (m Model) onParallelGroupKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } ordered := branchesByIndex(g.branches) - cursor := clampCursor(m.parallel.branchCursor, len(ordered)) + cursor := clampBounded(m.parallel.branchCursor, len(ordered)) if cursor >= len(ordered) { return m, nil } @@ -423,11 +423,6 @@ func (m Model) navigateAgentsList(msg tea.KeyPressMsg, list agentsSelectableList return control.cursor, control, true } -func agentsListPageSize(th theme.Theme, height int, list agentsSelectableList) int { - w := list.window(th, height) - return max(1, w.end-w.start) -} - func (m Model) agentsListGeometry() (theme.Theme, helpKeys, int, int) { th := m.deps.Theme layout := newAgentsOverlayLayout(th, m.agentsTab, m.width, m.vp.Height()) @@ -438,14 +433,33 @@ func (m Model) agentsListGeometry() (theme.Theme, helpKeys, int, int) { return th, m.helpKeyMarkings(), layout.bodyWidth, height } -func (m Model) subagentRosterPageSize(fleet []subagentLane) int { - th, hk, width, height := m.agentsListGeometry() - return agentsListPageSize(th, height, subagentSelectableList(th, m.subagents, fleet, hk, width)) -} - -func (m Model) parallelRosterPageSize(groups []parallelGroup) int { +// reconcileAgentsLists persists stable cursor and viewport anchors when streamed +// delegation collections change. Renderers receive Model state by value, so they +// cannot be the owner of this update. +func (m *Model) reconcileAgentsLists() { + if m.team.view == teamNone { + return + } th, hk, width, height := m.agentsListGeometry() - return agentsListPageSize(th, height, parallelSelectableList(th, m.parallel, groups, hk, width)) + if height <= 0 { + return + } + reconcile := func(list agentsSelectableList) boundedList { + control, _, _ := list.configuredControl(th, height) + return control + } + m.subagents.roster = reconcile(subagentSelectableList(th, m.subagents, m.conv.subagentFleet, hk, width)) + m.subagents.cursor = m.subagents.roster.cursor + m.parallel.roster = reconcile(parallelSelectableList(th, m.parallel, m.conv.parallelGroups, hk, width)) + m.parallel.cursor = m.parallel.roster.cursor + if group := findParallelGroup(m.conv.parallelGroups, m.parallel.group); group != nil { + m.parallel.branches = reconcile(parallelBranchSelectableList(th, m.parallel, group, hk, width)) + m.parallel.branchCursor = m.parallel.branches.cursor + } + if team := m.conv.latestTeamBlock(); team != nil { + m.team.roster = reconcile(teamSelectableList(th, m.team, team, hk, width)) + m.team.cursor = m.team.roster.cursor + } } func (m Model) onAgentsWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { @@ -739,7 +753,7 @@ func essentialSubagentBody(th theme.Theme, st subagentState, fleet []subagentLan } if st.view != subagentFocus { if len(fleet) > 0 { - body.selected = renderSubagentRosterTitle(th.Style("spinner"), "▶ ", &fleet[clampCursor(st.cursor, len(fleet))], bodyWidth) + body.selected = renderSubagentRosterTitle(th.Style("spinner"), "▶ ", &fleet[clampBounded(st.cursor, len(fleet))], bodyWidth) } return body } @@ -763,7 +777,7 @@ func essentialParallelBody(th theme.Theme, st parallelState, groups []parallelGr } if st.view != parallelGroupView { if len(groups) > 0 { - body.selected = line(th.Style("spinner"), "▶ ", parallelRosterLine(&groups[clampCursor(st.cursor, len(groups))])) + body.selected = line(th.Style("spinner"), "▶ ", parallelRosterLine(&groups[clampBounded(st.cursor, len(groups))])) } return body } @@ -776,7 +790,7 @@ func essentialParallelBody(th theme.Theme, st parallelState, groups []parallelGr body.title = line(th.Style("askTitle"), "", "parallel · join="+parallelJoinMode(group.join)) ordered := branchesByIndex(group.branches) if len(ordered) > 0 { - body.selected = line(th.Style("spinner"), "▶ ", parallelBranchLine(&ordered[clampCursor(st.branchCursor, len(ordered))])) + body.selected = line(th.Style("spinner"), "▶ ", parallelBranchLine(&ordered[clampBounded(st.branchCursor, len(ordered))])) body.extra = append(body.extra, line(th.Style("muted"), " ", fmt.Sprintf("· +%d more branch(es)", max(0, len(ordered)-1)))) } return body @@ -822,7 +836,7 @@ func essentialTeamBody(th theme.Theme, st teamState, b *block, hk helpKeys, line default: order := teamLaneOrder(b.teamLanes) if len(order) > 0 { - lane := &b.teamLanes[order[clampCursor(st.cursor, len(order))]] + lane := &b.teamLanes[order[clampBounded(st.cursor, len(order))]] body.selected = line(th.Style("spinner"), "▶ ", teamRosterLine(th, lane, teamNameWidth(b.teamLanes, order), b.teamDone)) } } @@ -1025,10 +1039,6 @@ func prepareSubagentTab(th theme.Theme, st subagentState, fleet []subagentLane, // mirroring renderTeamRoster: a header (running/done counts), the slice of rows that // fits with the selected row marked with the unbordered ▶ treatment, "+K above/below" tails, and an always-visible // footer hint. An empty fleet reads as a muted "(no subagents)". height<=0 shows all. -type agentsCursorWindow struct { - start, end, above, below int -} - type agentsSelectableList struct { header, footer string rows []string @@ -1077,21 +1087,11 @@ func (l agentsSelectableList) boundedView(th theme.Theme, height int) boundedLis return boundedListViewWithIndicators(&control, capacity, reveal) } -func (l agentsSelectableList) window(th theme.Theme, height int) agentsCursorWindow { - view := l.boundedView(th, height) - if len(view.rows) == 0 { - return agentsCursorWindow{} - } - start := view.rows[0].itemIndex - end := view.rows[len(view.rows)-1].itemIndex + 1 - return agentsCursorWindow{start: start, end: end, above: start, below: len(l.rows) - end} -} - func (l agentsSelectableList) render(th theme.Theme, height int) string { view := l.boundedView(th, height) var middle []string if len(view.rows) == 0 && len(l.rows) > 0 { - middle = append(middle, th.Style("spinner").Render("▶ "+l.rows[clampCursor(l.cursor, len(l.rows))])) + middle = append(middle, th.Style("spinner").Render("▶ "+l.rows[clampBounded(l.cursor, len(l.rows))])) } if view.above > 0 { count, noun := view.rows[0].itemIndex, l.noun @@ -1131,7 +1131,7 @@ func subagentSelectableList(th theme.Theme, st subagentState, fleet []subagentLa list := agentsSelectableList{ header: renderDelegationRows(th.Style("askTitle"), "", subagentRosterHeader(running, done), bodyWidth), footer: renderDynamicCardChromeLine(muted, "", hk.navUp+"/"+hk.navDown+" select · "+hk.scroll+" · "+hk.jumpTop+"/"+hk.jumpEnd+" · "+hk.choose+" focus · "+hk.cancelChild+" cancel · "+agentsEmptyHint(hk), bodyWidth), - cursor: clampCursor(st.cursor, len(fleet)), muted: muted, noun: "rows", + cursor: clampBounded(st.cursor, len(fleet)), muted: muted, noun: "rows", bodyWidth: bodyWidth, control: st.roster, } for row := range fleet { @@ -1173,12 +1173,6 @@ func subagentRosterLine(ln *subagentLane) string { return subagentRosterText(ln, 0, 0) } -// renderSubagentRosterRow applies a selection prefix only to the title. Details always -// begin at four columns, so selected and unselected rows have the same readable shape. -func renderSubagentRosterRow(style lipgloss.Style, prefix string, ln *subagentLane, bodyWidth int) string { - return style.Render(prefix + subagentRosterText(ln, bodyWidth, lipgloss.Width(prefix))) -} - func renderSubagentRosterTitle(style lipgloss.Style, prefix string, ln *subagentLane, bodyWidth int) string { return style.Render(prefix + subagentRosterTitle(ln, bodyWidth, lipgloss.Width(prefix))) } @@ -1485,7 +1479,7 @@ func parallelSelectableList(th theme.Theme, st parallelState, groups []parallelG list := agentsSelectableList{ header: renderDelegationRows(th.Style("askTitle"), "", fmt.Sprintf("parallel · %d running · %d done", running, done), bodyWidth), footer: renderCardChromeSegments(muted, []string{hk.closeOnly + " close", hk.navUp + "/" + hk.navDown + " select", hk.choose + " focus", hk.nextTab + " switch", hk.scroll + " page", hk.jumpTopFull + "·" + hk.jumpEndFull + " first/last"}, bodyWidth), - cursor: clampCursor(st.cursor, len(groups)), muted: muted, noun: "rows", + cursor: clampBounded(st.cursor, len(groups)), muted: muted, noun: "rows", bodyWidth: bodyWidth, control: st.roster, } for row := range groups { @@ -1585,7 +1579,7 @@ func parallelBranchSelectableList(th theme.Theme, st parallelState, g *parallelG } header += "\n" + muted.Render(indentWrap(boundedPreviewsParNote, bodyWidth)) ordered := branchesByIndex(g.branches) - cursor := clampCursor(st.branchCursor, len(ordered)) + cursor := clampBounded(st.branchCursor, len(ordered)) cancellable := false list := agentsSelectableList{header: header, cursor: cursor, muted: muted, noun: "branches", bodyWidth: bodyWidth, control: st.branches} r := &renderer{th: th, marks: hk, traceWidth: max(1, bodyWidth-lipgloss.Width(parallelBranchTraceGutter)-2)} @@ -1636,23 +1630,6 @@ func prepareParallelGroupFocus(th theme.Theme, st parallelState, groups []parall return func(height int) string { return list.render(th, height) } } -// renderParallelBranchRow renders a branch's two-level summary within a focused -// group. The title owns the selectable/winner markers; the details and activity below -// are visibly subordinate while retaining every existing trace line. -func renderParallelBranchRow(th theme.Theme, br *parallelBranch, winner int, selected bool, width int) string { - style, prefix := th.Style("muted"), " " - switch { - case selected: - style, prefix = th.Style("spinner"), "▶ " - if br.index == winner { - prefix += "★ " - } - case br.index == winner: - prefix = "★ " - } - return style.Render(prefix+parallelBranchText(br, width, lipgloss.Width(prefix))) + "\n" -} - const parallelBranchTraceGutter = " │ " func indentParallelBranchTrace(trace string, bodyWidth int) string { diff --git a/cmd/mecatui/ui/agents_overlay_test.go b/cmd/mecatui/ui/agents_overlay_test.go index 2f2d9ce3fa..0c1ad312c4 100644 --- a/cmd/mecatui/ui/agents_overlay_test.go +++ b/cmd/mecatui/ui/agents_overlay_test.go @@ -662,7 +662,7 @@ func TestSubagentBudgetStopThroughWire(t *testing.T) { } func TestSubagentRosterRowSeparatesTitleAndDetails(t *testing.T) { - ln := &subagentLane{ + ln := subagentLane{ childID: "explorer-abcdef", goal: "inspect the subagent roster layout carefully while checking every visible presentation detail", background: true, @@ -672,42 +672,28 @@ func TestSubagentRosterRowSeparatesTitleAndDetails(t *testing.T) { toolCount: 2, usage: client.Usage{InputTokens: 1200, OutputTokens: 340}, } - - selected := stripANSIstr(renderSubagentRosterRow(aztec().Style("spinner"), "▶ ", ln, 80)) - unselected := stripANSIstr(renderSubagentRosterRow(aztec().Style("muted"), " ", ln, 80)) - selectedRows := strings.Split(selected, "\n") - unselectedRows := strings.Split(unselected, "\n") - if len(selectedRows) != 2 || len(unselectedRows) != 2 { - t.Fatalf("normal-width rows = %q / %q, want title plus one details line", selected, unselected) - } - if !strings.HasPrefix(selectedRows[0], "▶ ◐ ") || !strings.HasPrefix(unselectedRows[0], " ◐ ") { - t.Fatalf("selection markers changed title semantics: %q / %q", selectedRows[0], unselectedRows[0]) - } - if selectedRows[1] != unselectedRows[1] || !strings.HasPrefix(selectedRows[1], " ") { - t.Fatalf("details should retain one shared four-column indent: %q / %q", selectedRows[1], unselectedRows[1]) + other := subagentLane{childID: "other", goal: "other"} + render := func(width, cursor int, lane subagentLane) string { + return stripANSIstr(renderSubagentRoster(aztec(), subagentState{cursor: cursor}, []subagentLane{lane, other}, defaultHelpKeys(), 0, width)) } - if !strings.Contains(selectedRows[0], "…") || !strings.Contains(selectedRows[0], "#abcdef ⇢ bg") { - t.Fatalf("title was not explicitly clipped while retaining child identity: %q", selectedRows[0]) + + selected := render(80, 0, ln) + unselected := render(80, 1, ln) + if !strings.Contains(selected, "▶ ◐ ") || !strings.Contains(unselected, " ◐ ") { + t.Fatalf("bounded roster lost selected/unselected title semantics: %q / %q", selected, unselected) } - if !strings.Contains(selectedRows[1], "gpt-5-mini") || !strings.Contains(selectedRows[1], "Grep… · 2 tools · ↑1.2K ↓340") { - t.Fatalf("details omitted readable metadata: %q", selectedRows[1]) + if !strings.Contains(selected, "\n ") || !strings.Contains(selected, "gpt-5-mini") || !strings.Contains(selected, "Grep… · 2 tools · ↑1.2K ↓340") { + t.Fatalf("bounded roster omitted indented metadata: %q", selected) } - - narrow := stripANSIstr(renderSubagentRosterRow(aztec().Style("spinner"), "▶ ", ln, 28)) - for _, row := range strings.Split(narrow, "\n") { + for _, row := range strings.Split(render(28, 0, ln), "\n") { if lipgloss.Width(row) > 28 { - t.Fatalf("narrow row overflows body width: %d: %q", lipgloss.Width(row), row) + t.Fatalf("narrow bounded roster row overflows: %d: %q", lipgloss.Width(row), row) } } - if len(strings.Split(narrow, "\n")) < 3 || !strings.Contains(narrow, "\n ") { - t.Fatalf("narrow details did not wrap with the required indent: %q", narrow) - } - - wide := *ln + wide := ln wide.goal = "調査🙂調査🙂調査🙂調査🙂" - wideRow := stripANSIstr(renderSubagentRosterRow(aztec().Style("spinner"), "▶ ", &wide, 24)) - if title := strings.Split(wideRow, "\n")[0]; lipgloss.Width(title) > 24 || !strings.Contains(title, "…") { - t.Fatalf("wide-rune title should be clipped to one physical line: %q", title) + if out := render(24, 0, wide); !strings.Contains(out, "…") { + t.Fatalf("wide-rune bounded roster title was not clipped: %q", out) } } @@ -730,7 +716,8 @@ func TestSubagentRosterWindowed(t *testing.T) { t.Fatalf("expected Subagents tab, got %v", m.agentsTab) } out := stripANSIstr(m.View().Content) - rows := m.subagentRosterPageSize(m.conv.subagentFleet) + listTh, hk, width, height := m.agentsListGeometry() + rows := len(subagentSelectableList(listTh, m.subagents, m.conv.subagentFleet, hk, width).boundedView(listTh, height).rows) if rows >= n { t.Fatalf("test premise broken: window %d must be < fleet %d", rows, n) } @@ -794,8 +781,8 @@ func TestDynamicCardChromeLine(t *testing.T) { } } -// TestRosterRouteNavigation verifies each top-level roster delegates navigation -// to navigateRosterCursor, including live key overrides and page-sized movement. +// TestRosterRouteNavigation verifies each top-level roster drives the shared +// bounded-list navigation path, including live key overrides and paging. func TestRosterRouteNavigation(t *testing.T) { rosters := []struct { name string @@ -873,13 +860,6 @@ func TestRosterRouteNavigation(t *testing.T) { } mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF6}) m = mm.(Model) - page := m.subagentRosterPageSize(m.conv.subagentFleet) - if tc.name == "parallel" { - page = m.parallelRosterPageSize(m.conv.parallelGroups) - } - if page >= n { - t.Fatalf("test premise broken: page %d must be < roster %d", page, n) - } mm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) m = mm.(Model) if got := tc.cursor(m); got <= 0 || got >= n { @@ -1466,7 +1446,7 @@ func TestMecatuiAgentsOverlayFit_Scenario1_SelectedRowsUseSessionsTreatment(t *t assertRows("team", renderTeamRoster(th, teamState{}, &block{teamLanes: []teamLane{{name: "selected"}, {name: "unselected"}}}, hk, 0, 120), "◆ · selected", "◆ · unselected") branches := []parallelBranch{{index: 0, label: "selected"}, {index: 1, label: "unselected"}} - assertRows("parallel branch", renderParallelBranchRow(th, &branches[0], -1, true, 120)+renderParallelBranchRow(th, &branches[1], -1, false, 120), "◐ selected", "◐ unselected") + assertRows("parallel branch", renderParallelGroupFocus(th, parallelState{view: parallelGroupView, group: "group"}, []parallelGroup{{parentCallID: "group", winner: -1, branches: branches}}, hk, 120, 0), "◐ selected", "◐ unselected") accentOpen, _, _ := strings.Cut(th.Style("spinner").Render("x"), "x") finalViews := []struct { @@ -1509,13 +1489,14 @@ func TestMecatuiAgentsOverlayFit_Scenario1_SelectedRowsUseSessionsTreatment(t *t func TestMecatuiAgentsOverlayFit_Scenario1_SelectedWrappedRowHasNoButtonChrome(t *testing.T) { th := aztec() branch := ¶llelBranch{index: 0, label: strings.Repeat("long label ", 8)} - selected := stripANSIstr(renderParallelBranchRow(th, branch, -1, true, 20)) - unselected := stripANSIstr(renderParallelBranchRow(th, branch, -1, false, 20)) - if got, want := strings.Count(selected, "\n"), strings.Count(unselected, "\n"); got != want { - t.Fatalf("selected wrapped row has %d physical rows, want unselected row's %d: %q", got, want, selected) - } - if got, want := strings.Replace(selected, "▶ ", " ", 1), unselected; got != want { - t.Fatalf("selected wrapped row differs from unselected beyond its marker (button chrome):\nselected: %q\nunselected: %q", selected, unselected) + branches := []parallelBranch{*branch, {index: 1, label: "other"}} + group := []parallelGroup{{parentCallID: "wrapped", winner: -1, branches: branches}} + selected := stripANSIstr(renderParallelGroupFocus(th, parallelState{view: parallelGroupView, group: "wrapped", branchCursor: 0}, group, defaultHelpKeys(), 20, 0)) + unselected := stripANSIstr(renderParallelGroupFocus(th, parallelState{view: parallelGroupView, group: "wrapped", branchCursor: 1}, group, defaultHelpKeys(), 20, 0)) + selectedLine := strings.Split(selected, "\n")[4] + unselectedLine := strings.Split(unselected, "\n")[4] + if got, want := strings.Replace(selectedLine, "▶ ", " ", 1), unselectedLine; got != want { + t.Fatalf("selected bounded row differs from unselected beyond its marker: %q / %q", selectedLine, unselectedLine) } m := resize(newMCPModel(t, th, nil), 32, 40) m.team, m.agentsTab = teamState{view: teamRoster}, tabParallel @@ -1530,8 +1511,8 @@ func TestMecatuiAgentsOverlayFit_Scenario1_SelectedWrappedRowHasNoButtonChrome(t func TestMecatuiAgentsOverlayFit_Scenario1_SelectedWinnerRetainsBothMarkers(t *testing.T) { th := aztec() branch := ¶llelBranch{index: 1, label: "winner"} - out := stripANSIstr(renderParallelBranchRow(th, branch, 1, true, 120)) - if !strings.HasPrefix(out, "▶ ★ ") { + out := stripANSIstr(renderParallelGroupFocus(th, parallelState{view: parallelGroupView, group: "winner"}, []parallelGroup{{parentCallID: "winner", winner: 1, branches: []parallelBranch{*branch}}}, defaultHelpKeys(), 120, 0)) + if !strings.Contains(out, "▶ ★ ") { t.Fatalf("selected winning branch markers = %q, want both ▶ and ★", out) } bar := stripANSIstr(agentsTabBar(th, tabParallel)) @@ -1968,9 +1949,9 @@ func TestMecatuiAgentsOverlayFit_Scenario2_WrappedDynamicContentFitsViewport(t * m.subagents.cursor = 4 listTh, hk, width, height := m.agentsListGeometry() list := subagentSelectableList(listTh, m.subagents, m.conv.subagentFleet, hk, width) - w := list.window(listTh, height) - if w.start > m.subagents.cursor || w.end <= m.subagents.cursor { - t.Fatalf("wrapped selected row %d is outside physical window [%d,%d)", m.subagents.cursor, w.start, w.end) + view := list.boundedView(listTh, height) + if len(view.rows) == 0 || view.rows[0].itemIndex > m.subagents.cursor || view.rows[len(view.rows)-1].itemIndex < m.subagents.cursor { + t.Fatalf("wrapped selected row %d is outside bounded physical view: %#v", m.subagents.cursor, view.rows) } body := stripANSIstr(list.render(listTh, height)) selected := stripANSIstr(list.rows[m.subagents.cursor]) diff --git a/cmd/mecatui/ui/bounded_scroll_adoption_test.go b/cmd/mecatui/ui/bounded_scroll_adoption_test.go index 63bdf551a9..9fb14a324b 100644 --- a/cmd/mecatui/ui/bounded_scroll_adoption_test.go +++ b/cmd/mecatui/ui/bounded_scroll_adoption_test.go @@ -63,6 +63,33 @@ func TestMecatuiBoundedScrollCursor_Scenario2_AllAgentsSubviewsFitOfferedGeometr } func TestMecatuiBoundedScrollCursor_Scenario2_AgentsModesUseSharedAccounting(t *testing.T) { + t.Run("stream refresh persists semantic roster anchors", func(t *testing.T) { + m := boundedScenarioAgentsModel(t, "subagent-roster") + m.conv.fleetIndex = make(map[string]int, len(m.conv.subagentFleet)) + for i := range m.conv.subagentFleet { + m.conv.fleetIndex[m.conv.subagentFleet[i].childID] = i + } + th, hk, width, height := m.agentsListGeometry() + control, _, _ := subagentSelectableList(th, m.subagents, m.conv.subagentFleet, hk, width).configuredControl(th, height) + control.setCursor(5) + control.scroll(boundedLineDown) + before := control.view().rows[0] + m.subagents.cursor, m.subagents.roster = control.cursor, control + + mm, _ := m.Update(client.SubagentMsg{Kind: client.SubagentTool, ChildID: "child-05", ToolName: strings.Repeat("streamed-tool-", 8), ToolCount: 1}) + m = mm.(Model) + if m.subagents.roster.cursorID != "child-05" { + t.Fatalf("stream refresh selected ID = %q, want child-05", m.subagents.roster.cursorID) + } + if len(m.subagents.roster.items) == 0 || !strings.Contains(m.subagents.roster.items[5].text, "streamed-tool-") { + t.Fatalf("stream refresh did not persist rebuilt roster items: %#v", m.subagents.roster.items) + } + after := m.subagents.roster.view().rows[0] + if after.id != before.id || after.itemLine != before.itemLine { + t.Fatalf("stream refresh top anchor = {%q,%d}, want {%q,%d}", after.id, after.itemLine, before.id, before.itemLine) + } + }) + th, hk := aztec(), defaultHelpKeys() fleet := boundedScenarioFleet(12) groups := boundedScenarioGroups(10) @@ -113,8 +140,8 @@ func TestMecatuiBoundedScrollCursor_Scenario2_ModelsFitsOfferedGeometry(t *testi s := boundedScenarioModelsState(t, 3) s.filtered[0].DisplayName = strings.Repeat("oversized ", 20) s.catalog.models = s.filtered - fixed := modelsPanelFixedRows(*s, "", s.deps.marks) - _, _ = s.Render(24, fixed+3) + prefix, suffix := modelsFixedLines(*s, "") + _, _ = s.Render(24, len(prefix)+len(suffix)+3) s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) if s.cursor != 0 || s.list.cursorLine == 0 { t.Fatalf("Page Down skipped wrapped item segment: cursor=%d line=%d", s.cursor, s.list.cursorLine) @@ -208,10 +235,31 @@ func TestMecatuiBoundedScrollCursor_Scenario3_AgentsWheelSubviewMatrix(t *testin if got := agentsScenarioOffset(m, mode); got != 0 { t.Fatalf("wheel up did not return %s to top: %d", mode, got) } + for range 200 { + mm, _ = m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = mm.(Model) + } + bottom := agentsScenarioOffset(m, mode) + if bottom <= 0 { + t.Fatalf("%s never reached a positive bottom offset", mode) + } + mm, _ = m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = mm.(Model) + if got := agentsScenarioOffset(m, mode); got != bottom { + t.Fatalf("%s wheel-down bottom clamp = %d, want %d", mode, got, bottom) + } + mm, _ = m.onMouseWheel(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + m = mm.(Model) + if got := agentsScenarioOffset(m, mode); got != bottom-1 { + t.Fatalf("%s wheel-up from bottom = %d, want one line to %d", mode, got, bottom-1) + } if strings.Contains(mode, "roster") || mode == "parallel-group" { mm, _, _ = m.onAgentsKey(tea.KeyPressMsg{Code: tea.KeyDown}) m = mm.(Model) + if !agentsScenarioSelectedVisible(m, mode) { + t.Fatalf("keyboard movement after wheel did not reveal selected %s item", mode) + } if plain := stripANSIstr(m.View().Content); !strings.Contains(plain, "▶ ") { t.Fatalf("keyboard movement after wheel did not reveal selected %s item:\n%s", mode, plain) } @@ -220,6 +268,28 @@ func TestMecatuiBoundedScrollCursor_Scenario3_AgentsWheelSubviewMatrix(t *testin } } +func agentsScenarioSelectedVisible(m Model, mode string) bool { + var list *boundedList + switch mode { + case "subagent-roster": + list = &m.subagents.roster + case "parallel-roster": + list = &m.parallel.roster + case "parallel-group": + list = &m.parallel.branches + case "team-roster": + list = &m.team.roster + default: + return true + } + for _, row := range list.view().rows { + if row.selected { + return true + } + } + return false +} + func agentsScenarioOffset(m Model, mode string) int { switch mode { case "subagent-roster": @@ -291,7 +361,18 @@ func TestMecatuiBoundedScrollCursor_Scenario3_ModelClickSelectsEnterActivates(t m = feedCmd(t, mm.(Model), cmd) s := modelsSurface(t, m) s.filtered[1].DisplayName = strings.Repeat("wrapped model identity ", 5) - m = resize(m, 40, 60) + m = resize(m, 40, 35) + _ = m.View() + s = modelsSurface(t, m) + wheelCursor := s.cursor + s.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + if s.list.viewport.offset != 1 || s.cursor != wheelCursor { + t.Fatalf("Models wheel down = offset/cursor %d/%d, want exact one line and cursor %d", s.list.viewport.offset, s.cursor, wheelCursor) + } + s.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + if s.list.viewport.offset != 0 || s.cursor != wheelCursor { + t.Fatalf("Models wheel up = offset/cursor %d/%d, want 0/%d", s.list.viewport.offset, s.cursor, wheelCursor) + } _ = m.View() s = modelsSurface(t, m) var first renderedHitRegion @@ -316,6 +397,7 @@ func TestMecatuiBoundedScrollCursor_Scenario3_ModelClickSelectsEnterActivates(t t.Fatalf("marker-cell click selected %d, want first model", got) } continuation := second[len(second)-1] + beforeClickOffset := s.list.viewport.offset globalX, globalY = m.metrics.localToGlobal(continuation.rect.x0+1, continuation.rect.y0) mm, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: globalX, Y: globalY}) m = mm.(Model) @@ -323,6 +405,16 @@ func TestMecatuiBoundedScrollCursor_Scenario3_ModelClickSelectsEnterActivates(t if s.cursor != 1 || s.intent != nil { t.Fatalf("click should move cursor only: cursor=%d intent=%T", s.cursor, s.intent) } + if s.list.viewport.offset != beforeClickOffset { + t.Fatalf("click on an already-visible model moved viewport from %d to %d", beforeClickOffset, s.list.viewport.offset) + } + visible := false + for _, row := range s.list.view().rows { + visible = visible || row.itemIndex == 1 && row.selected + } + if !visible { + t.Fatal("clicked Model cursor was not revealed in its viewport") + } mm, _, _ = m.onOverlayKey(tea.KeyPressMsg{Code: tea.KeyEnter}) m = mm.(Model) if m.modal != nil || m.phase != phaseConnecting { @@ -340,6 +432,8 @@ func TestMecatuiBoundedScrollCursor_Scenario3_StaleModelHitsIgnored(t *testing.T t.Fatal("Models frame rendered no row hits") } stale := m.hits.frame[0].id + mm, _ = m.Update(tea.WindowSizeMsg{Width: m.width - 7, Height: m.height - 2}) + m = mm.(Model) _ = m.View() s := modelsSurface(t, m) s.cursor = 2 diff --git a/cmd/mecatui/ui/bounded_scroll_cursor_test.go b/cmd/mecatui/ui/bounded_scroll_cursor_test.go index c70b4c290f..0064e37911 100644 --- a/cmd/mecatui/ui/bounded_scroll_cursor_test.go +++ b/cmd/mecatui/ui/bounded_scroll_cursor_test.go @@ -48,11 +48,13 @@ func TestMecatuiBoundedScrollCursor_Scenario1_RespectsWidthAndHeight(t *testing. } }) - for _, width := range []int{2, 1, 0, -1} { - var viewport boundedViewport - viewport.setGeometry(width, 2, 2, boundedWrap) - if got := viewport.view([]string{"content"}); len(got.rows) != 0 { - t.Errorf("width %d with gutter 2 rendered %d rows, want empty", width, len(got.rows)) + for _, policy := range []boundedWidthPolicy{boundedWrap, boundedClip} { + for _, width := range []int{2, 1, 0, -1} { + var viewport boundedViewport + viewport.setGeometry(width, 2, 2, policy) + if got := viewport.view([]string{"content"}); len(got.rows) != 0 { + t.Errorf("policy %d width %d with gutter 2 rendered %d rows, want empty", policy, width, len(got.rows)) + } } } } @@ -214,6 +216,11 @@ func TestMecatuiBoundedScrollCursor_Scenario1_ClampsContentAndDegenerateBounds(t if list.viewport.offset != 1 || len(list.view().rows) != 4 { t.Fatalf("valid geometry growth left a blank page: offset=%d rows=%d, want 1/4", list.viewport.offset, len(list.view().rows)) } + list.viewport.offset = 1 + list.setGeometry(12, 1, 0, boundedClip) + if list.viewport.offset != 1 || len(list.view().rows) != 1 { + t.Fatalf("valid geometry shrink lost/clobbered viewport: offset=%d rows=%d, want 1/1", list.viewport.offset, len(list.view().rows)) + } viewportType := reflect.TypeOf(boundedViewport{}) for _, name := range []string{"x", "xOffset", "horizontalOffset"} { if _, ok := viewportType.FieldByName(name); ok { @@ -230,6 +237,25 @@ func TestMecatuiBoundedScrollCursor_Scenario1_ClampsContentAndDegenerateBounds(t t.Errorf("bounds %v rendered %d rows, want empty", bounds, len(got.rows)) } } + + var indicators boundedList + indicators.setGeometry(12, 2, 2, boundedClip) + indicators.setItems([]boundedListItem{{id: "a", text: "a"}, {id: "b", text: "b"}, {id: "c", text: "c"}}) + indicators.viewport.offset = 1 + got := boundedListViewWithIndicators(&indicators, 2, false) + if len(got.rows) < 1 { + t.Fatalf("two-row viewport was consumed entirely by indicators: %#v", got) + } + chrome := 0 + if got.above > 0 { + chrome++ + } + if got.below > 0 { + chrome++ + } + if len(got.rows)+chrome > 2 { + t.Fatalf("content plus indicators use %d rows, want <= 2: %#v", len(got.rows)+chrome, got) + } } func TestMecatuiBoundedScrollCursor_Scenario1_RefreshPreservesSemanticAnchors(t *testing.T) { diff --git a/cmd/mecatui/ui/card_layout_focus_scroll_test.go b/cmd/mecatui/ui/card_layout_focus_scroll_test.go index 0b1b8e1170..fdeccf6ca4 100644 --- a/cmd/mecatui/ui/card_layout_focus_scroll_test.go +++ b/cmd/mecatui/ui/card_layout_focus_scroll_test.go @@ -47,10 +47,12 @@ func TestMecatuiCardLayout_Scenario3_FocusAndScrollableViewsRemainUsable(t *test models[i] = client.ModelInfo{ProviderID: "provider", ID: long + string(rune('a'+i))} } picker := &modelsState{catalog: modelCatalog{models: models}, filtered: models, deps: surfaceDeps{keys: defaultKeys(), theme: th, marks: hk}} - out, _ := picker.Render(width, modelsPanelFixedRows(*picker, "", hk)+3) + prefix, suffix := modelsFixedLines(*picker, "") + height := len(prefix) + len(suffix) + 3 + out, _ := picker.Render(width, height) assertFits(t, "models initial", out) picker.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) - out, _ = picker.Render(width, modelsPanelFixedRows(*picker, "", hk)+3) + out, _ = picker.Render(width, height) assertFits(t, "models paged", out) if picker.cursor != 0 || picker.list.cursorLine == 0 || !strings.Contains(stripANSIstr(out), "▶") { t.Fatalf("paged oversized model = cursor %d line %d, selected marker missing from %q", picker.cursor, picker.list.cursorLine, stripANSIstr(out)) diff --git a/cmd/mecatui/ui/delegation_overlay_chrome_test.go b/cmd/mecatui/ui/delegation_overlay_chrome_test.go index c9e92dcc67..1741278060 100644 --- a/cmd/mecatui/ui/delegation_overlay_chrome_test.go +++ b/cmd/mecatui/ui/delegation_overlay_chrome_test.go @@ -62,18 +62,9 @@ func TestDelegationOverlayChromeLongKeyLabelsStayOneRow(t *testing.T) { marker string rows int }{ - {"subagent roster", rosterOut, "goal-", func() int { - w := subagentSelectableList(th, subagentState{cursor: 6}, fleet, hk, bodyWidth).window(th, height) - return w.end - w.start - }()}, - {"parallel roster", parallelOut, "branches", func() int { - w := parallelSelectableList(th, parallelState{cursor: 6}, groups, hk, bodyWidth).window(th, height) - return w.end - w.start - }()}, - {"team roster", teamOut, "member-", func() int { - w := teamSelectableList(th, teamState{cursor: 6}, team, hk, bodyWidth).window(th, height) - return w.end - w.start - }()}, + {"subagent roster", rosterOut, "goal-", len(subagentSelectableList(th, subagentState{cursor: 6}, fleet, hk, bodyWidth).boundedView(th, height).rows)}, + {"parallel roster", parallelOut, "branches", len(parallelSelectableList(th, parallelState{cursor: 6}, groups, hk, bodyWidth).boundedView(th, height).rows)}, + {"team roster", teamOut, "member-", len(teamSelectableList(th, teamState{cursor: 6}, team, hk, bodyWidth).boundedView(th, height).rows)}, {"tasks", tasksOut, "task-", teamTasksRows(height)}, {"findings", findingsOut, "finding-", teamFindingsRows(height)}, } { diff --git a/cmd/mecatui/ui/models_surface.go b/cmd/mecatui/ui/models_surface.go index 38bbd7c3e7..77be2d7685 100644 --- a/cmd/mecatui/ui/models_surface.go +++ b/cmd/mecatui/ui/models_surface.go @@ -64,8 +64,7 @@ func (modelsGlobalDefaultIntent) isSurfaceIntent() {} func (s *modelsState) Render(width, height int) (string, []ClickableRegion) { prefix, suffix := modelsFixedLines(*s, s.provenance) - s.rowBudget = modelsRowBudgetFor(height, modelsPanelFixedRows(*s, s.provenance, s.deps.marks)) - s.rowBudget = min(s.rowBudget, max(0, height-len(prefix)-len(suffix))) + s.rowBudget = max(0, height-len(prefix)-len(suffix)) reveal := s.syncList(width, s.rowBudget) || s.revealCursor view := boundedListViewWithIndicators(&s.list, s.rowBudget, reveal) s.revealCursor = false @@ -212,7 +211,7 @@ func (s *modelsState) takeSurfaceIntent() surfaceIntent { } func (s *modelsState) syncFilter() { s.filtered = filterModels(s.catalog.models, s.filter.Value()) - s.cursor = clampModelsCursor(s.cursor, len(s.filtered)) + s.cursor = clampBounded(s.cursor, len(s.filtered)) s.list.setItems(modelsBoundedItems(s.catalog, s.filtered)) if len(s.filtered) > 0 && s.list.cursorID == "" { s.list.setCursor(s.cursor) @@ -245,17 +244,17 @@ func (s *modelsState) moveCursor(move boundedMove) { } switch move { case boundedLineUp: - s.cursor = clampModelsCursor(s.cursor-1, len(s.filtered)) + s.cursor = clampBounded(s.cursor-1, len(s.filtered)) case boundedLineDown: - s.cursor = clampModelsCursor(s.cursor+1, len(s.filtered)) + s.cursor = clampBounded(s.cursor+1, len(s.filtered)) case boundedPageUp: - s.cursor = clampModelsCursor(s.cursor-delta, len(s.filtered)) + s.cursor = clampBounded(s.cursor-delta, len(s.filtered)) case boundedPageDown: - s.cursor = clampModelsCursor(s.cursor+delta, len(s.filtered)) + s.cursor = clampBounded(s.cursor+delta, len(s.filtered)) case boundedTop: s.cursor = 0 case boundedEnd: - s.cursor = clampModelsCursor(len(s.filtered)-1, len(s.filtered)) + s.cursor = clampBounded(len(s.filtered)-1, len(s.filtered)) } } @@ -270,32 +269,6 @@ func modelsBoundedItems(catalog modelCatalog, models []client.ModelInfo) []bound return items } -func boundedListViewWithIndicators(list *boundedList, capacity int, reveal bool) boundedListView { - reserved := 0 - var view boundedListView - for range 3 { - list.viewport.height = max(0, capacity-reserved) - if reveal { - list.revealCursor(list.layout()) - } - view = list.view() - needed := 0 - if view.above > 0 { - needed++ - } - if view.below > 0 { - needed++ - } - if needed <= reserved { - list.reveal = false - return view - } - reserved = needed - } - list.reveal = false - return view -} - func modelsFixedLines(picker modelsState, prov string) (prefix, suffix []string) { title := "Models" if len(picker.filtered) > 0 { @@ -354,15 +327,6 @@ func (s *modelsState) chosen() (client.ModelInfo, bool) { return s.filtered[s.cursor], true } -func clampModelsCursor(c, n int) int { - if n <= 0 || c < 0 { - return 0 - } - if c >= n { - return n - 1 - } - return c -} func filterModels(models []client.ModelInfo, q string) []client.ModelInfo { if q == "" { return models @@ -379,38 +343,6 @@ func filterModels(models []client.ModelInfo, q string) []client.ModelInfo { const modelSwitchDisclosure = "Switching models is expensive as it clears caches." -func modelsRowBudgetFor(height, fixedRows int) int { - return max(0, height-fixedRows) -} - -// modelsPanelFixedRows derives the list's viewport budget from the same variable -// content rendered around it, including the switch disclosure and provider status. -func modelsPanelFixedRows(picker modelsState, prov string, hk helpKeys) int { - var b strings.Builder - b.WriteString("Models\n") - if prov != "" { - b.WriteString(prov + "\n") - } - b.WriteString(picker.filter.View() + "\n\n") - b.WriteString(modelSwitchDisclosure + "\n\n") - statuses := renderProviderStatusLines(picker.catalog.statuses, len(picker.catalog.models) == 0) - if modelsRowsRendered(picker) && len(statuses) > 0 { - b.WriteString("separator\n") - } - for range statuses { - b.WriteString("status\n") - } - b.WriteString("row\n\n") - b.WriteString("type to filter · ↑/↓/" + hk.scrollUp + " move · " + hk.choose + " use · " + hk.setGlobalDefault + " set global default · " + hk.closeOnly + " clear filter / close\n") - b.WriteString("● current ★ global default\n") - b.WriteString("reason = emits reasoning · set its effort tier with /effort") - return strings.Count(b.String(), "\n") -} - -func modelsRowsRendered(picker modelsState) bool { - return !picker.loading && picker.err == nil && len(picker.catalog.models) > 0 && len(picker.filtered) > 0 -} - const modelsDisabledNote = "Model selection is not available on this server.\nConfigure a provider on the server, then reconnect." const modelsErrorHint = "the model service may be unavailable — check mecated is running (log: $XDG_STATE_HOME/mecatl/mecatui.log)" const modelsGatewayEmptyNote = "your gateway credential lists no models — ask your platform admin or re-run `thv llm setup`" diff --git a/cmd/mecatui/ui/models_surface_test.go b/cmd/mecatui/ui/models_surface_test.go index 65a81d397a..239cb4c02d 100644 --- a/cmd/mecatui/ui/models_surface_test.go +++ b/cmd/mecatui/ui/models_surface_test.go @@ -123,11 +123,14 @@ func TestModelsProviderStatusesUseErrorStyleAndSeparateModelRows(t *testing.T) { withoutStatus := picker withoutStatus.catalog.statuses = nil - if got, want := modelsPanelFixedRows(picker, "", defaultHelpKeys()), modelsPanelFixedRows(withoutStatus, "", defaultHelpKeys())+2; got != want { - t.Fatalf("fixed rows with a separated status = %d, want %d", got, want) + withPrefix, withSuffix := modelsFixedLines(picker, "") + withoutPrefix, withoutSuffix := modelsFixedLines(withoutStatus, "") + if got, want := len(withPrefix)+len(withSuffix), len(withoutPrefix)+len(withoutSuffix)+1; got != want { + t.Fatalf("actual chrome rows with status = %d, want %d", got, want) } picker.deps = surfaceDeps{keys: defaultKeys(), theme: th} - _, _ = picker.Render(100, modelsPanelFixedRows(picker, "", defaultHelpKeys())+4) + prefix, suffix := modelsFixedLines(picker, "") + _, _ = picker.Render(100, len(prefix)+len(suffix)+4) if picker.rowBudget != 4 { t.Fatalf("page budget = %d, want 4 after status separation", picker.rowBudget) } @@ -144,7 +147,8 @@ func TestModelsSurfaceRenderOwnsCurrentPageBudget(t *testing.T) { filtered: models, deps: surfaceDeps{keys: defaultKeys(), theme: theme.New("aztec", theme.AztecPalette())}, } - _, _ = s.Render(100, modelsPanelFixedRows(*s, "", defaultHelpKeys())+4) + prefix, suffix := modelsFixedLines(*s, "") + _, _ = s.Render(100, len(prefix)+len(suffix)+4) if s.rowBudget != 4 { t.Fatalf("page budget = %d, want Render-derived 4", s.rowBudget) } diff --git a/cmd/mecatui/ui/parallel_overlay_test.go b/cmd/mecatui/ui/parallel_overlay_test.go index 872cadc5a1..37b7fd5be1 100644 --- a/cmd/mecatui/ui/parallel_overlay_test.go +++ b/cmd/mecatui/ui/parallel_overlay_test.go @@ -266,21 +266,15 @@ func TestParallelBranchRowSeparatesSummaryAndActivity(t *testing.T) { toolCount: 2, usage: client.Usage{InputTokens: 1200, OutputTokens: 340}, } - selected := stripANSIstr(strings.TrimSuffix(renderParallelBranchRow(aztec(), br, -1, true, 80), "\n")) - unselected := stripANSIstr(strings.TrimSuffix(renderParallelBranchRow(aztec(), br, -1, false, 80), "\n")) - selectedRows := strings.Split(selected, "\n") - unselectedRows := strings.Split(unselected, "\n") - if len(selectedRows) != 2 || len(unselectedRows) != 2 { - t.Fatalf("branch summaries = %q / %q, want title plus details", selected, unselected) + other := parallelBranch{index: 1, label: "other"} + group := []parallelGroup{{parentCallID: "group", winner: -1, branches: []parallelBranch{*br, other}}} + selected := stripANSIstr(renderParallelGroupFocus(aztec(), parallelState{view: parallelGroupView, group: "group"}, group, defaultHelpKeys(), 80, 0)) + unselected := stripANSIstr(renderParallelGroupFocus(aztec(), parallelState{view: parallelGroupView, group: "group", branchCursor: 1}, group, defaultHelpKeys(), 80, 0)) + if !strings.Contains(selected, "▶ ✓ branch-1") || !strings.Contains(unselected, " ✓ branch-1") { + t.Fatalf("bounded branch selection markers changed: %q / %q", selected, unselected) } - if !strings.HasPrefix(selectedRows[0], "▶ ✓ branch-1") || !strings.HasPrefix(unselectedRows[0], " ✓ branch-1") { - t.Fatalf("selection markers changed branch title semantics: %q / %q", selectedRows[0], unselectedRows[0]) - } - if selectedRows[1] != unselectedRows[1] || !strings.HasPrefix(selectedRows[1], " ") { - t.Fatalf("details should retain one shared four-column indent: %q / %q", selectedRows[1], unselectedRows[1]) - } - if !strings.Contains(selectedRows[0], "…") || !strings.Contains(selectedRows[1], "gpt-5-mini") { - t.Fatalf("summary did not clip title or retain routing details: %q", selected) + if !strings.Contains(selected, "\n ") || !strings.Contains(selected, "gpt-5-mini") { + t.Fatalf("bounded branch summary lost indented routing details: %q", selected) } if got := indentParallelBranchTrace(" ✓ Edit\n · changed file", 32); got != " │ ✓ Edit\n │ · changed file" { t.Fatalf("activity gutter = %q", got) @@ -396,7 +390,8 @@ func TestParallelRosterWindowed(t *testing.T) { t.Fatalf("expected Parallel tab, got %v", m.agentsTab) } out := stripANSIstr(m.View().Content) - rows := m.parallelRosterPageSize(m.conv.parallelGroups) + th, hk, width, height := m.agentsListGeometry() + rows := len(parallelSelectableList(th, m.parallel, m.conv.parallelGroups, hk, width).boundedView(th, height).rows) if rows >= n { t.Fatalf("test premise broken: window %d must be < groups %d", rows, n) } diff --git a/cmd/mecatui/ui/schedule.go b/cmd/mecatui/ui/schedule.go index 5b82ed26bd..d784559d55 100644 --- a/cmd/mecatui/ui/schedule.go +++ b/cmd/mecatui/ui/schedule.go @@ -177,7 +177,7 @@ func (m Model) onSchedulePanelActionKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd m.schedule.cursor = 0 return m, nil, true case key.Matches(msg, m.keys.ScrollBottom): - m.schedule.cursor = clampModelsCursor(len(m.schedule.filtered)-1, len(m.schedule.filtered)) + m.schedule.cursor = clampBounded(len(m.schedule.filtered)-1, len(m.schedule.filtered)) return m, nil, true case key.Matches(msg, m.keys.Choose): return m.openScheduleInspect() diff --git a/cmd/mecatui/ui/sessions_surface.go b/cmd/mecatui/ui/sessions_surface.go index 5187245e19..a3261e1edc 100644 --- a/cmd/mecatui/ui/sessions_surface.go +++ b/cmd/mecatui/ui/sessions_surface.go @@ -685,7 +685,7 @@ func (s *sessionsState) handleNavigationKey(msg tea.KeyPressMsg) (handled bool, } case "end": if key.Matches(msg, s.deps.keys.ScrollBottom) { - s.cursor = clampModelsCursor(len(s.filtered)-1, len(s.filtered)) + s.cursor = clampBounded(len(s.filtered)-1, len(s.filtered)) return true, false } } diff --git a/cmd/mecatui/ui/surface_arch_test.go b/cmd/mecatui/ui/surface_arch_test.go index e76e1ac681..9e879eb44e 100644 --- a/cmd/mecatui/ui/surface_arch_test.go +++ b/cmd/mecatui/ui/surface_arch_test.go @@ -40,7 +40,7 @@ var surfaceFileHomes = map[string]bool{ const surfaceFileCount = 9 // surfaceToken identifies declarations governed by the surface placement gate. -var surfaceToken = regexp.MustCompile(`^(?:surface|surfaceDeps|session[A-Za-z0-9]*|soulView|soulNone|soulPanel|soulBodyLines|soulState|soulMaxScroll|clampSoulScroll|soulContentLines|renderSoulPanel|renderSoulMeta|renderSoulBody|soulDisabledNote|soulTrustLabel|skillsView|skillsNone|skillsPanel|skillsDetail|skillsBodyLines|skillsState|filterSkills|cloneSkillGenerations|skillsDisabledNote|skillsEmptyCopy|skillsRowLines|renderSkillsPanel|renderLearnedSkillDetail|openSkills|closeSkills|onSkillsKey|updateSkillsMsg|skillsFilteredRowTotal|syncSkillsFilter|renderSkillsOverlay|mcpView|mcpNone|mcpPanel|mcpResources|mcpResourcePrev|mcpPrompts|mcpPromptArgs|mcpState|brokerMCPSetupState|canConnect|syncMCPSetup|renderBrokerMCPPanel|brokerEnrollmentLabel|brokerCatalogueLabel|argField|renderMCPOverlay|renderMCPPanel|renderMCPListHeader|renderResourceList|renderResourcePreview|renderPromptList|renderPromptArgs|mcpStatusLine|mcpPanelFooter|renderGroupsLine|renderRow|hasRequiredArgs|mcpEmptyCopy|mcpDisabledNote|runMCP|runMCPResources|runMCPPrompts|openMCP|closeMCP|onMCPKey|updateMCPMsg|insertIntoInput|joinContents|joinPromptMessages|handlePanelKey|handleResourceKey|handlePromptListKey|handlePromptArgsKey|focusArg|selectPrompt|submitPromptArgs|refreshPanel|modelsView|modelsNone|modelsPanel|modelsChrome|modelsState|modelsCatalogIntent|modelsSelectIntent|modelsGlobalDefaultIntent|modelsRowBudgetFor|filterModels|clampModelsCursor|modelsDisabledNote|modelsErrorHint|modelsGatewayEmptyNote|modelsEmptyCopy|promotedStatus|providerStatusLine|renderProviderStatusLines|modelRowText|modelLabel|modelCapSegments|openModels|configProvenanceProviderSet|availableNotDefaultStatus)$`) +var surfaceToken = regexp.MustCompile(`^(?:surface|surfaceDeps|session[A-Za-z0-9]*|soulView|soulNone|soulPanel|soulBodyLines|soulState|soulMaxScroll|clampSoulScroll|soulContentLines|renderSoulPanel|renderSoulMeta|renderSoulBody|soulDisabledNote|soulTrustLabel|skillsView|skillsNone|skillsPanel|skillsDetail|skillsBodyLines|skillsState|filterSkills|cloneSkillGenerations|skillsDisabledNote|skillsEmptyCopy|skillsRowLines|renderSkillsPanel|renderLearnedSkillDetail|openSkills|closeSkills|onSkillsKey|updateSkillsMsg|skillsFilteredRowTotal|syncSkillsFilter|renderSkillsOverlay|mcpView|mcpNone|mcpPanel|mcpResources|mcpResourcePrev|mcpPrompts|mcpPromptArgs|mcpState|brokerMCPSetupState|canConnect|syncMCPSetup|renderBrokerMCPPanel|brokerEnrollmentLabel|brokerCatalogueLabel|argField|renderMCPOverlay|renderMCPPanel|renderMCPListHeader|renderResourceList|renderResourcePreview|renderPromptList|renderPromptArgs|mcpStatusLine|mcpPanelFooter|renderGroupsLine|renderRow|hasRequiredArgs|mcpEmptyCopy|mcpDisabledNote|runMCP|runMCPResources|runMCPPrompts|openMCP|closeMCP|onMCPKey|updateMCPMsg|insertIntoInput|joinContents|joinPromptMessages|handlePanelKey|handleResourceKey|handlePromptListKey|handlePromptArgsKey|focusArg|selectPrompt|submitPromptArgs|refreshPanel|modelsView|modelsNone|modelsPanel|modelsChrome|modelsState|modelsCatalogIntent|modelsSelectIntent|modelsGlobalDefaultIntent|filterModels|modelsDisabledNote|modelsErrorHint|modelsGatewayEmptyNote|modelsEmptyCopy|promotedStatus|providerStatusLine|renderProviderStatusLines|modelRowText|modelLabel|modelCapSegments|openModels|configProvenanceProviderSet|availableNotDefaultStatus)$`) // modelsSymbolHomes pins the deliberate three-way /models split: Model-owned // installation and effects, durable catalog reduction, and dynamic surface behavior. @@ -58,7 +58,7 @@ var modelsSymbolHomes = map[string]string{ "modelsView": "models_surface.go", "modelsNone": "models_surface.go", "modelsPanel": "models_surface.go", "modelsChrome": "models_surface.go", "modelsState": "models_surface.go", "modelsCatalogIntent": "models_surface.go", "modelsSelectIntent": "models_surface.go", "modelsGlobalDefaultIntent": "models_surface.go", - "modelsRowBudgetFor": "models_surface.go", "filterModels": "models_surface.go", "clampModelsCursor": "models_surface.go", + "filterModels": "models_surface.go", "modelsDisabledNote": "models_surface.go", "modelsErrorHint": "models_surface.go", "modelsGatewayEmptyNote": "models_surface.go", "modelsEmptyCopy": "models_surface.go", "promotedStatus": "models_surface.go", "providerStatusLine": "models_surface.go", "renderProviderStatusLines": "models_surface.go", "modelRowText": "models_surface.go", "modelLabel": "models_surface.go", "modelCapSegments": "models_surface.go", diff --git a/cmd/mecatui/ui/team.go b/cmd/mecatui/ui/team.go index c6936b1b6b..dc18c0a891 100644 --- a/cmd/mecatui/ui/team.go +++ b/cmd/mecatui/ui/team.go @@ -6,7 +6,6 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/stacklok/mecatl/cmd/mecatui/theme" ) @@ -198,25 +197,6 @@ func (m Model) onTeamRosterKey(msg tea.KeyPressMsg, b *block) (tea.Model, tea.Cm return m, nil } -// navigateRosterCursor applies the shared roster navigation keys to cursor. -func navigateRosterCursor(msg tea.KeyPressMsg, keys keyMap, cursor, total, page int) (next int, handled bool) { - switch { - case key.Matches(msg, keys.Up): - return clampCursor(cursor-1, total), true - case key.Matches(msg, keys.Down): - return clampCursor(cursor+1, total), true - case key.Matches(msg, keys.ScrollU): - return clampCursor(cursor-page, total), true - case key.Matches(msg, keys.ScrollD): - return clampCursor(cursor+page, total), true - case key.Matches(msg, keys.JumpTop): - return clampCursor(0, total), true - case key.Matches(msg, keys.JumpEnd): - return clampCursor(total-1, total), true - } - return cursor, false -} - // cancelTeamLane sends a CancelChild frame for one team member's session id (the // member's MemberSessionID handle, arriving on team.member events). It is a no-op for // a nil lane, a finished team, an already-stopped member, or a lane that never learned @@ -231,22 +211,6 @@ func (m Model) cancelTeamLane(b *block, ln *teamLane) (tea.Model, tea.Cmd) { return m.cancelChildByID(ln.sessionID, "member "+sanitizeTerminal(ln.name)) } -// clampCursor clamps a candidate cursor index to [0, n-1] (and to 0 when the -// roster is empty), so all the jump/page math can be written without per-call -// bounds checks. -func clampCursor(i, n int) int { - if n <= 0 { - return 0 - } - if i < 0 { - return 0 - } - if i >= n { - return n - 1 - } - return i -} - // maxTeamFocusLines is the ABSOLUTE ceiling on how many lines of a focused // member's trace the focus pane renders — a sane upper bound so one verbose member // can't grow the overlay without limit (a DoS-by-output guard) on a very tall @@ -313,7 +277,7 @@ func teamSelectableList(th theme.Theme, st teamState, b *block, hk helpKeys, bod } order := teamLaneOrder(b.teamLanes) nameW := teamNameWidth(b.teamLanes, order) - cursor := clampCursor(st.cursor, len(order)) + cursor := clampBounded(st.cursor, len(order)) list := agentsSelectableList{ header: header, footer: renderDynamicCardChromeLine(muted, "", hk.navUp+"/"+hk.navDown+" select · "+hk.choose+" focus · "+hk.cancelChild+" cancel · "+hk.tasks+" tasks · "+hk.findings+" findings · "+agentsEmptyHint(hk), bodyWidth), @@ -339,20 +303,6 @@ func renderTeamRoster(th theme.Theme, st teamState, b *block, hk helpKeys, heigh return teamSelectableList(th, st, b, hk, bodyWidth).render(th, height) } -// renderTeamRosterRow uses three fixed physical lines: identity, work summary, and -// runtime metadata. Each line is clipped to its own budget instead of allowing an -// arbitrary wrap to split related fields across rows. -func renderTeamRosterRow(style lipgloss.Style, prefix string, ln *teamLane, nameW int, teamDone bool, bodyWidth int) string { - title := teamRosterTitle(ln, nameW, teamDone) - work, runtime := teamRosterWork(ln, teamDone), teamRosterRuntime(ln) - if bodyWidth > 0 { - title = truncateDisplayWidth(title, max(1, bodyWidth-lipgloss.Width(prefix))) - work = truncateDisplayWidth(work, max(1, bodyWidth-4)) - runtime = truncateDisplayWidth(runtime, max(1, bodyWidth-4)) - } - return style.Render(prefix + title + "\n " + work + "\n " + runtime) -} - func teamRosterTitle(ln *teamLane, nameW int, teamDone bool) string { name := truncate(sanitizeTerminal(ln.name), maxTeamNameWidth) if pad := nameW - len([]rune(name)); pad > 0 { diff --git a/cmd/mecatui/ui/team_overlay_test.go b/cmd/mecatui/ui/team_overlay_test.go index 851a22c5ac..d510a3db44 100644 --- a/cmd/mecatui/ui/team_overlay_test.go +++ b/cmd/mecatui/ui/team_overlay_test.go @@ -11,6 +11,7 @@ package ui import ( "fmt" + "slices" "strings" "testing" @@ -42,37 +43,6 @@ func bigRoster(n int) []client.TeamMemberSpec { return r } -func TestNavigateRosterCursor(t *testing.T) { - keys := defaultKeys() - for _, tc := range []struct { - name string - msg tea.KeyPressMsg - cursor int - total int - page int - want int - handled bool - }{ - {name: "up", msg: tea.KeyPressMsg{Code: tea.KeyUp}, cursor: 3, total: 8, page: 2, want: 2, handled: true}, - {name: "down", msg: tea.KeyPressMsg{Code: tea.KeyDown}, cursor: 3, total: 8, page: 2, want: 4, handled: true}, - {name: "up clamps at first", msg: tea.KeyPressMsg{Code: tea.KeyUp}, cursor: 0, total: 8, page: 2, want: 0, handled: true}, - {name: "down clamps at last", msg: tea.KeyPressMsg{Code: tea.KeyDown}, cursor: 7, total: 8, page: 2, want: 7, handled: true}, - {name: "page up", msg: tea.KeyPressMsg{Code: tea.KeyPgUp}, cursor: 6, total: 12, page: 4, want: 2, handled: true}, - {name: "page down", msg: tea.KeyPressMsg{Code: tea.KeyPgDown}, cursor: 2, total: 12, page: 4, want: 6, handled: true}, - {name: "home", msg: tea.KeyPressMsg{Code: tea.KeyHome}, cursor: 6, total: 8, page: 2, want: 0, handled: true}, - {name: "end", msg: tea.KeyPressMsg{Code: tea.KeyEnd}, cursor: 1, total: 8, page: 2, want: 7, handled: true}, - {name: "empty roster", msg: tea.KeyPressMsg{Code: tea.KeyDown}, cursor: 4, total: 0, page: 2, want: 0, handled: true}, - {name: "unhandled", msg: tea.KeyPressMsg{Code: 'z', Text: "z"}, cursor: 4, total: 8, page: 2, want: 4, handled: false}, - } { - t.Run(tc.name, func(t *testing.T) { - got, handled := navigateRosterCursor(tc.msg, keys, tc.cursor, tc.total, tc.page) - if got != tc.want || handled != tc.handled { - t.Errorf("navigateRosterCursor() = (%d, %t), want (%d, %t)", got, handled, tc.want, tc.handled) - } - }) - } -} - // TestAgentsOpensRoster asserts f6 over a populated team opens the roster. func TestAgentsOpensRoster(t *testing.T) { m := newMCPModel(t, aztec(), nil) @@ -715,7 +685,12 @@ func TestAgentsRosterWindowed(t *testing.T) { out := stripANSIstr(m.View().Content) th, hk, width, height := m.agentsListGeometry() - rows := agentsListPageSize(th, height, teamSelectableList(th, m.team, m.conv.latestTeamBlock(), hk, width)) + view := teamSelectableList(th, m.team, m.conv.latestTeamBlock(), hk, width).boundedView(th, height) + visible := make(map[int]struct{}) + for _, row := range view.rows { + visible[row.itemIndex] = struct{}{} + } + rows := len(visible) if rows >= n { t.Fatalf("test premise broken: window %d must be smaller than roster %d", rows, n) } @@ -873,19 +848,17 @@ func TestTeamRosterRowUsesIdentityWorkAndRuntimeLines(t *testing.T) { routedModel: "gpt-5.6-terra", usage: client.Usage{InputTokens: 100600, OutputTokens: 626}, } - row := stripANSIstr(renderTeamRosterRow(aztec().Style("spinner"), "▶ ", ln, 0, false, 100)) + row := stripANSIstr(renderTeamRoster(aztec(), teamState{}, &block{teamLanes: []teamLane{*ln}}, defaultHelpKeys(), 0, 100)) lines := strings.Split(row, "\n") - if len(lines) != 3 { - t.Fatalf("team row has %d lines, want identity/work/runtime: %q", len(lines), row) - } - if !strings.HasPrefix(lines[0], "▶ ◆ · overlay-reader") { - t.Fatalf("identity line lost selection or member identity: %q", lines[0]) + start := slices.IndexFunc(lines, func(line string) bool { return strings.HasPrefix(line, "▶ ◆ · overlay-reader") }) + if start < 0 || start+2 >= len(lines) { + t.Fatalf("bounded team roster lost identity/work/runtime rows: %q", row) } - if !strings.HasPrefix(lines[1], " RecordFinding… · Inspect the Agents") { - t.Fatalf("work line should group current action and role: %q", lines[1]) + if !strings.HasPrefix(lines[start+1], " RecordFinding… · Inspect the Agents") { + t.Fatalf("work line should group current action and role: %q", lines[start+1]) } - if !strings.HasPrefix(lines[2], " ↑100.6K ↓626 · ctx ") || !strings.Contains(lines[2], "medium → gpt-5.6-terra") { - t.Fatalf("runtime line should group tokens, context, and route: %q", lines[2]) + if !strings.HasPrefix(lines[start+2], " ↑100.6K ↓626 · ctx ") || !strings.Contains(lines[start+2], "medium → gpt-5.6-terra") { + t.Fatalf("runtime line should group tokens, context, and route: %q", lines[start+2]) } } diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 8794626e19..e3e2527840 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -1664,6 +1664,7 @@ func noticeLine(msg tea.Msg) string { // subagent event only costs the trace, never correctness or isolation. func (m *Model) applySubagent(msg client.SubagentMsg) { applySubagentTo(&m.conv, msg) + m.reconcileAgentsLists() // A BACKGROUND child finishing is otherwise invisible (its Subagent card // resolved long ago with the started-result), so surface a brief transient // footer notice — the same advisory channel as team-done / no-progress, never @@ -1707,6 +1708,7 @@ func applySubagentTo(c *conversation, msg client.SubagentMsg) { // are bounded/scrubbed/client-only (gauntlet #7). func (m *Model) applyParallel(msg client.ParallelMsg) { applyParallelTo(&m.conv, msg) + m.reconcileAgentsLists() } // applyParallelTo is the pure conversation-projection half of applyParallel: it @@ -1744,6 +1746,7 @@ func (m *Model) applyTeam(msg client.TeamMsg) { // the team card + the run's ResultMsg. m.statusMsg = m.deps.Theme.Style("muted").Render(fmt.Sprintf("team done · %s", plural(msg.Rounds, "round"))) } + m.reconcileAgentsLists() } // applyTeamTo is the pure conversation-projection half of applyTeam: it routes a diff --git a/cmd/mecatui/ui/window.go b/cmd/mecatui/ui/window.go index 02cb4dc4f8..0720dba35b 100644 --- a/cmd/mecatui/ui/window.go +++ b/cmd/mecatui/ui/window.go @@ -421,6 +421,51 @@ func clampBounded(value, count int) int { return min(value, count-1) } +func boundedListViewWithIndicators(list *boundedList, capacity int, reveal bool) boundedListView { + if capacity <= 0 { + list.viewport.height = 0 + list.reveal = false + return boundedListView{} + } + reserved := 0 + for range 3 { + list.viewport.height = max(1, capacity-reserved) + if reveal { + list.revealCursor(list.layout()) + } + view := list.view() + needed := 0 + if view.above > 0 { + needed++ + } + if view.below > 0 { + needed++ + } + next := min(needed, capacity-1) + if next == reserved { + break + } + reserved = next + } + // Recompute for the final reservation: the last iteration may have changed it. + list.viewport.height = max(1, capacity-reserved) + if reveal { + list.revealCursor(list.layout()) + } + view := list.view() + available := max(0, capacity-len(view.rows)) + if view.above > 0 && available > 0 { + available-- + } else { + view.above = 0 + } + if view.below > 0 && available == 0 { + view.below = 0 + } + list.reveal = false + return view +} + // scrollWindow returns the [start,end) slice bounds of a scrolling window of size // limit over n rows, kept around the selected cursor so it stays visible. It is a // pure function of (cursor, n, limit) — the window FOLLOWS the cursor (no stored diff --git a/cmd/mecatui/ui/worktrees.go b/cmd/mecatui/ui/worktrees.go index 8d0e177fc9..221d3fbbce 100644 --- a/cmd/mecatui/ui/worktrees.go +++ b/cmd/mecatui/ui/worktrees.go @@ -109,7 +109,7 @@ func (m Model) onWorktreesKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { m.worktrees.cursor = 0 return m, nil, true case key.Matches(msg, m.keys.ScrollBottom): - m.worktrees.cursor = clampModelsCursor(len(m.worktrees.filtered)-1, len(m.worktrees.filtered)) + 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 From af71a555aaca15f8e8d7df59874585793296cc79 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 15:05:02 -0700 Subject: [PATCH 08/10] fix(mecatui): finalize bounded selection mechanics Co-Authored-By: mecatl --- .../ui/bounded_scroll_adoption_test.go | 57 +++++++++++++++++++ cmd/mecatui/ui/models_surface.go | 5 +- cmd/mecatui/ui/models_surface_test.go | 44 +++++++++++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/cmd/mecatui/ui/bounded_scroll_adoption_test.go b/cmd/mecatui/ui/bounded_scroll_adoption_test.go index 9fb14a324b..0421bbfd54 100644 --- a/cmd/mecatui/ui/bounded_scroll_adoption_test.go +++ b/cmd/mecatui/ui/bounded_scroll_adoption_test.go @@ -154,6 +154,22 @@ func TestMecatuiBoundedScrollCursor_Scenario2_ModelsFitsOfferedGeometry(t *testi } func TestMecatuiBoundedScrollCursor_Scenario2_CursorAndStatusStylesStayDistinct(t *testing.T) { + t.Run("real render applies selected style to Models and Agents", func(t *testing.T) { + th := aztec() + models := boundedScenarioModelsState(t, 2) + models.catalog.active = client.ModelSelection{ProviderID: "provider", ModelID: "model-00"} + models.catalog.globalDefault = client.ModelSelection{ProviderID: "provider", ModelID: "model-00"} + modelOut, _ := models.Render(80, 24) + if want := strings.TrimSuffix(th.Style("spinner").Render("▶ "), "\x1b[m") + "●★ provider"; !strings.Contains(modelOut, want) { + t.Fatalf("Models selected row does not apply spinner style: want fragment %q in %q", want, modelOut) + } + + agentsOut := renderAgentsOverlay(th, tabParallel, subagentState{}, parallelState{cursor: 0}, teamState{view: teamRoster}, nil, nil, boundedScenarioGroups(1), defaultHelpKeys(), 80, 24, 24) + if want := strings.TrimSuffix(th.Style("spinner").Render("▶ "), "\x1b[m"); !strings.Contains(agentsOut, want) { + t.Fatalf("Agents selected row does not apply spinner style: want fragment %q in %q", want, agentsOut) + } + }) + th := aztec() s := boundedScenarioModelsState(t, 2) s.catalog.active = client.ModelSelection{ProviderID: "provider", ModelID: "model-00"} @@ -420,6 +436,47 @@ func TestMecatuiBoundedScrollCursor_Scenario3_ModelClickSelectsEnterActivates(t if m.modal != nil || m.phase != phaseConnecting { t.Fatalf("Enter did not retain activation: modal=%T phase=%v", m.modal, m.phase) } + assertModelsOverflowIndicatorClickMisses(t) +} + +func assertModelsOverflowIndicatorClickMisses(t *testing.T) { + m := newModelsModel(t, sampleModels(), &fakeStore{}, modelsCaps(), client.ModelSelection{}) + m.deps.NoAltScreen = false + mm, cmd := m.runModels() + m = feedCmd(t, mm.(Model), cmd) + m = resize(m, 40, 35) + _ = m.View() + + s := modelsSurface(t, m) + s.filtered[0].DisplayName = strings.Repeat("wrapped model identity ", 5) + s.catalog.models = s.filtered + _ = m.View() + s.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + if s.list.viewport.offset == 0 { + t.Fatal("Models setup did not create an overflow indicator") + } + out := m.View().Content + if !strings.Contains(stripANSIstr(out), "↑ ") { + t.Fatalf("Models render did not show the expected overflow indicator:\n%s", stripANSIstr(out)) + } + + var first renderedHitRegion + for _, region := range m.hits.frame { + if _, ok := s.hitItems[region.id]; ok && (first.id == 0 || region.rect.y0 < first.rect.y0) { + first = region + } + } + if first.id == 0 || first.rect.y0 == 0 { + t.Fatalf("cannot locate a rendered Models row below its overflow indicator: %#v", first) + } + before := s.cursor + x, y := m.metrics.localToGlobal(first.rect.x0, first.rect.y0-1) + mm, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) + m = mm.(Model) + s = modelsSurface(t, m) + if s.cursor != before || s.intent != nil { + t.Fatalf("overflow-indicator click must miss without moving or activating Models: cursor=%d intent=%T", s.cursor, s.intent) + } } func TestMecatuiBoundedScrollCursor_Scenario3_StaleModelHitsIgnored(t *testing.T) { diff --git a/cmd/mecatui/ui/models_surface.go b/cmd/mecatui/ui/models_surface.go index 77be2d7685..344774d02e 100644 --- a/cmd/mecatui/ui/models_surface.go +++ b/cmd/mecatui/ui/models_surface.go @@ -223,8 +223,8 @@ func (s *modelsState) syncList(width, height int) bool { hadCursor := s.list.cursorID != "" s.list.setGeometry(width, height, 2, boundedWrap) s.list.setItems(modelsBoundedItems(s.catalog, s.filtered)) - reveal := len(s.filtered) > 0 && (!hadCursor || s.list.cursor != s.cursor) - if reveal { + reveal := len(s.filtered) > 0 && !hadCursor + if !hadCursor { s.list.setCursor(s.cursor) } s.cursor = s.list.cursor @@ -256,6 +256,7 @@ func (s *modelsState) moveCursor(move boundedMove) { case boundedEnd: s.cursor = clampBounded(len(s.filtered)-1, len(s.filtered)) } + s.list.setCursor(s.cursor) } func modelsBoundedItems(catalog modelCatalog, models []client.ModelInfo) []boundedListItem { diff --git a/cmd/mecatui/ui/models_surface_test.go b/cmd/mecatui/ui/models_surface_test.go index 239cb4c02d..84e1883904 100644 --- a/cmd/mecatui/ui/models_surface_test.go +++ b/cmd/mecatui/ui/models_surface_test.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" "testing" @@ -63,6 +64,42 @@ func TestModelCapSegmentsDescribeImageInput(t *testing.T) { } } +func TestModelsSurfaceRefreshPreservesStableCursorAndTopAnchor(t *testing.T) { + models := make([]client.ModelInfo, 40) + for i := range models { + models[i] = client.ModelInfo{ProviderID: "provider", ID: fmt.Sprintf("model-%d", i), DisplayName: fmt.Sprintf("Model %d", i)} + } + fm := &fakeModels{models: models} + m := newModelsModel(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{}) + mm, cmd := m.runModels() + m = feedCmd(t, mm.(Model), cmd) + m = resize(m, 40, 15) + s := modelsSurface(t, m) + _, _ = s.Render(40, 15) + + s.list.setCursor(5) + s.cursor = s.list.cursor + s.list.scroll(boundedLineDown) + beforeTop := s.list.view().rows[0] + if s.list.cursorID != "provider\x00model-5" || beforeTop.id == "" { + t.Fatalf("refresh setup cursor/top = %q/%q", s.list.cursorID, beforeTop.id) + } + + reordered := append([]client.ModelInfo{models[5], models[0], models[1]}, models[2:5]...) + reordered = append(reordered, models[6:]...) + // The picker receives the newly reconciled catalog before its next frame. Render + // owns the geometry/list refresh that must retain the stable selection. + s.catalog.models, s.filtered = reordered, reordered + _, _ = s.Render(40, 15) + s = modelsSurface(t, m) + if got := s.list.cursorID; got != "provider\x00model-5" { + t.Fatalf("reordered refresh selected %q, want provider/model-5", got) + } + if afterTop := s.list.view().rows[0]; afterTop.id != beforeTop.id || afterTop.itemLine != beforeTop.itemLine { + t.Fatalf("reordered refresh top anchor = {%q,%d}, want {%q,%d}", afterTop.id, afterTop.itemLine, beforeTop.id, beforeTop.itemLine) + } +} + func TestModelsSurfaceConsumesWheelBeforeViewport(t *testing.T) { m := newModelsModel(t, sampleModels(), &fakeStore{}, modelsCaps(), client.ModelSelection{}) mm, cmd := m.runModels() @@ -125,8 +162,11 @@ func TestModelsProviderStatusesUseErrorStyleAndSeparateModelRows(t *testing.T) { withoutStatus.catalog.statuses = nil withPrefix, withSuffix := modelsFixedLines(picker, "") withoutPrefix, withoutSuffix := modelsFixedLines(withoutStatus, "") - if got, want := len(withPrefix)+len(withSuffix), len(withoutPrefix)+len(withoutSuffix)+1; got != want { - t.Fatalf("actual chrome rows with status = %d, want %d", got, want) + if got, want := len(withPrefix)+len(withSuffix), len(withoutPrefix)+len(withoutSuffix)+2; got != want { + t.Fatalf("actual chrome rows with status and separator = %d, want %d", got, want) + } + if len(withSuffix) < 2 || withSuffix[0] != "" || !strings.Contains(stripANSIstr(withSuffix[1]), statusLine) { + t.Fatalf("provider status suffix does not begin with separator then status: %q", withSuffix) } picker.deps = surfaceDeps{keys: defaultKeys(), theme: th} prefix, suffix := modelsFixedLines(picker, "") From cbfc48d2dd351d6b5fd75362c2c00d5613dc68b4 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 15:32:26 -0700 Subject: [PATCH 09/10] fix(mecatui): preserve rendered list anchors Co-Authored-By: mecatl --- cmd/mecatui/ui/agents_overlay.go | 23 +++--- .../ui/bounded_scroll_adoption_test.go | 75 +++++++++++++++++++ cmd/mecatui/ui/team.go | 5 +- user-docs/features/choose-models.md | 10 +-- 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/cmd/mecatui/ui/agents_overlay.go b/cmd/mecatui/ui/agents_overlay.go index ec09ca4b9d..255373b85d 100644 --- a/cmd/mecatui/ui/agents_overlay.go +++ b/cmd/mecatui/ui/agents_overlay.go @@ -445,7 +445,7 @@ func (m *Model) reconcileAgentsLists() { return } reconcile := func(list agentsSelectableList) boundedList { - control, _, _ := list.configuredControl(th, height) + control, _ := list.indicatorAdjustedControl(th, height) return control } m.subagents.roster = reconcile(subagentSelectableList(th, m.subagents, m.conv.subagentFleet, hk, width)) @@ -478,8 +478,10 @@ func (m Model) onAgentsWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { move = boundedLineUp } scrollList := func(list agentsSelectableList) boundedList { - control, _, _ := list.configuredControl(th, height) + control, _ := list.indicatorAdjustedControl(th, height) control.scroll(move) + list.control = control + control, _ = list.indicatorAdjustedControl(th, height) return control } switch m.agentsTab { @@ -1079,12 +1081,14 @@ func (l agentsSelectableList) configuredControl(th theme.Theme, height int) (bou return l.control, max(0, capacity), reveal } -func (l agentsSelectableList) boundedView(th theme.Theme, height int) boundedListView { - if len(l.rows) == 0 { - return boundedListView{} - } +func (l agentsSelectableList) indicatorAdjustedControl(th theme.Theme, height int) (boundedList, boundedListView) { control, capacity, reveal := l.configuredControl(th, height) - return boundedListViewWithIndicators(&control, capacity, reveal) + return control, boundedListViewWithIndicators(&control, capacity, reveal) +} + +func (l agentsSelectableList) boundedView(th theme.Theme, height int) boundedListView { + _, view := l.indicatorAdjustedControl(th, height) + return view } func (l agentsSelectableList) render(th theme.Theme, height int) string { @@ -1596,10 +1600,7 @@ func parallelBranchSelectableList(th theme.Theme, st parallelState, g *parallelG if trace := r.renderTrace(br.trace); trace != "" { row += "\n" + indentParallelBranchTrace(trace, bodyWidth) } - list.ids = append(list.ids, br.childID) - if br.childID == "" { - list.ids[len(list.ids)-1] = fmt.Sprintf("branch-%d", br.index) - } + list.ids = append(list.ids, fmt.Sprintf("branch-%d", br.index)) list.rows = append(list.rows, row) } list.footer = focusBackHint(hk) + " · " + hk.navUp + "/" + hk.navDown + " select · " + hk.scroll + " page · " + hk.jumpTop + "/" + hk.jumpEnd + " first/last" diff --git a/cmd/mecatui/ui/bounded_scroll_adoption_test.go b/cmd/mecatui/ui/bounded_scroll_adoption_test.go index 0421bbfd54..04bbe85f38 100644 --- a/cmd/mecatui/ui/bounded_scroll_adoption_test.go +++ b/cmd/mecatui/ui/bounded_scroll_adoption_test.go @@ -120,6 +120,81 @@ func TestMecatuiBoundedScrollCursor_Scenario2_AgentsModesUseSharedAccounting(t * } } +func TestMecatuiBoundedScrollCursor_Scenario2_AgentsPersistRenderedAnchor(t *testing.T) { + m := boundedScenarioAgentsModel(t, "subagent-roster") + m.conv.fleetIndex = make(map[string]int, len(m.conv.subagentFleet)) + for i := range m.conv.subagentFleet { + m.conv.fleetIndex[m.conv.subagentFleet[i].childID] = i + } + th, hk, width, height := m.agentsListGeometry() + control, _, _ := subagentSelectableList(th, m.subagents, m.conv.subagentFleet, hk, width).configuredControl(th, height) + control.setCursor(5) + m.subagents.cursor, m.subagents.roster = control.cursor, control + + // A streamed update rebuilds the control. Its persisted top anchor must be the + // anchor the renderer uses after reserving indicator rows. + mm, _ := m.Update(client.SubagentMsg{Kind: client.SubagentTool, ChildID: "child-05", ToolName: strings.Repeat("streamed-tool-", 8), ToolCount: 1}) + m = mm.(Model) + list := subagentSelectableList(th, m.subagents, m.conv.subagentFleet, hk, width) + rendered := list.boundedView(th, height).rows[0] + persisted := m.subagents.roster.view().rows[0] + if persisted.id != rendered.id || persisted.itemLine != rendered.itemLine { + t.Fatalf("persisted top anchor = {%q,%d}, rendered = {%q,%d}", persisted.id, persisted.itemLine, rendered.id, rendered.itemLine) + } +} + +func TestMecatuiBoundedScrollCursor_Scenario2_LateHandlesKeepStableListIDs(t *testing.T) { + th, hk := aztec(), defaultHelpKeys() + + t.Run("team member", func(t *testing.T) { + lanes := make([]teamLane, 8) + for i := range lanes { + lanes[i].name = fmt.Sprintf("member-%d", i) + } + b := &block{teamLanes: lanes} + before := teamSelectableList(th, teamState{}, b, hk, 80) + control, _, _ := before.configuredControl(th, 12) + control.setCursor(5) + top := control.view().rows[0] + for i := range b.teamLanes { + b.teamLanes[i].sessionID = fmt.Sprintf("team-t1-member-%d", i) + } + after := teamSelectableList(th, teamState{roster: control}, b, hk, 80) + control, _, _ = after.configuredControl(th, 12) + if got := control.cursorID; got != "member-5" { + t.Fatalf("late member session handle changed list identity to %q, want member-5", got) + } + gotTop := control.view().rows[0] + if gotTop.id != top.id || gotTop.itemLine != top.itemLine { + t.Fatalf("late member session handle moved top anchor to {%q,%d}, want {%q,%d}", gotTop.id, gotTop.itemLine, top.id, top.itemLine) + } + }) + + t.Run("parallel branch", func(t *testing.T) { + branches := make([]parallelBranch, 8) + for i := range branches { + branches[i] = parallelBranch{index: i, label: fmt.Sprintf("branch-%d", i+1)} + } + g := ¶llelGroup{branches: branches} + before := parallelBranchSelectableList(th, parallelState{}, g, hk, 80) + control, _, _ := before.configuredControl(th, 12) + control.setCursor(5) + top := control.view().rows[0] + for i := range g.branches { + g.branches[i].childID = fmt.Sprintf("parallel-p1-%d", i) + } + after := parallelBranchSelectableList(th, parallelState{branches: control}, g, hk, 80) + control, _, _ = after.configuredControl(th, 12) + if got := control.cursorID; got != "branch-5" { + t.Fatalf("late branch child handle changed list identity to %q, want branch-5", got) + } + gotTop := control.view().rows[0] + if gotTop.id != top.id || gotTop.itemLine != top.itemLine { + t.Fatalf("late branch child handle moved top anchor to {%q,%d}, want {%q,%d}", gotTop.id, gotTop.itemLine, top.id, top.itemLine) + } + }) +} + func TestMecatuiBoundedScrollCursor_Scenario2_ModelsFitsOfferedGeometry(t *testing.T) { for _, size := range []struct{ width, height int }{{1, 1}, {12, 5}, {32, 12}, {100, 30}} { t.Run(fmt.Sprintf("%dx%d", size.width, size.height), func(t *testing.T) { diff --git a/cmd/mecatui/ui/team.go b/cmd/mecatui/ui/team.go index dc18c0a891..b36d9a4330 100644 --- a/cmd/mecatui/ui/team.go +++ b/cmd/mecatui/ui/team.go @@ -285,11 +285,8 @@ func teamSelectableList(th theme.Theme, st teamState, b *block, hk helpKeys, bod bodyWidth: bodyWidth, control: st.roster, } for _, laneIndex := range order { - list.ids = append(list.ids, b.teamLanes[laneIndex].sessionID) - if list.ids[len(list.ids)-1] == "" { - list.ids[len(list.ids)-1] = b.teamLanes[laneIndex].name - } lane := &b.teamLanes[laneIndex] + list.ids = append(list.ids, lane.name) list.rows = append(list.rows, teamRosterTitle(lane, nameW, b.teamDone)+"\n "+teamRosterWork(lane, b.teamDone)+"\n "+teamRosterRuntime(lane)) } return list diff --git a/user-docs/features/choose-models.md b/user-docs/features/choose-models.md index 5f04119177..f565fe6487 100644 --- a/user-docs/features/choose-models.md +++ b/user-docs/features/choose-models.md @@ -22,11 +22,11 @@ For the rest of the terminal workflow, see [Use mecatui](/mecatui/index.md). ## Mecatui journey When the connected server advertises model selection, type `/models` in -`mecatui`. Filter the server's inventory and move the cursor with the keyboard, -the mouse wheel, or a primary click on a visible model row. Clicking changes the -cursor only. Press **Enter** to switch to the selected model. The picker warns -that the choice creates a peer session and carries over the visible -conversation. Replaying a long history may be costly. The existing session's +`mecatui`. Filter the server's inventory and use the keyboard or a primary click +on a visible model row to move the selection. The mouse wheel scrolls the +viewport while the selection remains pinned. Press **Enter** to switch to the +selected model. The picker warns that the choice creates a peer session and +carries over the visible conversation. Replaying a long history may be costly. The existing session's provider and base model do not change. A switch across providers keeps the visible conversation but drops provider- From 9a45f128a68e0e076a83ff3008f3b60edc28726e Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 17:35:23 -0700 Subject: [PATCH 10/10] docs(acceptance): mark scroll cursor slice landed Co-Authored-By: mecatl --- docs/acceptance/README.md | 2 +- docs/acceptance/mecatui-bounded-scroll-selection.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 3c8de84336..bfa490119d 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -123,7 +123,7 @@ PR after verification. There is no cleanup or status-only PR. operator-global `command_runner` defaults for the Shell interpreter and a deliberately constrained main-shell ambient credential grant, while managed temporary storage remains a separate lifecycle policy. Status: proposed. -- [Mecatui bounded scroll and cursor control](mecatui-bounded-scroll-selection.md) — establishes composed package-private physical-line viewport and stable-ID list cursor behavior, then proves complete geometry, caller-owned selection styling, independent wheel ownership, and Models click-to-cursor semantics across Agents and Models. Status: in-progress. +- [Mecatui bounded scroll and cursor control](mecatui-bounded-scroll-selection.md) — establishes composed package-private physical-line viewport and stable-ID list cursor behavior, then proves complete geometry, caller-owned selection styling, independent wheel ownership, and Models click-to-cursor semantics across Agents and Models. Status: landed in this implementation candidate; authoritative on merge. - [Mecatui unified Agents overlay fit](mecatui-unified-agents-overlay-fit.md) — aligns every F6 selectable roster with the Sessions picker’s unbordered selection treatment and bounds every Agents-overlay subview to its offered viewport, with reachable overflow and compact short-terminal fallback. Status: proposed. - [Mecatui live-feed reconnect](mecatui-live-reconnect.md) — regression closure for bearer-backed first-Recv authentication rejection, existing `/connect` recovery, cross-loop reconnect continuity/backoff, and real-event recovery without weakening generation, cancellation, or catch-up invariants. Status: landed. - [Mecatui effort-handoff recovery](mecatui-effort-handoff-recovery.md) — transactional `/effort` successor hydration that preserves the usable source until the target is validated, cleans failed or stale targets best-effort, and retires the source only after adoption. Status: proposed. diff --git a/docs/acceptance/mecatui-bounded-scroll-selection.md b/docs/acceptance/mecatui-bounded-scroll-selection.md index 2aedd67c6d..a00181623b 100644 --- a/docs/acceptance/mecatui-bounded-scroll-selection.md +++ b/docs/acceptance/mecatui-bounded-scroll-selection.md @@ -4,7 +4,7 @@ **Work classification:** Bounded — this establishes a substantive package-private TUI interaction contract across existing client surfaces without changing a public API, persistence boundary, protocol, or system architecture. **Decision record:** None — the control remains confined to `cmd/mecatui/ui`; its local ownership and interaction choices belong in this plan rather than a durable architecture record. **Phase:** bounded scroll-cursor convergence, slice 1 -**Status:** in-progress, 2026-09-15. Implementation began from approved PR #1593. +**Status:** landed in this implementation candidate, 2026-09-15. Authoritative when the implementation PR merges; the operator authorized opening without rebasing for repository-wide ac-trace fixes already present on current `origin/main`. **Amendment:** 2026-09-15 — after the early package-private API checkpoint, the operator explicitly waived a separate amendment PR and authorized this implementation-branch amendment for stable item identity, independent viewport/cursor anchors, caller-owned selected styling, and tiny-width behavior. **Delivery:** Split. Shared cursor, layout, style, and pointer semantics need human interface review before implementation changes multiple surfaces. **Expected tasks:** 2