Skip to content

Commit 8c62769

Browse files
Merge pull request #519 from corbitsdev/cl-6699-stop-slash-typeahead-from-opening-permission-gates-mid
Refresh slash popup in place instead of close+reopen
2 parents eb5f0c2 + c806d05 commit 8c62769

3 files changed

Lines changed: 310 additions & 28 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: 93 additions & 18 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,16 +5217,65 @@ export function openSlashCommands(shell: AppShell): boolean {
51955217
const matches = resolvePaletteCatalog(shell).filter((cmd) =>
51965218
cmd.id.toLowerCase().startsWith(q),
51975219
)
5220+
5221+
// Every keystroke lands here while the popup is already open. Closing and
5222+
// reopening released the overlay host between the two calls (closeSlashPopup
5223+
// routes through closeInsetOverlay, which fires notifyOverlayClosed) — long
5224+
// enough for a queued permission/operator gate to drain onto it. Refreshing
5225+
// the open palette in place never releases the host, so a queued gate has
5226+
// nothing to drain into. priorOverlay stacking is untouched here (it is only
5227+
// ever written by openListOverlay's stack-on-open path), so a palette
5228+
// 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.
5236+
if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") {
5237+
refreshSlashPopupInPlace(shell, matches)
5238+
return true
5239+
}
5240+
51985241
if (matches.length === 0) {
51995242
closeSlashPopup(shell)
52005243
return false
52015244
}
5245+
52025246
closeSlashPopup(shell)
52035247
openPalette(shell, { catalog: matches, title: "commands · /" })
52045248
slashPopups.add(shell)
52055249
return true
52065250
}
52075251

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+
52085279
function setPromptText(shell: AppShell, value: string): void {
52095280
shell.prompt.value = value
52105281
shell.prompt.cursorOffset = value.length
@@ -5242,8 +5313,12 @@ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean {
52425313
!key.option
52435314
) {
52445315
closeSlashPopup(shell)
5316+
// Zero matches: nothing to dispatch. Preserve the typed text instead of
5317+
// wiping it — pre-refresh this state was unreachable (popup closed on
5318+
// zero matches, so Enter fell through to normal prompt handling).
5319+
if (!active) return true
52455320
setPromptText(shell, "")
5246-
if (active) dispatchPaletteSelection(shell, active)
5321+
dispatchPaletteSelection(shell, active)
52475322
return true
52485323
}
52495324

0 commit comments

Comments
 (0)