@@ -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. */
13131339function 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+
52415279function setPromptText ( shell : AppShell , value : string ) : void {
52425280 shell . prompt . value = value
52435281 shell . prompt . cursorOffset = value . length
0 commit comments