diff --git a/cmd/mecatui/ui/builtins.go b/cmd/mecatui/ui/builtins.go index 060ffb210b..32f7383e80 100644 --- a/cmd/mecatui/ui/builtins.go +++ b/cmd/mecatui/ui/builtins.go @@ -440,10 +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 + 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/help.go b/cmd/mecatui/ui/help.go index defe575b10..9d203dd66b 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -5,11 +5,13 @@ 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" "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,126 @@ type helpRow struct { // widening just shifts the action column right uniformly. const helpKeyWidth = 22 -// 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) - 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) +// helpMaxCardWidth is the shared 128-cell cap for normal list and inspector cards +// (docs/tui.md, "Layout and navigation"). +const helpMaxCardWidth = 128 + +// 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 } -// 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 (*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. + 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 "", nil + } + 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(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) + } + } + 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(tea.Msg) (tea.Cmd, bool, bool) { return nil, false, false } + +func (s *helpState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { + switch msg.Button { + case tea.MouseWheelUp: + s.viewport.Move(bounded.LineUp, s.total) + case tea.MouseWheelDown: + s.viewport.Move(bounded.LineDown, s.total) + } + return nil, true } -// helpRenderedLines splits the help body into complete styled lines. helpBody -// renders every line independently, so windowing cannot leave a terminal style open. -func helpRenderedLines(body string) []string { - return strings.Split(body, "\n") +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 { + view bounded.ViewportView + total, window int + compact bool } -// 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) +// 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 := height < 1 + if !compact { + var measure bounded.Viewport + measure.SetGeometry(width, height, 0, bounded.Wrap) + measured := measure.View(lines) + total := measured.Above + len(measured.Rows) + measured.Below + window = height + if total > window { + window = height - len(helpIndicatorRows(hk, total-1, total, total, width)) + compact = window < 1 + } + } + if compact { + window = 1 } - return window + 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} +} + +func helpCardWidth(width int) int { + return min(max(0, width), helpMaxCardWidth) +} + +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 { + return strings.Split(ansi.Hardwrap(helpScrollIndicator(hk, start, end, total), width, true), "\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 { + 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 @@ -89,12 +167,13 @@ func helpWindowHeight(th theme.Theme, height, total int) int { // 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}, @@ -104,13 +183,14 @@ 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"}, }) - 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" @@ -121,7 +201,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)"}, @@ -131,7 +211,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}, @@ -152,7 +232,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"}, @@ -165,7 +245,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"}, @@ -179,55 +259,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/help_test.go b/cmd/mecatui/ui/help_test.go index 7f230940e5..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 @@ -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) @@ -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 6909cd86a1..bbaae1e78b 100644 --- a/cmd/mecatui/ui/helpgate_test.go +++ b/cmd/mecatui/ui/helpgate_test.go @@ -1,15 +1,28 @@ package ui import ( + "fmt" + "regexp" + "strconv" "strings" "testing" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + "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. @@ -18,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() { @@ -27,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() { @@ -38,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(), "?") { @@ -46,15 +59,25 @@ 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()) 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") } } @@ -68,7 +91,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") } } @@ -82,7 +105,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") } } @@ -91,8 +114,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) { @@ -102,8 +125,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) { @@ -119,42 +142,197 @@ 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: 100}, + {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 { + 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 := helpGeometry(m) + 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 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); helpSurface(m).viewport.Offset() != want { + t.Fatalf("end offset = %d, want %d", helpSurface(m).viewport.Offset(), want) + } + 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) + } +} + +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 := 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 := helpSurface(m).viewport.Offset(); got != 0 { + t.Fatalf("wheel at top offset = %d, want consumed endpoint 0", got) + } + assertHiddenUnchanged(beforeConversation, beforePrompt, beforeFocused, beforePromptSelection) + + 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 := helpSurface(m).viewport.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 := 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 := helpSurface(m).viewport.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}) - 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.helpScroll != 1 { - t.Fatalf("down moved help scroll to %d, want 1", m.helpScroll) + 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.helpScroll != clampScroll(1+window, total, window) { - t.Fatalf("pgdown moved help scroll to %d, want page movement", m.helpScroll) + 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.helpScroll != 1 { - t.Fatalf("pgup moved help scroll to %d, want 1", m.helpScroll) + 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.helpScroll != want { - t.Fatalf("end moved help scroll to %d, want %d", m.helpScroll, 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.helpScroll != 0 { - t.Fatalf("home moved help scroll to %d, want 0", m.helpScroll) + 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.helpScroll != 0 { - t.Fatalf("closing help should reset offset: open=%t offset=%d", m.showHelp, m.helpScroll) + 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.helpScroll != 0 { - t.Fatalf("opening help should reset offset: open=%t offset=%d", m.showHelp, m.helpScroll) + if m.modal == nil || helpSurface(m) == previous || helpSurface(m).viewport.Offset() != 0 { + t.Fatal("opening help should start with a fresh viewport") } } @@ -175,21 +353,24 @@ 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 := 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)) + 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 !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) @@ -211,7 +392,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) @@ -219,60 +400,102 @@ 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 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.helpScroll != want { - t.Fatalf("overridden ScrollD moved help scroll to %d, want %d", m.helpScroll, 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.helpScroll != 1 { - t.Fatalf("overridden ScrollU moved help scroll to %d, want 1", m.helpScroll) + 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.helpScroll != 0 { - t.Fatalf("overridden ScrollTop moved help scroll to %d, want 0", m.helpScroll) + 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.helpScroll != maxScroll { - t.Fatalf("overridden ScrollBottom moved help scroll to %d, want %d", m.helpScroll, 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.helpScroll != maxScroll-1 { - t.Fatalf("overridden Up moved help scroll to %d, want %d", m.helpScroll, 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.helpScroll != 0 { - t.Fatalf("overridden Close should close and reset help: open=%t offset=%d", m.showHelp, m.helpScroll) + 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.helpScroll + 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.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 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.helpScroll + before = helpSurface(m).viewport.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)) + 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) + } } } @@ -302,3 +525,114 @@ 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()) + 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} { + m = applyAll(m, tea.WindowSizeMsg{Width: width, Height: height}) + for _, position := range []string{"top", "middle", "end"} { + m.renderBody() + s := helpSurface(m) + switch position { + case "top": + s.viewport.Reset() + case "middle": + s.viewport.SetOffset(s.total/2, s.total) + case "end": + s.viewport.Move(bounded.End, s.total) + } + 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 TestHelpCompactFallback(t *testing.T) { + m := helpModel(t, allOnCaps()) + 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) + } + } + 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 := 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) { + t.Fatalf("non-overflowing Help should not show a line-range indicator:\n%s", body) + } +} + +// 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() + input, ok := inputRegionRect(m) + if !ok { + t.Fatal("precondition: prompt region should be known at this size") + } + 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{ + "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}, + "motion": tea.MouseMotionMsg{Button: tea.MouseLeft, X: 40, Y: 8}, + "release": tea.MouseReleaseMsg{Button: tea.MouseLeft, X: 40, Y: 8}, + } { + updated, cmd := m.Update(msg) + if cmd != nil { + t.Fatalf("%s returned a hidden interaction command", name) + } + m = updated.(Model) + switch { + case m.modal == nil: + t.Fatalf("%s closed help", name) + 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: + t.Fatalf("%s reached the hidden prompt", name) + } + } +} diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 78de78d3b0..fbe893af97 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -712,8 +712,6 @@ 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 cancelRun context.CancelFunc 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/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..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 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, - // 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 @@ -212,7 +211,7 @@ func (m *Model) renderModalSurface() string { style := m.deps.Theme.Style("askCard") outerW := bodyW - if source, ok := m.modal.(modalMaxOuterWidthSource); ok && source.modalMaxOuterWidth() > 0 { + if source, ok := m.modal.(modalMaxOuterWidthSource); ok { outerW = min(outerW, source.modalMaxOuterWidth()) } contentW := max(0, outerW-style.GetHorizontalFrameSize()) diff --git a/cmd/mecatui/ui/testdata/help_all_on.golden b/cmd/mecatui/ui/testdata/help_all_on.golden index 12570938b2..70b17c7091 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 ┃ + ┃ lines 1–14 of 71 · 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..1ac09b963b 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 ┃ + ┃ 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/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 4ea9cb5892..c125dbd1e5 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2043,7 +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() - m.clampHelpScroll() m.clampAgentsDetailScroll() if widthChanged && m.vp.Height() == viewportHeight { m.refreshView() @@ -2252,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 @@ -2362,52 +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) { - m.clampHelpScroll() - total, window := m.helpScrollGeometry() - switch { - case key.Matches(msg, m.keys.Help), key.Matches(msg, m.keys.Close): - m.showHelp = false - m.helpScroll = 0 - _ = m.prompt.Focus() - case key.Matches(msg, m.keys.ScrollD): - m.helpScroll = clampScroll(m.helpScroll+window, total, window) - case key.Matches(msg, m.keys.ScrollU): - m.helpScroll = clampScroll(m.helpScroll-window, total, window) - case key.Matches(msg, m.keys.Down): - m.helpScroll = clampScroll(m.helpScroll+1, total, window) - case key.Matches(msg, m.keys.Up): - m.helpScroll = clampScroll(m.helpScroll-1, total, window) - case key.Matches(msg, m.keys.ScrollBottom): - m.helpScroll = maxScrollOffset(total, window) - case key.Matches(msg, m.keys.ScrollTop): - m.helpScroll = 0 - } - return m, nil -} - -// helpScrollGeometry derives the same complete rendered lines 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())) - return len(lines), helpWindowHeight(m.deps.Theme, m.vp.Height(), len(lines)) -} - -// 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) -} - // 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 @@ -2971,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 } @@ -3020,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 @@ -3327,12 +3280,9 @@ 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. - m.showHelp = true - m.helpScroll = 0 - 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): @@ -4269,11 +4219,22 @@ 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 { + 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 { if wheel, ok := msg.(tea.MouseWheelMsg); ok { return m.onAgentsInvWheel(wheel) diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index 8b8152ee69..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.helpScroll, 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/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( diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index 9750f68850..23e40861af 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -8,12 +8,8 @@ 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. - -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. +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. ## Everyday keys