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 6ca5a8cfcb..255373b85d 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. @@ -76,7 +78,8 @@ 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 } // openAgents opens the unified f6 agents overlay. It picks the CONTEXT-SENSITIVE @@ -156,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 } @@ -181,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 } @@ -229,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) @@ -293,12 +296,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 { @@ -308,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 { @@ -339,27 +342,25 @@ 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 { 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 } @@ -378,12 +379,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,9 +399,28 @@ func (m Model) onParallelRosterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } -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) 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 (m Model) agentsListGeometry() (theme.Theme, helpKeys, int, int) { @@ -413,41 +433,127 @@ func (m Model) agentsListGeometry() (theme.Theme, helpKeys, int, int) { return th, m.helpKeyMarkings(), layout.bodyWidth, height } -func (m Model) subagentRosterPageSize(fleet []subagentLane) 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, subagentSelectableList(th, m.subagents, fleet, hk, width)) + if height <= 0 { + return + } + reconcile := func(list agentsSelectableList) boundedList { + control, _ := list.indicatorAdjustedControl(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) parallelRosterPageSize(groups []parallelGroup) int { +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, parallelSelectableList(th, m.parallel, groups, hk, width)) + layout := newAgentsOverlayLayout(th, m.agentsTab, m.width, m.vp.Height()) + if height == 0 || !m.agentsNormalBodyFits(layout, hk) { + return m, nil + } + move := boundedLineDown + if msg.Mouse().Button == tea.MouseWheelUp { + move = boundedLineUp + } + scrollList := func(list agentsSelectableList) boundedList { + control, _ := list.indicatorAdjustedControl(th, height) + control.scroll(move) + list.control = control + control, _ = list.indicatorAdjustedControl(th, height) + return control + } + switch m.agentsTab { + case tabSubagents: + if m.subagents.view == subagentFocus { + 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 + } + 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 { + m.team.detail, _ = m.moveAgentsDetail(m.team.detail, move) + } + } + return m, nil } -func (m Model) parallelBranchPageSize(g *parallelGroup) int { - if g == nil { - return 1 - } - th, hk, width, height := m.agentsListGeometry() - return agentsListPageSize(th, height, parallelBranchSelectableList(th, m.parallel, g, hk, width)) +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, scroll int) (int, bool) { - total, window := m.agentsDetailMetrics() +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() { @@ -455,14 +561,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) } } } @@ -486,9 +598,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 { @@ -496,20 +607,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) } } } @@ -577,7 +685,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 } @@ -647,7 +755,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 } @@ -658,6 +766,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 } @@ -669,19 +779,20 @@ 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 } 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)) 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 @@ -705,6 +816,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") @@ -725,7 +838,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)) } } @@ -894,13 +1007,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) } } } @@ -914,13 +1027,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) } } @@ -928,98 +1041,92 @@ 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 - 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) indicatorAdjustedControl(th theme.Theme, height int) (boundedList, boundedListView) { + control, capacity, reveal := l.configuredControl(th, height) + 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 { - w := l.window(th, height) + view := l.boundedView(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)) + if len(view.rows) == 0 && len(l.rows) > 0 { + middle = append(middle, th.Style("spinner").Render("▶ "+l.rows[clampBounded(l.cursor, len(l.rows))])) } - 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)) - } - return l.header + "\n\n" + strings.Join(middle, "\n") + "\n\n" + l.footer -} - -// 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 { - 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 + 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 { @@ -1028,14 +1135,12 @@ 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 { - 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 } @@ -1072,12 +1177,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))) } @@ -1257,14 +1356,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 { @@ -1308,23 +1407,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) } @@ -1371,7 +1469,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) } } @@ -1385,14 +1483,12 @@ 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 { - 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 } @@ -1487,24 +1583,29 @@ 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"} - 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, 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) @@ -1526,27 +1627,10 @@ 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) } } -// 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_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 cd710e7535..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]) + 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 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[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,17 +860,10 @@ 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 != 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 +1210,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}) @@ -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)) @@ -1564,13 +1545,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 +1581,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 +1606,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 +1621,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 +1635,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) } @@ -1779,9 +1760,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 +1869,45 @@ 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) + 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, 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 { @@ -1961,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]) @@ -1978,14 +1966,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..04bbe85f38 --- /dev/null +++ b/cmd/mecatui/ui/bounded_scroll_adoption_test.go @@ -0,0 +1,685 @@ +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) + 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) + } + } + }) + } + } +} + +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) + 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, 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) + } + } +} + +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) { + 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 + 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) + } + 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) { + 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"} + 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) + 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} + 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) + 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 { + if afterCursor[i] != beforeCursor[i] { + 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) + } + 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) + } + } + }) + } +} + +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": + 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 + 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 || 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) + } + + 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) + _, _ = 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) { + 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, 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 + 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) + } + } + 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)) + } + 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) + } + 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) + 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) + } + 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 { + 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) { + 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 + 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 + 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") + } +} + +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/bounded_scroll_cursor_test.go b/cmd/mecatui/ui/bounded_scroll_cursor_test.go new file mode 100644 index 0000000000..0064e37911 --- /dev/null +++ b/cmd/mecatui/ui/bounded_scroll_cursor_test.go @@ -0,0 +1,330 @@ +package ui + +import ( + "reflect" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestMecatuiBoundedScrollCursor_Scenario1_RespectsWidthAndHeight(t *testing.T) { + for _, tc := range []struct { + name string + policy boundedWidthPolicy + }{{"wrap", boundedWrap}, {"clip", boundedClip}} { + t.Run(tc.name, func(t *testing.T) { + 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 := 2 + ansi.StringWidth(ansi.Strip(row)); got > 8 { + t.Errorf("row %d width with gutter = %d, want <= 8: %q", i, got, ansi.Strip(row)) + } + } + }) + } + + t.Run("wrap preserves styled wide graphemes", func(t *testing.T) { + const content = "a界🙂e\u0301Z" + 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 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, "\x1b[31m") || !strings.HasSuffix(row, "\x1b[0m") { + t.Errorf("row %d did not preserve and close ANSI style: %q", i, row) + } + rebuilt.WriteString(ansi.Strip(row)) + } + if got := rebuilt.String(); got != content { + t.Fatalf("wrapped content = %q, want byte-preserved graphemes %q", got, content) + } + }) + + 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)) + } + } + } +} + +func TestMecatuiBoundedScrollCursor_Scenario1_MultilinePagingTargets(t *testing.T) { + 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 list boundedList + list.setGeometry(20, 3, 2, boundedClip) + list.setItems(items) + + 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) + } + 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) + } + 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) + } + 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) + } + list.move(boundedEnd) + if list.cursor != 3 || list.cursorID != "three" { + t.Fatalf("End cursor = (%d,%q), want last item", list.cursor, list.cursorID) + } + 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 := []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 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) + } + 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 row.cursorMarker != (rowIndex == 0) { + t.Errorf("forward page %d row %d marker = %v, want %v", page, rowIndex, row.cursorMarker, rowIndex == 0) + } + if strings.Contains(row.text, "\x1b[35m") { + t.Errorf("control applied caller-owned selected style: %q", row.text) + } + 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 = "▶ " + } + 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) + } + } + } + + 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) + } + 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) + } + content.WriteString(ansi.Strip(row.text)) + } + 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 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) + } + 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) + } + + 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) + } + 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) + } + + 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.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)) + } + 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 { + 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) + } + for _, bounds := range [][2]int{{0, 2}, {12, 0}, {-1, 2}, {12, -1}} { + 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)) + } + } + + 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) { + 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} + list.cursorLine = 2 + 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 || 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) + } + + 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) + } + + 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 5f40c9615c..fdeccf6ca4 100644 --- a/cmd/mecatui/ui/card_layout_focus_scroll_test.go +++ b/cmd/mecatui/ui/card_layout_focus_scroll_test.go @@ -47,13 +47,15 @@ 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 != 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/delegation_overlay_chrome_test.go b/cmd/mecatui/ui/delegation_overlay_chrome_test.go index d347ecc235..1741278060 100644 --- a/cmd/mecatui/ui/delegation_overlay_chrome_test.go +++ b/cmd/mecatui/ui/delegation_overlay_chrome_test.go @@ -62,24 +62,15 @@ 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)}, } { 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/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 77af08d34e..344774d02e 100644 --- a/cmd/mecatui/ui/models_surface.go +++ b/cmd/mecatui/ui/models_surface.go @@ -3,15 +3,16 @@ 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" ) // modelsView is the active /models overlay. @@ -20,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. @@ -36,7 +36,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 +63,60 @@ type modelsGlobalDefaultIntent struct { func (modelsGlobalDefaultIntent) isSurfaceIntent() {} func (s *modelsState) Render(width, height int) (string, []ClickableRegion) { - 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 + prefix, suffix := modelsFixedLines(*s, s.provenance) + 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 +126,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 +157,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 +192,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 @@ -140,7 +211,115 @@ 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) + } + 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 + if !hadCursor { + 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() { + 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 = clampBounded(s.cursor-1, len(s.filtered)) + case boundedLineDown: + s.cursor = clampBounded(s.cursor+1, len(s.filtered)) + case boundedPageUp: + s.cursor = clampBounded(s.cursor-delta, len(s.filtered)) + case boundedPageDown: + s.cursor = clampBounded(s.cursor+delta, len(s.filtered)) + case boundedTop: + s.cursor = 0 + 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 { + 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 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")) + } + 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 +} + +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) { @@ -149,15 +328,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 @@ -174,41 +344,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 { - if b := height - fixedRows; b >= modelsMinRows { - return b - } - return modelsMinRows -} - -// 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`" @@ -272,66 +407,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..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() @@ -83,8 +120,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 +143,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,17 +154,23 @@ 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 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)+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} - _, _ = 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) } @@ -143,12 +187,13 @@ 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) } 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 8ea2ad1523..2e49ac9929 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) @@ -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/parallel_overlay_test.go b/cmd/mecatui/ui/parallel_overlay_test.go index 076d03685d..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) } @@ -469,12 +464,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= 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 @@ -314,18 +277,17 @@ 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), cursor: cursor, muted: muted, noun: "rows", + bodyWidth: bodyWidth, control: st.roster, } - for row, laneIndex := range order { - style, prefix := muted, " " - if row == cursor { - style, prefix = th.Style("spinner"), "▶ " - } - list.rows = append(list.rows, renderTeamRosterRow(style, prefix, &b.teamLanes[laneIndex], nameW, b.teamDone, bodyWidth)) + for _, laneIndex := range order { + 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 } @@ -338,20 +300,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 { @@ -437,14 +385,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 { @@ -469,15 +417,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 @@ -494,13 +434,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) } @@ -645,18 +588,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" @@ -667,8 +610,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) } } @@ -767,18 +713,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" @@ -789,8 +735,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/team_overlay_test.go b/cmd/mecatui/ui/team_overlay_test.go index 5ce9fc6f5e..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) @@ -708,14 +678,19 @@ func TestAgentsRosterWindowed(t *testing.T) { const n = 20 big := bigRoster(n) m := newMCPModel(t, aztec(), nil) - m = resize(m, 100, 24) // vp height 16 → ~6 lane rows + m = resize(m, 100, 30) // vp height 16 → ~6 lane rows m = seedTeam(m, func(c *conversation) { c.setTeamStart("t1", "", big) }) mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF6}) m = mm.(Model) 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) } @@ -742,7 +717,7 @@ func TestAgentsWindowFollowsCursor(t *testing.T) { const n = 20 big := bigRoster(n) 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) @@ -763,15 +738,22 @@ func TestAgentsWindowFollowsCursor(t *testing.T) { if !strings.Contains(out, "above") { t.Errorf("jump-to-end should show a '+K above' tail, got %q", out) } - if strings.Contains(out, "below") { - t.Errorf("jump-to-end should NOT show a '+K below' tail, got %q", out) + if strings.Contains(out, "rows below") { + t.Errorf("jump-to-end should not hide a later logical row, got %q", out) } - // The selected (highlighted) row must be the last member — find the › row. + // The selected segment must carry the cursor marker. + selectedFound := false for _, ln := range strings.Split(out, "\n") { - if strings.Contains(ln, "›") && !strings.Contains(ln, last) { - t.Errorf("selected row is not the last member: %q", ln) + if strings.Contains(ln, "▶") { + selectedFound = true + if !strings.Contains(ln, last) { + t.Errorf("selected row is not the last member: %q", ln) + } } } + if !selectedFound { + t.Error("selected last member has no cursor marker") + } // home/g jumps back to the first. mm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyHome}) @@ -787,24 +769,22 @@ func TestAgentsPageKeys(t *testing.T) { const n = 20 big := bigRoster(n) 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) - th, hk, width, height := m.agentsListGeometry() - page := agentsListPageSize(th, height, teamSelectableList(th, m.team, m.conv.latestTeamBlock(), hk, width)) + beforeDown := m.team.cursor mm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) m = mm.(Model) - if m.team.cursor != page { - t.Errorf("pgdn moved cursor to %d, want one page (%d)", m.team.cursor, page) + if m.team.cursor <= beforeDown { + t.Errorf("pgdn did not move to a later bounded item: %d", m.team.cursor) } - w := teamSelectableList(th, m.team, m.conv.latestTeamBlock(), hk, width).window(th, height) - wantUp := max(0, m.team.cursor-(w.end-w.start)) + beforeUp := m.team.cursor mm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp}) m = mm.(Model) - if m.team.cursor != wantUp { - t.Errorf("pgup moved cursor to %d, want current physical-window move to %d", m.team.cursor, wantUp) + if m.team.cursor >= beforeUp { + t.Errorf("pgup did not move to an earlier bounded item: %d", m.team.cursor) } } @@ -868,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]) } } @@ -1387,7 +1365,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 +1422,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 +1464,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.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_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_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_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_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_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/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/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..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 @@ -3943,6 +3946,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 19ed3e5b6c..0720dba35b 100644 --- a/cmd/mecatui/ui/window.go +++ b/cmd/mecatui/ui/window.go @@ -4,16 +4,475 @@ import ( "fmt" "strings" + "github.com/charmbracelet/x/ansi" + "github.com/stacklok/mecatl/cmd/mecatui/theme" ) +type boundedWidthPolicy uint8 + +const ( + boundedWrap boundedWidthPolicy = iota + boundedClip +) + +type boundedMove uint8 + +const ( + boundedLineUp boundedMove = iota + boundedLineDown + boundedPageUp + boundedPageDown + boundedTop + boundedEnd +) + +type boundedViewport struct { + width, height int + gutter int + policy boundedWidthPolicy + offset int +} + +type boundedViewportView struct { + rows []string + 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 + } +} + +func (v *boundedViewport) valid() bool { + return v.width >= v.gutter+1 && v.height > 0 +} + +func (v *boundedViewport) contentWidth() int { + if !v.valid() { + return 0 + } + return v.width - v.gutter +} + +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)...) + } + } + return rows +} + +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 (v *boundedViewport) view(lines []string) boundedViewportView { + rows := v.layout(lines) + if len(rows) == 0 { + v.offset = 0 + return boundedViewportView{} + } + window := v.window(len(rows)) + return boundedViewportView{ + rows: append([]string(nil), rows[window.start:window.end]...), + above: window.start, + below: len(rows) - window.end, + } +} + +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 + } + switch move { + case boundedLineUp: + v.offset-- + case boundedLineDown: + v.offset++ + case boundedPageUp: + v.offset -= v.height + case boundedPageDown: + v.offset += v.height + case boundedTop: + v.offset = 0 + case boundedEnd: + 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 + reveal bool +} + +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 + l.reveal = true + layout := l.layout() + l.revealCursor(layout) +} + +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) { + layout := l.layout() + if len(l.items) == 0 || len(layout.rows) == 0 || !l.viewport.valid() { + return + } + l.clamp(layout) + l.reveal = true + itemHeight := layout.ends[l.cursor] - layout.starts[l.cursor] + switch move { + case boundedLineUp: + l.setCursor(l.cursor - 1) + case boundedLineDown: + l.setCursor(l.cursor + 1) + case boundedTop: + l.setCursor(0) + case boundedEnd: + l.setCursor(len(l.items) - 1) + case boundedPageDown: + l.pageDown(layout, itemHeight) + case boundedPageUp: + 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 + } + } + l.setCursor(len(l.items) - 1) +} + +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 + } + 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 + } + } + 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 { + l.cursorLine = 0 + l.viewport.offset = target + } +} + +func (l *boundedList) view() boundedListView { + layout := l.layout() + if len(layout.rows) == 0 { + l.viewport.offset = 0 + return boundedListView{} + } + 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 + } + } + return boundedListView{rows: rows, above: window.start, below: len(layout.rows) - window.end} +} + +func (l *boundedList) layout() boundedListLayout { + layout := boundedListLayout{ + starts: make([]int, len(l.items)), + ends: make([]int, len(l.items)), + } + 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[itemIndex] = len(layout.rows) + } + return layout +} + +func (l *boundedList) revealCursor(layout boundedListLayout) { + if len(l.items) == 0 || !l.viewport.valid() { + return + } + 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 + } + 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 nil + } + if policy == boundedClip { + return []string{ansi.Cut(line, 0, width) + "\x1b[0m"} + } + 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+"\x1b[0m") + continue + } + segment := ansi.Cut(line, left, left+rowWidth) + left += rowWidth + if ansi.StringWidth(segment) <= width { + lines = append(lines, segment+"\x1b[0m") + } + } + if len(lines) == 0 { + return []string{"\x1b[0m"} + } + return lines +} + +func lastBoundedPageOffset(itemHeight, pageHeight int) int { + if itemHeight <= pageHeight || pageHeight <= 0 { + return 0 + } + return (itemHeight - 1) / pageHeight * pageHeight +} + +func clampBounded(value, count int) int { + if count <= 0 || value < 0 { + return 0 + } + 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 // 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 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 diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index e962101142..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 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: 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 5429e80f3e..a00181623b 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:** 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 **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` 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..f565fe6487 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 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- 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. |