opentui(v6): composer/transcript UX polish from dogfood feedback (glitch 2026-06-13)

Three Tier-1 fixes from live use:

- bare `/` hydrates the full command menu again (reverses F1's "name char
  first" gate). The lead-token grammar still rejects `/abs/path` (F2) and a
  `/ ` trailing-space is still not arg-completion on an empty name.
- `!cmd` shell mode now reads unmistakably: the composer glyph flips ❯ → `$`
  in the alert (warn) color and an amber "shell mode — Enter runs this in your
  shell (no model turn)" note rides the slot the slash/path dropdown would use
  (they never coexist). New optional `brand.shellPrompt` ($), skin-overridable.
- the transcript scrollbox reserves a 1-cell right gutter (contentOptions
  paddingRight) so the vertical scrollbar no longer paints OVER hard-width
  content — markdown table / code-block right borders were clipped under it.

Gate green (714 tests); F1/F2/F7/F8 slash specs + the slashMenu frame test
updated to the new bare-/ behavior. Verified live in tmux.
This commit is contained in:
alt-glitch 2026-06-13 20:43:16 +05:30
parent 5747d9a2d8
commit 353a8c1c8f
6 changed files with 46 additions and 15 deletions

View file

@ -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

View file

@ -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: '┊',

View file

@ -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()
})

View file

@ -129,16 +129,15 @@ async function mountComposer(historyEntries: string[] = []): Promise<Harness> {
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()
}

View file

@ -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 `!`). */}
<Show when={shellMode()}>
<box style={{ flexDirection: 'row', flexShrink: 0, paddingLeft: GUTTER }}>
<text selectable={false} fg={theme().color.warn}>
{'shell mode — Enter runs this in your shell (no model turn) · Esc/⌫ to leave'}
</text>
</box>
</Show>
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.prompt}</b>
{/* the glyph flips to the shell prompt in an alert color while in `!`-mode */}
<span style={{ fg: shellMode() ? theme().color.warn : theme().color.primary }}>
<b>{shellMode() ? (theme().brand.shellPrompt ?? '$') : theme().brand.prompt}</b>
</span>
</text>
</box>

View file

@ -478,7 +478,16 @@ export function Transcript(props: { store: SessionStore }) {
return (
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
{/* 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). */}
<scrollbox
ref={setScroll}
style={{ flexGrow: 1, minHeight: 0 }}
contentOptions={{ paddingRight: 1 }}
stickyScroll
stickyStart="bottom"
>
<ScrollAnchorProvider scroll={scroll}>
{/* display flags (/compact, /details — Epic 3) for the rows below */}
<DisplayProvider flags={() => ({ compact: props.store.state.compact, details: props.store.state.details })}>