Skip to content

Commit b29f864

Browse files
committed
Drop the bare ? shortcut-list binding, move the version to its own row
? was the only bare, unmodified punctuation key bound anywhere in the shell, and it shadowed a character an operator might genuinely want to type at the prompt. Remove the binding and its SHELL_SHORTCUTS row; the shortcut list itself stays reachable as /help, which the command registry already exposed. Move the running build version out of the landing screen's hero block (where it sat beside the mark and the two doors) into its own row pinned to the terminal's last line, right-aligned, distinct from landing.ts's composition. It only borrows that row while the landing screen is showing — a session's transcript content gets it back — and hides itself on a narrow or short terminal well before any other actionable chrome would need to shrink.
1 parent 2480825 commit b29f864

9 files changed

Lines changed: 324 additions & 88 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ Profiles with `orchestrator: true` may themselves call `task` (one hop only): ne
210210

211211
**Session records** (`src/subagent/session-store.ts`): each spawn is retained as an inspectable child session (id, profile, description, brief, status, tool activity, transcript entries). Child events land only in this store — not in the parent chat transcript. Live progress still uses the light `onProgress` channel for the status bar. Completed sessions are capped (`maxCompleted`) so a long chat does not grow without bound.
212212

213-
**Observe (OpenTUI)**: the command palette's **observe** action (`src/tui-opentui/palette.ts`) asks the host for a live session (`onObserveRequest``observeSessionFromSubAgents`, `src/tui-opentui/runner-host.ts`), which picks the newest running child, else the most recent session of any status. Entering observe swaps the transcript for that child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui-opentui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops.
213+
**Observe (OpenTUI)**: `shell.ts:enterSubagentObserve` swaps the transcript for a child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui-opentui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops. The host-injection point that resolves a live session (`onObserveRequest``observeSessionFromSubAgents`, `src/tui-opentui/runner-host.ts`, picking the newest running child else the most recent session of any status) remains wired up, but nothing in the product currently triggers it — the command palette action that used to call it is gone (removed along with `palette.ts`).
214214

215215
Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlugin.agents[]` from `agents/*.md` or flat `*.md` in the plugin directory, with optional co-located `skills/`. `loadPluginEntry` tries JS entrypoints first, then falls back to this layout (`/plugins` add-by-path supports filesystem completion via `listPathSuggestions`).
216216

docs/TUI.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ same last-resort floor every other optional zone shares.
143143
## How pop-ups should feel
144144

145145
A blocking surface (permissions, an operator question, the model/provider
146-
picker, settings, help, the command palette, …) shares one overlay host and
146+
picker, settings, help, the `/` command list, …) shares one overlay host and
147147
one height path — there is no second modal stack with independent row
148148
accounting (`src/tui-opentui/geometry/resolve.ts`,
149149
`src/tui-opentui/shell.ts:openListOverlay`). Opening a second surface either
@@ -181,7 +181,7 @@ rather than a background fill.
181181
## How selectors should work
182182

183183
Every list surface — permissions, the operator question, the model picker,
184-
the command palette, resume/session-mode pickers, settings — shares one list
184+
the `/` command list, resume/session-mode pickers, settings — shares one list
185185
viewport kit: shared windowing, keep-active-visible, and page/jump behavior.
186186
There is exactly one scroll lease at a time; keyboard paging and the mouse
187187
wheel both follow whichever surface currently holds it, so a modal open on
@@ -217,6 +217,23 @@ overlay this reuses is still internally called `"palette"` (`shell.ts`'s
217217
also opened it; that chord is gone (see keybindings.ts), and the identifier
218218
stayed because renaming an internal overlay tag has no user-facing effect.
219219

220+
`?` no longer binds anything — it is a literal character everywhere, prompt
221+
or transcript. The shortcut list it used to open is still reachable, as
222+
`/help` (`src/tui/commands/built-in.ts`, routed to `shell.ts:openHelpOverlay`
223+
via `openCommandSurface`'s `"help"` case, `command-surfaces.ts`); the `/` row
224+
in `SHELL_SHORTCUTS` documents that in place of a dedicated `?` row.
225+
226+
The running build version is chrome, not part of the landing composition:
227+
`shell.ts`'s `versionRow`/`versionBadge`, a dedicated row pinned to the
228+
terminal's last line and right-aligned, distinct from `landing.ts`'s hero and
229+
below sections. It only reserves that row while the landing screen is
230+
showing (`relayout`'s `versionReserved`/`terminalForGeometry`) — once there
231+
is real transcript content the row goes back to whatever needed it, and the
232+
badge stops rendering. On a narrow or short terminal it hides
233+
(`versionBadgeVisible`, thresholds `VERSION_BADGE_MIN_COLUMNS`/
234+
`VERSION_BADGE_MIN_ROWS` in `landing.ts`) well before the prompt box or any
235+
other actionable chrome would need to shrink.
236+
220237
The model/provider picker is provider-first
221238
(`src/tui-opentui/product-host.ts:groupModelsForPicker`/`openLevel`): recent
222239
and favorite provider+model pairs stay flat at the top of the list (already

src/tui-opentui/keybindings.test.ts

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ import { PROMPT_KEY_BINDINGS } from "./prompt-input.js"
2020
import { SHELL_SHORTCUTS } from "./keybindings.js"
2121
import { createHarness, withTestRenderer, type Harness } from "./harness.js"
2222
import { mountRunnerHost } from "./runner-host.js"
23+
import { openCommandSurface } from "./command-surfaces.js"
2324
import { focusOwner } from "./focus/focus-state.js"
2425
import {
2526
appendStreamRow,
2627
createAppShell,
2728
isSlashPopupOpen,
29+
openHelpOverlay,
2830
setMentionSuggestionSource,
2931
setPaletteCatalog,
3032
setPromptImageSource,
@@ -336,24 +338,6 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
336338
shell.prompt.value = ""
337339
},
338340
},
339-
"?": {
340-
group: "surfaces",
341-
probe: ({ h, shell, chords }) => {
342-
shellFocusPrompt(shell)
343-
shell.prompt.value = ""
344-
press(h, chords[0])
345-
// The condition, not just the chord: at the prompt it is a character.
346-
expect(shell.overlayKind).toBeNull()
347-
348-
shellFocusTranscript(shell)
349-
press(h, chords[0])
350-
expect(shell.overlayKind).toBe("help")
351-
press(h, chords[0])
352-
expect(shell.overlayKind).toBeNull()
353-
shellFocusPrompt(shell)
354-
shell.prompt.value = ""
355-
},
356-
},
357341
"@": {
358342
group: "surfaces",
359343
probe: async ({ h, shell, chords }) => {
@@ -678,3 +662,73 @@ describe("the runner host does not shadow the prompt bindings the catalog claims
678662
})
679663
})
680664

665+
describe("? no longer opens help", () => {
666+
test("bare ? types a literal character instead of opening the shortcut list", async () => {
667+
const harness = await createHarness({ width: 80, height: 24 })
668+
try {
669+
const shell = createAppShell(harness.renderer, {
670+
terminal: { columns: 80, rows: 24 },
671+
wireKeys: true,
672+
run: "idle",
673+
})
674+
try {
675+
shellFocusPrompt(shell)
676+
shell.prompt.value = ""
677+
harness.pressKey("?")
678+
expect(shell.overlayKind).toBeNull()
679+
expect(shell.prompt.value).toBe("?")
680+
681+
shellFocusTranscript(shell)
682+
harness.pressKey("?")
683+
// No binding claims it with the transcript focused either — help has
684+
// no chord left at all, only the /help command.
685+
expect(shell.overlayKind).toBeNull()
686+
} finally {
687+
shell.dispose()
688+
}
689+
} finally {
690+
harness.destroy()
691+
}
692+
})
693+
})
694+
695+
describe("help stays reachable as a command", () => {
696+
test("/help still opens the shortcut list", async () => {
697+
const notifications: string[] = []
698+
await withTestRenderer(async (h) => {
699+
const shell = createAppShell(h.renderer, {
700+
terminal: { columns: 80, rows: 24 },
701+
run: "idle",
702+
})
703+
try {
704+
expect(shell.overlayKind).toBeNull()
705+
const opened = openCommandSurface(shell, "help", {
706+
notify: (text) => notifications.push(text),
707+
})
708+
expect(opened).toBe(true)
709+
expect(shell.overlayKind).toBe("help")
710+
} finally {
711+
shell.dispose()
712+
}
713+
})
714+
})
715+
716+
test("openHelpOverlay (the /help handler) opens the same overlay the removed ? chord used to", async () => {
717+
const harness = await createHarness({ width: 80, height: 24 })
718+
try {
719+
const shell = createAppShell(harness.renderer, {
720+
terminal: { columns: 80, rows: 24 },
721+
run: "idle",
722+
})
723+
try {
724+
openHelpOverlay(shell)
725+
expect(shell.overlayKind).toBe("help")
726+
} finally {
727+
shell.dispose()
728+
}
729+
} finally {
730+
harness.destroy()
731+
}
732+
})
733+
})
734+

src/tui-opentui/keybindings.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,8 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
3939
{ keys: "Ctrl+Y", description: "yank the last kill at the cursor" },
4040
{ keys: "Alt+Y", description: "replace the text just yanked with the next-older kill" },
4141
{ keys: "Ctrl+V / Ctrl+P", description: "attach an image from the clipboard to the next message" },
42-
{ keys: "?", description: "with the transcript focused, open this shortcut list; press again to close it" },
4342
{ keys: "@", description: "at the start of a word, open file suggestions for the @mention being typed" },
44-
{ keys: "/", description: "at an empty prompt, open the command list (Tab completes, Enter runs)" },
43+
{ keys: "/", description: "at an empty prompt, open the command list (Tab completes, Enter runs); also lists /help" },
4544
{ keys: "Up / Down", description: "recall previously sent messages, from the prompt's first / last row" },
4645
{ keys: "Arrow keys", description: "move the cursor left / right / up / down in the prompt" },
4746
{

src/tui-opentui/landing.test.ts

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import {
2929
landingSuggestionFor,
3030
resolveMarkGrid,
3131
splitLandingRows,
32+
VERSION_BADGE_MIN_COLUMNS,
33+
VERSION_BADGE_MIN_ROWS,
34+
versionBadgeVisible,
3235
wrapLanding,
3336
} from "./landing"
3437
import { LOCKUP_WORDMARK } from "./lockup"
@@ -106,7 +109,10 @@ describe("landing layout math", () => {
106109
expect(resolveMarkGrid(12, 96)).toBe(MARK_MID)
107110
expect(resolveMarkGrid(9, 96)).toBe(MARK_SMALL)
108111
// Narrow enough that the mark would crowd the hints: the hints win.
109-
expect(resolveMarkGrid(20, 50)).toBe(MARK_SMALL)
112+
// (The version moved off this hint block into the shell's own chrome —
113+
// CL-5736 — so the block is narrower and a bit more room stays for the
114+
// mark at this width than before.)
115+
expect(resolveMarkGrid(20, 50)).toBe(MARK_MID)
110116
expect(resolveMarkGrid(20, 30)).toBeNull()
111117
expect(resolveMarkGrid(3, 96)).toBeNull()
112118
})
@@ -161,9 +167,20 @@ describe("landing screen", () => {
161167
descriptionColumns.add(row!.indexOf(hint.rest))
162168
}
163169
expect(descriptionColumns.size).toBe(1)
164-
// The version sits with the hints, and cannot drift from package.json.
170+
// The version is chrome, not part of the hero: it never shares a row
171+
// with a hint, and cannot drift from package.json.
165172
expect(LANDING_VERSION).toBe(`v${pkg.version}`)
166-
expect(h.captureCharFrame()).toContain(LANDING_VERSION)
173+
for (const hint of LANDING_HINTS) {
174+
const row = painted.find((line) => line.includes(hint.rest))
175+
expect(row).not.toContain(LANDING_VERSION)
176+
}
177+
const versionRow = painted.findIndex((row) => row.includes(LANDING_VERSION))
178+
expect(versionRow).toBeGreaterThanOrEqual(0)
179+
// Bottom-right: on the terminal's last content row, hugging the right
180+
// edge rather than sitting under the hints.
181+
expect(versionRow).toBeGreaterThanOrEqual(SIZE.height - 2)
182+
const versionCol = painted[versionRow]!.lastIndexOf(LANDING_VERSION)
183+
expect(versionCol + LANDING_VERSION.length).toBeGreaterThan(SIZE.width - 4)
167184
const noticeRow = painted.findIndex((row) => row.includes("telemetry"))
168185
expect(noticeRow).toBeGreaterThan(bottom)
169186
for (const item of LANDING_SUGGESTIONS) {
@@ -306,7 +323,9 @@ describe("landing screen", () => {
306323
const ruleRow = painted.findIndex((row) =>
307324
row.includes(LOCKUP_WORDMARK),
308325
)
309-
// The prompt box rests on the terminal's last row.
326+
// Session-active: the version row only reserves space on the landing
327+
// screen (see `relayout`), so once there is real transcript content
328+
// the box is back on the terminal's very last row.
310329
expect(ruleRow).toBe(SIZE.height - 1)
311330
const row = painted[ruleRow]!
312331
// Left end of the rule, inside the shell gutter, costing no row.
@@ -605,4 +624,103 @@ describe("landing screen", () => {
605624
}
606625
}, SIZE)
607626
})
627+
628+
test("the version is chrome, not the hero: it hides before actionable chrome does on a narrow terminal", async () => {
629+
// Comfortably above the badge's own thresholds but below nothing else —
630+
// proves the badge is what degrades, and degrades first.
631+
const roomy = { width: VERSION_BADGE_MIN_COLUMNS + 20, height: VERSION_BADGE_MIN_ROWS + 8 }
632+
await withTestRenderer(
633+
async (h) => {
634+
const shell = createAppShell(h.renderer, {
635+
terminal: { columns: roomy.width, rows: roomy.height },
636+
wireKeys: false,
637+
run: "idle",
638+
})
639+
try {
640+
await settle(h)
641+
expect(h.captureCharFrame()).toContain(LANDING_VERSION)
642+
} finally {
643+
shell.dispose()
644+
}
645+
},
646+
roomy,
647+
)
648+
649+
// Just under the badge's column floor: the badge is gone, but the prompt
650+
// field — genuinely actionable chrome — is still on screen.
651+
const narrowColumns = {
652+
width: VERSION_BADGE_MIN_COLUMNS - 1,
653+
height: VERSION_BADGE_MIN_ROWS + 8,
654+
}
655+
expect(versionBadgeVisible(narrowColumns.width, narrowColumns.height)).toBe(false)
656+
await withTestRenderer(
657+
async (h) => {
658+
const shell = createAppShell(h.renderer, {
659+
terminal: { columns: narrowColumns.width, rows: narrowColumns.height },
660+
wireKeys: false,
661+
run: "idle",
662+
})
663+
try {
664+
await settle(h)
665+
const frame = h.captureCharFrame()
666+
expect(frame).not.toContain(LANDING_VERSION)
667+
expect(frame).toContain("message")
668+
} finally {
669+
shell.dispose()
670+
}
671+
},
672+
narrowColumns,
673+
)
674+
675+
// Just under the badge's row floor: same story, short rather than narrow.
676+
const shortRows = {
677+
width: VERSION_BADGE_MIN_COLUMNS + 20,
678+
height: VERSION_BADGE_MIN_ROWS - 1,
679+
}
680+
expect(versionBadgeVisible(shortRows.width, shortRows.height)).toBe(false)
681+
await withTestRenderer(
682+
async (h) => {
683+
const shell = createAppShell(h.renderer, {
684+
terminal: { columns: shortRows.width, rows: shortRows.height },
685+
wireKeys: false,
686+
run: "idle",
687+
})
688+
try {
689+
await settle(h)
690+
const frame = h.captureCharFrame()
691+
expect(frame).not.toContain(LANDING_VERSION)
692+
expect(frame).toContain("message")
693+
} finally {
694+
shell.dispose()
695+
}
696+
},
697+
shortRows,
698+
)
699+
})
700+
701+
test("the version never appears inside the hero block beside the mark/hints", async () => {
702+
await withTestRenderer(
703+
async (h) => {
704+
const shell = createAppShell(h.renderer, {
705+
terminal: { columns: SIZE.width, rows: SIZE.height },
706+
wireKeys: false,
707+
run: "idle",
708+
})
709+
try {
710+
await settle(h)
711+
const painted = rows(h)
712+
const heroEnd = painted.findIndex((row) => /[]/.test(row))
713+
expect(heroEnd).toBeGreaterThan(0)
714+
// Nothing above the box's own top border carries the version — the
715+
// hero (mark + hint doors) is exactly the two lines, no third.
716+
for (const row of painted.slice(0, heroEnd)) {
717+
expect(row).not.toContain(LANDING_VERSION)
718+
}
719+
} finally {
720+
shell.dispose()
721+
}
722+
},
723+
SIZE,
724+
)
725+
})
608726
})

0 commit comments

Comments
 (0)