From 5746424272c466adebf185cb590c4e8a097644a9 Mon Sep 17 00:00:00 2001 From: Gnanam Date: Fri, 3 Jul 2026 06:57:18 +0530 Subject: [PATCH] fix(tui): render the ? help as an overlay, not a full-screen replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing ? replaced the entire TUI with the shortcut block: View() short- circuited to renderKeybindingHelpOverlay, which centers the block on a blank canvas, so the transcript, title bar, and composer all vanished. Every other popup (model picker, suggestions, wizards) composites over the chat instead. Route the help overlay through the SAME viewport-overlay path the pickers use: add keybindingHelpOverlay(width) (returns "" when closed, else the centered block) as the highest-priority case in transcriptView's overlay switch and in twoColumnTranscriptView, and drop the content-replacement branch in View(). The overlay now draws on top of the chat via the existing overlayViewportLines compositing, keeping the transcript/title bar/composer visible behind it. Input handling is unchanged — the modal key-swallow + ?/Esc/q/Enter dismiss run before render — so only the presentation changes. Tests: the help overlay composites over the chat chrome (title bar + composer survive) and over a populated transcript row (red-green: with the old full-replace both the chrome and the body are gone); existing open/close/swallow/content tests unchanged. Fixes #419 --- internal/tui/keybinding_help.go | 12 +++++++ internal/tui/keybinding_help_test.go | 53 ++++++++++++++++++++++++++++ internal/tui/model.go | 12 +++++-- 3 files changed, 74 insertions(+), 3 deletions(-) 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))