diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index e82ad9fdc05..b481160d766 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -118,10 +118,10 @@ function isPathLike(word: string): boolean { /** * Decide what to complete for the composer text + cursor offset: - * - the text is a slash command — `/` at the very start followed by at least - * one command-name char (`/m`, `/model foo`) → `complete.slash {text}`. A - * bare `/` (F1) or a `/abs/path` whose first token isn't a valid name (F2) → - * no slash menu. + * - the text is a slash command — `/` at the very start → `complete.slash + * {text}`. A bare `/` opens the full command list immediately (glitch + * 2026-06-13); `/m`, `/model foo` narrow it. A `/abs/path` whose first token + * isn't a valid name (F2) → no slash menu. * - the WORD under the cursor is an `@`-mention → `complete.path {word}` for * file/dir tagging (F8b). * - otherwise nothing. @@ -140,7 +140,11 @@ export function planCompletion(text: string, cursor: number = text.length): Comp const body = text.slice(1) const space = body.search(/\s/) const name = space === -1 ? body : body.slice(0, space) - if (SLASH_NAME_RE.test(name)) { + // Hydrate on a BARE `/` (body === '', glitch 2026-06-13 — open the full + // command list on the first slash) or a valid command name. A `/abs/path` + // (the lead token contains a `/`) is never a command (F2), and a `/ ` with a + // trailing space past an empty name is not arg-completion on nothing. + if (body === '' || SLASH_NAME_RE.test(name)) { return { from: 0, method: 'complete.slash', params: { text } } } return null diff --git a/ui-opentui/src/logic/theme.ts b/ui-opentui/src/logic/theme.ts index 19ac4d28ee3..4c22730e13d 100644 --- a/ui-opentui/src/logic/theme.ts +++ b/ui-opentui/src/logic/theme.ts @@ -74,6 +74,8 @@ export interface ThemeBrand { name: string icon: string prompt: string + /** The composer glyph while in `!`-shell mode (defaults to `$`); skin-overridable. */ + shellPrompt?: string welcome: string goodbye: string tool: string @@ -244,6 +246,7 @@ const BRAND: ThemeBrand = { name: 'Hermes Agent', icon: '⚕', prompt: '❯', + shellPrompt: '$', welcome: 'Type your message or /help for commands.', goodbye: 'Goodbye! ⚕', tool: '┊', diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index b97fc4d27c8..cc387972667 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -88,8 +88,9 @@ describe('planCompletion (items 5 + 13)', () => { }) }) - test('a bare `/` does NOT open the slash menu (F1)', () => { - expect(planCompletion('/')).toBeNull() + test('a bare `/` opens the slash menu (hydrate immediately — glitch 2026-06-13)', () => { + expect(planCompletion('/')).toEqual({ from: 0, method: 'complete.slash', params: { text: '/' } }) + // a trailing space past the (empty) name is not a command — no arg-complete on nothing. expect(planCompletion('/ ')).toBeNull() }) diff --git a/ui-opentui/src/test/slashMenu.test.tsx b/ui-opentui/src/test/slashMenu.test.tsx index f33ff6eca67..7321cba22f4 100644 --- a/ui-opentui/src/test/slashMenu.test.tsx +++ b/ui-opentui/src/test/slashMenu.test.tsx @@ -129,16 +129,15 @@ async function mountComposer(historyEntries: string[] = []): Promise { return { probe, submitted, typed } } -describe('slash menu — opens only after a name char (F1)', () => { - test('a bare `/` does NOT open the menu (F1 — type a char first)', async () => { +describe('slash menu — opens on the first slash, hydrating the full command list', () => { + test('a bare `/` opens the menu immediately (hydrate on first slash — glitch 2026-06-13)', async () => { const h = await mountComposer() try { await h.probe.keys.typeText('/') await h.probe.settle() const frame = h.probe.frame() - expect(frame).not.toContain('/clear') - expect(frame).not.toContain('Esc dismiss') - expect(frame).toContain('/') // the slash stays in the composer + expect(frame).toContain('/clear') + expect(frame).toContain('Esc dismiss') } finally { h.probe.destroy() } diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index 6d0dbc7ed2a..3bf7e226543 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -198,6 +198,10 @@ export function Composer(props: { const s = suggested() return s ? [{ display: `/${s.name}`, meta: 'did you mean? (Tab/Enter to accept)', text: s.name }] : [] } + /** `!`-shell mode (F9): the buffer leads with `!`, so submit runs the rest in + * the shell (no model turn). Drives the distinct prompt glyph + alert note so + * the mode is visually unmistakable. */ + const shellMode = createMemo(() => bufText().startsWith('!')) // Native highlight plumbing: one SyntaxStyle per mount holding the token // style; ranges are recomputed from `analysis()` on every change (clear+add — @@ -507,11 +511,22 @@ export function Composer(props: { not a background tint. PRIMARY BOLD: the idle view's one bright action (design pass — gold sits on the newest answer and on the ❯ waiting for the next command, nowhere else). */} + {/* shell-mode (F9) affordance: a `!`-led buffer runs in the shell, so the + input wears an alert-colored note (this slot is otherwise the slash/path + dropdown, which never coexists with `!`). */} + + + + {'shell mode — Enter runs this in your shell (no model turn) · Esc/⌫ to leave'} + + + - - {theme().brand.prompt} + {/* the glyph flips to the shell prompt in an alert color while in `!`-mode */} + + {shellMode() ? (theme().brand.shellPrompt ?? '$') : theme().brand.prompt} diff --git a/ui-opentui/src/view/transcript.tsx b/ui-opentui/src/view/transcript.tsx index bb519183c74..75d571be45a 100644 --- a/ui-opentui/src/view/transcript.tsx +++ b/ui-opentui/src/view/transcript.tsx @@ -478,7 +478,16 @@ export function Transcript(props: { store: SessionStore }) { return ( - + {/* reserve a 1-cell right gutter so the vertical scrollbar never paints OVER + hard-width content (markdown tables / code blocks clipped their right + border under the bar — glitch 2026-06-13). */} + {/* display flags (/compact, /details — Epic 3) for the rows below */} ({ compact: props.store.state.compact, details: props.store.state.details })}>