Fix command palette over focused scratchpad

The stdin loop's scratchpad-input branch ran before the palette branch
and silently dropped every byte except a handful of app-level chords,
so palette typing and Esc never reached the palette while a pad was
focused. Skip the pad-input branch whenever st.palette != nil.

closePalette also called repaintFocused() on cancel / no-op action
paths, which paints the empty focused-child slot (focusedID == "" while
a pad is focused) and leaves the palette's top border drawn over the
pad. Route those branches through a restoreView helper that picks
repaintFocusedPad when a pad is focused.

Switching from a pad to a child via the palette now clears the pad
focus and wipes the viewport, matching focusProcess's pad-exit path.

Adds a harness scenario (palette_over_scratchpad) that opens a pad,
opens the palette, types a query, and verifies that Esc leaves the
pad correctly repainted with no palette chrome lingering.
This commit is contained in:
2026-05-15 00:35:28 +01:00
parent 0d578d54f1
commit 81a8ac2ba0
4 changed files with 73 additions and 10 deletions

View File

@@ -63,6 +63,17 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
renders the canonical `--flag` form.
### Fixed
- Opening the command palette while a scratchpad was focused left the
palette wedged — typing did nothing and Esc left the palette's top
border drawn over the pad until you closed the pad with Ctrl-W and
re-opened the palette. The stdin loop's scratchpad-input branch ran
before the palette branch and silently dropped every byte except a
handful of app-level chords, so palette filter input and Esc never
reached `palette.handleInput`. The palette branch now takes
precedence whenever the palette is open, and `closePalette` repaints
the pad (instead of the empty focused-child slot) on cancel / no-op
action. Switching from a pad to a child via the palette now clears
the pad focus and wipes the viewport, matching `focusProcess`.
- Tab bar and bottom status row no longer get overwritten by long
claude / codex sessions. Three holes were letting child output land
on the chrome: (1) absolute cursor moves — CUP / HVP / VPA — added

View File

@@ -12,10 +12,6 @@
still feels slow after ≥15 minutes — the structural drivers the
audit named are all addressed, so a remaining symptom is a new
one and probably wants fresh profiling.
- [ ] Opening the command palette with a scratchpad open creates very buggy ui.
- Typing into the command palette doesn't work at all
- Hitting esc causes buggy chrome, the top border of the command palette is still visible
- This is only fixed by Ctrl + W, hitting esc again to close the palette, then re-opening it when over an agent view.
- [ ] Context aware command palette options
- Options for current scratchpad (delete, rename, edit) at the top when a scratchpad is selected.
- Options for current agent (rename [renames tab], close) at the top when an agent is selected.

View File

@@ -1178,7 +1178,11 @@ func (st *uiState) processStdin(chunk []byte) {
// palette, Ctrl-WASD focus, Ctrl-B scrollback) fall through to
// the handlers below; everything else is swallowed silently so
// typing into a pad view can't leak to a child PTY.
if st.focusedPad != "" {
//
// When the palette is open we skip this block entirely so the
// palette handler below receives every byte. Otherwise typing
// (and Esc) get swallowed here and the palette appears wedged.
if st.focusedPad != "" && st.palette == nil {
if b == 0x1b { // ESC or CSI
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
@@ -1553,16 +1557,32 @@ func (st *uiState) closePalette(action paletteAction) {
st.outMu.Unlock()
st.clearScreen()
// When a scratchpad is focused, the "main viewport" is showing the
// pad's rendered body, not a child PTY. repaintFocused() would draw
// an empty state (focusedID == "") and leave the previous palette's
// top border visible in the pad area. Use the pad-aware helper for
// any branch below that wants to restore the prior view.
restoreView := func() {
st.mu.Lock()
padFocused := st.focusedPad != ""
st.mu.Unlock()
if padFocused {
st.repaintFocusedPad()
return
}
st.repaintFocused()
}
switch action.kind {
case "", "cancel":
st.repaintFocused()
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "spawn-agent":
if action.preset == nil {
st.repaintFocused()
restoreView()
return
}
l := st.layoutSnapshot()
@@ -1575,7 +1595,7 @@ func (st *uiState) closePalette(action paletteAction) {
case "spawn-process":
if action.preset == nil {
st.repaintFocused()
restoreView()
return
}
l := st.layoutSnapshot()
@@ -1586,7 +1606,7 @@ func (st *uiState) closePalette(action paletteAction) {
case "spawn-process-submit":
if action.command == "" {
st.repaintFocused()
restoreView()
return
}
l := st.layoutSnapshot()
@@ -1617,16 +1637,24 @@ func (st *uiState) closePalette(action paletteAction) {
case "switch":
c := st.sess.FindChild(action.childID)
if c == nil || (c.Kind == KindAgent && c.Status() != StatusRunning) {
st.repaintFocused()
restoreView()
return
}
layout := st.layoutSnapshot()
st.mu.Lock()
leavingPad := st.focusedPad != ""
st.focusedPad = ""
st.focusedID = action.childID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout)
st.mu.Unlock()
// Switching from a pad to a child: wipe the pad body so the
// child's snapshot paints onto a clean canvas, mirroring
// focusProcess.
if leavingPad {
st.clearViewportArea()
}
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()

View File

@@ -0,0 +1,28 @@
{
"name": "palette_over_scratchpad",
"cols": 120,
"rows": 30,
"steps": [
{
"type": "mcp_call",
"method": "scratchpad_write",
"params": { "name": "pad-marker.md", "content": "# Pad Heading\n\nzealot-marker body line" }
},
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "send_chord", "chord": "ctrl-s" },
{ "type": "wait_text", "contains": "zealot-marker", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "Pad Heading" },
{ "type": "send_chord", "chord": "ctrl-k" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "send_text", "text": "quit" },
{ "type": "wait_text", "contains": "quit", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "quit" },
{ "type": "send_chord", "chord": "escape" },
{ "type": "wait_text", "contains": "zealot-marker", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "Pad Heading" },
{ "type": "assert_contains", "contains": "zealot-marker" },
{ "type": "assert_not_contains", "contains": "quit" }
]
}