Compare commits

...
1 Commits
Author SHA1 Message Date
Gnanam 5746424272 fix(tui): render the ? help as an overlay, not a full-screen replace
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
2026-07-03 06:57:18 +05:30
3 changed files with 74 additions and 3 deletions

No files matched your search

+12
View File
@@ -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.
+53
View File
@@ -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)
}
}
+9 -3
View File
@@ -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))