diff --git a/internal/tui/keybinding_help.go b/internal/tui/keybinding_help.go index f12d6a13e..078c09c69 100644 --- a/internal/tui/keybinding_help.go +++ b/internal/tui/keybinding_help.go @@ -133,6 +133,18 @@ func (m model) renderKeybindingHelpOverlay(width int, height int) string { return centerRenderedBlock(block, width) } +// keybindingHelpOverlay returns the `?` shortcut overlay as a centered block to +// COMPOSITE over the transcript — the same viewport-overlay path the model +// picker and suggestions use — or "" when it is not open. Rendering it this way +// (rather than replacing the whole View) keeps the chat visible behind it, so +// `?` behaves like every other popup instead of blanking the screen (#419). +func (m model) keybindingHelpOverlay(width int) string { + if !m.helpOverlay { + return "" + } + return m.renderKeybindingHelpOverlay(width, m.height) +} + // keybindingHelpOverlayWidth picks the overlay width: wide enough that the // descriptions don't truncate next to the key column, capped, and never wider // than the terminal. diff --git a/internal/tui/keybinding_help_test.go b/internal/tui/keybinding_help_test.go index c7d78671f..f749b1185 100644 --- a/internal/tui/keybinding_help_test.go +++ b/internal/tui/keybinding_help_test.go @@ -105,3 +105,56 @@ func TestKeybindingGroupsAreWellFormed(t *testing.T) { } } } + +// #419: the `?` help overlay must render ON TOP of the chat (like the model +// picker), not REPLACE the whole screen. The old full-screen replace produced +// only the centered shortcut block on a blank canvas — no title bar, no +// composer. So with the overlay open, the surrounding chat chrome that is NOT +// covered by the centered box (the model title bar at top, the composer at +// bottom) must still be present alongside "Keyboard Shortcuts". +func TestHelpOverlayCompositesOverChatNotReplacingIt(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.width = 100 + m.height = 40 + m.altScreen = true + + base := plainRender(t, m.View()) // no overlay: baseline chrome + m.helpOverlay = true + over := plainRender(t, m.View()) + + if !strings.Contains(over, "Keyboard Shortcuts") { + t.Fatalf("help overlay not rendered:\n%s", over) + } + // Chrome that renders in the baseline (and sits outside the centered overlay + // box) must survive behind the overlay. The full-screen replace showed none + // of it. + for _, marker := range []string{"gpt-4o", "describe a task"} { + if !strings.Contains(base, marker) { + t.Fatalf("precondition: baseline chat should contain %q:\n%s", marker, base) + } + if !strings.Contains(over, marker) { + t.Fatalf("#419: help replaced the chat instead of overlaying it; %q is gone:\n%s", marker, over) + } + } +} + +// A populated transcript row also survives behind the overlay (peeking out to +// the left of the centered box), proving the chat body — not just the chrome — +// is composited under the overlay rather than discarded. +func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.width = 120 + m.height = 40 + m.altScreen = true + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello there this is a chat line"}) + m.helpOverlay = true + + view := plainRender(t, m.View()) + if !strings.Contains(view, "Keyboard Shortcuts") { + t.Fatalf("help overlay not rendered:\n%s", view) + } + // The start of the transcript line peeks to the left of the centered box. + if !strings.Contains(view, "hello") { + t.Fatalf("#419: transcript body was replaced by the help overlay:\n%s", view) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 964fe9b43..eaab3cfb9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1068,7 +1068,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m = m.disarmCancelConfirmation() } // The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every - // other key is swallowed so nothing types into the hidden composer. + // other key is swallowed so nothing types into the composer behind it. if m.helpOverlay { if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') { m.helpOverlay = false @@ -2118,8 +2118,6 @@ func (m model) View() tea.View { var content string if m.setup.visible { content = m.setupView(chatWidth(m.width)) - } else if m.helpOverlay { - content = m.renderKeybindingHelpOverlay(chatWidth(m.width), m.height) } else if m.transcriptDetailed { content = m.detailedTranscriptView() } else { @@ -2194,6 +2192,7 @@ func (m model) transcriptView() string { return body + footer } + helpOverlay := m.keybindingHelpOverlay(width) suggestionOverlay := m.suggestionOverlay(width) providerOverlay := m.providerWizardOverlay(width) mcpAddOverlay := m.mcpAddWizardOverlay(width) @@ -2201,6 +2200,8 @@ func (m model) transcriptView() string { pickerOverlay := m.pickerOverlay(width) viewportOverlay := "" switch { + case helpOverlay != "": + viewportOverlay = helpOverlay case providerOverlay != "": viewportOverlay = providerOverlay case mcpAddOverlay != "": @@ -2262,6 +2263,11 @@ func (m model) twoColumnTranscriptView() string { if m.transcriptEmpty() && !m.pending { overlayForViewport = "" } + // The `?` help overlay composites over the chat like the pickers — set it + // after the empty-transcript clear so a user-toggled overlay always shows. + if helpOverlay := m.keybindingHelpOverlay(width); helpOverlay != "" { + overlayForViewport = helpOverlay + } header := m.pinnedTitleBar(width) chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport))