Skip to content

Commit b207592

Browse files
committed
Show slash command descriptions under the list
1 parent 9f1c38a commit b207592

6 files changed

Lines changed: 238 additions & 12 deletions

File tree

‎docs/TUI.md‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -282,16 +282,25 @@ are painted at the geometry resolver's shared `contentWidth`
282282
leading marker column and no per-row kind column; the selected row is marked
283283
by text color only (`paintPaletteList` in `shell.ts`: "the highlighted row
284284
already stands out by sitting under the cursor, so a leading `>` and a grey
285-
block would both be saying the same thing twice"). The list also paints with
286-
no title rule — the filter row (`> query`) directly under the box already
287-
shows what was typed, so a second header line would say nothing new
288-
(`repaintPalette`).
285+
block would both be saying the same thing twice"). Rows stay name-only
286+
(`/help`, `/model`); the focused command's registry description paints in the
287+
shared two-line description zone under the list (`openListOverlay({ describe })`,
288+
`paintDescriptionZone` in `shell.ts`). A missing or blank `description` still
289+
reserves the zone (rule plus two blank lines); it does not collapse. Built-ins
290+
have copy; this is the empty-description edge. The list also paints with no
291+
title rule — slash-popup query lives in the prompt, so an orphan `>` filter
292+
row under the box would be chrome that says nothing the prompt isn't already
293+
showing (`repaintPalette`).
289294

290295
## Slash commands and pickers
291296

292297
`/` at an empty prompt opens the command list, narrowed by name prefix as
293-
more is typed; Tab completes the name so arguments can be typed, Enter runs
294-
it. Every entry is backed by the live command registry
298+
more is typed (`cmd.id` in `openSlashCommands`); Tab completes the name so
299+
arguments can be typed, Enter runs it. The query lives in the prompt — list
300+
chrome is in How selectors should work above. When the prefix matches
301+
nothing, the overlay closes and the prompt is left as typed: `/` then `z`
302+
with no `z…` command vanishes the list and leaves `/z`. Slash never paints
303+
a `(no matches)` row. Every entry is backed by the live command registry
295304
(`src/tui/command-catalog.ts:commandItemsFromRegistry`) — there is no
296305
separate palette overlay and no shell-owned action outside the registry. The
297306
overlay this reuses is still internally called `"palette"` (`shell.ts`'s
@@ -331,9 +340,9 @@ The model/provider picker is one flat, type-to-filter list
331340
(`src/tui/product-host.ts` + `openModelPickerOverlay({ typeToFilter: true })`):
332341
recent and favorite provider+model pairs sit at the top, then every
333342
`provider / model` leaf from the catalog. Typing narrows the list in place
334-
(printable keys claimed by the filter row, same pattern as the command
335-
palette); Enter selects. Escape closes the picker. The row matching the
336-
session's live active model gets a `(current)` suffix. Alt+F on a model row
343+
(printable keys claimed by the picker's own `>` filter row); Enter selects.
344+
Escape closes the picker. The row matching the session's live active model
345+
gets a `(current)` suffix. Alt+F on a model row
337346
still toggles favorite when a favorite hook is wired. While type-to-filter is
338347
active, bare `j`/`k` type into the filter rather than moving the highlight —
339348
use arrow keys (or the filtered list's navigation) to move.

‎src/tui/command-catalog.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ describe("commandItemsFromRegistry", () => {
1515
{
1616
id: "tasks",
1717
label: "/tasks",
18+
description: "Show work list",
1819
keywords: ["tasks", "Show work list", "slash", "command"],
1920
},
2021
{
2122
id: "clear",
2223
label: "/clear",
24+
description: "Clear screen",
2325
keywords: ["clear", "Clear screen", "slash", "command"],
2426
},
2527
])
@@ -43,6 +45,24 @@ describe("filterPaletteCommands", () => {
4345
expect(filterPaletteCommands("picker", catalog).map((c) => c.id)).toEqual([
4446
"model",
4547
])
48+
// A rewrite that mapped hits to `{ id, label, keywords }` would stay green
49+
// on `.id` alone and blank the overlay description zone after a keystroke.
50+
expect(filterPaletteCommands("picker", catalog)[0]?.description).toBe(
51+
"Open model picker",
52+
)
53+
})
54+
55+
test("empty or whitespace description maps into keywords without false matches", () => {
56+
const sparse = commandItemsFromRegistry([
57+
{ name: "quiet", description: "" },
58+
{ name: "padded", description: " " },
59+
])
60+
expect(sparse[0]?.keywords).toEqual(["quiet", "", "slash", "command"])
61+
expect(sparse[1]?.keywords).toEqual(["padded", " ", "slash", "command"])
62+
expect(filterPaletteCommands("picker", sparse)).toEqual([])
63+
expect(filterPaletteCommands("quiet", sparse).map((c) => c.id)).toEqual([
64+
"quiet",
65+
])
4666
})
4767

4868
test("no match returns an empty list", () => {

‎src/tui/command-catalog.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type PaletteCommand = {
2121
readonly label: string
2222
/** Optional keywords for name-prefix / substring filter. */
2323
readonly keywords?: readonly string[]
24+
/** Registry description for the overlay zone; rows stay name-only. */
25+
readonly description?: string
2426
}
2527

2628
/** Map registry command definitions to `/` list items. */
@@ -29,9 +31,11 @@ export function commandItemsFromRegistry(
2931
): PaletteCommand[] {
3032
return commands.map((c) => ({
3133
id: c.name,
32-
// Name-only rows keep the slash popup scannable; description stays in
33-
// keywords so typed filter still finds prose matches.
34+
// Name-only rows keep the slash popup scannable; description is a
35+
// dedicated field for the overlay zone and stays in keywords so typed
36+
// filter still finds prose matches.
3437
label: `/${c.name}`,
38+
description: c.description,
3539
keywords: [c.name, c.description, "slash", "command"],
3640
}))
3741
}

‎src/tui/palette-paint.test.ts‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,160 @@ describe("palette filters as you type", () => {
188188
})
189189
})
190190

191+
const DESCRIBED_CATALOG: readonly PaletteCommand[] = [
192+
{
193+
id: "help",
194+
label: "/help",
195+
description: "Show the keyboard shortcut and command overlay",
196+
},
197+
{
198+
id: "model",
199+
label: "/model",
200+
description: "Switch the active model or provider",
201+
},
202+
{
203+
id: "mcp",
204+
label: "/mcp",
205+
description: "Manage MCP servers",
206+
},
207+
]
208+
209+
const HELP_DESC = "Show the keyboard shortcut and command overlay"
210+
const MODEL_DESC = "Switch the active model or provider"
211+
212+
function stripFrameLines(frame: string): string[] {
213+
return frame
214+
.split("\n")
215+
.map((line) => line.replace(/^\s*│/, "").replace(/│\s*$/, "").trimEnd())
216+
}
217+
218+
/** Interior zone rows under the list rule, before the overlay's bottom border. */
219+
function zoneAfterList(
220+
lines: readonly string[],
221+
labels: readonly string[],
222+
): readonly string[] | undefined {
223+
let last = -1
224+
for (const [i, line] of lines.entries()) {
225+
if (labels.some((label) => line.includes(label))) last = i
226+
}
227+
if (last < 0) return undefined
228+
const below = lines.slice(last + 1)
229+
const ruleAt = below.findIndex(
230+
(r) => r.includes("─") && !/[┌┐└┘╭╮╰╯]/.test(r),
231+
)
232+
if (ruleAt < 0) return undefined
233+
const afterRule = below.slice(ruleAt + 1)
234+
const boxBottom = afterRule.findIndex((r) => /[└┘]/.test(r))
235+
return boxBottom >= 0 ? afterRule.slice(0, boxBottom) : afterRule
236+
}
237+
238+
function expectNameOnlyRows(
239+
lines: readonly string[],
240+
labels: readonly string[],
241+
): void {
242+
for (const label of labels) {
243+
const row = lines.find((r) => r.includes(label))
244+
expect(row).toBeDefined()
245+
expect(row!.trim()).toBe(label)
246+
}
247+
}
248+
249+
function expectDescriptionUnderListRule(
250+
lines: readonly string[],
251+
description: string,
252+
labels: readonly string[],
253+
): void {
254+
for (const label of labels) {
255+
const row = lines.find((r) => r.includes(label))
256+
expect(row).toBeDefined()
257+
expect(row).not.toContain(description)
258+
}
259+
const zone = zoneAfterList(lines, labels)
260+
expect(zone).toBeDefined()
261+
expect(zone!.some((r) => r.includes(description))).toBe(true)
262+
}
263+
264+
describe("command list description zone", () => {
265+
test("paints the focused command's registry description, not on the row", async () => {
266+
await withTestRenderer(
267+
async (h) => {
268+
const shell = createAppShell(h.renderer, {
269+
terminal: { columns: 100, rows: 32 },
270+
wireKeys: false,
271+
run: "idle",
272+
})
273+
openPalette(shell, { catalog: DESCRIBED_CATALOG })
274+
await h.renderOnce()
275+
const labels = DESCRIBED_CATALOG.map((c) => c.label)
276+
const lines = stripFrameLines(h.captureCharFrame())
277+
expectNameOnlyRows(lines, labels)
278+
expectDescriptionUnderListRule(lines, HELP_DESC, labels)
279+
},
280+
{ width: 100, height: 32 },
281+
)
282+
})
283+
284+
test("moving the overlay selection updates the zone to the newly focused command", async () => {
285+
await withTestRenderer(
286+
async (h) => {
287+
const shell = createAppShell(h.renderer, {
288+
terminal: { columns: 100, rows: 32 },
289+
wireKeys: false,
290+
run: "idle",
291+
})
292+
openPalette(shell, { catalog: DESCRIBED_CATALOG })
293+
await h.renderOnce()
294+
const labels = DESCRIBED_CATALOG.map((c) => c.label)
295+
const before = stripFrameLines(h.captureCharFrame())
296+
expectDescriptionUnderListRule(before, HELP_DESC, labels)
297+
expect(before.join("\n")).not.toContain(MODEL_DESC)
298+
299+
moveOverlaySelection(shell, 1)
300+
await h.renderOnce()
301+
const after = stripFrameLines(h.captureCharFrame())
302+
expectNameOnlyRows(after, labels)
303+
expectDescriptionUnderListRule(after, MODEL_DESC, labels)
304+
expect(after.join("\n")).not.toContain(HELP_DESC)
305+
},
306+
{ width: 100, height: 32 },
307+
)
308+
})
309+
310+
test("an undescribed row leaves the zone blank without leftover neighbor copy", async () => {
311+
const mixed: readonly PaletteCommand[] = [
312+
{ id: "help", label: "/help", description: HELP_DESC },
313+
{ id: "model", label: "/model" },
314+
]
315+
await withTestRenderer(
316+
async (h) => {
317+
const shell = createAppShell(h.renderer, {
318+
terminal: { columns: 100, rows: 32 },
319+
wireKeys: false,
320+
run: "idle",
321+
})
322+
openPalette(shell, { catalog: mixed })
323+
await h.renderOnce()
324+
const labels = mixed.map((c) => c.label)
325+
const described = stripFrameLines(h.captureCharFrame())
326+
expectNameOnlyRows(described, labels)
327+
expectDescriptionUnderListRule(described, HELP_DESC, labels)
328+
const reserved = shell.layout.heights.overlay_host
329+
330+
moveOverlaySelection(shell, 1)
331+
await h.renderOnce()
332+
const blank = stripFrameLines(h.captureCharFrame())
333+
expectNameOnlyRows(blank, labels)
334+
const zone = zoneAfterList(blank, labels)
335+
expect(zone).toBeDefined()
336+
expect(zone!.every((r) => r.trim() === "")).toBe(true)
337+
expect(blank.join("\n")).not.toContain(HELP_DESC)
338+
expect(shell.layout.heights.overlay_host).toBe(reserved)
339+
},
340+
{ width: 100, height: 32 },
341+
)
342+
})
343+
})
344+
191345
describe("command list width", () => {
192346
// Both boxes are children of the same padded root; a width computed a
193347
// second way for the floating list drifts from the prompt box's "100%".

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import {
1919
} from "./shell"
2020

2121
const CATALOG: readonly PaletteCommand[] = [
22-
{ id: "model", label: "/model" },
22+
{
23+
id: "model",
24+
label: "/model",
25+
description: "Open model picker",
26+
keywords: ["model", "Open model picker", "slash", "command"],
27+
},
2328
{ id: "mcp", label: "/mcp" },
2429
{ id: "compact", label: "/compact" },
2530
]
@@ -29,6 +34,7 @@ type Ctx = {
2934
readonly dispatched: string[]
3035
readonly press: (key: string) => void
3136
readonly render: () => Promise<void>
37+
readonly frame: () => string
3238
}
3339

3440
function withShell(fn: (ctx: Ctx) => Promise<void>): Promise<void> {
@@ -48,6 +54,7 @@ function withShell(fn: (ctx: Ctx) => Promise<void>): Promise<void> {
4854
dispatched,
4955
press: (key) => h.pressKey(key as Parameters<typeof h.pressKey>[0]),
5056
render: h.renderOnce,
57+
frame: () => h.captureCharFrame(),
5158
})
5259
} finally {
5360
shell.dispose()
@@ -152,6 +159,32 @@ describe("slash command popup", () => {
152159
expect(shell.prompt.value).toBe("src/")
153160
})
154161
})
162+
163+
test("an unmatched name prefix closes the popup and keeps the typed text", async () => {
164+
await withShell(async ({ shell, press, render, frame }) => {
165+
press("/")
166+
press("z")
167+
await render()
168+
expect(isSlashPopupOpen(shell)).toBe(false)
169+
expect(shell.overlayList).toBeNull()
170+
expect(shell.prompt.value).toBe("/z")
171+
expect(shell.overlayItems).not.toContain("(no matches)")
172+
expect(frame()).not.toContain("(no matches)")
173+
})
174+
})
175+
176+
test("description prose does not keep the slash list open", async () => {
177+
await withShell(async ({ shell, press, render, frame }) => {
178+
press("/")
179+
press("p")
180+
await render()
181+
expect(isSlashPopupOpen(shell)).toBe(false)
182+
expect(shell.overlayList).toBeNull()
183+
expect(shell.prompt.value).toBe("/p")
184+
expect(shell.overlayItems).not.toContain("(no matches)")
185+
expect(frame()).not.toContain("(no matches)")
186+
})
187+
})
155188
})
156189

157190
describe("Ctrl+C exit", () => {

‎src/tui/shell.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3791,6 +3791,12 @@ function repaintPalette(shell: AppShell): void {
37913791
kind: "palette",
37923792
title: state.title,
37933793
items: labels,
3794+
itemIds: commands.map((c) => c.id),
3795+
describe: (id) => {
3796+
const cmd = commands.find((c) => c.id === id)
3797+
const what = cmd?.description?.trim()
3798+
return what ? { what } : null
3799+
},
37943800
// Typed filter row only when the overlay owns keystrokes. The `/` popup
37953801
// keeps its query in the prompt, so a body of `>` would be orphan chrome.
37963802
...(state.typeToFilter ? { body: `> ${state.query}` } : {}),

0 commit comments

Comments
 (0)