Skip to content

Commit 7e224e9

Browse files
committed
Keep slash popup owned on zero-match filter and relayout in place
1 parent a5e558c commit 7e224e9

3 files changed

Lines changed: 136 additions & 51 deletions

File tree

src/tui/prompt-slash-exit.test.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -160,29 +160,38 @@ describe("slash command popup", () => {
160160
})
161161
})
162162

163-
test("an unmatched name prefix closes the popup and keeps the typed text", async () => {
163+
test("an unmatched name prefix refreshes in place instead of closing", async () => {
164164
await withShell(async ({ shell, press, render, frame }) => {
165165
press("/")
166166
press("z")
167167
await render()
168-
expect(isSlashPopupOpen(shell)).toBe(false)
169-
expect(shell.overlayList).toBeNull()
168+
// The popup was already open (from "/") when the filter zeroed out —
169+
// closing here would release the host, which is exactly the gap a
170+
// queued gate can drain into mid-filter. It stays owned and shows the
171+
// same "(no matches)" row the general palette uses.
172+
expect(isSlashPopupOpen(shell)).toBe(true)
173+
expect(shell.overlayList).not.toBeNull()
170174
expect(shell.prompt.value).toBe("/z")
171-
expect(shell.overlayItems).not.toContain("(no matches)")
172-
expect(frame()).not.toContain("(no matches)")
175+
expect(shell.overlayItems).toEqual(["(no matches)"])
176+
expect(frame()).toContain("(no matches)")
177+
178+
// A backspace that restores a match refreshes back in place.
179+
press("Backspace")
180+
expect(isSlashPopupOpen(shell)).toBe(true)
181+
expect(shell.paletteCommands.map((c) => c.id)).toEqual(CATALOG.map((c) => c.id))
173182
})
174183
})
175184

176-
test("description prose does not keep the slash list open", async () => {
185+
test("description prose keeps the slash list open with no matches", async () => {
177186
await withShell(async ({ shell, press, render, frame }) => {
178187
press("/")
179188
press("p")
180189
await render()
181-
expect(isSlashPopupOpen(shell)).toBe(false)
182-
expect(shell.overlayList).toBeNull()
190+
expect(isSlashPopupOpen(shell)).toBe(true)
191+
expect(shell.overlayList).not.toBeNull()
183192
expect(shell.prompt.value).toBe("/p")
184-
expect(shell.overlayItems).not.toContain("(no matches)")
185-
expect(frame()).not.toContain("(no matches)")
193+
expect(shell.overlayItems).toEqual(["(no matches)"])
194+
expect(frame()).toContain("(no matches)")
186195
})
187196
})
188197
})

src/tui/shell.ts

Lines changed: 79 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,32 @@ function overlayRowsPerItem(kind: PrimaryOverlayKind | null): number {
13091309
return isDecisionOverlay(kind) ? DECISION_CHOICE_ROWS : 1
13101310
}
13111311

1312+
/**
1313+
* Recompute the overlay host's row budget from the current item count and
1314+
* relayout into it. Callers that refresh an already-open overlay's items in
1315+
* place (rather than reopening) must call this themselves — a filter that
1316+
* narrows a list and then widens it again would otherwise stay pinned at
1317+
* whatever size it first opened at.
1318+
*/
1319+
function relayoutOverlayHost(shell: AppShell, itemCount: number): void {
1320+
const perItem = overlayRowsPerItem(shell.overlayKind)
1321+
const hostRows = overlayHostRows(
1322+
shell,
1323+
shell.overlayBodyLines.length,
1324+
itemCount * perItem,
1325+
)
1326+
const minHostRows = overlayMinHostRows(
1327+
shell,
1328+
shell.overlayBodyLines.length,
1329+
itemCount > 0,
1330+
)
1331+
relayout(shell, {
1332+
overlayMode: "inset",
1333+
overlayBodyRows: hostRows,
1334+
overlayMinBodyRows: minHostRows,
1335+
})
1336+
}
1337+
13121338
/** Columns a body/choice row may paint into, inside border and leading space. */
13131339
function overlayRowWidth(shell: AppShell): number {
13141340
return Math.max(8, Math.max(20, shell.layout.contentWidth) - 4)
@@ -3690,20 +3716,9 @@ export function openListOverlay(
36903716
// against OVERLAY_MAX_FRACTION and the transcript floor, and applyLayout
36913717
// shrinks the viewport to whatever survived — so a longer list scrolls
36923718
// instead of growing, and a short one leaves no dead rows below it.
3693-
const perItem = overlayRowsPerItem(shell.overlayKind)
36943719
// An empty list charges no rows: a chooser with nothing to choose must not
36953720
// reserve a blank band the operator can neither read nor act on.
36963721
const listItems = labels.length
3697-
const hostRows = overlayHostRows(
3698-
shell,
3699-
shell.overlayBodyLines.length,
3700-
listItems * perItem,
3701-
)
3702-
const minHostRows = overlayMinHostRows(
3703-
shell,
3704-
shell.overlayBodyLines.length,
3705-
listItems > 0,
3706-
)
37073722

37083723
shell.overlayList = createListViewport({
37093724
count: labels.length,
@@ -3720,11 +3735,7 @@ export function openListOverlay(
37203735
target: focusTarget,
37213736
scrollOwner: isPalette ? "palette" : "overlay",
37223737
})
3723-
relayout(shell, {
3724-
overlayMode: "inset",
3725-
overlayBodyRows: hostRows,
3726-
overlayMinBodyRows: minHostRows,
3727-
})
3738+
relayoutOverlayHost(shell, listItems)
37283739
applyFocus(shell)
37293740
paintOverlayList(shell)
37303741
}
@@ -4301,13 +4312,24 @@ export function setOverlayItems(
43014312
items: readonly string[],
43024313
itemIds?: readonly string[],
43034314
itemValues?: readonly (string | undefined)[],
4315+
opts?: { readonly resetActive?: boolean },
43044316
): void {
43054317
if (!shell.overlayList) return
43064318
shell.overlayItems = items
43074319
const bag = internals.get(shell)
43084320
if (bag && itemIds) bag.overlayItemIds = [...itemIds]
43094321
if (bag && itemValues) bag.overlayItemValues = [...itemValues]
4310-
shell.overlayList = setListCount(shell.overlayList, items.length)
4322+
// Most callers (mention/model-picker filtering) keep the operator's current
4323+
// selection as the list narrows. The `/` popup instead resets to the top
4324+
// row on every keystroke, matching pre-refresh behavior where each filter
4325+
// reopened the overlay fresh.
4326+
shell.overlayList = opts?.resetActive
4327+
? createListViewport({
4328+
count: items.length,
4329+
height: shell.overlayList.height,
4330+
activeIndex: 0,
4331+
})
4332+
: setListCount(shell.overlayList, items.length)
43114333
paintOverlayList(shell)
43124334
}
43134335

@@ -5195,10 +5217,6 @@ export function openSlashCommands(shell: AppShell): boolean {
51955217
const matches = resolvePaletteCatalog(shell).filter((cmd) =>
51965218
cmd.id.toLowerCase().startsWith(q),
51975219
)
5198-
if (matches.length === 0) {
5199-
closeSlashPopup(shell)
5200-
return false
5201-
}
52025220

52035221
// Every keystroke lands here while the popup is already open. Closing and
52045222
// reopening released the overlay host between the two calls (closeSlashPopup
@@ -5208,36 +5226,56 @@ export function openSlashCommands(shell: AppShell): boolean {
52085226
// nothing to drain into. priorOverlay stacking is untouched here (it is only
52095227
// ever written by openListOverlay's stack-on-open path), so a palette
52105228
// stacked over a prior overlay keeps that snapshot across the refresh.
5229+
//
5230+
// A typo that zeroes the matches must not fall through to closeSlashPopup
5231+
// while the popup is already open — that closes through the same
5232+
// notifyOverlayClosed path and drains a queued gate mid-filter. Instead
5233+
// this refreshes in place to a "(no matches)" row, same as the general
5234+
// palette does, and holds the host until a real dismiss (deleting the `/`,
5235+
// Esc, accept) or a backspace that restores matches.
52115236
if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") {
5212-
shell.paletteCommands = matches
5213-
const bag = internals.get(shell)
5214-
if (bag) {
5215-
bag.paletteFilter = {
5216-
query: bag.paletteFilter?.query ?? "",
5217-
title: "commands · /",
5218-
catalog: matches,
5219-
typeToFilter: false,
5220-
}
5221-
bag.overlayDescribe = (id) => {
5222-
const cmd = matches.find((c) => c.id === id)
5223-
const what = cmd?.description?.trim()
5224-
return what ? { what } : null
5225-
}
5226-
}
5227-
setOverlayItems(
5228-
shell,
5229-
paletteLabels(matches),
5230-
matches.map((c) => c.id),
5231-
)
5237+
refreshSlashPopupInPlace(shell, matches)
52325238
return true
52335239
}
52345240

5241+
if (matches.length === 0) {
5242+
closeSlashPopup(shell)
5243+
return false
5244+
}
5245+
52355246
closeSlashPopup(shell)
52365247
openPalette(shell, { catalog: matches, title: "commands · /" })
52375248
slashPopups.add(shell)
52385249
return true
52395250
}
52405251

5252+
/** Refresh the already-open `/` popup's rows in place for the given matches. */
5253+
function refreshSlashPopupInPlace(
5254+
shell: AppShell,
5255+
matches: readonly PaletteCommand[],
5256+
): void {
5257+
const labels = matches.length > 0 ? paletteLabels(matches) : ["(no matches)"]
5258+
shell.paletteCommands = matches
5259+
const bag = internals.get(shell)
5260+
if (bag) {
5261+
bag.paletteFilter = {
5262+
query: bag.paletteFilter?.query ?? "",
5263+
title: "commands · /",
5264+
catalog: matches,
5265+
typeToFilter: false,
5266+
}
5267+
bag.overlayDescribe = (id) => {
5268+
const cmd = matches.find((c) => c.id === id)
5269+
const what = cmd?.description?.trim()
5270+
return what ? { what } : null
5271+
}
5272+
}
5273+
setOverlayItems(shell, labels, matches.map((c) => c.id), undefined, {
5274+
resetActive: true,
5275+
})
5276+
relayoutOverlayHost(shell, labels.length)
5277+
}
5278+
52415279
function setPromptText(shell: AppShell, value: string): void {
52425280
shell.prompt.value = value
52435281
shell.prompt.cursorOffset = value.length

src/tui/slash-popup-gate.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { wireGates } from "./gate-wire"
1414
import {
1515
createAppShell,
1616
isSlashPopupOpen,
17+
onOverlayClosed,
1718
type AppShell,
1819
} from "./shell"
1920

@@ -62,6 +63,18 @@ describe("/ popup keeps a queued gate queued across a filter refresh", () => {
6263
await withShell(async ({ shell, press }) => {
6364
const emitter = new EventEmitter()
6465
const dispose = wireGates(emitter, shell)
66+
// The host closing (onOverlayClosed) is what the queued gate waits
67+
// on to drain — see gate-wire.ts's onOverlayClosed/pending. Under the
68+
// old close-then-reopen refresh this fires on every filter keystroke
69+
// (closeSlashPopup -> closeInsetOverlay -> notifyOverlayClosed) even
70+
// though the palette immediately re-stacks on top and every assertion
71+
// on shell.overlayKind alone sees only "palette" again by the time it
72+
// runs. Counting this call directly is what actually distinguishes
73+
// the in-place refresh from the old close+reopen one.
74+
let closedCount = 0
75+
const disposeClosedSpy = onOverlayClosed(shell, () => {
76+
closedCount++
77+
})
6578
try {
6679
press("/")
6780
expect(isSlashPopupOpen(shell)).toBe(true)
@@ -83,6 +96,7 @@ describe("/ popup keeps a queued gate queued across a filter refresh", () => {
8396
// Queued, not opened — the slash popup still owns the host.
8497
expect(shell.overlayKind).toBe("palette")
8598
expect(resolved).toBeUndefined()
99+
expect(closedCount).toBe(0)
86100

87101
// Refreshing the filter must not release the host to the queued gate.
88102
press("m")
@@ -94,20 +108,44 @@ describe("/ popup keeps a queued gate queued across a filter refresh", () => {
94108
"mcp",
95109
])
96110
expect(resolved).toBeUndefined()
111+
expect(closedCount).toBe(0)
97112

98113
// Filtering keeps working after the refresh.
99114
press("o")
100115
expect(shell.prompt.value).toBe("/mo")
101116
expect(shell.paletteCommands.map((c) => c.id)).toEqual(["model"])
102117
expect(isSlashPopupOpen(shell)).toBe(true)
103118
expect(resolved).toBeUndefined()
119+
expect(closedCount).toBe(0)
120+
121+
// A keystroke that drops matches to zero must not dismiss the popup
122+
// either — it stays owned with a "(no matches)" row, and the gate
123+
// stays queued behind it.
124+
press("z")
125+
expect(shell.prompt.value).toBe("/moz")
126+
expect(isSlashPopupOpen(shell)).toBe(true)
127+
expect(shell.overlayKind).toBe("palette")
128+
expect(shell.paletteCommands).toEqual([])
129+
expect(shell.overlayItems).toEqual(["(no matches)"])
130+
expect(resolved).toBeUndefined()
131+
expect(closedCount).toBe(0)
132+
133+
// A backspace that restores matches refreshes back in place too.
134+
press("Backspace")
135+
expect(shell.prompt.value).toBe("/mo")
136+
expect(shell.paletteCommands.map((c) => c.id)).toEqual(["model"])
137+
expect(isSlashPopupOpen(shell)).toBe(true)
138+
expect(resolved).toBeUndefined()
139+
expect(closedCount).toBe(0)
104140

105141
// A true dismiss still drains the queue as before.
106142
press("Escape")
107143
await Bun.sleep(60)
108144
expect(shell.overlayKind).toBe("permissions")
109145
expect(resolved).toBeUndefined()
146+
expect(closedCount).toBe(1)
110147
} finally {
148+
disposeClosedSpy()
111149
dispose()
112150
}
113151
})

0 commit comments

Comments
 (0)