From 77a3fe3edf908d9b29766b86460f45fdd082107a Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Thu, 1 Oct 2026 11:53:07 +0200 Subject: [PATCH 01/11] spike(mecatui): prototype responsive Help browser Co-authored-by: Mecatl --- cmd/mecatui/ui/builtins.go | 2 + cmd/mecatui/ui/help.go | 114 ++++++++++++++++++++++---------- cmd/mecatui/ui/help_test.go | 4 +- cmd/mecatui/ui/helpgate_test.go | 85 +++++++++++++++++++++++- cmd/mecatui/ui/model.go | 8 ++- cmd/mecatui/ui/update.go | 29 ++++---- cmd/mecatui/ui/view.go | 2 +- 7 files changed, 189 insertions(+), 55 deletions(-) diff --git a/cmd/mecatui/ui/builtins.go b/cmd/mecatui/ui/builtins.go index 060ffb210b..4f22c0bf0f 100644 --- a/cmd/mecatui/ui/builtins.go +++ b/cmd/mecatui/ui/builtins.go @@ -444,6 +444,8 @@ func (m Model) runCompact() (tea.Model, tea.Cmd) { // sets (see onIdleKey). It blurs the textarea so the overlay owns the keyboard. func (m Model) runHelp() (tea.Model, tea.Cmd) { m.showHelp = true + m.helpScroll = 0 + m.helpViewport.Reset() m.prompt.Blur() return m, nil } diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index defe575b10..80cabe3924 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -6,10 +6,12 @@ import ( "charm.land/bubbles/v2/key" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" "github.com/stacklok/mecatl/cmd/mecatui/theme" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" "github.com/stacklok/mecatl/cmd/mecatui/ui/welcome" ) @@ -38,50 +40,94 @@ type helpRow struct { // widening just shifts the action column right uniformly. const helpKeyWidth = 22 +// helpMinCardWidth keeps the Help overlay stable and readable once the terminal +// is large enough, while helpCardWidthFraction prevents it occupying the entire +// conversation on wider terminals. +const ( + helpMinCardWidth = 69 + helpCardWidthFraction = 80 +) + // renderHelpOverlay draws the "?" keys-&-features overlay centred over the -// conversation region. When the body is taller than the offered height, it windows -// complete ANSI lines and reserves a row for its scroll indicator. -func renderHelpOverlay(th theme.Theme, caps client.Capabilities, width, height, scroll int, hk helpKeys) string { - body := helpBody(th, caps, hk) +// conversation region. Its root-owned viewport wraps the live help lines to the +// card's inner width before windowing them. +func renderHelpOverlay(th theme.Theme, caps client.Capabilities, width, height int, viewport *bounded.Viewport, hk helpKeys) string { + lines := helpRenderedLines(helpBody(th, caps, hk)) if height <= 0 { - return centerCard(th, body, width, height) - } - lines := helpRenderedLines(body) - cardChrome := lipgloss.Height(th.Style("askCard").Render("")) - if height <= cardChrome { - // A card cannot fit in this exceptionally small viewport. Keep the overlay - // usable rather than overflowing the conversation region. - return lines[clampScroll(scroll, len(lines), 1)] - } - window := helpWindowHeight(th, height, len(lines)) - scroll = clampScroll(scroll, len(lines), window) - body = strings.TrimSuffix(windowRenderedLinesWithIndicator(th, lines, scroll, window, func(start, end, total int) string { - return helpScrollIndicator(hk, start, end, total) - }), "\n") - return centerCard(th, body, width, height) + return centerCard(th, strings.Join(lines, "\n"), width, height) + } + view, total, window := helpViewportView(th, lines, width, height, viewport, hk) + if len(view.Rows) == 0 { + return "" + } + if height <= lipgloss.Height(th.Style("askCard").Render("")) { + return view.Rows[0] + } + body := strings.Join(view.Rows, "\n") + if total > window { + for _, indicator := range helpIndicatorRows(hk, view.Above, view.Above+len(view.Rows), total, helpBodyWidth(th, width)) { + body += "\n" + th.Style("muted").Render(indicator) + } + } + return centerHelpCard(th, body, width, height) } -// helpScrollIndicator keeps the navigation affordances in every clipped frame, -// including the initial top view where the help body's footer is not visible. -func helpScrollIndicator(hk helpKeys, start, end, total int) string { - return fmt.Sprintf("lines %d–%d of %d · %s close · %s/%s scroll · %s page · %s jump", start+1, end, total, hk.close, hk.navUp, hk.navDown, hk.scroll, hk.jump) +func centerHelpCard(th theme.Theme, body string, width, height int) string { + card := th.Style("askCard").Width(helpCardWidth(width)).Render(body) + if width <= 0 || height <= 0 { + return card + } + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, card) +} + +// helpViewportView configures the caller-owned help viewport for one frame. It +// first measures wrapped content with every available body row, then reserves a +// final row for Help's own navigation indicator only when the content overflows. +func helpViewportView(th theme.Theme, lines []string, width, height int, viewport *bounded.Viewport, hk helpKeys) (bounded.ViewportView, int, int) { + bodyWidth := helpBodyWidth(th, width) + cardHeight := lipgloss.Height(th.Style("askCard").Render("")) + bodyHeight := max(1, height-cardHeight) + measure := *viewport + measure.SetGeometry(bodyWidth, bodyHeight, 0, bounded.Wrap) + measured := measure.View(lines) + total := measured.Above + len(measured.Rows) + measured.Below + window := bodyHeight + if total > window { + window = max(1, bodyHeight-len(helpIndicatorRows(hk, 0, 0, total, bodyWidth))) + } + viewport.SetGeometry(bodyWidth, window, 0, bounded.Wrap) + view := viewport.View(lines) + total = view.Above + len(view.Rows) + view.Below + return view, total, window +} + +func helpCardWidth(width int) int { + if width <= 0 { + return helpMinCardWidth + } + return min(width, max(helpMinCardWidth, width*helpCardWidthFraction/100)) +} + +func helpBodyWidth(th theme.Theme, width int) int { + return max(1, helpCardWidth(width)-th.Style("askCard").GetHorizontalFrameSize()) +} + +func helpIndicatorRows(hk helpKeys, start, end, total, width int) []string { + if width <= 0 { + return nil + } + return strings.Split(ansi.Hardwrap(helpScrollIndicator(hk, start, end, total), width, true), "\n") } -// helpRenderedLines splits the help body into complete styled lines. helpBody -// renders every line independently, so windowing cannot leave a terminal style open. +// helpRenderedLines splits the independently styled help body into source lines. func helpRenderedLines(body string) []string { return strings.Split(body, "\n") } -// helpWindowHeight accounts for the card chrome and its scroll indicator. The -// indicator replaces one content row only when it is needed, keeping the card within -// the actual conversation viewport. -func helpWindowHeight(th theme.Theme, height, total int) int { - window := max(1, height-lipgloss.Height(th.Style("askCard").Render(""))) - if total > window { - window = max(1, window-1) - } - return window +// helpScrollIndicator keeps the navigation affordances in every clipped frame, +// including the initial top view where the help body's footer is not visible. +func helpScrollIndicator(hk helpKeys, start, end, total int) string { + return fmt.Sprintf("lines %d–%d of %d · %s close · %s/%s scroll · %s page · %s jump", start+1, end, total, hk.close, hk.navUp, hk.navDown, hk.scroll, hk.jump) } // helpBody builds the overlay's text: a title, grouped chord sections (each row diff --git a/cmd/mecatui/ui/help_test.go b/cmd/mecatui/ui/help_test.go index 7f230940e5..905bf51165 100644 --- a/cmd/mecatui/ui/help_test.go +++ b/cmd/mecatui/ui/help_test.go @@ -71,7 +71,7 @@ func qmark() tea.KeyPressMsg { return tea.KeyPressMsg{Code: '?', Text: "?"} } // TestHelpOverlayEmbeddedGolden locks the help overlay under embedded defaults: // mcp/commands/skills rows carry [not enabled]; memory/teams/shell are available. func TestHelpOverlayEmbeddedGolden(t *testing.T) { - m := helpModel(t, embeddedCaps()) + m := applyAll(helpModel(t, embeddedCaps()), tea.WindowSizeMsg{Width: 200, Height: 30}) m = goldenStatusFrame(t, m) got := stripANSI([]byte(m.View().Content)) compareGolden(t, "help_embedded.golden", got) @@ -81,7 +81,7 @@ func TestHelpOverlayEmbeddedGolden(t *testing.T) { // server: every row available, no [not enabled] tags, plus the commands + // memory prose lines. func TestHelpOverlayAllOnGolden(t *testing.T) { - m := helpModel(t, allOnCaps()) + m := applyAll(helpModel(t, allOnCaps()), tea.WindowSizeMsg{Width: 200, Height: 30}) m = goldenStatusFrame(t, m) got := stripANSI([]byte(m.View().Content)) compareGolden(t, "help_all_on.golden", got) diff --git a/cmd/mecatui/ui/helpgate_test.go b/cmd/mecatui/ui/helpgate_test.go index 6909cd86a1..c6f97e95fe 100644 --- a/cmd/mecatui/ui/helpgate_test.go +++ b/cmd/mecatui/ui/helpgate_test.go @@ -1,11 +1,13 @@ package ui import ( + "fmt" "strings" "testing" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" ) @@ -119,6 +121,85 @@ func TestHelpPreservesGlobalLifecycleKeys(t *testing.T) { }) } +func TestHelpCardWidthIsTerminalBounded(t *testing.T) { + th := aztec() + frame := th.Style("askCard").GetHorizontalFrameSize() + for _, tc := range []struct{ terminal, want int }{ + {terminal: 40, want: 40}, + {terminal: 69, want: 69}, + {terminal: 100, want: 80}, + {terminal: 200, want: 160}, + } { + t.Run(fmt.Sprintf("%d columns", tc.terminal), func(t *testing.T) { + if got := helpBodyWidth(th, tc.terminal) + frame; got != tc.want { + t.Fatalf("card width = %d, want %d", got, tc.want) + } + }) + } +} + +func renderedHelpCardWidth(body string) int { + for _, line := range strings.Split(ansi.Strip(body), "\n") { + start := strings.Index(line, "┏") + if start < 0 { + continue + } + if end := strings.Index(line[start:], "┓"); end >= 0 { + return ansi.StringWidth(line[start : start+end+len("┓")]) + } + } + return 0 +} + +func TestHelpFitsAvailableWidth(t *testing.T) { + for _, width := range []int{40, 100} { + t.Run(fmt.Sprintf("%d columns", width), func(t *testing.T) { + m := applyAll(helpModel(t, allOnCaps()), tea.WindowSizeMsg{Width: width, Height: 24}) + wantCardWidth := helpCardWidth(width) + bodies := []string{m.renderBody()} + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) + bodies = append(bodies, m.renderBody()) + for _, body := range bodies { + if got := renderedHelpCardWidth(body); got != wantCardWidth { + t.Fatalf("card width = %d, want %d", got, wantCardWidth) + } + for _, line := range strings.Split(body, "\n") { + if got := ansi.StringWidth(ansi.Strip(line)); got > m.width { + t.Fatalf("help line width %d exceeds card width %d: %q", got, m.width, line) + } + } + } + }) + } +} + +func TestHelpWrapsNarrowBodyAndNavigatesWrappedRows(t *testing.T) { + m := helpModel(t, allOnCaps()) + m = applyAll(m, tea.WindowSizeMsg{Width: 40, Height: 24}) + total, window := m.helpScrollGeometry() + if total <= window { + t.Fatalf("precondition: wrapped help should overflow (total=%d window=%d)", total, window) + } + for _, line := range strings.Split(m.renderBody(), "\n") { + if got := ansi.StringWidth(ansi.Strip(line)); got > m.width { + t.Fatalf("help line width %d exceeds card width %d: %q", got, m.width, line) + } + } + + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.helpScroll != 1 { + t.Fatalf("down should advance one wrapped row, got offset %d", m.helpScroll) + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) + if want := maxScrollOffset(total, window); m.helpScroll != want { + t.Fatalf("end offset = %d, want %d", m.helpScroll, want) + } + view := m.helpViewport.View(helpRenderedLines(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings()))) + if view.Below != 0 { + t.Fatalf("end should reveal the final wrapped row, with %d rows still below", view.Below) + } +} + func TestHelpScrollNavigationAndReset(t *testing.T) { m := helpModel(t, allOnCaps()) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 24}) @@ -175,8 +256,8 @@ func TestHelpRenderingIsHeightBoundedAndShowsScrollGuidance(t *testing.T) { t.Fatalf("help body is %d lines, exceeds offered viewport height %d", got, limit) } - initial := stripANSIstr(m.renderBody()) - for _, want := range []string{"lines ", " of ", "ctrl+f1 or ? close", "ctrl+f2/ctrl+f3 scroll", "ctrl+f4/ctrl+f5 page", "ctrl+f6/ctrl+f7 jump"} { + initial := strings.Join(strings.Fields(strings.ReplaceAll(stripANSIstr(m.renderBody()), "┃", "")), "") + for _, want := range []string{"lines", "of", "ctrl+f1or?close", "ctrl+f2/ctrl+f3scroll", "ctrl+f4/ctrl+f5page", "ctrl+f6", "ctrl+f7jump"} { if !strings.Contains(initial, want) { t.Fatalf("initial clipped help should show %q:\n%s", want, initial) } diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 78de78d3b0..2343a796e1 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -19,6 +19,7 @@ import ( customization "github.com/stacklok/mecatl/cmd/mecatui/customization" "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" "github.com/stacklok/mecatl/cmd/mecatui/theme" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" "github.com/stacklok/mecatl/cmd/mecatui/ui/platform" "github.com/stacklok/mecatl/cmd/mecatui/ui/prompttextarea" @@ -712,9 +713,10 @@ type Model struct { // lets the picker label the current model "picked this session" (vs a launch-time // workspace/global default). Zero until a restart-now pick. Display-only. pickedThisSession client.ModelSelection - showHelp bool // the "?" keys-&-features overlay is open (caps-driven; see help.go) - helpScroll int // first visible complete help-body line while the overlay is open - stream *client.Stream // current run's stream + showHelp bool // the "?" keys-&-features overlay is open (caps-driven; see help.go) + helpScroll int // first visible wrapped help-body row while the overlay is open + helpViewport bounded.Viewport // root-owned wrapped help-body viewport + stream *client.Stream // current run's stream cancelRun context.CancelFunc // quitArmed is true after a first ctrl+c on an empty prompt: a second ctrl+c diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 4ea9cb5892..8b28999e91 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2366,42 +2366,44 @@ func (m Model) onPendingApprovalRecoveryKey(msg tea.KeyPressMsg) (tea.Model, tea // phase routing, so navigation never reaches the conversation and every other key // remains swallowed. func (m Model) onHelpKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - m.clampHelpScroll() - total, window := m.helpScrollGeometry() + total, _ := m.helpScrollGeometry() switch { case key.Matches(msg, m.keys.Help), key.Matches(msg, m.keys.Close): m.showHelp = false m.helpScroll = 0 + m.helpViewport.Reset() _ = m.prompt.Focus() case key.Matches(msg, m.keys.ScrollD): - m.helpScroll = clampScroll(m.helpScroll+window, total, window) + m.helpViewport.Move(bounded.PageDown, total) case key.Matches(msg, m.keys.ScrollU): - m.helpScroll = clampScroll(m.helpScroll-window, total, window) + m.helpViewport.Move(bounded.PageUp, total) case key.Matches(msg, m.keys.Down): - m.helpScroll = clampScroll(m.helpScroll+1, total, window) + m.helpViewport.Move(bounded.LineDown, total) case key.Matches(msg, m.keys.Up): - m.helpScroll = clampScroll(m.helpScroll-1, total, window) + m.helpViewport.Move(bounded.LineUp, total) case key.Matches(msg, m.keys.ScrollBottom): - m.helpScroll = maxScrollOffset(total, window) + m.helpViewport.Move(bounded.End, total) case key.Matches(msg, m.keys.ScrollTop): - m.helpScroll = 0 + m.helpViewport.Move(bounded.Top, total) } + m.helpScroll = m.helpViewport.Offset() return m, nil } -// helpScrollGeometry derives the same complete rendered lines and window used by +// helpScrollGeometry derives the same wrapped rows and window used by // renderHelpOverlay, keeping key navigation and height-bounded rendering aligned. -func (m Model) helpScrollGeometry() (total, window int) { +func (m *Model) helpScrollGeometry() (total, window int) { lines := helpRenderedLines(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings())) - return len(lines), helpWindowHeight(m.deps.Theme, m.vp.Height(), len(lines)) + _, total, window = helpViewportView(m.deps.Theme, lines, m.width, m.vp.Height(), &m.helpViewport, m.helpKeyMarkings()) + m.helpScroll = m.helpViewport.Offset() + return total, window } // clampHelpScroll keeps a retained offset valid after a relayout changes the // viewport geometry. It deliberately preserves a still-valid offset rather than // pinning an earlier End selection to the new bottom. func (m *Model) clampHelpScroll() { - total, window := m.helpScrollGeometry() - m.helpScroll = clampScroll(m.helpScroll, total, window) + m.helpScrollGeometry() } // selection owner is active. @@ -3331,6 +3333,7 @@ func (m Model) onIdleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // in onKey; blur the input while it is up. m.showHelp = true m.helpScroll = 0 + m.helpViewport.Reset() m.prompt.Blur() return m, nil case key.Matches(msg, m.keys.MCPPanel): diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index 8b8152ee69..a7b4ab7484 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -101,7 +101,7 @@ func (m Model) renderBody() string { case m.sessionDetailsOpen: return renderSessionDetails(m.deps.Theme, m.sessionDetails(), m.helpKeyMarkings(), m.width, m.vp.Height()) case m.showHelp: - return renderHelpOverlay(m.deps.Theme, m.caps, m.width, m.vp.Height(), m.helpScroll, m.helpKeyMarkings()) + return renderHelpOverlay(m.deps.Theme, m.caps, m.width, m.vp.Height(), &m.helpViewport, m.helpKeyMarkings()) case m.team.view != teamNone: return renderAgentsOverlay(m.deps.Theme, m.agentsTab, m.subagents, m.parallel, m.team, m.teamBlockForOverlay(), m.conv.subagentFleet, m.conv.parallelGroups, m.helpKeyMarkings(), m.width, m.vp.Height(), m.height) case m.agentsInv.view != agentsInvNone: From 4e6efce3789ba0c9131a13cd77541841823e8c8d Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Thu, 1 Oct 2026 13:45:02 +0200 Subject: [PATCH 02/11] feat(mecatui): make Help browser responsive Co-authored-by: Mecatl --- cmd/mecatui/ui/helpgate_test.go | 75 ++++++++++++++++++++++++++++++++ cmd/mecatui/ui/update.go | 21 +++++++++ user-docs/mecatui/keybindings.md | 5 ++- 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/cmd/mecatui/ui/helpgate_test.go b/cmd/mecatui/ui/helpgate_test.go index c6f97e95fe..8698cc6af3 100644 --- a/cmd/mecatui/ui/helpgate_test.go +++ b/cmd/mecatui/ui/helpgate_test.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" ) // TestHelpOpensOnlyOnEmptyInput pins the "?"-is-printable gotcha: "?" opens the @@ -200,6 +201,80 @@ func TestHelpWrapsNarrowBodyAndNavigatesWrappedRows(t *testing.T) { } } +func TestHelpWheelOwnershipAndPhysicalRowBrowsing(t *testing.T) { + m := applyAll(helpModel(t, allOnCaps()), tea.WindowSizeMsg{Width: 40, Height: 24}) + m.vp.SetContent(strings.Repeat("conversation\n", 100)) + m.vp.GotoBottom() + m.vp.ScrollUp(5) + m.prompt.Rewrite("hidden draft") + m.prompt.SelectAll() + + assertHiddenUnchanged := func(beforeConversation int, beforePrompt string, beforeFocused, beforePromptSelection bool) { + t.Helper() + if got := m.vp.YOffset(); got != beforeConversation { + t.Fatalf("wheel leaked to hidden conversation: got %d, want %d", got, beforeConversation) + } + if m.sel.active { + t.Fatal("wheel activated hidden conversation selection") + } + if got := m.prompt.Value(); got != beforePrompt { + t.Fatalf("wheel changed hidden prompt from %q to %q", beforePrompt, got) + } + if got := m.prompt.Focused(); got != beforeFocused { + t.Fatalf("wheel changed hidden prompt focus from %t to %t", beforeFocused, got) + } + if got := m.prompt.HasSelection(); got != beforePromptSelection { + t.Fatalf("wheel changed hidden prompt selection from %t to %t", beforePromptSelection, got) + } + } + beforeConversation := m.vp.YOffset() + beforePrompt, beforeFocused, beforePromptSelection := m.prompt.Value(), m.prompt.Focused(), m.prompt.HasSelection() + + updated, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 1, Y: 1}) + m = updated.(Model) + if got := m.helpViewport.Offset(); got != 1 { + t.Fatalf("wheel down offset = %d, want one physical row", got) + } + assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) + + updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp, X: 1, Y: 1}) + m = updated.(Model) + if got := m.helpViewport.Offset(); got != 0 { + t.Fatalf("wheel at top offset = %d, want consumed endpoint 0", got) + } + assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) + + total, _ := m.helpScrollGeometry() + m.helpViewport.Move(bounded.End, total) + atEnd := m.helpViewport.Offset() + updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 1, Y: 1}) + m = updated.(Model) + if got := m.helpViewport.Offset(); got != atEnd { + t.Fatalf("wheel at end offset = %d, want consumed endpoint %d", got, atEnd) + } + assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) + + m = applyAll(m, tea.WindowSizeMsg{Width: 10, Height: 3}) + beforeConversation = m.vp.YOffset() + beforePrompt, beforeFocused, beforePromptSelection = m.prompt.Value(), m.prompt.Focused(), m.prompt.HasSelection() + updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 1, Y: 1}) + m = updated.(Model) + assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) + + m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 1000}) + if total, window := m.helpScrollGeometry(); total > window { + t.Fatalf("precondition: tall Help viewport should not overflow (total=%d window=%d)", total, window) + } + beforeConversation = m.vp.YOffset() + beforePrompt, beforeFocused, beforePromptSelection = m.prompt.Value(), m.prompt.Focused(), m.prompt.HasSelection() + updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 1, Y: 1}) + m = updated.(Model) + if got := m.helpViewport.Offset(); got != 0 { + t.Fatalf("wheel without overflow offset = %d, want consumed endpoint 0", got) + } + assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) +} + func TestHelpScrollNavigationAndReset(t *testing.T) { m := helpModel(t, allOnCaps()) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 24}) diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 8b28999e91..0e7f2470e2 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2390,6 +2390,21 @@ func (m Model) onHelpKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } +// onHelpWheel keeps physical scrolling owned by the visible Help overlay. +// It configures Help's wrapped viewport before every move so wheel events are +// consumed at endpoints and in compact or non-overflow geometry. +func (m Model) onHelpWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { + total, _ := m.helpScrollGeometry() + switch msg.Button { + case tea.MouseWheelUp: + m.helpViewport.Move(bounded.LineUp, total) + case tea.MouseWheelDown: + m.helpViewport.Move(bounded.LineDown, total) + } + m.helpScroll = m.helpViewport.Offset() + return m, nil +} + // helpScrollGeometry derives the same wrapped rows and window used by // renderHelpOverlay, keeping key navigation and height-bounded rendering aligned. func (m *Model) helpScrollGeometry() (total, window int) { @@ -4277,6 +4292,12 @@ func (m Model) onMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { // re-discriminates the concrete mouse type here, where the dispatch logically // belongs. func (m Model) onMouseMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.showHelp { + if wheel, ok := msg.(tea.MouseWheelMsg); ok { + return m.onHelpWheel(wheel) + } + return m, nil + } if m.agentsInv.view != agentsInvNone { if wheel, ok := msg.(tea.MouseWheelMsg); ok { return m.onAgentsInvWheel(wheel) diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index 9750f68850..55d633022b 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -12,8 +12,9 @@ Press `?` on an empty prompt to open the live help overlay. The overlay shows your active bindings and dims features that the connected server does not provide. -Use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and -**Home/End** to jump to the beginning or end of a long help overlay. +The overlay adapts to the available terminal space. When its content overflows, +use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and +**Home/End** to jump to the beginning or end. The mouse wheel also scrolls it. ## Everyday keys From 2b9bf0bad3b52ba040a9c191c6cb05ee6734fea9 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Wed, 7 Oct 2026 11:01:24 +0200 Subject: [PATCH 03/11] regenerate golden files --- cmd/mecatui/ui/testdata/help_all_on.golden | 44 ++++++++++---------- cmd/mecatui/ui/testdata/help_embedded.golden | 44 ++++++++++---------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/cmd/mecatui/ui/testdata/help_all_on.golden b/cmd/mecatui/ui/testdata/help_all_on.golden index 12570938b2..f36228b20d 100644 --- a/cmd/mecatui/ui/testdata/help_all_on.golden +++ b/cmd/mecatui/ui/testdata/help_all_on.golden @@ -1,30 +1,30 @@ mecatui · Golden session · fixture/fixture-model/default · mode default -──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Help ┃ -┃ ┃ -┃ Prompting ┃ -┃ enter send prompt ┃ -┃ shift+enter insert newline (also ctrl+j, ctrl+enter, alt+enter) ┃ -┃ / open available commands, including workspace commands when enabled ┃ -┃ ? or /help open this help; the shortcut works when the prompt is empty ┃ -┃ @ attach a file; supported images and audio are sent as media, other files as text ┃ -┃ ctrl+v paste a clipboard image, or paste text when image attachments are unavailable ┃ -┃ ctrl+u clear the current draft ┃ -┃ esc, release, esc clear the current idle draft within 500ms; the first press makes no visible change ┃ -┃ also clears attachments, large pasted text, and pending media; requires a terminal with enhanced key-event support ┃ -┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ -┃ use the remappable Clear prompt action (ctrl+u) on any terminal ┃ -┃ lines 1–14 of 70 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ ┃ + ┃ Help ┃ + ┃ ┃ + ┃ Prompting ┃ + ┃ enter send prompt ┃ + ┃ shift+enter insert newline (also ctrl+j, ctrl+enter, alt+enter) ┃ + ┃ / open available commands, including workspace commands when enabled ┃ + ┃ ? or /help open this help; the shortcut works when the prompt is empty ┃ + ┃ @ attach a file; supported images and audio are sent as media, other files as text ┃ + ┃ ctrl+v paste a clipboard image, or paste text when image attachments are unavailable ┃ + ┃ ctrl+u clear the current draft ┃ + ┃ esc, release, esc clear the current idle draft within 500ms; the first press makes no visible change ┃ + ┃ also clears attachments, large pasted text, and pending media; requires a terminal with enhanced key-event support ┃ + ┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ + ┃ use the remappable Clear prompt action (ctrl+u) on any terminal ┃ + ┃ lines 1–14 of 70 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ + ┃ ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) │ │ -──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0% +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0% ? help · / commands · ctrl+c quit \ No newline at end of file diff --git a/cmd/mecatui/ui/testdata/help_embedded.golden b/cmd/mecatui/ui/testdata/help_embedded.golden index aed887c921..564030ac22 100644 --- a/cmd/mecatui/ui/testdata/help_embedded.golden +++ b/cmd/mecatui/ui/testdata/help_embedded.golden @@ -1,30 +1,30 @@ mecatui · Golden session · fixture/fixture-model/default · mode default -──────────────────────────────────────────────────────────────────────────────────────────────────── -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ ┃ -┃ Help ┃ -┃ ┃ -┃ Prompting ┃ -┃ enter send prompt ┃ -┃ shift+enter insert newline (also ctrl+j, ctrl+enter, alt+enter) ┃ -┃ / open available commands, including workspace commands when enabled ┃ -┃ ? or /help open this help; the shortcut works when the prompt is empty ┃ -┃ @ attach a file; supported images and audio are sent as media, other files as text ┃ -┃ ctrl+v paste a clipboard image, or paste text when image attachments are unavailable ┃ -┃ ctrl+u clear the current draft ┃ -┃ esc, release, esc clear the current idle draft within 500ms; the first press makes no visible change ┃ -┃ also clears attachments, large pasted text, and pending media; requires a terminal with enhanced key-event support ┃ -┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ -┃ use the remappable Clear prompt action (ctrl+u) on any terminal ┃ -┃ lines 1–14 of 69 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ -┃ ┃ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ ┃ + ┃ Help ┃ + ┃ ┃ + ┃ Prompting ┃ + ┃ enter send prompt ┃ + ┃ shift+enter insert newline (also ctrl+j, ctrl+enter, alt+enter) ┃ + ┃ / open available commands, including workspace commands when enabled ┃ + ┃ ? or /help open this help; the shortcut works when the prompt is empty ┃ + ┃ @ attach a file; supported images and audio are sent as media, other files as text ┃ + ┃ ctrl+v paste a clipboard image, or paste text when image attachments are unavailable ┃ + ┃ ctrl+u clear the current draft ┃ + ┃ esc, release, esc clear the current idle draft within 500ms; the first press makes no visible change ┃ + ┃ also clears attachments, large pasted text, and pending media; requires a terminal with enhanced key-event support ┃ + ┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ + ┃ use the remappable Clear prompt action (ctrl+u) on any terminal ┃ + ┃ lines 1–14 of 69 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ + ┃ ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ │ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help) │ │ -──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0% +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0% ? help · / commands · ctrl+c quit \ No newline at end of file From 2a1d975b7f32cf344b7a18b4596e0fa811e91c68 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Wed, 7 Oct 2026 19:59:06 +0200 Subject: [PATCH 04/11] fixes based on ai review --- cmd/mecatui/ui/builtins.go | 1 - cmd/mecatui/ui/help.go | 90 ++++---- cmd/mecatui/ui/helpgate_test.go | 228 ++++++++++++++----- cmd/mecatui/ui/model.go | 8 +- cmd/mecatui/ui/testdata/help_all_on.golden | 38 ++-- cmd/mecatui/ui/testdata/help_embedded.golden | 38 ++-- cmd/mecatui/ui/update.go | 64 +++--- cmd/mecatui/ui/view.go | 2 +- cmd/mecatui/ui/window.go | 36 --- user-docs/mecatui/keybindings.md | 6 +- 10 files changed, 302 insertions(+), 209 deletions(-) diff --git a/cmd/mecatui/ui/builtins.go b/cmd/mecatui/ui/builtins.go index 4f22c0bf0f..a7b68bffa3 100644 --- a/cmd/mecatui/ui/builtins.go +++ b/cmd/mecatui/ui/builtins.go @@ -444,7 +444,6 @@ func (m Model) runCompact() (tea.Model, tea.Cmd) { // sets (see onIdleKey). It blurs the textarea so the overlay owns the keyboard. func (m Model) runHelp() (tea.Model, tea.Cmd) { m.showHelp = true - m.helpScroll = 0 m.helpViewport.Reset() m.prompt.Blur() return m, nil diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index 80cabe3924..ca6edceff2 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -40,72 +40,89 @@ type helpRow struct { // widening just shifts the action column right uniformly. const helpKeyWidth = 22 -// helpMinCardWidth keeps the Help overlay stable and readable once the terminal -// is large enough, while helpCardWidthFraction prevents it occupying the entire -// conversation on wider terminals. +// helpMinCardWidth keeps the Help overlay readable once the terminal is large +// enough, helpCardWidthFraction keeps it from filling wide terminals, and +// helpMaxCardWidth is the shared 128-cell cap for normal list and inspector cards +// (docs/tui.md, "Layout and navigation"). const ( helpMinCardWidth = 69 helpCardWidthFraction = 80 + helpMaxCardWidth = 128 ) // renderHelpOverlay draws the "?" keys-&-features overlay centred over the // conversation region. Its root-owned viewport wraps the live help lines to the // card's inner width before windowing them. func renderHelpOverlay(th theme.Theme, caps client.Capabilities, width, height int, viewport *bounded.Viewport, hk helpKeys) string { - lines := helpRenderedLines(helpBody(th, caps, hk)) + lines := strings.Split(helpBody(th, caps, hk), "\n") if height <= 0 { return centerCard(th, strings.Join(lines, "\n"), width, height) } - view, total, window := helpViewportView(th, lines, width, height, viewport, hk) - if len(view.Rows) == 0 { + frame := helpViewportView(th, lines, width, height, viewport, hk) + if len(frame.view.Rows) == 0 { return "" } - if height <= lipgloss.Height(th.Style("askCard").Render("")) { - return view.Rows[0] + if frame.compact { + // A card with its chrome and scroll indicator cannot fit this viewport. Keep + // the overlay usable as one scrollable row rather than overflowing the + // conversation region. + return frame.view.Rows[0] } - body := strings.Join(view.Rows, "\n") - if total > window { - for _, indicator := range helpIndicatorRows(hk, view.Above, view.Above+len(view.Rows), total, helpBodyWidth(th, width)) { + body := strings.Join(frame.view.Rows, "\n") + if frame.total > frame.window { + for _, indicator := range helpIndicatorRows(hk, frame.view.Above, frame.view.Above+len(frame.view.Rows), frame.total, helpBodyWidth(th, width)) { body += "\n" + th.Style("muted").Render(indicator) } } - return centerHelpCard(th, body, width, height) -} - -func centerHelpCard(th theme.Theme, body string, width, height int) string { card := th.Style("askCard").Width(helpCardWidth(width)).Render(body) - if width <= 0 || height <= 0 { + if width <= 0 { return card } return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, card) } +// helpFrame is one frame's viewport projection plus the wrapped-row total and the +// body window it was configured with. compact means the normal card cannot fit. +type helpFrame struct { + view bounded.ViewportView + total, window int + compact bool +} + // helpViewportView configures the caller-owned help viewport for one frame. It -// first measures wrapped content with every available body row, then reserves a -// final row for Help's own navigation indicator only when the content overflows. -func helpViewportView(th theme.Theme, lines []string, width, height int, viewport *bounded.Viewport, hk helpKeys) (bounded.ViewportView, int, int) { +// measures the wrapped content with every available body row on a throwaway +// viewport, then reserves rows for Help's own navigation indicator (sized for its +// widest possible digits) only when the content overflows. When no body row would +// remain, it falls back to a single-row window. +func helpViewportView(th theme.Theme, lines []string, width, height int, viewport *bounded.Viewport, hk helpKeys) helpFrame { bodyWidth := helpBodyWidth(th, width) - cardHeight := lipgloss.Height(th.Style("askCard").Render("")) - bodyHeight := max(1, height-cardHeight) - measure := *viewport - measure.SetGeometry(bodyWidth, bodyHeight, 0, bounded.Wrap) - measured := measure.View(lines) - total := measured.Above + len(measured.Rows) + measured.Below - window := bodyHeight - if total > window { - window = max(1, bodyHeight-len(helpIndicatorRows(hk, 0, 0, total, bodyWidth))) + bodyHeight := height - lipgloss.Height(th.Style("askCard").Render("")) + window := 1 + compact := bodyHeight < 1 + if !compact { + var measure bounded.Viewport + measure.SetGeometry(bodyWidth, bodyHeight, 0, bounded.Wrap) + measured := measure.View(lines) + total := measured.Above + len(measured.Rows) + measured.Below + window = bodyHeight + if total > window { + window = bodyHeight - len(helpIndicatorRows(hk, total-1, total, total, bodyWidth)) + compact = window < 1 + } + } + if compact { + window = 1 } viewport.SetGeometry(bodyWidth, window, 0, bounded.Wrap) view := viewport.View(lines) - total = view.Above + len(view.Rows) + view.Below - return view, total, window + return helpFrame{view: view, total: view.Above + len(view.Rows) + view.Below, window: window, compact: compact} } func helpCardWidth(width int) int { if width <= 0 { return helpMinCardWidth } - return min(width, max(helpMinCardWidth, width*helpCardWidthFraction/100)) + return min(width, helpMaxCardWidth, max(helpMinCardWidth, width*helpCardWidthFraction/100)) } func helpBodyWidth(th theme.Theme, width int) int { @@ -113,17 +130,9 @@ func helpBodyWidth(th theme.Theme, width int) int { } func helpIndicatorRows(hk helpKeys, start, end, total, width int) []string { - if width <= 0 { - return nil - } return strings.Split(ansi.Hardwrap(helpScrollIndicator(hk, start, end, total), width, true), "\n") } -// helpRenderedLines splits the independently styled help body into source lines. -func helpRenderedLines(body string) []string { - return strings.Split(body, "\n") -} - // helpScrollIndicator keeps the navigation affordances in every clipped frame, // including the initial top view where the help body's footer is not visible. func helpScrollIndicator(hk helpKeys, start, end, total int) string { @@ -150,7 +159,8 @@ func helpBody(th theme.Theme, caps client.Capabilities, hk helpKeys) string { {key: hk.paste, action: "paste a clipboard image, or paste text when image attachments are unavailable"}, {key: hk.clearPrompt, action: "clear the current draft"}, {key: "esc, release, esc", action: "clear the current idle draft within 500ms; the first press makes no visible change"}, - {key: "", action: "also clears attachments, large pasted text, and pending media; requires a terminal with enhanced key-event support"}, + {key: "", action: "also clears attachments, large pasted text, and pending media"}, + {key: "", action: "requires a terminal with enhanced key-event support"}, {key: "", action: "fixed shortcut; repeats do not count, and other views handle esc first"}, {key: "", action: "use the remappable Clear prompt action (" + hk.clearPrompt + ") on any terminal"}, {key: hk.cancel, action: "cancel the current run"}, diff --git a/cmd/mecatui/ui/helpgate_test.go b/cmd/mecatui/ui/helpgate_test.go index 8698cc6af3..65ea9c6b7d 100644 --- a/cmd/mecatui/ui/helpgate_test.go +++ b/cmd/mecatui/ui/helpgate_test.go @@ -2,6 +2,8 @@ package ui import ( "fmt" + "regexp" + "strconv" "strings" "testing" @@ -129,7 +131,8 @@ func TestHelpCardWidthIsTerminalBounded(t *testing.T) { {terminal: 40, want: 40}, {terminal: 69, want: 69}, {terminal: 100, want: 80}, - {terminal: 200, want: 160}, + {terminal: 160, want: 128}, + {terminal: 200, want: 128}, } { t.Run(fmt.Sprintf("%d columns", tc.terminal), func(t *testing.T) { if got := helpBodyWidth(th, tc.terminal) + frame; got != tc.want { @@ -188,14 +191,14 @@ func TestHelpWrapsNarrowBodyAndNavigatesWrappedRows(t *testing.T) { } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyDown}) - if m.helpScroll != 1 { - t.Fatalf("down should advance one wrapped row, got offset %d", m.helpScroll) + if m.helpViewport.Offset() != 1 { + t.Fatalf("down should advance one wrapped row, got offset %d", m.helpViewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) - if want := maxScrollOffset(total, window); m.helpScroll != want { - t.Fatalf("end offset = %d, want %d", m.helpScroll, want) + if want := maxScrollOffset(total, window); m.helpViewport.Offset() != want { + t.Fatalf("end offset = %d, want %d", m.helpViewport.Offset(), want) } - view := m.helpViewport.View(helpRenderedLines(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings()))) + view := m.helpViewport.View(strings.Split(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings()), "\n")) if view.Below != 0 { t.Fatalf("end should reveal the final wrapped row, with %d rows still below", view.Below) } @@ -284,33 +287,33 @@ func TestHelpScrollNavigationAndReset(t *testing.T) { } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyDown}) - if m.helpScroll != 1 { - t.Fatalf("down moved help scroll to %d, want 1", m.helpScroll) + if m.helpViewport.Offset() != 1 { + t.Fatalf("down moved help scroll to %d, want 1", m.helpViewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyPgDown}) - if m.helpScroll != clampScroll(1+window, total, window) { - t.Fatalf("pgdown moved help scroll to %d, want page movement", m.helpScroll) + if m.helpViewport.Offset() != clampScroll(1+window, total, window) { + t.Fatalf("pgdown moved help scroll to %d, want page movement", m.helpViewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyPgUp}) - if m.helpScroll != 1 { - t.Fatalf("pgup moved help scroll to %d, want 1", m.helpScroll) + if m.helpViewport.Offset() != 1 { + t.Fatalf("pgup moved help scroll to %d, want 1", m.helpViewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) - if want := maxScrollOffset(total, window); m.helpScroll != want { - t.Fatalf("end moved help scroll to %d, want %d", m.helpScroll, want) + if want := maxScrollOffset(total, window); m.helpViewport.Offset() != want { + t.Fatalf("end moved help scroll to %d, want %d", m.helpViewport.Offset(), want) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyHome}) - if m.helpScroll != 0 { - t.Fatalf("home moved help scroll to %d, want 0", m.helpScroll) + if m.helpViewport.Offset() != 0 { + t.Fatalf("home moved help scroll to %d, want 0", m.helpViewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}, qmark()) - if m.showHelp || m.helpScroll != 0 { - t.Fatalf("closing help should reset offset: open=%t offset=%d", m.showHelp, m.helpScroll) + if m.showHelp || m.helpViewport.Offset() != 0 { + t.Fatalf("closing help should reset offset: open=%t offset=%d", m.showHelp, m.helpViewport.Offset()) } m = applyAll(m, qmark()) - if !m.showHelp || m.helpScroll != 0 { - t.Fatalf("opening help should reset offset: open=%t offset=%d", m.showHelp, m.helpScroll) + if !m.showHelp || m.helpViewport.Offset() != 0 { + t.Fatalf("opening help should reset offset: open=%t offset=%d", m.showHelp, m.helpViewport.Offset()) } } @@ -331,21 +334,24 @@ func TestHelpRenderingIsHeightBoundedAndShowsScrollGuidance(t *testing.T) { t.Fatalf("help body is %d lines, exceeds offered viewport height %d", got, limit) } - initial := strings.Join(strings.Fields(strings.ReplaceAll(stripANSIstr(m.renderBody()), "┃", "")), "") - for _, want := range []string{"lines", "of", "ctrl+f1or?close", "ctrl+f2/ctrl+f3scroll", "ctrl+f4/ctrl+f5page", "ctrl+f6", "ctrl+f7jump"} { + initial := compactHelpText(m.renderBody()) + for _, want := range []string{"ctrl+f1or?close", "ctrl+f2/ctrl+f3scroll", "ctrl+f4/ctrl+f5page", "ctrl+f6", "ctrl+f7jump"} { if !strings.Contains(initial, want) { t.Fatalf("initial clipped help should show %q:\n%s", want, initial) } } + if start, _, total := helpIndicatorRange(t, initial); start != 1 || total <= 1 { + t.Fatalf("initial indicator should start at row 1 of a longer body, got start=%d total=%d:\n%s", start, total, initial) + } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyF7, Mod: tea.ModCtrl}) body := stripANSIstr(m.renderBody()) total, window := m.helpScrollGeometry() - if m.helpScroll != maxScrollOffset(total, window) { - t.Fatalf("end scroll offset = %d, want %d", m.helpScroll, maxScrollOffset(total, window)) + if m.helpViewport.Offset() != maxScrollOffset(total, window) { + t.Fatalf("end scroll offset = %d, want %d", m.helpViewport.Offset(), maxScrollOffset(total, window)) } - if !strings.Contains(body, "lines ") || !strings.Contains(body, " of ") { - t.Fatalf("end-scrolled help should show a line-range indicator:\n%s", body) + if start, end, rows := helpIndicatorRange(t, compactHelpText(body)); start <= 1 || end != rows { + t.Fatalf("end-scrolled indicator should move past row 1 and finish on the last row, got %d–%d of %d:\n%s", start, end, rows, body) } if !strings.Contains(body, "ctrl+f1 or ? close") { t.Fatalf("end-scrolled help should retain the live close hint:\n%s", body) @@ -375,60 +381,54 @@ func TestHelpNavigationRespectsKeyOverrides(t *testing.T) { press := func(ch rune) { m = applyAll(m, tea.KeyPressMsg{Code: ch, Text: string(ch)}) } press('j') - if m.helpScroll != 1 { - t.Fatalf("overridden Down moved help scroll to %d, want 1", m.helpScroll) + if m.helpViewport.Offset() != 1 { + t.Fatalf("overridden Down moved help scroll to %d, want 1", m.helpViewport.Offset()) } press('d') - if want := clampScroll(1+window, total, window); m.helpScroll != want { - t.Fatalf("overridden ScrollD moved help scroll to %d, want %d", m.helpScroll, want) + if want := clampScroll(1+window, total, window); m.helpViewport.Offset() != want { + t.Fatalf("overridden ScrollD moved help scroll to %d, want %d", m.helpViewport.Offset(), want) } press('u') - if m.helpScroll != 1 { - t.Fatalf("overridden ScrollU moved help scroll to %d, want 1", m.helpScroll) + if m.helpViewport.Offset() != 1 { + t.Fatalf("overridden ScrollU moved help scroll to %d, want 1", m.helpViewport.Offset()) } press('t') - if m.helpScroll != 0 { - t.Fatalf("overridden ScrollTop moved help scroll to %d, want 0", m.helpScroll) + if m.helpViewport.Offset() != 0 { + t.Fatalf("overridden ScrollTop moved help scroll to %d, want 0", m.helpViewport.Offset()) } press('b') - if m.helpScroll != maxScroll { - t.Fatalf("overridden ScrollBottom moved help scroll to %d, want %d", m.helpScroll, maxScroll) + if m.helpViewport.Offset() != maxScroll { + t.Fatalf("overridden ScrollBottom moved help scroll to %d, want %d", m.helpViewport.Offset(), maxScroll) } press('k') - if m.helpScroll != maxScroll-1 { - t.Fatalf("overridden Up moved help scroll to %d, want %d", m.helpScroll, maxScroll-1) + if m.helpViewport.Offset() != maxScroll-1 { + t.Fatalf("overridden Up moved help scroll to %d, want %d", m.helpViewport.Offset(), maxScroll-1) } press('c') - if m.showHelp || m.helpScroll != 0 { - t.Fatalf("overridden Close should close and reset help: open=%t offset=%d", m.showHelp, m.helpScroll) + if m.showHelp || m.helpViewport.Offset() != 0 { + t.Fatalf("overridden Close should close and reset help: open=%t offset=%d", m.showHelp, m.helpViewport.Offset()) } } func TestHelpScrollClampsAfterResizeWithoutFollowingEnd(t *testing.T) { m := helpModel(t, allOnCaps()) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 24}, tea.KeyPressMsg{Code: tea.KeyEnd}) - before := m.helpScroll + before := m.helpViewport.Offset() m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 32}) total, window := m.helpScrollGeometry() want := clampScroll(before, total, window) - if m.helpScroll != want { - t.Fatalf("resize left stale offset %d, want clamped %d", m.helpScroll, want) - } - - m.helpScroll = before // Exercise navigation's defensive clamp independently. - m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyUp}) - if want > 0 && m.helpScroll != want-1 { - t.Fatalf("relative navigation began at %d, want clamped offset %d then up", m.helpScroll, want) + if m.helpViewport.Offset() != want { + t.Fatalf("resize left stale offset %d, want clamped %d", m.helpViewport.Offset(), want) } m = helpModel(t, allOnCaps()) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 32}, tea.KeyPressMsg{Code: tea.KeyEnd}) - before = m.helpScroll + before = m.helpViewport.Offset() m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 24}) total, window = m.helpScrollGeometry() - if m.helpScroll != before || m.helpScroll == maxScrollOffset(total, window) { - t.Fatalf("smaller viewport should retain the prior offset, not follow End: got=%d before=%d end=%d", m.helpScroll, before, maxScrollOffset(total, window)) + if m.helpViewport.Offset() != before || m.helpViewport.Offset() == maxScrollOffset(total, window) { + t.Fatalf("smaller viewport should retain the prior offset, not follow End: got=%d before=%d end=%d", m.helpViewport.Offset(), before, maxScrollOffset(total, window)) } } @@ -458,3 +458,127 @@ func footerHelpLine(t *testing.T, caps client.Capabilities) string { lines := strings.Split(stripANSIstr(m.renderFooter()), "\n") return lines[len(lines)-1] } + +var helpIndicatorPattern = regexp.MustCompile(`lines(\d+)–(\d+)of(\d+)`) + +// compactHelpText strips ANSI, card borders, and all whitespace so a wrapped +// indicator can be matched regardless of where it breaks. +func compactHelpText(body string) string { + return strings.Join(strings.Fields(strings.ReplaceAll(stripANSIstr(body), "┃", "")), "") +} + +// helpIndicatorRange parses "lines S–E of N" from compactHelpText output. +func helpIndicatorRange(t *testing.T, compact string) (start, end, total int) { + t.Helper() + match := helpIndicatorPattern.FindStringSubmatch(compact) + if match == nil { + t.Fatalf("no line-range indicator in:\n%s", compact) + } + start, _ = strconv.Atoi(match[1]) + end, _ = strconv.Atoi(match[2]) + total, _ = strconv.Atoi(match[3]) + return start, end, total +} + +// TestHelpStaysWithinOfferedHeightAcrossNarrowShortGeometry sweeps the geometry +// where the wrapped indicator competes with the body for rows. Every frame must +// fit the offered height at the top, middle, and end offsets. +func TestHelpStaysWithinOfferedHeightAcrossNarrowShortGeometry(t *testing.T) { + m := helpModel(t, allOnCaps()) + th, hk := m.deps.Theme, m.helpKeyMarkings() + lines := strings.Split(helpBody(th, m.caps, hk), "\n") + for _, width := range []int{8, 12, 20, 26, 33, 40, 45, 69} { + for _, height := range []int{1, 3, 5, 6, 8, 10, 12, 14, 24} { + viewport := new(bounded.Viewport) + for _, position := range []string{"top", "middle", "end"} { + total := helpViewportView(th, lines, width, height, viewport, hk).total + switch position { + case "top": + viewport.Reset() + case "middle": + viewport.SetOffset(total/2, total) + case "end": + viewport.Move(bounded.End, total) + } + out := renderHelpOverlay(th, m.caps, width, height, viewport, hk) + if got := lipgloss.Height(out); got > height { + t.Fatalf("width=%d height=%d at %s: help is %d rows, exceeds offered height", width, height, position, got) + } + } + } + } +} + +func TestHelpCompactFallbackAndBareCard(t *testing.T) { + m := helpModel(t, allOnCaps()) + th, hk := m.deps.Theme, m.helpKeyMarkings() + chrome := lipgloss.Height(th.Style("askCard").Render("")) + + for _, height := range []int{1, chrome} { + out := renderHelpOverlay(th, m.caps, 100, height, new(bounded.Viewport), hk) + if lipgloss.Height(out) != 1 || strings.Contains(stripANSIstr(out), "┏") { + t.Fatalf("height %d should render one unframed row, got:\n%s", height, out) + } + } + + // A body window starved by the wrapped indicator takes the same fallback + // instead of overflowing the card. + starved := renderHelpOverlay(th, m.caps, 20, chrome+3, new(bounded.Viewport), hk) + if lipgloss.Height(starved) != 1 { + t.Fatalf("a starved body window should fall back to one row, got %d rows:\n%s", lipgloss.Height(starved), starved) + } + + for _, size := range [][2]int{{100, 0}, {0, 0}, {0, 24}} { + out := stripANSIstr(renderHelpOverlay(th, m.caps, size[0], size[1], new(bounded.Viewport), hk)) + if size[1] <= 0 && !strings.Contains(out, "┏") { + t.Fatalf("nonpositive geometry %v should keep the bare card, got:\n%s", size, out) + } + } +} + +func TestHelpShowsNoIndicatorWhenContentFits(t *testing.T) { + m := applyAll(helpModel(t, allOnCaps()), tea.WindowSizeMsg{Width: 100, Height: 1000}) + if total, window := m.helpScrollGeometry(); total > window { + t.Fatalf("precondition: tall Help should not overflow (total=%d window=%d)", total, window) + } + if body := compactHelpText(m.renderBody()); helpIndicatorPattern.MatchString(body) { + t.Fatalf("non-overflowing Help should not show a line-range indicator:\n%s", body) + } +} + +// TestHelpSwallowsNonWheelMouseWhileOpen pins that clicks, motion, and releases +// are consumed by the visible overlay, like the wheel, and cannot reach the hidden +// prompt or conversation. +func TestHelpSwallowsNonWheelMouseWhileOpen(t *testing.T) { + m := applyAll(helpModel(t, allOnCaps()), tea.WindowSizeMsg{Width: 100, Height: 24}) + m.prompt.Rewrite("hidden draft") + m.prompt.SelectAll() + input, ok := inputRegionRect(m) + if !ok { + t.Fatal("precondition: prompt region should be known at this size") + } + beforeOffset, beforeYOffset := m.helpViewport.Offset(), m.vp.YOffset() + beforeValue, beforeFocused, beforeSelection := m.prompt.Value(), m.prompt.Focused(), m.prompt.HasSelection() + + for name, msg := range map[string]tea.Msg{ + "left click in the prompt": tea.MouseClickMsg{Button: tea.MouseLeft, X: input.x0 + 1, Y: input.y0}, + "left click in the card": tea.MouseClickMsg{Button: tea.MouseLeft, X: 50, Y: 8}, + "right click": tea.MouseClickMsg{Button: tea.MouseRight, X: 50, Y: 8}, + "middle click": tea.MouseClickMsg{Button: tea.MouseMiddle, X: 50, Y: 8}, + "motion": tea.MouseMotionMsg{Button: tea.MouseLeft, X: 40, Y: 8}, + "release": tea.MouseReleaseMsg{Button: tea.MouseLeft, X: 40, Y: 8}, + } { + updated, _ := m.Update(msg) + m = updated.(Model) + switch { + case !m.showHelp: + t.Fatalf("%s closed help", name) + case m.helpViewport.Offset() != beforeOffset: + t.Fatalf("%s moved help offset to %d, want %d", name, m.helpViewport.Offset(), beforeOffset) + case m.vp.YOffset() != beforeYOffset || m.sel.active: + t.Fatalf("%s reached the hidden conversation", name) + case m.prompt.Value() != beforeValue || m.prompt.Focused() != beforeFocused || m.prompt.HasSelection() != beforeSelection: + t.Fatalf("%s reached the hidden prompt", name) + } + } +} diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 2343a796e1..74dc6a16f9 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -713,10 +713,9 @@ type Model struct { // lets the picker label the current model "picked this session" (vs a launch-time // workspace/global default). Zero until a restart-now pick. Display-only. pickedThisSession client.ModelSelection - showHelp bool // the "?" keys-&-features overlay is open (caps-driven; see help.go) - helpScroll int // first visible wrapped help-body row while the overlay is open - helpViewport bounded.Viewport // root-owned wrapped help-body viewport - stream *client.Stream // current run's stream + showHelp bool // the "?" keys-&-features overlay is open (caps-driven; see help.go) + helpViewport *bounded.Viewport // root-owned wrapped help-body viewport; its offset is the only scroll state + stream *client.Stream // current run's stream cancelRun context.CancelFunc // quitArmed is true after a first ctrl+c on an empty prompt: a second ctrl+c @@ -1089,6 +1088,7 @@ func New(deps Deps) Model { keys: keys, rend: rend, hits: &hitRegions{}, + helpViewport: new(bounded.Viewport), metrics: &renderedSurfaceMetrics{}, phase: phaseConnecting, prompt: prompt, diff --git a/cmd/mecatui/ui/testdata/help_all_on.golden b/cmd/mecatui/ui/testdata/help_all_on.golden index f36228b20d..70b17c7091 100644 --- a/cmd/mecatui/ui/testdata/help_all_on.golden +++ b/cmd/mecatui/ui/testdata/help_all_on.golden @@ -1,24 +1,24 @@ mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ ┃ - ┃ Help ┃ - ┃ ┃ - ┃ Prompting ┃ - ┃ enter send prompt ┃ - ┃ shift+enter insert newline (also ctrl+j, ctrl+enter, alt+enter) ┃ - ┃ / open available commands, including workspace commands when enabled ┃ - ┃ ? or /help open this help; the shortcut works when the prompt is empty ┃ - ┃ @ attach a file; supported images and audio are sent as media, other files as text ┃ - ┃ ctrl+v paste a clipboard image, or paste text when image attachments are unavailable ┃ - ┃ ctrl+u clear the current draft ┃ - ┃ esc, release, esc clear the current idle draft within 500ms; the first press makes no visible change ┃ - ┃ also clears attachments, large pasted text, and pending media; requires a terminal with enhanced key-event support ┃ - ┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ - ┃ use the remappable Clear prompt action (ctrl+u) on any terminal ┃ - ┃ lines 1–14 of 70 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ - ┃ ┃ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ ┃ + ┃ Help ┃ + ┃ ┃ + ┃ Prompting ┃ + ┃ enter send prompt ┃ + ┃ shift+enter insert newline (also ctrl+j, ctrl+enter, alt+enter) ┃ + ┃ / open available commands, including workspace commands when enabled ┃ + ┃ ? or /help open this help; the shortcut works when the prompt is empty ┃ + ┃ @ attach a file; supported images and audio are sent as media, other files as text ┃ + ┃ ctrl+v paste a clipboard image, or paste text when image attachments are unavailable ┃ + ┃ ctrl+u clear the current draft ┃ + ┃ esc, release, esc clear the current idle draft within 500ms; the first press makes no visible change ┃ + ┃ also clears attachments, large pasted text, and pending media ┃ + ┃ requires a terminal with enhanced key-event support ┃ + ┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ + ┃ lines 1–14 of 71 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ + ┃ ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ diff --git a/cmd/mecatui/ui/testdata/help_embedded.golden b/cmd/mecatui/ui/testdata/help_embedded.golden index 564030ac22..1ac09b963b 100644 --- a/cmd/mecatui/ui/testdata/help_embedded.golden +++ b/cmd/mecatui/ui/testdata/help_embedded.golden @@ -1,24 +1,24 @@ mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ ┃ - ┃ Help ┃ - ┃ ┃ - ┃ Prompting ┃ - ┃ enter send prompt ┃ - ┃ shift+enter insert newline (also ctrl+j, ctrl+enter, alt+enter) ┃ - ┃ / open available commands, including workspace commands when enabled ┃ - ┃ ? or /help open this help; the shortcut works when the prompt is empty ┃ - ┃ @ attach a file; supported images and audio are sent as media, other files as text ┃ - ┃ ctrl+v paste a clipboard image, or paste text when image attachments are unavailable ┃ - ┃ ctrl+u clear the current draft ┃ - ┃ esc, release, esc clear the current idle draft within 500ms; the first press makes no visible change ┃ - ┃ also clears attachments, large pasted text, and pending media; requires a terminal with enhanced key-event support ┃ - ┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ - ┃ use the remappable Clear prompt action (ctrl+u) on any terminal ┃ - ┃ lines 1–14 of 69 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ - ┃ ┃ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ ┃ + ┃ Help ┃ + ┃ ┃ + ┃ Prompting ┃ + ┃ enter send prompt ┃ + ┃ shift+enter insert newline (also ctrl+j, ctrl+enter, alt+enter) ┃ + ┃ / open available commands, including workspace commands when enabled ┃ + ┃ ? or /help open this help; the shortcut works when the prompt is empty ┃ + ┃ @ attach a file; supported images and audio are sent as media, other files as text ┃ + ┃ ctrl+v paste a clipboard image, or paste text when image attachments are unavailable ┃ + ┃ ctrl+u clear the current draft ┃ + ┃ esc, release, esc clear the current idle draft within 500ms; the first press makes no visible change ┃ + ┃ also clears attachments, large pasted text, and pending media ┃ + ┃ requires a terminal with enhanced key-event support ┃ + ┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ + ┃ lines 1–14 of 70 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ + ┃ ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 0e7f2470e2..7da9f67b40 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2043,7 +2043,11 @@ func (m Model) onResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { // lipgloss.Height of the rendered regions in chrome(). m.relayout() m.configureAgentsInvViewport() - m.clampHelpScroll() + if m.showHelp { + // Re-apply geometry so a retained offset is clamped to the new layout + // without pinning an earlier End position to the new bottom. + m.helpScrollGeometry() + } m.clampAgentsDetailScroll() if widthChanged && m.vp.Height() == viewportHeight { m.refreshView() @@ -2366,59 +2370,55 @@ func (m Model) onPendingApprovalRecoveryKey(msg tea.KeyPressMsg) (tea.Model, tea // phase routing, so navigation never reaches the conversation and every other key // remains swallowed. func (m Model) onHelpKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - total, _ := m.helpScrollGeometry() switch { case key.Matches(msg, m.keys.Help), key.Matches(msg, m.keys.Close): m.showHelp = false - m.helpScroll = 0 m.helpViewport.Reset() _ = m.prompt.Focus() case key.Matches(msg, m.keys.ScrollD): - m.helpViewport.Move(bounded.PageDown, total) + m.moveHelp(bounded.PageDown) case key.Matches(msg, m.keys.ScrollU): - m.helpViewport.Move(bounded.PageUp, total) + m.moveHelp(bounded.PageUp) case key.Matches(msg, m.keys.Down): - m.helpViewport.Move(bounded.LineDown, total) + m.moveHelp(bounded.LineDown) case key.Matches(msg, m.keys.Up): - m.helpViewport.Move(bounded.LineUp, total) + m.moveHelp(bounded.LineUp) case key.Matches(msg, m.keys.ScrollBottom): - m.helpViewport.Move(bounded.End, total) + m.moveHelp(bounded.End) case key.Matches(msg, m.keys.ScrollTop): - m.helpViewport.Move(bounded.Top, total) + m.moveHelp(bounded.Top) } - m.helpScroll = m.helpViewport.Offset() return m, nil } -// onHelpWheel keeps physical scrolling owned by the visible Help overlay. -// It configures Help's wrapped viewport before every move so wheel events are -// consumed at endpoints and in compact or non-overflow geometry. +// onHelpWheel keeps physical scrolling owned by the visible Help overlay. The +// event is consumed at endpoints and in compact or non-overflow geometry because +// moveHelp always runs against the current layout. func (m Model) onHelpWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { - total, _ := m.helpScrollGeometry() switch msg.Button { case tea.MouseWheelUp: - m.helpViewport.Move(bounded.LineUp, total) + m.moveHelp(bounded.LineUp) case tea.MouseWheelDown: - m.helpViewport.Move(bounded.LineDown, total) + m.moveHelp(bounded.LineDown) } - m.helpScroll = m.helpViewport.Offset() return m, nil } -// helpScrollGeometry derives the same wrapped rows and window used by -// renderHelpOverlay, keeping key navigation and height-bounded rendering aligned. -func (m *Model) helpScrollGeometry() (total, window int) { - lines := helpRenderedLines(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings())) - _, total, window = helpViewportView(m.deps.Theme, lines, m.width, m.vp.Height(), &m.helpViewport, m.helpKeyMarkings()) - m.helpScroll = m.helpViewport.Offset() - return total, window +// moveHelp applies one movement to Help's viewport after re-applying the current +// geometry, so the move clamps against the same rows renderHelpOverlay draws. +func (m *Model) moveHelp(move bounded.Move) { + total, _ := m.helpScrollGeometry() + m.helpViewport.Move(move, total) } -// clampHelpScroll keeps a retained offset valid after a relayout changes the -// viewport geometry. It deliberately preserves a still-valid offset rather than -// pinning an earlier End selection to the new bottom. -func (m *Model) clampHelpScroll() { - m.helpScrollGeometry() +// helpScrollGeometry configures the pointer-owned Help viewport for the current +// layout and returns the wrapped row total and body window. It mutates the +// viewport (geometry and offset clamp) on purpose: render, keys, wheel and resize +// all go through helpViewportView, so they cannot disagree about the rows. +func (m *Model) helpScrollGeometry() (total, window int) { + lines := strings.Split(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings()), "\n") + frame := helpViewportView(m.deps.Theme, lines, m.width, m.vp.Height(), m.helpViewport, m.helpKeyMarkings()) + return frame.total, frame.window } // selection owner is active. @@ -3346,11 +3346,7 @@ func (m Model) onIdleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // "?" is printable: open help only on an empty prompt so "?" in prose still // inserts literally. The overlay claims the keyboard via the m.showHelp gate // in onKey; blur the input while it is up. - m.showHelp = true - m.helpScroll = 0 - m.helpViewport.Reset() - m.prompt.Blur() - return m, nil + return m.runHelp() case key.Matches(msg, m.keys.MCPPanel): return m.runMCP() case key.Matches(msg, m.keys.Resources): diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index a7b4ab7484..cf106144db 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -101,7 +101,7 @@ func (m Model) renderBody() string { case m.sessionDetailsOpen: return renderSessionDetails(m.deps.Theme, m.sessionDetails(), m.helpKeyMarkings(), m.width, m.vp.Height()) case m.showHelp: - return renderHelpOverlay(m.deps.Theme, m.caps, m.width, m.vp.Height(), &m.helpViewport, m.helpKeyMarkings()) + return renderHelpOverlay(m.deps.Theme, m.caps, m.width, m.vp.Height(), m.helpViewport, m.helpKeyMarkings()) case m.team.view != teamNone: return renderAgentsOverlay(m.deps.Theme, m.agentsTab, m.subagents, m.parallel, m.team, m.teamBlockForOverlay(), m.conv.subagentFleet, m.conv.parallelGroups, m.helpKeyMarkings(), m.width, m.vp.Height(), m.height) case m.agentsInv.view != agentsInvNone: diff --git a/cmd/mecatui/ui/window.go b/cmd/mecatui/ui/window.go index ae21aeb294..00ac84d9fb 100644 --- a/cmd/mecatui/ui/window.go +++ b/cmd/mecatui/ui/window.go @@ -1,11 +1,5 @@ package ui -import ( - "strings" - - "github.com/stacklok/mecatl/cmd/mecatui/theme" -) - // 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 @@ -64,33 +58,3 @@ func clampBounded(value, count int) int { type renderedLineWindowBounds struct { start, end, total, window int } - -func renderedLineWindow(scroll, total, window int) renderedLineWindowBounds { - if window < 0 { - window = 0 - } - start := clampScroll(scroll, total, window) - return renderedLineWindowBounds{start: start, end: min(start+window, total), total: total, window: window} -} - -// windowRenderedLinesWithIndicator windows ALREADY-RENDERED (ANSI-carrying) lines to a fixed -// window starting at scroll, appending a caller-owned overflow indicator when -// the content overflows the window. Each input line must be a COMPLETE styled -// line (lipgloss renders multi-line strings with per-line SGR sequences — the -// same property capRenderedLines relies on), so slicing never severs an escape. -// Like capRenderedLines it must NOT terminaltext.Sanitize its input (that would strip -// the embedded styling); the line TEXT is sanitized by the callers at render -// time. Every emitted line carries a trailing newline so the callers' footer -// concatenation stays uniform across the scrolled and unscrolled cases. -func windowRenderedLinesWithIndicator(th theme.Theme, lines []string, scroll, window int, indicator func(start, end, total int) string) string { - w := renderedLineWindow(scroll, len(lines), window) - - var b strings.Builder - for _, ln := range lines[w.start:w.end] { - b.WriteString(ln + "\n") - } - if w.total > w.window { - b.WriteString(th.Style("muted").Render(indicator(w.start, w.end, w.total)) + "\n") - } - return b.String() -} diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index 55d633022b..9774d1c235 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -12,9 +12,9 @@ Press `?` on an empty prompt to open the live help overlay. The overlay shows your active bindings and dims features that the connected server does not provide. -The overlay adapts to the available terminal space. When its content overflows, -use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and -**Home/End** to jump to the beginning or end. The mouse wheel also scrolls it. +Use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and +**Home/End** to jump to the beginning or end of a long help overlay. When mouse +capture is on, the mouse wheel also scrolls it. ## Everyday keys From 972af9101595595acf11fa066efdaa6d2cd5eb3a Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 9 Oct 2026 15:06:11 -0700 Subject: [PATCH 05/11] fix(mecatui): migrate Help to surface contract Co-authored-by: Mecatl --- cmd/mecatui/ui/builtins.go | 10 +- cmd/mecatui/ui/builtins_test.go | 2 +- cmd/mecatui/ui/double_escape_test.go | 11 +- cmd/mecatui/ui/dream.go | 4 +- cmd/mecatui/ui/help.go | 108 +++++--- cmd/mecatui/ui/help_test.go | 4 +- cmd/mecatui/ui/helpgate_test.go | 253 +++++++++++------- cmd/mecatui/ui/model.go | 6 +- .../ui/palette_bounded_list_scenario_test.go | 2 +- cmd/mecatui/ui/paste_large_test.go | 2 +- cmd/mecatui/ui/paste_test.go | 4 +- cmd/mecatui/ui/primary_paste_test.go | 9 +- cmd/mecatui/ui/prompt_input_test.go | 2 +- .../ui/quieter_toolcalls_scenario2_test.go | 6 +- cmd/mecatui/ui/selection.go | 2 +- cmd/mecatui/ui/selection_test.go | 9 +- cmd/mecatui/ui/skills.go | 4 +- cmd/mecatui/ui/soul.go | 4 +- cmd/mecatui/ui/steer_test.go | 2 +- cmd/mecatui/ui/surface.go | 12 +- cmd/mecatui/ui/ui_test.go | 2 +- cmd/mecatui/ui/update.go | 81 +----- cmd/mecatui/ui/usermodel.go | 6 +- cmd/mecatui/ui/view.go | 2 - 24 files changed, 283 insertions(+), 264 deletions(-) diff --git a/cmd/mecatui/ui/builtins.go b/cmd/mecatui/ui/builtins.go index a7b68bffa3..32f7383e80 100644 --- a/cmd/mecatui/ui/builtins.go +++ b/cmd/mecatui/ui/builtins.go @@ -440,11 +440,13 @@ func (m Model) runCompact() (tea.Model, tea.Cmd) { return m, client.CompactSessionCmd(m.deps.Ctx, m.deps.Compactor, m.sessionID, m.compactRequestToken) } -// runHelp opens the "?" keys-&-features overlay — the same state the "?" key -// sets (see onIdleKey). It blurs the textarea so the overlay owns the keyboard. +// runHelp opens the keys-and-features surface and blurs the composer. func (m Model) runHelp() (tea.Model, tea.Cmd) { - m.showHelp = true - m.helpViewport.Reset() + if m.modal != nil { + return m, nil + } + deps := (&m).surfaceDeps() + m.modal = &helpState{deps: deps, lines: strings.Split(helpBody(deps.theme, deps.caps, deps.marks), "\n")} m.prompt.Blur() return m, nil } diff --git a/cmd/mecatui/ui/builtins_test.go b/cmd/mecatui/ui/builtins_test.go index ec599ba2e1..0f99a34a31 100644 --- a/cmd/mecatui/ui/builtins_test.go +++ b/cmd/mecatui/ui/builtins_test.go @@ -729,7 +729,7 @@ func TestHelpBuiltinOpensOverlay(t *testing.T) { m = typeText(t, m, "/help") m, cmd := pressEnter(t, m) - if !m.showHelp { + if m.modal == nil { t.Error("/help should open the help overlay (showHelp=true)") } if m.prompt.Focused() { diff --git a/cmd/mecatui/ui/double_escape_test.go b/cmd/mecatui/ui/double_escape_test.go index a27088f041..a7d34245b8 100644 --- a/cmd/mecatui/ui/double_escape_test.go +++ b/cmd/mecatui/ui/double_escape_test.go @@ -220,7 +220,6 @@ func TestDoubleEscapeOwnersConsumeAndDisarm(t *testing.T) { assertFreshNext := func(t *testing.T, m Model) { t.Helper() m.phase = phaseIdle - m.showHelp = false m.closeModal() m.prompt.Focus() m = applyAll(m, escapePress()) @@ -240,10 +239,12 @@ func TestDoubleEscapeOwnersConsumeAndDisarm(t *testing.T) { }) t.Run("help", func(t *testing.T) { m := enableEventTypes(doubleEscapeDraft(t)) - m.doubleEscapeArmed, m.doubleEscapeReleased, m.showHelp = true, true, true + m.doubleEscapeArmed, m.doubleEscapeReleased = true, true + mm, _ := m.runHelp() + m = mm.(Model) m = applyAll(m, escapePress()) - if m.doubleEscapeArmed || m.showHelp || m.prompt.Empty() { - t.Fatalf("help did not consume/disarm: armed=%t open=%t", m.doubleEscapeArmed, m.showHelp) + if m.doubleEscapeArmed || m.modal != nil || m.prompt.Empty() { + t.Fatalf("help did not consume/disarm: armed=%t open=%t", m.doubleEscapeArmed, m.modal != nil) } assertFreshNext(t, m) }) @@ -292,7 +293,7 @@ func TestDoubleEscapeAllEscapeOwnersSuppressGesture(t *testing.T) { }}, {"paused queue", func(m Model) Model { m.queuePaused = "paused"; return m }}, {"session details", func(m Model) Model { m.sessionDetailsOpen = true; return m }}, - {"help", func(m Model) Model { m.showHelp = true; return m }}, + {"help", func(m Model) Model { mm, _ := m.runHelp(); return mm.(Model) }}, {"modal", func(m Model) Model { m.modal = shellAskModel(t, `{"command":"true"}`).modal; return m }}, {"team", func(m Model) Model { m.team.view = teamRoster; return m }}, {"agents inventory", func(m Model) Model { m.agentsInv.view = agentsInvPanel; return m }}, diff --git a/cmd/mecatui/ui/dream.go b/cmd/mecatui/ui/dream.go index bad1ec4b17..2103fdba71 100644 --- a/cmd/mecatui/ui/dream.go +++ b/cmd/mecatui/ui/dream.go @@ -55,8 +55,8 @@ type dreamResultMsg struct { result client.DreamMsg } -func (*dreamState) modalMaxOuterWidth() int { return 128 } -func (s *dreamState) modalFrame() bool { return !s.compact } +func (*dreamState) modalMaxOuterWidth(int) int { return 128 } +func (s *dreamState) modalFrame() bool { return !s.compact } func (m Model) openDream() (tea.Model, tea.Cmd) { if m.phase != phaseIdle || m.modal != nil || m.deps.Dream == nil || m.caps.ManualDream == nil { diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index ca6edceff2..dd2db4ed61 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -5,7 +5,7 @@ import ( "strings" "charm.land/bubbles/v2/key" - "charm.land/lipgloss/v2" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" @@ -50,37 +50,78 @@ const ( helpMaxCardWidth = 128 ) -// renderHelpOverlay draws the "?" keys-&-features overlay centred over the -// conversation region. Its root-owned viewport wraps the live help lines to the -// card's inner width before windowing them. -func renderHelpOverlay(th theme.Theme, caps client.Capabilities, width, height int, viewport *bounded.Viewport, hk helpKeys) string { - lines := strings.Split(helpBody(th, caps, hk), "\n") - if height <= 0 { - return centerCard(th, strings.Join(lines, "\n"), width, height) - } - frame := helpViewportView(th, lines, width, height, viewport, hk) +// helpState owns the Help overlay's wrapped viewport and frame geometry. +type helpState struct { + deps surfaceDeps + viewport bounded.Viewport + lines []string + // view cache: refreshed by Render before each input event. + total, window int + compact bool +} + +func (*helpState) modalMaxOuterWidth(available int) int { return helpCardWidth(available) } +func (s *helpState) modalFrame() bool { return !s.compact } + +func (s *helpState) Render(width, height int) (string, []ClickableRegion) { + // Retain the card's former one-row empty-content allowance. + frame := helpViewportView(s.lines, width, height-1, &s.viewport, s.deps.marks) + s.total, s.window, s.compact = frame.total, frame.window, frame.compact if len(frame.view.Rows) == 0 { - return "" + return "", nil } - if frame.compact { - // A card with its chrome and scroll indicator cannot fit this viewport. Keep - // the overlay usable as one scrollable row rather than overflowing the - // conversation region. - return frame.view.Rows[0] + if s.compact { + return frame.view.Rows[0], nil } body := strings.Join(frame.view.Rows, "\n") if frame.total > frame.window { - for _, indicator := range helpIndicatorRows(hk, frame.view.Above, frame.view.Above+len(frame.view.Rows), frame.total, helpBodyWidth(th, width)) { - body += "\n" + th.Style("muted").Render(indicator) + for _, indicator := range helpIndicatorRows(s.deps.marks, frame.view.Above, frame.view.Above+len(frame.view.Rows), frame.total, width) { + body += "\n" + s.deps.theme.Style("muted").Render(indicator) } } - card := th.Style("askCard").Width(helpCardWidth(width)).Render(body) - if width <= 0 { - return card + return body, nil +} + +func (s *helpState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { + switch { + case key.Matches(msg, s.deps.keys.Help), key.Matches(msg, s.deps.keys.Close): + return nil, true, true + case key.Matches(msg, s.deps.keys.ScrollD): + s.viewport.Move(bounded.PageDown, s.total) + case key.Matches(msg, s.deps.keys.ScrollU): + s.viewport.Move(bounded.PageUp, s.total) + case key.Matches(msg, s.deps.keys.Down): + s.viewport.Move(bounded.LineDown, s.total) + case key.Matches(msg, s.deps.keys.Up): + s.viewport.Move(bounded.LineUp, s.total) + case key.Matches(msg, s.deps.keys.ScrollBottom): + s.viewport.Move(bounded.End, s.total) + case key.Matches(msg, s.deps.keys.ScrollTop): + s.viewport.Move(bounded.Top, s.total) + } + return nil, true, false +} + +func (*helpState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { + switch msg.(type) { + case tea.MouseClickMsg, tea.MouseMotionMsg, tea.MouseReleaseMsg: + return nil, true, false } - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, card) + return nil, false, false } +func (s *helpState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { + switch msg.Button { + case tea.MouseWheelUp: + s.viewport.Move(bounded.LineUp, s.total) + case tea.MouseWheelDown: + s.viewport.Move(bounded.LineDown, s.total) + } + return nil, true +} + +func (*helpState) Close() {} + // helpFrame is one frame's viewport projection plus the wrapped-row total and the // body window it was configured with. compact means the normal card cannot fit. type helpFrame struct { @@ -89,31 +130,28 @@ type helpFrame struct { compact bool } -// helpViewportView configures the caller-owned help viewport for one frame. It -// measures the wrapped content with every available body row on a throwaway -// viewport, then reserves rows for Help's own navigation indicator (sized for its -// widest possible digits) only when the content overflows. When no body row would -// remain, it falls back to a single-row window. -func helpViewportView(th theme.Theme, lines []string, width, height int, viewport *bounded.Viewport, hk helpKeys) helpFrame { - bodyWidth := helpBodyWidth(th, width) - bodyHeight := height - lipgloss.Height(th.Style("askCard").Render("")) +// helpViewportView configures the surface-owned help viewport for one frame of +// card-content geometry. It reserves rows for the wrapped navigation indicator +// when the content overflows, or falls back to one unframed row. +func helpViewportView(lines []string, width, height int, viewport *bounded.Viewport, hk helpKeys) helpFrame { + width = max(1, width) window := 1 - compact := bodyHeight < 1 + compact := height < 1 if !compact { var measure bounded.Viewport - measure.SetGeometry(bodyWidth, bodyHeight, 0, bounded.Wrap) + measure.SetGeometry(width, height, 0, bounded.Wrap) measured := measure.View(lines) total := measured.Above + len(measured.Rows) + measured.Below - window = bodyHeight + window = height if total > window { - window = bodyHeight - len(helpIndicatorRows(hk, total-1, total, total, bodyWidth)) + window = height - len(helpIndicatorRows(hk, total-1, total, total, width)) compact = window < 1 } } if compact { window = 1 } - viewport.SetGeometry(bodyWidth, window, 0, bounded.Wrap) + viewport.SetGeometry(width, window, 0, bounded.Wrap) view := viewport.View(lines) return helpFrame{view: view, total: view.Above + len(view.Rows) + view.Below, window: window, compact: compact} } diff --git a/cmd/mecatui/ui/help_test.go b/cmd/mecatui/ui/help_test.go index 905bf51165..d80d2f66cc 100644 --- a/cmd/mecatui/ui/help_test.go +++ b/cmd/mecatui/ui/help_test.go @@ -59,7 +59,7 @@ func helpModel(t *testing.T, caps client.Capabilities, tweak ...func(*Deps)) Mod m.conv.addUser("hello") m.refreshView() m = applyAll(m, qmark()) - if !m.showHelp { + if m.modal == nil { t.Fatal("help overlay did not open on '?' with empty input") } return m @@ -484,7 +484,7 @@ func TestHelpKeyOverrideEndToEnd(t *testing.T) { m.conv.addUser("hello") m.refreshView() m = applyAll(m, qmark()) - if !m.showHelp { + if m.modal == nil { t.Fatal("help overlay did not open on '?' with empty input") } diff --git a/cmd/mecatui/ui/helpgate_test.go b/cmd/mecatui/ui/helpgate_test.go index 65ea9c6b7d..8d8ea5f18c 100644 --- a/cmd/mecatui/ui/helpgate_test.go +++ b/cmd/mecatui/ui/helpgate_test.go @@ -15,6 +15,14 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" ) +func helpSurface(m Model) *helpState { return m.modal.(*helpState) } + +func helpGeometry(m Model) (int, int) { + m.renderBody() + s := helpSurface(m) + return s.total, s.window +} + // TestHelpOpensOnlyOnEmptyInput pins the "?"-is-printable gotcha: "?" opens the // help overlay only when the prompt input is empty; with text in the input, "?" // types into the textarea instead. @@ -23,7 +31,7 @@ func TestHelpOpensOnlyOnEmptyInput(t *testing.T) { // Empty input: "?" opens help and blurs the textarea. m = applyAll(m, qmark()) - if !m.showHelp { + if m.modal == nil { t.Fatal("'?' on empty input should open help") } if m.prompt.Focused() { @@ -32,7 +40,7 @@ func TestHelpOpensOnlyOnEmptyInput(t *testing.T) { // "?" again closes it and refocuses. m = applyAll(m, qmark()) - if m.showHelp { + if m.modal != nil { t.Fatal("'?' should close the open help overlay") } if !m.prompt.Focused() { @@ -43,7 +51,7 @@ func TestHelpOpensOnlyOnEmptyInput(t *testing.T) { // the textarea. m = typeRune(t, m, 'h') m = applyAll(m, qmark()) - if m.showHelp { + if m.modal != nil { t.Fatal("'?' on a non-empty input must NOT open help") } if !strings.Contains(m.prompt.Value(), "?") { @@ -55,11 +63,11 @@ func TestHelpOpensOnlyOnEmptyInput(t *testing.T) { func TestHelpEscCloses(t *testing.T) { m := zeroStateModel(t, embeddedCaps()) m = applyAll(m, qmark()) - if !m.showHelp { + if m.modal == nil { t.Fatal("help should be open") } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEscape}) - if m.showHelp { + if m.modal != nil { t.Fatal("esc should close the help overlay") } } @@ -73,7 +81,7 @@ func TestHelpDoesNotOpenOverAnotherOverlay(t *testing.T) { t.Fatal("MCP overlay should be open") } m = applyAll(m, qmark()) - if m.showHelp { + if _, ok := m.modal.(*helpState); ok { t.Fatal("'?' must not open help while another overlay owns the keyboard") } } @@ -87,7 +95,7 @@ func TestHelpSwallowsOtherKeysWhileOpen(t *testing.T) { if mcpActive(m) != nil { t.Fatal("ctrl+o should be swallowed while help is up") } - if !m.showHelp { + if m.modal == nil { t.Fatal("help should still be open after a swallowed key") } } @@ -96,8 +104,8 @@ func TestHelpPreservesGlobalLifecycleKeys(t *testing.T) { t.Run("quit", func(t *testing.T) { m := helpModel(t, allOnCaps()) m, _ = pressKey(m, ctrlC()) - if !m.quitArmed || !m.showHelp { - t.Fatalf("ctrl+c should arm quit without closing help: armed=%t help=%t", m.quitArmed, m.showHelp) + if !m.quitArmed || m.modal == nil { + t.Fatalf("ctrl+c should arm quit without closing help: armed=%t help=%t", m.quitArmed, m.modal != nil) } _, cmd := pressKey(m, ctrlC()) if !isQuitCmd(cmd) { @@ -107,8 +115,8 @@ func TestHelpPreservesGlobalLifecycleKeys(t *testing.T) { t.Run("quitD", func(t *testing.T) { m := helpModel(t, allOnCaps()) m, _ = pressKey(m, ctrlD()) - if !m.quitDArmed || !m.showHelp { - t.Fatalf("ctrl+d should arm quit without closing help: armed=%t help=%t", m.quitDArmed, m.showHelp) + if !m.quitDArmed || m.modal == nil { + t.Fatalf("ctrl+d should arm quit without closing help: armed=%t help=%t", m.quitDArmed, m.modal != nil) } _, cmd := pressKey(m, ctrlD()) if !isQuitCmd(cmd) { @@ -180,7 +188,7 @@ func TestHelpFitsAvailableWidth(t *testing.T) { func TestHelpWrapsNarrowBodyAndNavigatesWrappedRows(t *testing.T) { m := helpModel(t, allOnCaps()) m = applyAll(m, tea.WindowSizeMsg{Width: 40, Height: 24}) - total, window := m.helpScrollGeometry() + total, window := helpGeometry(m) if total <= window { t.Fatalf("precondition: wrapped help should overflow (total=%d window=%d)", total, window) } @@ -191,14 +199,14 @@ func TestHelpWrapsNarrowBodyAndNavigatesWrappedRows(t *testing.T) { } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyDown}) - if m.helpViewport.Offset() != 1 { - t.Fatalf("down should advance one wrapped row, got offset %d", m.helpViewport.Offset()) + if helpSurface(m).viewport.Offset() != 1 { + t.Fatalf("down should advance one wrapped row, got offset %d", helpSurface(m).viewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) - if want := maxScrollOffset(total, window); m.helpViewport.Offset() != want { - t.Fatalf("end offset = %d, want %d", m.helpViewport.Offset(), want) + if want := maxScrollOffset(total, window); helpSurface(m).viewport.Offset() != want { + t.Fatalf("end offset = %d, want %d", helpSurface(m).viewport.Offset(), want) } - view := m.helpViewport.View(strings.Split(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings()), "\n")) + view := helpSurface(m).viewport.View(strings.Split(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings()), "\n")) if view.Below != 0 { t.Fatalf("end should reveal the final wrapped row, with %d rows still below", view.Below) } @@ -235,24 +243,24 @@ func TestHelpWheelOwnershipAndPhysicalRowBrowsing(t *testing.T) { updated, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 1, Y: 1}) m = updated.(Model) - if got := m.helpViewport.Offset(); got != 1 { + if got := helpSurface(m).viewport.Offset(); got != 1 { t.Fatalf("wheel down offset = %d, want one physical row", got) } assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp, X: 1, Y: 1}) m = updated.(Model) - if got := m.helpViewport.Offset(); got != 0 { + if got := helpSurface(m).viewport.Offset(); got != 0 { t.Fatalf("wheel at top offset = %d, want consumed endpoint 0", got) } assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) - total, _ := m.helpScrollGeometry() - m.helpViewport.Move(bounded.End, total) - atEnd := m.helpViewport.Offset() + total, _ := helpGeometry(m) + helpSurface(m).viewport.Move(bounded.End, total) + atEnd := helpSurface(m).viewport.Offset() updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 1, Y: 1}) m = updated.(Model) - if got := m.helpViewport.Offset(); got != atEnd { + if got := helpSurface(m).viewport.Offset(); got != atEnd { t.Fatalf("wheel at end offset = %d, want consumed endpoint %d", got, atEnd) } assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) @@ -265,14 +273,14 @@ func TestHelpWheelOwnershipAndPhysicalRowBrowsing(t *testing.T) { assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 1000}) - if total, window := m.helpScrollGeometry(); total > window { + if total, window := helpGeometry(m); total > window { t.Fatalf("precondition: tall Help viewport should not overflow (total=%d window=%d)", total, window) } beforeConversation = m.vp.YOffset() beforePrompt, beforeFocused, beforePromptSelection = m.prompt.Value(), m.prompt.Focused(), m.prompt.HasSelection() updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 1, Y: 1}) m = updated.(Model) - if got := m.helpViewport.Offset(); got != 0 { + if got := helpSurface(m).viewport.Offset(); got != 0 { t.Fatalf("wheel without overflow offset = %d, want consumed endpoint 0", got) } assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) @@ -281,39 +289,40 @@ func TestHelpWheelOwnershipAndPhysicalRowBrowsing(t *testing.T) { func TestHelpScrollNavigationAndReset(t *testing.T) { m := helpModel(t, allOnCaps()) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 24}) - total, window := m.helpScrollGeometry() + total, window := helpGeometry(m) if maxScrollOffset(total, window) == 0 { t.Fatalf("precondition: help should overflow at this height (total=%d window=%d)", total, window) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyDown}) - if m.helpViewport.Offset() != 1 { - t.Fatalf("down moved help scroll to %d, want 1", m.helpViewport.Offset()) + if helpSurface(m).viewport.Offset() != 1 { + t.Fatalf("down moved help scroll to %d, want 1", helpSurface(m).viewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyPgDown}) - if m.helpViewport.Offset() != clampScroll(1+window, total, window) { - t.Fatalf("pgdown moved help scroll to %d, want page movement", m.helpViewport.Offset()) + if helpSurface(m).viewport.Offset() != clampScroll(1+window, total, window) { + t.Fatalf("pgdown moved help scroll to %d, want page movement", helpSurface(m).viewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyPgUp}) - if m.helpViewport.Offset() != 1 { - t.Fatalf("pgup moved help scroll to %d, want 1", m.helpViewport.Offset()) + if helpSurface(m).viewport.Offset() != 1 { + t.Fatalf("pgup moved help scroll to %d, want 1", helpSurface(m).viewport.Offset()) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) - if want := maxScrollOffset(total, window); m.helpViewport.Offset() != want { - t.Fatalf("end moved help scroll to %d, want %d", m.helpViewport.Offset(), want) + if want := maxScrollOffset(total, window); helpSurface(m).viewport.Offset() != want { + t.Fatalf("end moved help scroll to %d, want %d", helpSurface(m).viewport.Offset(), want) } m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyHome}) - if m.helpViewport.Offset() != 0 { - t.Fatalf("home moved help scroll to %d, want 0", m.helpViewport.Offset()) + if helpSurface(m).viewport.Offset() != 0 { + t.Fatalf("home moved help scroll to %d, want 0", helpSurface(m).viewport.Offset()) } + previous := helpSurface(m) m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}, qmark()) - if m.showHelp || m.helpViewport.Offset() != 0 { - t.Fatalf("closing help should reset offset: open=%t offset=%d", m.showHelp, m.helpViewport.Offset()) + if m.modal != nil || previous == m.modal || previous.viewport.Offset() == 0 { + t.Fatal("closing help should release the scrolled surface") } m = applyAll(m, qmark()) - if !m.showHelp || m.helpViewport.Offset() != 0 { - t.Fatalf("opening help should reset offset: open=%t offset=%d", m.showHelp, m.helpViewport.Offset()) + if m.modal == nil || helpSurface(m) == previous || helpSurface(m).viewport.Offset() != 0 { + t.Fatal("opening help should start with a fresh viewport") } } @@ -346,9 +355,9 @@ func TestHelpRenderingIsHeightBoundedAndShowsScrollGuidance(t *testing.T) { m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyF7, Mod: tea.ModCtrl}) body := stripANSIstr(m.renderBody()) - total, window := m.helpScrollGeometry() - if m.helpViewport.Offset() != maxScrollOffset(total, window) { - t.Fatalf("end scroll offset = %d, want %d", m.helpViewport.Offset(), maxScrollOffset(total, window)) + total, window := helpGeometry(m) + if helpSurface(m).viewport.Offset() != maxScrollOffset(total, window) { + t.Fatalf("end scroll offset = %d, want %d", helpSurface(m).viewport.Offset(), maxScrollOffset(total, window)) } if start, end, rows := helpIndicatorRange(t, compactHelpText(body)); start <= 1 || end != rows { t.Fatalf("end-scrolled indicator should move past row 1 and finish on the last row, got %d–%d of %d:\n%s", start, end, rows, body) @@ -373,7 +382,7 @@ func TestHelpNavigationRespectsKeyOverrides(t *testing.T) { } }) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 24}) - total, window := m.helpScrollGeometry() + total, window := helpGeometry(m) maxScroll := maxScrollOffset(total, window) if maxScroll == 0 { t.Fatalf("precondition: help should overflow at this height (total=%d window=%d)", total, window) @@ -381,54 +390,102 @@ func TestHelpNavigationRespectsKeyOverrides(t *testing.T) { press := func(ch rune) { m = applyAll(m, tea.KeyPressMsg{Code: ch, Text: string(ch)}) } press('j') - if m.helpViewport.Offset() != 1 { - t.Fatalf("overridden Down moved help scroll to %d, want 1", m.helpViewport.Offset()) + if helpSurface(m).viewport.Offset() != 1 { + t.Fatalf("overridden Down moved help scroll to %d, want 1", helpSurface(m).viewport.Offset()) } press('d') - if want := clampScroll(1+window, total, window); m.helpViewport.Offset() != want { - t.Fatalf("overridden ScrollD moved help scroll to %d, want %d", m.helpViewport.Offset(), want) + if want := clampScroll(1+window, total, window); helpSurface(m).viewport.Offset() != want { + t.Fatalf("overridden ScrollD moved help scroll to %d, want %d", helpSurface(m).viewport.Offset(), want) } press('u') - if m.helpViewport.Offset() != 1 { - t.Fatalf("overridden ScrollU moved help scroll to %d, want 1", m.helpViewport.Offset()) + if helpSurface(m).viewport.Offset() != 1 { + t.Fatalf("overridden ScrollU moved help scroll to %d, want 1", helpSurface(m).viewport.Offset()) } press('t') - if m.helpViewport.Offset() != 0 { - t.Fatalf("overridden ScrollTop moved help scroll to %d, want 0", m.helpViewport.Offset()) + if helpSurface(m).viewport.Offset() != 0 { + t.Fatalf("overridden ScrollTop moved help scroll to %d, want 0", helpSurface(m).viewport.Offset()) } press('b') - if m.helpViewport.Offset() != maxScroll { - t.Fatalf("overridden ScrollBottom moved help scroll to %d, want %d", m.helpViewport.Offset(), maxScroll) + if helpSurface(m).viewport.Offset() != maxScroll { + t.Fatalf("overridden ScrollBottom moved help scroll to %d, want %d", helpSurface(m).viewport.Offset(), maxScroll) } press('k') - if m.helpViewport.Offset() != maxScroll-1 { - t.Fatalf("overridden Up moved help scroll to %d, want %d", m.helpViewport.Offset(), maxScroll-1) + if helpSurface(m).viewport.Offset() != maxScroll-1 { + t.Fatalf("overridden Up moved help scroll to %d, want %d", helpSurface(m).viewport.Offset(), maxScroll-1) } press('c') - if m.showHelp || m.helpViewport.Offset() != 0 { - t.Fatalf("overridden Close should close and reset help: open=%t offset=%d", m.showHelp, m.helpViewport.Offset()) + if m.modal != nil { + t.Fatal("overridden Close should release help") } } func TestHelpScrollClampsAfterResizeWithoutFollowingEnd(t *testing.T) { m := helpModel(t, allOnCaps()) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 24}, tea.KeyPressMsg{Code: tea.KeyEnd}) - before := m.helpViewport.Offset() + before := helpSurface(m).viewport.Offset() m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 32}) - total, window := m.helpScrollGeometry() + total, window := helpGeometry(m) want := clampScroll(before, total, window) - if m.helpViewport.Offset() != want { - t.Fatalf("resize left stale offset %d, want clamped %d", m.helpViewport.Offset(), want) + if helpSurface(m).viewport.Offset() != want { + t.Fatalf("resize left stale offset %d, want clamped %d", helpSurface(m).viewport.Offset(), want) } m = helpModel(t, allOnCaps()) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 32}, tea.KeyPressMsg{Code: tea.KeyEnd}) - before = m.helpViewport.Offset() + before = helpSurface(m).viewport.Offset() m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 24}) - total, window = m.helpScrollGeometry() - if m.helpViewport.Offset() != before || m.helpViewport.Offset() == maxScrollOffset(total, window) { - t.Fatalf("smaller viewport should retain the prior offset, not follow End: got=%d before=%d end=%d", m.helpViewport.Offset(), before, maxScrollOffset(total, window)) + total, window = helpGeometry(m) + if helpSurface(m).viewport.Offset() != before || helpSurface(m).viewport.Offset() == maxScrollOffset(total, window) { + t.Fatalf("smaller viewport should retain the prior offset, not follow End: got=%d before=%d end=%d", helpSurface(m).viewport.Offset(), before, maxScrollOffset(total, window)) + } +} + +func TestHelpRetainsScrollAcrossWideNarrowResize(t *testing.T) { + m := applyAll(helpModel(t, allOnCaps()), tea.WindowSizeMsg{Width: 160, Height: 24}) + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) + before := helpSurface(m).viewport.Offset() + if before == 0 { + t.Fatal("precondition: wide Help must overflow") + } + for _, width := range []int{32, 160, 32} { + m = applyAll(m, tea.WindowSizeMsg{Width: width, Height: 24}) + total, window := helpGeometry(m) + want := clampScroll(before, total, window) + if got := helpSurface(m).viewport.Offset(); got != want { + t.Fatalf("width %d: offset %d, want retained/clamped %d", width, got, want) + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if got := helpSurface(m).viewport.Offset(); got != clampScroll(want+1, total, window) { + t.Fatalf("width %d: down moved to %d from %d", width, got, want) + } + before = helpSurface(m).viewport.Offset() + } +} + +func TestHelpNarrowWrappedIndicatorUsesCapturedOverrides(t *testing.T) { + m := helpModel(t, allOnCaps(), func(deps *Deps) { + deps.KeyOverrides = map[string][]string{ + "Close": {"ctrl+f1"}, "Up": {"ctrl+f2"}, "Down": {"ctrl+f3"}, + "ScrollU": {"ctrl+f4"}, "ScrollD": {"ctrl+f5"}, + "ScrollTop": {"ctrl+f6"}, "ScrollBottom": {"ctrl+f7"}, + } + }) + m = applyAll(m, tea.WindowSizeMsg{Width: 32, Height: 24}) + body := m.renderBody() + if total, window := helpGeometry(m); total <= window { + t.Fatal("precondition: narrow help must overflow") + } + compact := compactHelpText(body) + start, end, total := helpIndicatorRange(t, compact) + rows := helpIndicatorRows(helpSurface(m).deps.marks, start-1, end, total, helpBodyWidth(m.deps.Theme, m.width)) + if len(rows) < 2 || !strings.Contains(compact, compactHelpText(strings.Join(rows, "\n"))) { + t.Fatalf("expected the complete wrapped indicator in narrow Help:\n%s", body) + } + for _, marking := range []string{"ctrl+f1or?close", "ctrl+f2/ctrl+f3scroll", "ctrl+f4/ctrl+f5page", "ctrl+f6", "ctrl+f7jump"} { + if !strings.Contains(compact, marking) { + t.Fatalf("wrapped indicator missing %q:\n%s", marking, body) + } } } @@ -485,60 +542,47 @@ func helpIndicatorRange(t *testing.T, compact string) (start, end, total int) { // fit the offered height at the top, middle, and end offsets. func TestHelpStaysWithinOfferedHeightAcrossNarrowShortGeometry(t *testing.T) { m := helpModel(t, allOnCaps()) - th, hk := m.deps.Theme, m.helpKeyMarkings() - lines := strings.Split(helpBody(th, m.caps, hk), "\n") for _, width := range []int{8, 12, 20, 26, 33, 40, 45, 69} { for _, height := range []int{1, 3, 5, 6, 8, 10, 12, 14, 24} { - viewport := new(bounded.Viewport) + m = applyAll(m, tea.WindowSizeMsg{Width: width, Height: height}) for _, position := range []string{"top", "middle", "end"} { - total := helpViewportView(th, lines, width, height, viewport, hk).total + m.renderBody() + s := helpSurface(m) switch position { case "top": - viewport.Reset() + s.viewport.Reset() case "middle": - viewport.SetOffset(total/2, total) + s.viewport.SetOffset(s.total/2, s.total) case "end": - viewport.Move(bounded.End, total) + s.viewport.Move(bounded.End, s.total) } - out := renderHelpOverlay(th, m.caps, width, height, viewport, hk) - if got := lipgloss.Height(out); got > height { - t.Fatalf("width=%d height=%d at %s: help is %d rows, exceeds offered height", width, height, position, got) + out := m.renderBody() + if got := lipgloss.Height(out); got > m.vp.Height() { + t.Fatalf("width=%d height=%d at %s: help is %d rows, exceeds offered height %d", width, height, position, got, m.vp.Height()) } } } } } -func TestHelpCompactFallbackAndBareCard(t *testing.T) { +func TestHelpCompactFallback(t *testing.T) { m := helpModel(t, allOnCaps()) - th, hk := m.deps.Theme, m.helpKeyMarkings() - chrome := lipgloss.Height(th.Style("askCard").Render("")) - - for _, height := range []int{1, chrome} { - out := renderHelpOverlay(th, m.caps, 100, height, new(bounded.Viewport), hk) - if lipgloss.Height(out) != 1 || strings.Contains(stripANSIstr(out), "┏") { - t.Fatalf("height %d should render one unframed row, got:\n%s", height, out) + for _, height := range []int{3, 5} { + m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: height}) + body := m.renderBody() + if strings.Contains(stripANSIstr(body), "┏") { + t.Fatalf("height %d should use unframed Help, got:\n%s", height, body) } } - - // A body window starved by the wrapped indicator takes the same fallback - // instead of overflowing the card. - starved := renderHelpOverlay(th, m.caps, 20, chrome+3, new(bounded.Viewport), hk) - if lipgloss.Height(starved) != 1 { - t.Fatalf("a starved body window should fall back to one row, got %d rows:\n%s", lipgloss.Height(starved), starved) - } - - for _, size := range [][2]int{{100, 0}, {0, 0}, {0, 24}} { - out := stripANSIstr(renderHelpOverlay(th, m.caps, size[0], size[1], new(bounded.Viewport), hk)) - if size[1] <= 0 && !strings.Contains(out, "┏") { - t.Fatalf("nonpositive geometry %v should keep the bare card, got:\n%s", size, out) - } + m = applyAll(m, tea.WindowSizeMsg{Width: 20, Height: 9}) + if !helpSurface(m).compact { + t.Fatal("indicator-starved Help should use compact fallback") } } func TestHelpShowsNoIndicatorWhenContentFits(t *testing.T) { m := applyAll(helpModel(t, allOnCaps()), tea.WindowSizeMsg{Width: 100, Height: 1000}) - if total, window := m.helpScrollGeometry(); total > window { + if total, window := helpGeometry(m); total > window { t.Fatalf("precondition: tall Help should not overflow (total=%d window=%d)", total, window) } if body := compactHelpText(m.renderBody()); helpIndicatorPattern.MatchString(body) { @@ -557,7 +601,7 @@ func TestHelpSwallowsNonWheelMouseWhileOpen(t *testing.T) { if !ok { t.Fatal("precondition: prompt region should be known at this size") } - beforeOffset, beforeYOffset := m.helpViewport.Offset(), m.vp.YOffset() + beforeOffset, beforeYOffset := helpSurface(m).viewport.Offset(), m.vp.YOffset() beforeValue, beforeFocused, beforeSelection := m.prompt.Value(), m.prompt.Focused(), m.prompt.HasSelection() for name, msg := range map[string]tea.Msg{ @@ -568,13 +612,16 @@ func TestHelpSwallowsNonWheelMouseWhileOpen(t *testing.T) { "motion": tea.MouseMotionMsg{Button: tea.MouseLeft, X: 40, Y: 8}, "release": tea.MouseReleaseMsg{Button: tea.MouseLeft, X: 40, Y: 8}, } { - updated, _ := m.Update(msg) + updated, cmd := m.Update(msg) + if cmd != nil { + t.Fatalf("%s returned a hidden interaction command", name) + } m = updated.(Model) switch { - case !m.showHelp: + case m.modal == nil: t.Fatalf("%s closed help", name) - case m.helpViewport.Offset() != beforeOffset: - t.Fatalf("%s moved help offset to %d, want %d", name, m.helpViewport.Offset(), beforeOffset) + case helpSurface(m).viewport.Offset() != beforeOffset: + t.Fatalf("%s moved help offset to %d, want %d", name, helpSurface(m).viewport.Offset(), beforeOffset) case m.vp.YOffset() != beforeYOffset || m.sel.active: t.Fatalf("%s reached the hidden conversation", name) case m.prompt.Value() != beforeValue || m.prompt.Focused() != beforeFocused || m.prompt.HasSelection() != beforeSelection: diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 74dc6a16f9..fbe893af97 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -19,7 +19,6 @@ import ( customization "github.com/stacklok/mecatl/cmd/mecatui/customization" "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" "github.com/stacklok/mecatl/cmd/mecatui/theme" - "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" "github.com/stacklok/mecatl/cmd/mecatui/ui/platform" "github.com/stacklok/mecatl/cmd/mecatui/ui/prompttextarea" @@ -713,9 +712,7 @@ type Model struct { // lets the picker label the current model "picked this session" (vs a launch-time // workspace/global default). Zero until a restart-now pick. Display-only. pickedThisSession client.ModelSelection - showHelp bool // the "?" keys-&-features overlay is open (caps-driven; see help.go) - helpViewport *bounded.Viewport // root-owned wrapped help-body viewport; its offset is the only scroll state - stream *client.Stream // current run's stream + stream *client.Stream // current run's stream cancelRun context.CancelFunc // quitArmed is true after a first ctrl+c on an empty prompt: a second ctrl+c @@ -1088,7 +1085,6 @@ func New(deps Deps) Model { keys: keys, rend: rend, hits: &hitRegions{}, - helpViewport: new(bounded.Viewport), metrics: &renderedSurfaceMetrics{}, phase: phaseConnecting, prompt: prompt, diff --git a/cmd/mecatui/ui/palette_bounded_list_scenario_test.go b/cmd/mecatui/ui/palette_bounded_list_scenario_test.go index c7956167b9..4fdf2fbb27 100644 --- a/cmd/mecatui/ui/palette_bounded_list_scenario_test.go +++ b/cmd/mecatui/ui/palette_bounded_list_scenario_test.go @@ -355,7 +355,7 @@ func TestMecatuiSlashPaletteBoundedList_Scenario1_PreservesInteractionOwnership( builtin.palette.list.SetCursor(1) // /help dispatched, _ := builtin.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) builtin = dispatched.(Model) - if !builtin.showHelp { + if builtin.modal == nil { t.Fatal("eligible built-in was not dispatched from the palette") } diff --git a/cmd/mecatui/ui/paste_large_test.go b/cmd/mecatui/ui/paste_large_test.go index 609ef1bce5..22dc1c777c 100644 --- a/cmd/mecatui/ui/paste_large_test.go +++ b/cmd/mecatui/ui/paste_large_test.go @@ -270,7 +270,7 @@ func TestPlaceholderIgnoredBehindOverlay(t *testing.T) { m := zeroStateModel(t, embeddedCaps()) mm, _ := m.Update(qmark()) m = mm.(Model) - if !m.showHelp { + if m.modal == nil { t.Fatalf("help overlay should be open after '?'") } diff --git a/cmd/mecatui/ui/paste_test.go b/cmd/mecatui/ui/paste_test.go index af92131381..abc7d9393d 100644 --- a/cmd/mecatui/ui/paste_test.go +++ b/cmd/mecatui/ui/paste_test.go @@ -164,7 +164,7 @@ func TestPasteIgnoredWhileHelpOpen(t *testing.T) { // "?" on an empty prompt opens the help overlay. mm, _ := m.Update(qmark()) m = mm.(Model) - if !m.showHelp { + if m.modal == nil { t.Fatalf("help overlay should be open after '?'") } @@ -174,7 +174,7 @@ func TestPasteIgnoredWhileHelpOpen(t *testing.T) { if got := m.prompt.Value(); got != "" { t.Fatalf("paste leaked into input behind help overlay: %q", got) } - if !m.showHelp { + if m.modal == nil { t.Fatalf("help overlay should still be open after an ignored paste") } } diff --git a/cmd/mecatui/ui/primary_paste_test.go b/cmd/mecatui/ui/primary_paste_test.go index 222f142c1e..c6bce369ec 100644 --- a/cmd/mecatui/ui/primary_paste_test.go +++ b/cmd/mecatui/ui/primary_paste_test.go @@ -146,13 +146,14 @@ func TestMiddleClickGatedUnderOverlay(t *testing.T) { cb := &fakeClipboard{primary: "should not appear"} m, _ := newClipboardModel(t, client.Capabilities{}, cb) - m.showHelp = true + mm, _ := m.runHelp() + m = mm.(Model) mm, cmd := pressMiddle(m) if cmd != nil { t.Error("middle-click under the help overlay returned a command, want nil") } - m = mm - m.showHelp = false + m = mm.(Model) + m.closeModal() m.phase = phaseAwaitingApproval _, cmd = pressMiddle(m) if cmd != nil { @@ -202,7 +203,7 @@ func TestMiddleClickDoesNotAdvanceClickCount(t *testing.T) { // the permission-approval modal (the same pair TestMiddleClickGatedUnderOverlay // pins at PRESS time). var gateClosers = map[string]func(m *Model){ - "help overlay": func(m *Model) { m.showHelp = true }, + "help overlay": func(m *Model) { mm, _ := m.runHelp(); *m = mm.(Model) }, "approval modal": func(m *Model) { m.phase = phaseAwaitingApproval }, } diff --git a/cmd/mecatui/ui/prompt_input_test.go b/cmd/mecatui/ui/prompt_input_test.go index 1fe77c90fd..cf1f85f681 100644 --- a/cmd/mecatui/ui/prompt_input_test.go +++ b/cmd/mecatui/ui/prompt_input_test.go @@ -184,7 +184,7 @@ func TestSelectableMatchesAllBodyOwners(t *testing.T) { {"awaiting approval", func(m *Model) { m.phase = phaseAwaitingApproval }, false}, {"replay", func(m *Model) { m.phase = phaseReplay }, false}, {"session details", func(m *Model) { m.sessionDetailsOpen = true }, false}, - {"help", func(m *Model) { m.showHelp = true }, false}, + {"help", func(m *Model) { mm, _ := m.runHelp(); *m = mm.(Model) }, false}, {"team", func(m *Model) { m.team.view = teamRoster }, false}, {"agents inventory", func(m *Model) { m.agentsInv.view = agentsInvPanel }, false}, {"modal surface", func(m *Model) { m.modal = &bodyOwnerTestSurface{} }, false}, diff --git a/cmd/mecatui/ui/quieter_toolcalls_scenario2_test.go b/cmd/mecatui/ui/quieter_toolcalls_scenario2_test.go index 1bb029ff0d..af06288fbf 100644 --- a/cmd/mecatui/ui/quieter_toolcalls_scenario2_test.go +++ b/cmd/mecatui/ui/quieter_toolcalls_scenario2_test.go @@ -261,12 +261,12 @@ func TestMecatuiQuieterToolCalls_Scenario2_ContextualShortcutAndOverrides(t *tes t.Run("help overlay swallows Toolcalls", func(t *testing.T) { m := scenario2Model(t, 100, 30, false, phaseIdle, nil) m, _ = pressKey(m, qmark()) - if !m.showHelp { + if m.modal == nil { t.Fatal("precondition: help did not open") } m, _ = pressKey(m, ctrlT) - if !m.showHelp || m.modal != nil || m.prompt.Value() != "" { - t.Fatalf("help must own Toolcalls: showHelp=%v modal=%T prompt=%q", m.showHelp, m.modal, m.prompt.Value()) + if m.modal == nil || m.prompt.Value() != "" { + t.Fatalf("help must own Toolcalls: modal=%T prompt=%q", m.modal, m.prompt.Value()) } }) diff --git a/cmd/mecatui/ui/selection.go b/cmd/mecatui/ui/selection.go index 922828ceed..f67361fa7b 100644 --- a/cmd/mecatui/ui/selection.go +++ b/cmd/mecatui/ui/selection.go @@ -107,7 +107,7 @@ func convTopRow(m Model) int { // bodyOwnerOpen is the single inventory of overlays and modals that replace the // conversation body and own Escape before root prompt handling. func bodyOwnerOpen(m Model) bool { - return m.sessionDetailsOpen || m.showHelp || m.team.view != teamNone || + return m.sessionDetailsOpen || m.team.view != teamNone || m.agentsInv.view != agentsInvNone || m.modal != nil || m.reflections.view != reflectionsNone || m.connect.open || m.effort.view != effortNone || diff --git a/cmd/mecatui/ui/selection_test.go b/cmd/mecatui/ui/selection_test.go index 9db2fc03ae..4099a6c85c 100644 --- a/cmd/mecatui/ui/selection_test.go +++ b/cmd/mecatui/ui/selection_test.go @@ -1075,7 +1075,8 @@ func TestEscClearsSelectionThenRestoresSemantics(t *testing.T) { // starts NO selection (Req 8). func TestSelectionBlockedUnderOverlay(t *testing.T) { m, _ := selModel(t) - m.showHelp = true // help owns the body + mm, _ := m.runHelp() + m = mm.(Model) // help owns the body top := convTopRow(m) m, _ = pressMouse(m, tea.MouseLeft, 0, top) @@ -1395,7 +1396,7 @@ func TestOpeningOverlayClearsSelection(t *testing.T) { // "?" on an empty prompt opens help. m, _ = pressKey(m, tea.KeyPressMsg{Code: '?'}) - if !m.showHelp { + if m.modal == nil { t.Fatal("precondition: ? should open help") } if m.sel.active { @@ -2087,7 +2088,7 @@ func TestSelectableGateBlocksAndClears(t *testing.T) { {"modelsOverlay", func(m *Model) { m.modal = &modelsState{view: modelsPanel} }}, {"worktreesOverlay", func(m *Model) { m.worktrees.view = worktreesPanel }}, {"soulOverlay", func(m *Model) { m.modal = &soulState{view: soulPanel} }}, - {"help", func(m *Model) { m.showHelp = true }}, + {"help", func(m *Model) { mm, _ := m.runHelp(); *m = mm.(Model) }}, {"awaitingApproval", func(m *Model) { m.phase = phaseAwaitingApproval }}, {"fatal", func(m *Model) { m.phase = phaseFatal }}, } @@ -2516,7 +2517,7 @@ func TestTripleClickEmptyLineNoCopy(t *testing.T) { // start NO selection AND leave clickCount==0 (Req 9, count is AFTER the gate). func TestMultiClickInertUnderOverlay(t *testing.T) { cases := []nonSelectableCase{ - {"help", func(m *Model) { m.showHelp = true }}, + {"help", func(m *Model) { mm, _ := m.runHelp(); *m = mm.(Model) }}, {"noMouse", func(m *Model) { m.deps.NoMouse = true }}, {"noAltScreen", func(m *Model) { m.deps.NoAltScreen = true }}, } diff --git a/cmd/mecatui/ui/skills.go b/cmd/mecatui/ui/skills.go index fc5792a32f..161fd2a323 100644 --- a/cmd/mecatui/ui/skills.go +++ b/cmd/mecatui/ui/skills.go @@ -119,8 +119,8 @@ type skillsResultMsg struct { result client.SkillsMsg } -func (*skillsState) modalMaxOuterWidth() int { return skillsMaxOuterWidth } -func (s *skillsState) modalFrame() bool { return !s.compact } +func (*skillsState) modalMaxOuterWidth(int) int { return skillsMaxOuterWidth } +func (s *skillsState) modalFrame() bool { return !s.compact } // Render measures and renders unframed content from one parent-provided offer. func (s *skillsState) Render(width, height int) (string, []ClickableRegion) { diff --git a/cmd/mecatui/ui/soul.go b/cmd/mecatui/ui/soul.go index e7250641db..ec25e8fff1 100644 --- a/cmd/mecatui/ui/soul.go +++ b/cmd/mecatui/ui/soul.go @@ -56,8 +56,8 @@ type soulState struct { compact bool } -func (*soulState) modalMaxOuterWidth() int { return 128 } -func (s *soulState) modalFrame() bool { return !s.compact } +func (*soulState) modalMaxOuterWidth(int) int { return 128 } +func (s *soulState) modalFrame() bool { return !s.compact } // Render receives the parent's measured askCard content offer. func (s *soulState) Render(width, height int) (string, []ClickableRegion) { diff --git a/cmd/mecatui/ui/steer_test.go b/cmd/mecatui/ui/steer_test.go index 2898f584d0..ff9b536659 100644 --- a/cmd/mecatui/ui/steer_test.go +++ b/cmd/mecatui/ui/steer_test.go @@ -763,7 +763,7 @@ func TestSteer_RunningSlashCommandsInterceptLocalBuiltins(t *testing.T) { mm, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) runBatchLeaves(cmd) // execute the actual returned local-builtin command m = mm.(Model) - if !m.showHelp { + if m.modal == nil { t.Fatal("bare /help must open local help while the run remains active") } if m.phase != phaseRunning { diff --git a/cmd/mecatui/ui/surface.go b/cmd/mecatui/ui/surface.go index ab4c0e1d7a..52b6993635 100644 --- a/cmd/mecatui/ui/surface.go +++ b/cmd/mecatui/ui/surface.go @@ -53,9 +53,9 @@ type surface interface { // surface down. HandleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, handled bool, closed bool) - // HandleMsg consumes or passes a NON-input message: an RPC result - // (client.SoulMsg), a timer tick, a status notice. The Model routes every - // non-key/wheel Msg through the open modal BEFORE its own generic reducer, + // HandleMsg consumes or passes messages other than keys and wheel events: + // RPC results, timers, notices, and non-wheel pointer events. The Model routes + // non-key/wheel messages through the open modal BEFORE its own generic reducer, // so the surface owns its RPC-backed state and can be created dynamically // at Open with no pre-declared Model field. The surface consumes (handled), // kills (closed), or passes (handled=false); on closed the Model tears the @@ -91,7 +91,7 @@ type modalPlacementSource interface { // modalMaxOuterWidthSource caps a parent-framed card without moving framing or // centering responsibility into the surface. type modalMaxOuterWidthSource interface { - modalMaxOuterWidth() int + modalMaxOuterWidth(available int) int } // modalFrameSource lets a surface select an unframed compact rendering after it @@ -212,8 +212,8 @@ func (m *Model) renderModalSurface() string { style := m.deps.Theme.Style("askCard") outerW := bodyW - if source, ok := m.modal.(modalMaxOuterWidthSource); ok && source.modalMaxOuterWidth() > 0 { - outerW = min(outerW, source.modalMaxOuterWidth()) + if source, ok := m.modal.(modalMaxOuterWidthSource); ok && source.modalMaxOuterWidth(bodyW) > 0 { + outerW = min(outerW, source.modalMaxOuterWidth(bodyW)) } contentW := max(0, outerW-style.GetHorizontalFrameSize()) contentH := max(0, bodyH-style.GetVerticalFrameSize()) diff --git a/cmd/mecatui/ui/ui_test.go b/cmd/mecatui/ui/ui_test.go index 6f6bb16031..d2e25c3a21 100644 --- a/cmd/mecatui/ui/ui_test.go +++ b/cmd/mecatui/ui/ui_test.go @@ -685,7 +685,7 @@ func TestHelpBuiltinProgram(t *testing.T) { // Final-model assertion (deterministic): the overlay opened then closed, so the // final frame no longer shows the help body. fm := tm.FinalModel(t).(Model) - if fm.showHelp { + if fm.modal != nil { t.Error("/help overlay should be closed after esc") } // The overlay's distinctive body row (not shared with the zero-state card) is diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 7da9f67b40..8e2dc02cc0 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2043,11 +2043,6 @@ func (m Model) onResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { // lipgloss.Height of the rendered regions in chrome(). m.relayout() m.configureAgentsInvViewport() - if m.showHelp { - // Re-apply geometry so a retained offset is clamped to the new layout - // without pinning an earlier End position to the new bottom. - m.helpScrollGeometry() - } m.clampAgentsDetailScroll() if widthChanged && m.vp.Height() == viewportHeight { m.refreshView() @@ -2256,12 +2251,6 @@ func (m Model) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return mm, cmd } - // Help owns the remaining keys while open: its documented navigation and close - // controls act on the overlay and every ordinary key is swallowed. - if m.showHelp { - return m.onHelpKey(msg) - } - // Disarm whichever quit guards are armed: any non-ctrl+c key disarms the Quit // guard and any non-ctrl+d key disarms the QuitD guard (each guard's window spans // only its own consecutive presses). The two guards are INDEPENDENT — neither key @@ -2366,65 +2355,10 @@ func (m Model) onPendingApprovalRecoveryKey(msg tea.KeyPressMsg) (tea.Model, tea return m, tea.Quit, true } -// onHelpKey handles the help overlay's complete keyboard contract. It runs before -// phase routing, so navigation never reaches the conversation and every other key -// remains swallowed. -func (m Model) onHelpKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - switch { - case key.Matches(msg, m.keys.Help), key.Matches(msg, m.keys.Close): - m.showHelp = false - m.helpViewport.Reset() - _ = m.prompt.Focus() - case key.Matches(msg, m.keys.ScrollD): - m.moveHelp(bounded.PageDown) - case key.Matches(msg, m.keys.ScrollU): - m.moveHelp(bounded.PageUp) - case key.Matches(msg, m.keys.Down): - m.moveHelp(bounded.LineDown) - case key.Matches(msg, m.keys.Up): - m.moveHelp(bounded.LineUp) - case key.Matches(msg, m.keys.ScrollBottom): - m.moveHelp(bounded.End) - case key.Matches(msg, m.keys.ScrollTop): - m.moveHelp(bounded.Top) - } - return m, nil -} - -// onHelpWheel keeps physical scrolling owned by the visible Help overlay. The -// event is consumed at endpoints and in compact or non-overflow geometry because -// moveHelp always runs against the current layout. -func (m Model) onHelpWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { - switch msg.Button { - case tea.MouseWheelUp: - m.moveHelp(bounded.LineUp) - case tea.MouseWheelDown: - m.moveHelp(bounded.LineDown) - } - return m, nil -} - -// moveHelp applies one movement to Help's viewport after re-applying the current -// geometry, so the move clamps against the same rows renderHelpOverlay draws. -func (m *Model) moveHelp(move bounded.Move) { - total, _ := m.helpScrollGeometry() - m.helpViewport.Move(move, total) -} - -// helpScrollGeometry configures the pointer-owned Help viewport for the current -// layout and returns the wrapped row total and body window. It mutates the -// viewport (geometry and offset clamp) on purpose: render, keys, wheel and resize -// all go through helpViewportView, so they cannot disagree about the rows. -func (m *Model) helpScrollGeometry() (total, window int) { - lines := strings.Split(helpBody(m.deps.Theme, m.caps, m.helpKeyMarkings()), "\n") - frame := helpViewportView(m.deps.Theme, lines, m.width, m.vp.Height(), m.helpViewport, m.helpKeyMarkings()) - return frame.total, frame.window -} - // selection owner is active. func (m Model) clearAnySelection(msg tea.KeyPressMsg) (Model, bool) { if !key.Matches(msg, m.keys.Cancel) || (!m.sel.active && !m.prompt.HasSelection()) || - m.showHelp || m.modal != nil || m.team.view != teamNone || m.agentsInv.view != agentsInvNone || + m.modal != nil || m.team.view != teamNone || m.agentsInv.view != agentsInvNone || m.reflections.view != reflectionsNone || m.effort.view != effortNone || m.worktrees.view != worktreesNone { return m, false @@ -2988,7 +2922,7 @@ func normalizePastedNewlines(content string) string { // primary-selection paste trigger (onMousePress), so the two paths can never // drift apart. func (m Model) pasteGateOpen() bool { - if m.showHelp || m.phase == phaseAwaitingApproval || + if m.phase == phaseAwaitingApproval || m.modal != nil || m.team.view != teamNone || m.agentsInv.view != agentsInvNone { return false } @@ -3344,7 +3278,7 @@ func (m Model) onIdleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.editBackQueue() case key.Matches(msg, m.keys.Help) && strings.TrimSpace(m.prompt.Value()) == "": // "?" is printable: open help only on an empty prompt so "?" in prose still - // inserts literally. The overlay claims the keyboard via the m.showHelp gate + // inserts literally. The overlay claims the keyboard through modal routing // in onKey; blur the input while it is up. return m.runHelp() case key.Matches(msg, m.keys.MCPPanel): @@ -4288,11 +4222,12 @@ func (m Model) onMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { // re-discriminates the concrete mouse type here, where the dispatch logically // belongs. func (m Model) onMouseMsg(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.showHelp { - if wheel, ok := msg.(tea.MouseWheelMsg); ok { - return m.onHelpWheel(wheel) + if m.modal != nil { + if _, wheel := msg.(tea.MouseWheelMsg); !wheel { + if mm, cmd, handled := m.dispatchSurfaceMsg(msg); handled { + return mm, cmd + } } - return m, nil } if m.agentsInv.view != agentsInvNone { if wheel, ok := msg.(tea.MouseWheelMsg); ok { diff --git a/cmd/mecatui/ui/usermodel.go b/cmd/mecatui/ui/usermodel.go index 8b6530e287..568d90983a 100644 --- a/cmd/mecatui/ui/usermodel.go +++ b/cmd/mecatui/ui/usermodel.go @@ -51,9 +51,9 @@ func (m Model) openUserModel() (tea.Model, tea.Cmd) { return m, client.GetUserModelCmdTagged(m.deps.Ctx, m.deps.UserModel, s.generation) } -func (*userModelState) modalMaxOuterWidth() int { return 128 } -func (s *userModelState) modalFrame() bool { return !s.compact } -func (*userModelState) Close() {} +func (*userModelState) modalMaxOuterWidth(int) int { return 128 } +func (s *userModelState) modalFrame() bool { return !s.compact } +func (*userModelState) Close() {} func (s *userModelState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if key.Matches(msg, s.deps.keys.Close) { diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index cf106144db..322e5b21d2 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -100,8 +100,6 @@ func (m Model) renderBody() string { switch { case m.sessionDetailsOpen: return renderSessionDetails(m.deps.Theme, m.sessionDetails(), m.helpKeyMarkings(), m.width, m.vp.Height()) - case m.showHelp: - return renderHelpOverlay(m.deps.Theme, m.caps, m.width, m.vp.Height(), m.helpViewport, m.helpKeyMarkings()) case m.team.view != teamNone: return renderAgentsOverlay(m.deps.Theme, m.agentsTab, m.subagents, m.parallel, m.team, m.teamBlockForOverlay(), m.conv.subagentFleet, m.conv.parallelGroups, m.helpKeyMarkings(), m.width, m.vp.Height(), m.height) case m.agentsInv.view != agentsInvNone: From b300b936ead8f3f9328ad7cd360a474d59231198 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 9 Oct 2026 15:44:55 -0700 Subject: [PATCH 06/11] feat(mecatui): open Help during runs Co-authored-by: Mecatl --- cmd/mecatui/ui/help.go | 47 ++++++++++++++++---------------- cmd/mecatui/ui/helpgate_test.go | 10 +++++++ cmd/mecatui/ui/update.go | 2 ++ user-docs/mecatui/keybindings.md | 6 ++-- 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index dd2db4ed61..1830e52984 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -182,12 +182,13 @@ func helpScrollIndicator(hk helpKeys, start, end, total int) string { // hk carries the LIVE key markings from the model's keyMap, so a rebinding // propagates here. func helpBody(th theme.Theme, caps client.Capabilities, hk helpKeys) string { - muted := th.Style("muted") + heading := th.Style("toolName") + body := th.Style("toolArgs") var b strings.Builder b.WriteString(th.Style("askTitle").Render("Help") + "\n\n") - b.WriteString(muted.Render("Prompting") + "\n") + b.WriteString(heading.Render("Prompting") + "\n") writeHelpRows(&b, th, []helpRow{ {key: hk.submit, action: "send prompt"}, {key: hk.newlineFirst, action: "insert newline" + hk.newlineAlso}, @@ -204,7 +205,7 @@ func helpBody(th theme.Theme, caps client.Capabilities, hk helpKeys) string { {key: hk.cancel, action: "cancel the current run"}, }) - b.WriteString("\n" + muted.Render("While a run is active") + "\n") + b.WriteString("\n" + heading.Render("While a run is active") + "\n") streamingSubmit := "queue a follow-up to send after this run" if caps.Steer { streamingSubmit = "guide the running agent at its next step; built-in commands still run here" @@ -215,7 +216,7 @@ func helpBody(th theme.Theme, caps client.Capabilities, hk helpKeys) string { {key: hk.cancel, action: "cancel the current run"}, }) - b.WriteString("\n" + muted.Render("Permission request") + "\n") + b.WriteString("\n" + heading.Render("Permission request") + "\n") writeHelpRows(&b, th, []helpRow{ {key: hk.allow, action: "allow once"}, {key: hk.allowAlways, action: "always allow for this session (main agent only)"}, @@ -225,7 +226,7 @@ func helpBody(th theme.Theme, caps client.Capabilities, hk helpKeys) string { {key: hk.rawArgs, action: "show raw arguments in the full view"}, }) - b.WriteString("\n" + muted.Render("Inspect and manage") + "\n") + b.WriteString("\n" + heading.Render("Inspect and manage") + "\n") inspectRows := []helpRow{ {key: hk.mcpPanel, action: "open MCP servers and tools", available: caps.MCP, gated: true}, {key: hk.resources, action: "browse MCP resources", available: caps.MCP, gated: true}, @@ -246,7 +247,7 @@ func helpBody(th theme.Theme, caps client.Capabilities, hk helpKeys) string { ) writeHelpRows(&b, th, inspectRows) - b.WriteString("\n" + muted.Render("Conversation and navigation") + "\n") + b.WriteString("\n" + heading.Render("Conversation and navigation") + "\n") writeHelpRows(&b, th, []helpRow{ {key: hk.selectAll, action: "select all prompt text"}, {key: hk.copySelection, action: "copy selected prompt or conversation text"}, @@ -259,7 +260,7 @@ func helpBody(th theme.Theme, caps client.Capabilities, hk helpKeys) string { {key: "middle-click", action: "paste the primary selection (X11/Wayland)"}, }) - b.WriteString("\n" + muted.Render("Exit and suspend") + "\n") + b.WriteString("\n" + heading.Render("Exit and suspend") + "\n") writeHelpRows(&b, th, []helpRow{ {key: "/quit", action: "quit immediately (alias: /exit; cancels an active run)"}, {key: hk.suspend, action: "suspend to the shell; the run continues, and fg resumes the TUI"}, @@ -273,55 +274,55 @@ func helpBody(th theme.Theme, caps client.Capabilities, hk helpKeys) string { // inventory IS browsable via /skills — so the copy is caps-aware: it points at // /skills when enabled, and keeps the "run automatically, not browsable" framing // when skills are off (nothing to browse). - b.WriteString("\n" + muted.Render("Features") + "\n") + b.WriteString("\n" + heading.Render("Features") + "\n") if caps.Skills { - writeHelpMutedLines(&b, th, + writeHelpBodyLines(&b, th, "The agent loads skills when needed. Type /skills to browse available skills.") } else { - writeHelpMutedLines(&b, th, + writeHelpBodyLines(&b, th, "This server does not provide a skills inventory.") } // Agent definitions, when served, are browsable via /agents (the inventory the // Subagent tool routes delegations to). Distinct from caps.Teams / f6, which is // the live overlay of a team that has actually run. if caps.Agents { - b.WriteString(muted.Render("Type /agents to browse the agent-definition inventory.") + "\n") + b.WriteString(body.Render("Type /agents to browse the agent-definition inventory.") + "\n") } if caps.SlashCommands { - b.WriteString(muted.Render("Type / to browse slash commands.") + "\n") + b.WriteString(body.Render("Type / to browse slash commands.") + "\n") } if caps.Memory { - b.WriteString(muted.Render("Cross-session memory is enabled.") + "\n") + b.WriteString(body.Render("Cross-session memory is enabled.") + "\n") } switch { case caps.Image && caps.Audio: - b.WriteString(muted.Render("Type @ to attach a file — images and audio go to the model as media.") + "\n") + b.WriteString(body.Render("Type @ to attach a file — images and audio go to the model as media.") + "\n") case caps.Image: - b.WriteString(muted.Render("Type @ to attach a file — images go to the model as media.") + "\n") + b.WriteString(body.Render("Type @ to attach a file — images go to the model as media.") + "\n") case caps.Audio: - b.WriteString(muted.Render("Type @ to attach a file — audio goes to the model as media.") + "\n") + b.WriteString(body.Render("Type @ to attach a file — audio goes to the model as media.") + "\n") default: - b.WriteString(muted.Render("This model accepts text only; attached files are inserted as text.") + "\n") + b.WriteString(body.Render("This model accepts text only; attached files are inserted as text.") + "\n") } // Usage legend: decode the footer/turn-stat token arrows AND the cache percentage, // so "↑1.2K ↓340 ⊕1.2K · cache 88%" is self-explanatory — the input/output/cache-write // glyphs, plus the share of input tokens served from cache (the number behind a // surprisingly large prompt). - b.WriteString("\n" + muted.Render("Usage") + "\n") - b.WriteString(muted.Render("↑ input · ↓ output · ⊕ cache write · cache N% input served from cache") + "\n") + b.WriteString("\n" + heading.Render("Usage") + "\n") + b.WriteString(body.Render("↑ input · ↓ output · ⊕ cache write · cache N% input served from cache") + "\n") // The navigation and close affordances use the LIVE bindings, so a keymap // override never leaves an unusable scrollable overlay. - b.WriteString("\n" + muted.Render(hk.close+" close · "+hk.navUp+"/"+hk.navDown+" scroll · "+hk.scroll+" page · "+hk.jump+" jump")) + b.WriteString("\n" + body.Render(hk.close+" close · "+hk.navUp+"/"+hk.navDown+" scroll · "+hk.scroll+" page · "+hk.jump+" jump")) return b.String() } -// writeHelpMutedLines renders each line independently so helpRenderedLines can +// writeHelpBodyLines renders each line independently so helpRenderedLines can // safely window the ANSI output without severing a style sequence. -func writeHelpMutedLines(b *strings.Builder, th theme.Theme, lines ...string) { +func writeHelpBodyLines(b *strings.Builder, th theme.Theme, lines ...string) { for _, line := range lines { - b.WriteString(th.Style("muted").Render(line) + "\n") + b.WriteString(th.Style("toolArgs").Render(line) + "\n") } } diff --git a/cmd/mecatui/ui/helpgate_test.go b/cmd/mecatui/ui/helpgate_test.go index 8d8ea5f18c..2cc1343af7 100644 --- a/cmd/mecatui/ui/helpgate_test.go +++ b/cmd/mecatui/ui/helpgate_test.go @@ -59,6 +59,16 @@ func TestHelpOpensOnlyOnEmptyInput(t *testing.T) { } } +func TestHelpOpensWhileRunning(t *testing.T) { + m := zeroStateModel(t, embeddedCaps()) + m.phase = phaseRunning + + m = applyAll(m, qmark()) + if _, ok := m.modal.(*helpState); !ok { + t.Fatalf("'?' should open Help while running, modal=%T", m.modal) + } +} + // TestHelpEscCloses asserts esc also closes the overlay. func TestHelpEscCloses(t *testing.T) { m := zeroStateModel(t, embeddedCaps()) diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 8e2dc02cc0..25157dd321 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2971,6 +2971,8 @@ func (m Model) onRunningKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // into the textarea for editing. It is distinct from Escape, which cancels // the running turn without changing the queue. return m.editBackQueue() + case key.Matches(msg, m.keys.Help) && strings.TrimSpace(m.prompt.Value()) == "": + return m.runHelp() case key.Matches(msg, m.keys.Agents): // f6 opens the unified agents overlay MID-RUN (Gap B): the deep view is // most useful while agents stream. openAgents permits phaseRunning, reads the diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index 9774d1c235..869cf63119 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -8,9 +8,9 @@ description: # Keybindings -Press `?` on an empty prompt to open the live help overlay. The overlay shows -your active bindings and dims features that the connected server does not -provide. +Press `?` on an empty prompt to open the live help overlay, whether the client is +idle or a run is active. The overlay shows your active bindings and dims features +that the connected server does not provide. Use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and **Home/End** to jump to the beginning or end of a long help overlay. When mouse From 9ce953ade65dc194805c9984f7a09a36754c2b0a Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 9 Oct 2026 16:03:18 -0700 Subject: [PATCH 07/11] fix(mecatui): use shared Help card width Co-authored-by: Mecatl --- cmd/mecatui/ui/help.go | 13 ++----------- cmd/mecatui/ui/helpgate_test.go | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index 1830e52984..ce6fbafe86 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -40,15 +40,9 @@ type helpRow struct { // widening just shifts the action column right uniformly. const helpKeyWidth = 22 -// helpMinCardWidth keeps the Help overlay readable once the terminal is large -// enough, helpCardWidthFraction keeps it from filling wide terminals, and // helpMaxCardWidth is the shared 128-cell cap for normal list and inspector cards // (docs/tui.md, "Layout and navigation"). -const ( - helpMinCardWidth = 69 - helpCardWidthFraction = 80 - helpMaxCardWidth = 128 -) +const helpMaxCardWidth = 128 // helpState owns the Help overlay's wrapped viewport and frame geometry. type helpState struct { @@ -157,10 +151,7 @@ func helpViewportView(lines []string, width, height int, viewport *bounded.Viewp } func helpCardWidth(width int) int { - if width <= 0 { - return helpMinCardWidth - } - return min(width, helpMaxCardWidth, max(helpMinCardWidth, width*helpCardWidthFraction/100)) + return min(max(0, width), helpMaxCardWidth) } func helpBodyWidth(th theme.Theme, width int) int { diff --git a/cmd/mecatui/ui/helpgate_test.go b/cmd/mecatui/ui/helpgate_test.go index 2cc1343af7..9b1fa6babe 100644 --- a/cmd/mecatui/ui/helpgate_test.go +++ b/cmd/mecatui/ui/helpgate_test.go @@ -148,7 +148,7 @@ func TestHelpCardWidthIsTerminalBounded(t *testing.T) { for _, tc := range []struct{ terminal, want int }{ {terminal: 40, want: 40}, {terminal: 69, want: 69}, - {terminal: 100, want: 80}, + {terminal: 100, want: 100}, {terminal: 160, want: 128}, {terminal: 200, want: 128}, } { From d657e18d0c9be8497052342fbec37f6721c430d1 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 9 Oct 2026 16:26:32 -0700 Subject: [PATCH 08/11] refactor(mecatui): simplify modal width cap Co-authored-by: Mecatl --- cmd/mecatui/ui/dream.go | 4 ++-- cmd/mecatui/ui/help.go | 4 ++-- cmd/mecatui/ui/skills.go | 4 ++-- cmd/mecatui/ui/soul.go | 4 ++-- cmd/mecatui/ui/surface.go | 6 +++--- cmd/mecatui/ui/usermodel.go | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cmd/mecatui/ui/dream.go b/cmd/mecatui/ui/dream.go index 2103fdba71..bad1ec4b17 100644 --- a/cmd/mecatui/ui/dream.go +++ b/cmd/mecatui/ui/dream.go @@ -55,8 +55,8 @@ type dreamResultMsg struct { result client.DreamMsg } -func (*dreamState) modalMaxOuterWidth(int) int { return 128 } -func (s *dreamState) modalFrame() bool { return !s.compact } +func (*dreamState) modalMaxOuterWidth() int { return 128 } +func (s *dreamState) modalFrame() bool { return !s.compact } func (m Model) openDream() (tea.Model, tea.Cmd) { if m.phase != phaseIdle || m.modal != nil || m.deps.Dream == nil || m.caps.ManualDream == nil { diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index ce6fbafe86..385a24c287 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -54,8 +54,8 @@ type helpState struct { compact bool } -func (*helpState) modalMaxOuterWidth(available int) int { return helpCardWidth(available) } -func (s *helpState) modalFrame() bool { return !s.compact } +func (*helpState) modalMaxOuterWidth() int { return helpMaxCardWidth } +func (s *helpState) modalFrame() bool { return !s.compact } func (s *helpState) Render(width, height int) (string, []ClickableRegion) { // Retain the card's former one-row empty-content allowance. diff --git a/cmd/mecatui/ui/skills.go b/cmd/mecatui/ui/skills.go index 161fd2a323..fc5792a32f 100644 --- a/cmd/mecatui/ui/skills.go +++ b/cmd/mecatui/ui/skills.go @@ -119,8 +119,8 @@ type skillsResultMsg struct { result client.SkillsMsg } -func (*skillsState) modalMaxOuterWidth(int) int { return skillsMaxOuterWidth } -func (s *skillsState) modalFrame() bool { return !s.compact } +func (*skillsState) modalMaxOuterWidth() int { return skillsMaxOuterWidth } +func (s *skillsState) modalFrame() bool { return !s.compact } // Render measures and renders unframed content from one parent-provided offer. func (s *skillsState) Render(width, height int) (string, []ClickableRegion) { diff --git a/cmd/mecatui/ui/soul.go b/cmd/mecatui/ui/soul.go index ec25e8fff1..e7250641db 100644 --- a/cmd/mecatui/ui/soul.go +++ b/cmd/mecatui/ui/soul.go @@ -56,8 +56,8 @@ type soulState struct { compact bool } -func (*soulState) modalMaxOuterWidth(int) int { return 128 } -func (s *soulState) modalFrame() bool { return !s.compact } +func (*soulState) modalMaxOuterWidth() int { return 128 } +func (s *soulState) modalFrame() bool { return !s.compact } // Render receives the parent's measured askCard content offer. func (s *soulState) Render(width, height int) (string, []ClickableRegion) { diff --git a/cmd/mecatui/ui/surface.go b/cmd/mecatui/ui/surface.go index 52b6993635..5ffa49165e 100644 --- a/cmd/mecatui/ui/surface.go +++ b/cmd/mecatui/ui/surface.go @@ -91,7 +91,7 @@ type modalPlacementSource interface { // modalMaxOuterWidthSource caps a parent-framed card without moving framing or // centering responsibility into the surface. type modalMaxOuterWidthSource interface { - modalMaxOuterWidth(available int) int + modalMaxOuterWidth() int } // modalFrameSource lets a surface select an unframed compact rendering after it @@ -212,8 +212,8 @@ func (m *Model) renderModalSurface() string { style := m.deps.Theme.Style("askCard") outerW := bodyW - if source, ok := m.modal.(modalMaxOuterWidthSource); ok && source.modalMaxOuterWidth(bodyW) > 0 { - outerW = min(outerW, source.modalMaxOuterWidth(bodyW)) + if source, ok := m.modal.(modalMaxOuterWidthSource); ok { + outerW = min(outerW, source.modalMaxOuterWidth()) } contentW := max(0, outerW-style.GetHorizontalFrameSize()) contentH := max(0, bodyH-style.GetVerticalFrameSize()) diff --git a/cmd/mecatui/ui/usermodel.go b/cmd/mecatui/ui/usermodel.go index 568d90983a..8b6530e287 100644 --- a/cmd/mecatui/ui/usermodel.go +++ b/cmd/mecatui/ui/usermodel.go @@ -51,9 +51,9 @@ func (m Model) openUserModel() (tea.Model, tea.Cmd) { return m, client.GetUserModelCmdTagged(m.deps.Ctx, m.deps.UserModel, s.generation) } -func (*userModelState) modalMaxOuterWidth(int) int { return 128 } -func (s *userModelState) modalFrame() bool { return !s.compact } -func (*userModelState) Close() {} +func (*userModelState) modalMaxOuterWidth() int { return 128 } +func (s *userModelState) modalFrame() bool { return !s.compact } +func (*userModelState) Close() {} func (s *userModelState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if key.Matches(msg, s.deps.keys.Close) { From 0a9ca638f96c4707c3b76012fcf79fc77c920d81 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 9 Oct 2026 16:48:43 -0700 Subject: [PATCH 09/11] refactor(mecatui): centralize modal pointer routing Co-authored-by: Mecatl --- cmd/mecatui/ui/help.go | 8 +------- cmd/mecatui/ui/helpgate_test.go | 9 +++------ cmd/mecatui/ui/surface.go | 13 ++++++------- cmd/mecatui/ui/update.go | 20 ++++++++++++-------- user-docs/mecatui/keybindings.md | 5 ++--- 5 files changed, 24 insertions(+), 31 deletions(-) diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index 385a24c287..9d203dd66b 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -96,13 +96,7 @@ func (s *helpState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { return nil, true, false } -func (*helpState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { - switch msg.(type) { - case tea.MouseClickMsg, tea.MouseMotionMsg, tea.MouseReleaseMsg: - return nil, true, false - } - return nil, false, false -} +func (*helpState) HandleMsg(tea.Msg) (tea.Cmd, bool, bool) { return nil, false, false } func (s *helpState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { switch msg.Button { diff --git a/cmd/mecatui/ui/helpgate_test.go b/cmd/mecatui/ui/helpgate_test.go index 9b1fa6babe..bbaae1e78b 100644 --- a/cmd/mecatui/ui/helpgate_test.go +++ b/cmd/mecatui/ui/helpgate_test.go @@ -600,10 +600,9 @@ func TestHelpShowsNoIndicatorWhenContentFits(t *testing.T) { } } -// TestHelpSwallowsNonWheelMouseWhileOpen pins that clicks, motion, and releases -// are consumed by the visible overlay, like the wheel, and cannot reach the hidden -// prompt or conversation. -func TestHelpSwallowsNonWheelMouseWhileOpen(t *testing.T) { +// TestModalSwallowsNonWheelMouseWhileOpen pins the uniform modal pointer policy: +// left-click, motion, and release events cannot reach hidden conversation or prompt state. +func TestModalSwallowsNonWheelMouseWhileOpen(t *testing.T) { m := applyAll(helpModel(t, allOnCaps()), tea.WindowSizeMsg{Width: 100, Height: 24}) m.prompt.Rewrite("hidden draft") m.prompt.SelectAll() @@ -617,8 +616,6 @@ func TestHelpSwallowsNonWheelMouseWhileOpen(t *testing.T) { for name, msg := range map[string]tea.Msg{ "left click in the prompt": tea.MouseClickMsg{Button: tea.MouseLeft, X: input.x0 + 1, Y: input.y0}, "left click in the card": tea.MouseClickMsg{Button: tea.MouseLeft, X: 50, Y: 8}, - "right click": tea.MouseClickMsg{Button: tea.MouseRight, X: 50, Y: 8}, - "middle click": tea.MouseClickMsg{Button: tea.MouseMiddle, X: 50, Y: 8}, "motion": tea.MouseMotionMsg{Button: tea.MouseLeft, X: 40, Y: 8}, "release": tea.MouseReleaseMsg{Button: tea.MouseLeft, X: 40, Y: 8}, } { diff --git a/cmd/mecatui/ui/surface.go b/cmd/mecatui/ui/surface.go index 5ffa49165e..3097b6e9c2 100644 --- a/cmd/mecatui/ui/surface.go +++ b/cmd/mecatui/ui/surface.go @@ -53,13 +53,12 @@ type surface interface { // surface down. HandleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, handled bool, closed bool) - // HandleMsg consumes or passes messages other than keys and wheel events: - // RPC results, timers, notices, and non-wheel pointer events. The Model routes - // non-key/wheel messages through the open modal BEFORE its own generic reducer, - // so the surface owns its RPC-backed state and can be created dynamically - // at Open with no pre-declared Model field. The surface consumes (handled), - // kills (closed), or passes (handled=false); on closed the Model tears the - // surface down, exactly as the HandleKey closed path. + // HandleMsg consumes or passes asynchronous messages and parent-translated + // surface hits. The Model routes RPC results, timers, and notices through the + // open modal before its own generic reducer, so the surface owns its RPC-backed + // state and can be created dynamically at Open with no pre-declared Model field. + // The surface consumes (handled), kills (closed), or passes (handled=false); on + // closed the Model tears the surface down, exactly as the HandleKey closed path. HandleMsg(msg tea.Msg) (cmd tea.Cmd, handled bool, closed bool) // HandleWheel returns handled=true when the surface handled the event. The diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 25157dd321..c125dbd1e5 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -4219,16 +4219,20 @@ func (m Model) onMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { return m, cmd } -// onMouseMsg fans the four mouse message types out to their handlers. It is one -// switch case in update() (keeping update()'s cyclomatic complexity bounded) that -// re-discriminates the concrete mouse type here, where the dispatch logically -// belongs. +// onMouseMsg fans the four mouse message types out to their handlers. An open +// modal owns left clicks, motion, release, and wheel input; right-click copy and +// middle-click paste retain their root-owned behavior. It is one switch case in +// update() (keeping update()'s cyclomatic complexity bounded) that re-discriminates +// the concrete mouse type here, where the dispatch logically belongs. func (m Model) onMouseMsg(msg tea.Msg) (tea.Model, tea.Cmd) { if m.modal != nil { - if _, wheel := msg.(tea.MouseWheelMsg); !wheel { - if mm, cmd, handled := m.dispatchSurfaceMsg(msg); handled { - return mm, cmd - } + switch msg := msg.(type) { + case tea.MouseWheelMsg: + return m.onMouseWheel(msg) + case tea.MouseClickMsg: + return m.onMousePress(msg.Mouse()) + case tea.MouseMotionMsg, tea.MouseReleaseMsg: + return m, nil } } if m.agentsInv.view != agentsInvNone { diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index 869cf63119..f10d440ddc 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -8,9 +8,8 @@ description: # Keybindings -Press `?` on an empty prompt to open the live help overlay, whether the client is -idle or a run is active. The overlay shows your active bindings and dims features -that the connected server does not provide. +Press `?` on an empty prompt to open the live help overlay. It shows your active +bindings and dims features that the connected server does not provide. Use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and **Home/End** to jump to the beginning or end of a long help overlay. When mouse From 8ca3aed85b68b4958c5ab2a3093810627c7980f0 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 9 Oct 2026 18:11:00 -0700 Subject: [PATCH 10/11] test(sdk): stabilize HTTP schedule timestamp fixture Co-authored-by: Mecatl --- sdk/typescript/e2e/http.e2e.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/e2e/http.e2e.test.ts b/sdk/typescript/e2e/http.e2e.test.ts index 894a8a8600..0b8484a496 100644 --- a/sdk/typescript/e2e/http.e2e.test.ts +++ b/sdk/typescript/e2e/http.e2e.test.ts @@ -36,7 +36,8 @@ describe("offline HTTP wire", () => { await withDaemon({ durable: true, http: true }, async ({ ready }) => { if (ready.http_address === undefined) throw new Error("mecated omitted HTTP readiness"); const client = connect({ baseUrl: `http://${ready.http_address}` }); - const oneShot = timestampFromDate(new Date(Date.now() + 3_600_123)); + const oneShotBase = Math.ceil(Date.now() / 1_000) * 1_000; + const oneShot = timestampFromDate(new Date(oneShotBase + 3_600_123)); const fireTimeout = durationFromMs(1_500); try { const created = await client.schedules.create( From fdb1b7c6aff1902ff26de3f2a072697e7db1e200 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 9 Oct 2026 18:41:07 -0700 Subject: [PATCH 11/11] docs(mecatui): remove obvious Help navigation instructions Co-authored-by: Mecatl --- user-docs/mecatui/keybindings.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index f10d440ddc..23e40861af 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -11,10 +11,6 @@ description: Press `?` on an empty prompt to open the live help overlay. It shows your active bindings and dims features that the connected server does not provide. -Use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and -**Home/End** to jump to the beginning or end of a long help overlay. When mouse -capture is on, the mouse wheel also scrolls it. - ## Everyday keys |Key|Action|