mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
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:
parent
5747d9a2d8
commit
353a8c1c8f
6 changed files with 46 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '┊',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue