Merge remote-tracking branch 'origin/main' into fix/bundle-size

This commit is contained in:
ethernet 2026-05-11 16:01:00 -04:00
commit 3197b4de6d
1437 changed files with 219762 additions and 11968 deletions

View file

@ -12,6 +12,7 @@
"@nanostores/react": "^1.1.0",
"ink": "^6.8.0",
"ink-text-input": "^6.0.0",
"nanostores": "^1.2.0",
"react": "^19.2.4",
"unicode-animations": "^1.0.3"
},
@ -5319,7 +5320,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": "^20.0.0 || >=22.0.0"
}

View file

@ -20,6 +20,7 @@
"@nanostores/react": "^1.1.0",
"ink": "^6.8.0",
"ink-text-input": "^6.0.0",
"nanostores": "^1.2.0",
"react": "^19.2.4",
"unicode-animations": "^1.0.3"
},

View file

@ -1 +1 @@
export * from './dist/ink-bundle.js'
export * from './dist/entry-exports.js'

View file

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/ink-bundle.js"
"build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outdir=dist"
},
"sideEffects": true,
"main": "./index.js",

View file

@ -73,7 +73,13 @@ import {
startSelection,
updateSelection
} from './selection.js'
import { supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js'
import {
needsAltScreenResizeScrollbackClear,
supportsExtendedKeys,
SYNC_OUTPUT_SUPPORTED,
type Terminal,
writeDiffToTerminal
} from './terminal.js'
import {
CURSOR_HOME,
cursorMove,
@ -82,7 +88,8 @@ import {
DISABLE_MODIFY_OTHER_KEYS,
ENABLE_KITTY_KEYBOARD,
ENABLE_MODIFY_OTHER_KEYS,
ERASE_SCREEN
ERASE_SCREEN,
ERASE_SCROLLBACK
} from './termio/csi.js'
import {
DBP,
@ -121,6 +128,11 @@ const ERASE_THEN_HOME_PATCH = Object.freeze({
content: ERASE_SCREEN + CURSOR_HOME
})
const DEEP_ERASE_THEN_HOME_PATCH = Object.freeze({
type: 'stdout' as const,
content: ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
})
// Cached per-Ink-instance, invalidated on resize. frame.cursor.y for
// alt-screen is always terminalRows - 1 (renderer.ts).
function makeAltScreenParkPatch(terminalRows: number) {
@ -863,17 +875,17 @@ export default class Ink {
// position independently. Parking at bottom (not 0,0) keeps the guide
// where the user's attention is.
//
// After resize, prepend ERASE_SCREEN too. The diff only writes cells
// After resize, prepend a clear too. The diff only writes cells
// that changed; cells where new=blank and prev-buffer=blank get skipped
// — but the physical terminal still has stale content there (shorter
// lines at new width leave old-width text tails visible). ERASE inside
// BSU/ESU is atomic: old content stays visible until the whole
// erase+paint lands, then swaps in one go. Writing ERASE_SCREEN
// synchronously in handleResize would blank the screen for the ~80ms
// render() takes.
// lines at new width leave old-width text tails visible). Apple Terminal
// can also preserve alt-screen reflow artifacts in scrollback during
// resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is
// supported, the clear+paint lands atomically; otherwise the final state
// is still healed even if the repaint is visible.
if (this.needsEraseBeforePaint) {
this.needsEraseBeforePaint = false
optimized.unshift(ERASE_THEN_HOME_PATCH)
optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH)
} else {
optimized.unshift(CURSOR_HOME_PATCH)
}

View file

@ -30,10 +30,10 @@ const paint = (screen: Screen, y: number, text: string) => {
}
}
const mkFrame = (screen: Screen, viewportW: number, viewportH: number): Frame => ({
const mkFrame = (screen: Screen, viewportW: number, viewportH: number, cursorY = 0): Frame => ({
screen,
viewport: { width: viewportW, height: viewportH },
cursor: { x: 0, y: 0, visible: true }
cursor: { x: 0, y: cursorY, visible: true }
})
const stdoutOnly = (diff: ReturnType<LogUpdate['render']>) =>
@ -112,4 +112,46 @@ describe('LogUpdate.render diff contract', () => {
expect(stdoutOnly(diff)).toBe('')
expect(diff.some(p => p.type === 'clearTerminal')).toBe(false)
})
it('ignores main-screen scrollback-only changes instead of resetting repeatedly', () => {
const w = 20
const viewportH = 5
const h = 8
const prev = mkScreen(w, h)
paint(prev, 0, 'timer 1s')
paint(prev, 6, 'visible prompt')
const next = mkScreen(w, h)
paint(next, 0, 'timer 2s')
paint(next, 6, 'visible prompt')
next.damage = { x: 0, y: 0, width: w, height: h }
const log = new LogUpdate({ isTTY: true, stylePool })
const diff = log.render(mkFrame(prev, w, viewportH, h), mkFrame(next, w, viewportH, h), false, false)
expect(diff.some(p => p.type === 'clearTerminal')).toBe(false)
expect(stdoutOnly(diff)).not.toContain('timer2s')
})
it('keeps alt-screen full reset for unreachable scrollback row changes', () => {
const w = 20
const viewportH = 5
const h = 8
const prev = mkScreen(w, h)
paint(prev, 0, 'timer 1s')
paint(prev, 6, 'visible prompt')
const next = mkScreen(w, h)
paint(next, 0, 'timer 2s')
paint(next, 6, 'visible prompt')
next.damage = { x: 0, y: 0, width: w, height: h }
const log = new LogUpdate({ isTTY: true, stylePool })
const diff = log.render(mkFrame(prev, w, viewportH, h), mkFrame(next, w, viewportH, h), true, false)
expect(diff.some(p => p.type === 'clearTerminal')).toBe(true)
expect(stdoutOnly(diff)).toContain('timer2s')
})
})

View file

@ -226,7 +226,13 @@ export class LogUpdate {
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool)
}
if (prev.screen.height >= prev.viewport.height && prev.screen.height > 0 && cursorAtBottom && !isGrowing) {
if (
altScreen &&
prev.screen.height >= prev.viewport.height &&
prev.screen.height > 0 &&
cursorAtBottom &&
!isGrowing
) {
// viewportY = rows in scrollback from content overflow
// +1 for the row pushed by cursor-restore scroll
const viewportY = prev.screen.height - prev.viewport.height
@ -330,8 +336,15 @@ export class LogUpdate {
}
// If the cell outside the viewport range has changed, we need to reset
// because we can't move the cursor there to draw.
// because we can't move the cursor there to draw. In main-screen mode,
// those rows are already in terminal scrollback and invisible; resetting
// on every scrollback-only update can loop when a resize changes the
// physical buffer. Shrink-to-visible cases are handled above.
if (y < viewportY) {
if (!altScreen) {
return
}
needsFullReset = true
resetTriggerY = y

View file

@ -96,3 +96,41 @@ describe('mouse wheel modifier decoding', () => {
expect(key).toMatchObject({ name: 'wheelup', meta: true })
})
})
describe('fragmented SGR mouse recovery', () => {
it('re-synthesizes bracket-only SGR mouse tails as mouse events', () => {
const [[mouse]] = parseMultipleKeypresses(INITIAL_STATE, '[<35;159;11M')
expect(mouse).toMatchObject({ kind: 'mouse', button: 35, col: 159, row: 11, action: 'press' })
})
it('re-synthesizes angle-only SGR mouse tails as mouse events', () => {
const [[mouse]] = parseMultipleKeypresses(INITIAL_STATE, '<35;159;11M')
expect(mouse).toMatchObject({ kind: 'mouse', button: 35, col: 159, row: 11, action: 'press' })
})
it('re-synthesizes degraded SGR mouse bursts without leaking prompt text', () => {
const [events] = parseMultipleKeypresses(INITIAL_STATE, '5;142;11M<35;159;11M35;124;26M35;119;26Mtyped')
expect(events.slice(0, 4)).toEqual([
expect.objectContaining({ kind: 'mouse', button: 5, col: 142, row: 11 }),
expect.objectContaining({ kind: 'mouse', button: 35, col: 159, row: 11 }),
expect.objectContaining({ kind: 'mouse', button: 35, col: 124, row: 26 }),
expect.objectContaining({ kind: 'mouse', button: 35, col: 119, row: 26 })
])
expect(events[4]).toMatchObject({ kind: 'key', sequence: 'typed' })
})
it('keeps isolated semicolon text that only resembles a prefixless mouse report', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'see 1;2;3M for details')
expect(key).toMatchObject({ kind: 'key', sequence: 'see 1;2;3M for details' })
})
it('does not match prefixless fragments inside longer digit runs', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '1234;56;78M9;10;11M')
expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' })
})
})

View file

@ -63,6 +63,7 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
// eslint-disable-next-line no-control-regex
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
const SGR_MOUSE_FRAGMENT_RE = /(?<!\d)(?:\[<|<)?(?:[0-9]|[1-9][0-9]|1\d{2}|2[0-4]\d|25[0-5]);\d+;\d+[Mm]/g
function createPasteKey(content: string): ParsedKey {
return {
@ -267,23 +268,22 @@ export function parseMultipleKeypresses(
} else if (token.type === 'text') {
if (inPaste) {
pasteBuffer += token.value
} else if (/^\[<\d+;\d+;\d+[Mm]$/.test(token.value) || /^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)) {
// Orphaned SGR/X10 mouse tail (fullscreen only — mouse tracking is off
// otherwise). A heavy render blocked the event loop past App's 50ms
// flush timer, so the buffered ESC was flushed as a lone Escape and
// the continuation `[<btn;col;rowM` arrived as text. Re-synthesize
// with the ESC prefix so the scroll event still fires instead of
// leaking into the prompt. The spurious Escape is gone; App.tsx's
// readableLength check prevents it. The X10 Cb slot is narrowed to
// the wheel range [\x60-\x7f] (0x40|modifiers + 32) — a full [\x20-]
// range would match typed input like `[MAX]` batched into one read
// and silently drop it as a phantom click. Click/drag orphans leak
// as visible garbage instead; deletable garbage beats silent loss.
const resynthesized = '\x1b' + token.value
const mouse = parseMouseEvent(resynthesized)
keys.push(mouse ?? parseKeypress(resynthesized))
} else {
keys.push(parseKeypress(token.value))
const mouseFragments = parseTextWithSgrMouseFragments(token.value)
if (mouseFragments) {
keys.push(...mouseFragments)
} else if (/^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)) {
// Orphaned X10 wheel tail (fullscreen only — mouse tracking is off
// otherwise). A heavy render blocked the event loop past App's 50ms
// flush timer, so the buffered ESC was flushed as a lone Escape and
// the continuation arrived as text. Re-synthesize with ESC so the
// scroll event still fires instead of leaking into the prompt.
const resynthesized = '\x1b' + token.value
keys.push(parseKeypress(resynthesized))
} else {
keys.push(parseKeypress(token.value))
}
}
}
}
@ -625,6 +625,77 @@ function parseMouseEvent(s: string): ParsedMouse | null {
}
}
function normalizeSgrMouseFragment(fragment: string): string {
if (fragment.startsWith('[<')) {
return `\x1b${fragment}`
}
if (fragment.startsWith('<')) {
return `\x1b[${fragment}`
}
return `\x1b[<${fragment}`
}
function parseSgrMouseFragment(fragment: string): ParsedInput {
const sequence = normalizeSgrMouseFragment(fragment)
return parseMouseEvent(sequence) ?? parseKeypress(sequence)
}
function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {
SGR_MOUSE_FRAGMENT_RE.lastIndex = 0
const matches = [...text.matchAll(SGR_MOUSE_FRAGMENT_RE)]
if (matches.length === 0) {
return null
}
const parsed: ParsedInput[] = []
let cursor = 0
let consumedAny = false
for (let i = 0; i < matches.length;) {
const first = matches[i]!
const run: RegExpMatchArray[] = [first]
let runEnd = first.index! + first[0].length
i++
while (i < matches.length && matches[i]!.index === runEnd) {
run.push(matches[i]!)
runEnd = matches[i]!.index! + matches[i]![0].length
i++
}
const hasExplicitMousePrefix = run.some(match => match[0].startsWith('[<') || match[0].startsWith('<'))
const isFragmentBurst = run.length > 1
if (!hasExplicitMousePrefix && !isFragmentBurst) {
continue
}
if (first.index! > cursor) {
parsed.push(parseKeypress(text.slice(cursor, first.index!)))
}
for (const match of run) {
parsed.push(parseSgrMouseFragment(match[0]))
}
cursor = runEnd
consumedAny = true
}
if (!consumedAny) {
return null
}
if (cursor < text.length) {
parsed.push(parseKeypress(text.slice(cursor)))
}
return parsed
}
function parseKeypress(s: string = ''): ParsedKey {
let parts

View file

@ -260,23 +260,6 @@ function applyStylesToWrappedText(
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx]!
// In trim mode, skip leading whitespace that was trimmed from this line.
// Only skip if the original has whitespace but the output line doesn't start
// with whitespace (meaning it was trimmed). If both have whitespace, the
// whitespace was preserved and we shouldn't skip.
if (trimEnabled && line.length > 0) {
const lineStartsWithWhitespace = /\s/.test(line[0]!)
const originalHasWhitespace = charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)
// Only skip if original has whitespace but line doesn't
if (originalHasWhitespace && !lineStartsWithWhitespace) {
while (charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)) {
charIndex++
}
}
}
let styledLine = ''
let runStart = 0
let runSegmentIndex = charToSegment[charIndex] ?? 0
@ -333,26 +316,10 @@ function applyStylesToWrappedText(
// split lines.
if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') {
charIndex++
}
// In trim mode, skip whitespace that was replaced by newline when wrapping.
// We skip whitespace in the original until we reach a character that matches
// the first character of the next line. This handles cases like:
// - "AB \tD" wrapped to "AB\n\tD" - skip spaces until we hit the tab
// In non-trim mode, whitespace is preserved so no skipping is needed.
if (trimEnabled && lineIdx < lines.length - 1) {
const nextLine = lines[lineIdx + 1]!
const nextLineFirstChar = nextLine.length > 0 ? nextLine[0] : null
// Skip whitespace until we hit a char that matches the next line's first char
while (charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)) {
// Stop if we found the character that starts the next line
if (nextLineFirstChar !== null && originalPlain[charIndex] === nextLineFirstChar) {
break
}
charIndex++
}
} else if (trimEnabled && lineIdx < lines.length - 1 && /\s/.test(originalPlain[charIndex] ?? '')) {
// wrap-trim removes exactly one whitespace character at each soft-wrap boundary.
// Keep the style map aligned without eating preserved indentation/spaces.
charIndex++
}
}

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { needsAltScreenResizeScrollbackClear } from './terminal.js'
describe('terminal resize quirks', () => {
it('uses a deeper alt-screen resize clear for Apple Terminal', () => {
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(true)
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: ' Apple_Terminal ' })).toBe(true)
})
it('keeps the normal resize repaint path for modern terminals', () => {
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: 'vscode' })).toBe(false)
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: 'iTerm.app' })).toBe(false)
})
})

View file

@ -168,6 +168,10 @@ export function isXtermJs(): boolean {
return xtversionName?.startsWith('xterm.js') ?? false
}
export function needsAltScreenResizeScrollbackClear(env: NodeJS.ProcessEnv = process.env): boolean {
return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal'
}
// Terminals known to correctly implement the Kitty keyboard protocol
// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
// disambiguation. We previously enabled unconditionally (#23350), assuming

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import wrapText from './wrap-text.js'
describe('wrapText wrap-trim', () => {
it('removes a single soft-wrap boundary space', () => {
expect(wrapText('Let me', 5, 'wrap-trim')).toBe('Let\nme')
})
it('preserves extra original spacing at soft-wrap boundaries', () => {
expect(wrapText('foo bar', 5, 'wrap-trim')).toBe('foo \nbar')
})
it('preserves leading whitespace on unwrapped source lines', () => {
expect(wrapText(' indented', 20, 'wrap-trim')).toBe(' indented')
})
})

View file

@ -77,6 +77,32 @@ function truncate(text: string, columns: number, position: 'start' | 'middle' |
return sliceFit(text, 0, columns - 1) + ELLIPSIS
}
function trimSoftWrapBoundaries(text: string, maxWidth: number): string {
return text
.split('\n')
.map(line => {
const pieces = wrapAnsi(line, maxWidth, { trim: false, hard: true }).split('\n')
if (pieces.length === 1) {
return pieces[0]!
}
for (let index = 0; index < pieces.length - 1; index++) {
const current = pieces[index]!
const next = pieces[index + 1]!
if (/\s$/.test(current)) {
pieces[index] = current.replace(/\s$/, '')
} else if (/^\s/.test(next)) {
pieces[index + 1] = next.replace(/^\s/, '')
}
}
return pieces.join('\n')
})
.join('\n')
}
function computeWrap(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
if (wrapType === 'wrap') {
return wrapAnsi(text, maxWidth, { trim: false, hard: true })
@ -87,7 +113,7 @@ function computeWrap(text: string, maxWidth: number, wrapType: Styles['textWrap'
}
if (wrapType === 'wrap-trim') {
return wrapAnsi(text, maxWidth, { trim: true, hard: true })
return trimSoftWrapBoundaries(text, maxWidth)
}
if (wrapType!.startsWith('truncate')) {

View file

@ -100,11 +100,22 @@ describe('isUsableClipboardText', () => {
})
describe('writeClipboardText', () => {
it('does nothing off macOS', async () => {
const start = vi.fn()
it('does nothing off macOS when no tools are available', async () => {
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
cb(1) // non-zero exit = failure
}
await expect(writeClipboardText('hello', 'linux', start)).resolves.toBe(false)
expect(start).not.toHaveBeenCalled()
return child
}),
stdin: { end: vi.fn() }
}
const start = vi.fn().mockReturnValue(child)
// Linux with no WAYLAND_DISPLAY / no WSL_INTEROP — falls through xclip then xsel, both fail
await expect(writeClipboardText('hello', 'linux', start, {})).resolves.toBe(false)
})
it('writes text to pbcopy on macOS', async () => {
@ -148,4 +159,171 @@ describe('writeClipboardText', () => {
await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false)
})
it('uses wl-copy on Wayland Linux', async () => {
const stdin = { end: vi.fn() }
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
cb(0)
}
return child
}),
stdin
}
const start = vi.fn().mockReturnValue(child)
await expect(
writeClipboardText('wayland text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
).resolves.toBe(true)
expect(start).toHaveBeenCalledWith(
'wl-copy',
['--type', 'text/plain'],
expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
)
expect(stdin.end).toHaveBeenCalledWith('wayland text')
})
it('falls back to xclip when wl-copy fails on Wayland', async () => {
let callCount = 0
const stdin = { end: vi.fn() }
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
callCount++
// wl-copy fails, xclip succeeds
cb(callCount === 1 ? 1 : 0)
}
return child
}),
stdin
}
const start = vi.fn().mockReturnValue(child)
await expect(
writeClipboardText('x11 text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
).resolves.toBe(true)
expect(start).toHaveBeenNthCalledWith(
1,
'wl-copy',
['--type', 'text/plain'],
expect.anything()
)
expect(start).toHaveBeenNthCalledWith(
2,
'xclip',
['-selection', 'clipboard', '-in'],
expect.anything()
)
})
it('falls back to xsel when both wl-copy and xclip fail', async () => {
let callCount = 0
const stdin = { end: vi.fn() }
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
callCount++
cb(callCount < 3 ? 1 : 0) // first two fail, third (xsel) succeeds
}
return child
}),
stdin
}
const start = vi.fn().mockReturnValue(child)
await expect(
writeClipboardText('xsel text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
).resolves.toBe(true)
expect(start).toHaveBeenNthCalledWith(3, 'xsel', ['--clipboard', '--input'], expect.anything())
})
it('uses PowerShell on WSL2 when WSL_DISTRO_NAME is set', async () => {
const stdin = { end: vi.fn() }
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
cb(0)
}
return child
}),
stdin
}
const start = vi.fn().mockReturnValue(child)
await expect(writeClipboardText('wsl text', 'linux', start as any, { WSL_DISTRO_NAME: 'Ubuntu' })).resolves.toBe(true)
expect(start).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
expect(stdin.end).toHaveBeenCalledWith('wsl text')
})
it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => {
const stdin = { end: vi.fn() }
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
cb(0)
}
return child
}),
stdin
}
const start = vi.fn().mockReturnValue(child)
await expect(
writeClipboardText('wslg text', 'linux', start as any, {
WAYLAND_DISPLAY: 'wayland-0',
WSL_DISTRO_NAME: 'Ubuntu'
})
).resolves.toBe(true)
expect(start).toHaveBeenNthCalledWith(
1,
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
expect(stdin.end).toHaveBeenCalledWith('wslg text')
})
it('uses PowerShell on Windows', async () => {
const stdin = { end: vi.fn() }
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
cb(0)
}
return child
}),
stdin
}
const start = vi.fn().mockReturnValue(child)
await expect(writeClipboardText('windows text', 'win32', start as any)).resolves.toBe(true)
expect(start).toHaveBeenCalledWith(
'powershell',
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
})
})

View file

@ -132,6 +132,33 @@ describe('createGatewayEventHandler', () => {
expect(ctx.system.sys).toHaveBeenCalledWith('compressing 968 messages (~123,400 tok)…')
})
it('surfaces self-improvement review summaries as a persistent system line', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const onEvent = createGatewayEventHandler(ctx)
onEvent({
payload: { text: "💾 Self-improvement review: Skill 'hermes-release' patched" },
type: 'review.summary'
} as any)
expect(ctx.system.sys).toHaveBeenCalledWith(
"💾 Self-improvement review: Skill 'hermes-release' patched"
)
})
it('ignores review.summary events with empty or missing text', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const onEvent = createGatewayEventHandler(ctx)
onEvent({ payload: { text: '' }, type: 'review.summary' } as any)
onEvent({ payload: { text: ' ' }, type: 'review.summary' } as any)
onEvent({ payload: undefined, type: 'review.summary' } as any)
expect(ctx.system.sys).not.toHaveBeenCalled()
})
it('clears the visible todo list when the todo tool returns an empty list', () => {
const appended: Msg[] = []
const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]

View file

@ -18,12 +18,33 @@ describe('createSlashHandler', () => {
expect(getOverlayState().picker).toBe(true)
})
it('treats /provider as a local /model alias', () => {
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/provider')).toBe(true)
expect(getOverlayState().modelPicker).toBe(true)
expect(createSlashHandler(ctx)('/redraw')).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn')
})
it('exits locally for /quit', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/quit')).toBe(true)
expect(ctx.session.die).toHaveBeenCalledTimes(1)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('routes /status to live session.status instead of slash worker', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/status')).toBe(true)
expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', 'Status')
})
})
it('keeps typed /model switches session-scoped by default', async () => {
@ -165,12 +186,105 @@ describe('createSlashHandler', () => {
})
})
it('shows usage for an unknown /skills subcommand', () => {
it('delegates non-native /skills subcommands to slash.exec', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills zzz')
createSlashHandler(ctx)('/skills check')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'skills check',
session_id: null
})
})
it('passes /new <title> through to the session lifecycle', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/new sprint planning')
getOverlayState().confirm?.onConfirm()
expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', 'sprint planning')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('reloads skills in the live gateway and refreshes the catalog', async () => {
const rpc = vi.fn((method: string) => {
if (method === 'skills.reload') {
return Promise.resolve({ output: '42 skill(s) available' })
}
if (method === 'commands.catalog') {
return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
}
return Promise.resolve({})
})
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/reload-skills')
expect(rpc).toHaveBeenCalledWith('skills.reload', {})
await vi.waitFor(() => {
expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills')
expect(ctx.local.setCatalog).toHaveBeenCalledWith(
expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
)
})
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
// Regressions from Copilot review on #19835: /voice output + frontend
// binding state must both track the gateway's fresh ``record_key`` on
// every response, or a config edit shows the new shortcut in text
// while push-to-talk still fires the old one until the next mtime
// poll (~5s).
it('/voice status renders the gateway record_key and pushes it into frontend state', async () => {
const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'ctrl+space', tts: false }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/voice status')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Record key: Ctrl+Space')
})
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space' })
)
})
it('/voice on renders the configured binding for the start/stop hint', async () => {
const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'alt+r', tts: false }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/voice on')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled')
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording')
})
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' }))
})
it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => {
const rpc = vi.fn(() => Promise.resolve({ enabled: false, tts: false }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/voice status')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Record key: Ctrl+B')
})
})
// Round-2 Copilot review on #19835: a response missing ``record_key``
// (e.g. the old tts branch, or any future branch that forgets to
// include it) MUST NOT clobber the user's cached binding back to
// Ctrl+B. The label still renders the default for display; the
// frontend state keeps whatever was last authoritatively set.
it('/voice tts without record_key does not clobber cached frontend binding', async () => {
const rpc = vi.fn(() => Promise.resolve({ enabled: true, tts: true }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/voice tts')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice TTS enabled.')
})
expect(ctx.voice.setVoiceRecordKey).not.toHaveBeenCalled()
})
it('cycles details mode and persists it', async () => {
@ -397,17 +511,17 @@ describe('createSlashHandler', () => {
local: {
catalog: {
canon: {
'/status': '/status',
'/statusbar': '/statusbar'
'/profile': '/profile',
'/plugins': '/plugins'
}
}
}
})
expect(createSlashHandler(ctx)('/status')).toBe(true)
expect(createSlashHandler(ctx)('/profile')).toBe(true)
await vi.waitFor(() => {
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'status',
command: 'profile',
session_id: null
})
})
@ -625,7 +739,8 @@ const buildLocal = () => ({
catalog: null,
getHistoryItems: vi.fn(() => []),
getLastUserMsg: vi.fn(() => ''),
maybeWarn: vi.fn()
maybeWarn: vi.fn(),
setCatalog: vi.fn()
})
const buildSession = () => ({
@ -648,7 +763,8 @@ const buildTranscript = () => ({
})
const buildVoice = () => ({
setVoiceEnabled: vi.fn()
setVoiceEnabled: vi.fn(),
setVoiceRecordKey: vi.fn()
})
interface Ctx {

View file

@ -0,0 +1,386 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GatewayClient } from '../gatewayClient.js'
interface ListenerEntry {
callback: (event: any) => void
once: boolean
}
class FakeWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: FakeWebSocket[] = []
readyState = FakeWebSocket.CONNECTING
sent: string[] = []
readonly url: string
private listeners = new Map<string, ListenerEntry[]>()
constructor(url: string) {
this.url = url
FakeWebSocket.instances.push(this)
}
static reset() {
FakeWebSocket.instances = []
}
addEventListener(type: string, callback: (event: any) => void, options?: unknown) {
const once =
typeof options === 'object' &&
options !== null &&
'once' in options &&
Boolean((options as { once?: unknown }).once)
const entries = this.listeners.get(type) ?? []
entries.push({ callback, once })
this.listeners.set(type, entries)
}
removeEventListener(type: string, callback: (event: any) => void) {
const entries = this.listeners.get(type)
if (!entries) {
return
}
this.listeners.set(
type,
entries.filter(entry => entry.callback !== callback)
)
}
send(payload: string) {
if (this.readyState !== FakeWebSocket.OPEN) {
throw new Error('socket not open')
}
this.sent.push(payload)
}
close(code = 1000) {
if (this.readyState === FakeWebSocket.CLOSED) {
return
}
this.readyState = FakeWebSocket.CLOSED
this.emit('close', { code })
}
open() {
this.readyState = FakeWebSocket.OPEN
this.emit('open', {})
}
message(data: string) {
this.emit('message', { data })
}
private emit(type: string, event: any) {
const entries = [...(this.listeners.get(type) ?? [])]
for (const entry of entries) {
entry.callback(event)
if (entry.once) {
this.removeEventListener(type, entry.callback)
}
}
}
}
describe('GatewayClient websocket attach mode', () => {
const originalWebSocket = globalThis.WebSocket
let originalGatewayUrl: string | undefined
let originalSidecarUrl: string | undefined
beforeEach(() => {
originalGatewayUrl = process.env.HERMES_TUI_GATEWAY_URL
originalSidecarUrl = process.env.HERMES_TUI_SIDECAR_URL
FakeWebSocket.reset()
;(globalThis as { WebSocket?: unknown }).WebSocket = FakeWebSocket as unknown as typeof WebSocket
})
afterEach(() => {
if (originalGatewayUrl === undefined) {
delete process.env.HERMES_TUI_GATEWAY_URL
} else {
process.env.HERMES_TUI_GATEWAY_URL = originalGatewayUrl
}
if (originalSidecarUrl === undefined) {
delete process.env.HERMES_TUI_SIDECAR_URL
} else {
process.env.HERMES_TUI_SIDECAR_URL = originalSidecarUrl
}
FakeWebSocket.reset()
if (originalWebSocket) {
globalThis.WebSocket = originalWebSocket
} else {
delete (globalThis as { WebSocket?: unknown }).WebSocket
}
})
it('waits for websocket open and resolves RPC requests', async () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
gw.start()
const gatewaySocket = FakeWebSocket.instances[0]!
const req = gw.request<{ ok: boolean }>('session.create', { cols: 80 })
expect(gatewaySocket.sent).toHaveLength(0)
gatewaySocket.open()
await vi.waitFor(() => expect(gatewaySocket.sent).toHaveLength(1))
const frame = JSON.parse(gatewaySocket.sent[0] ?? '{}') as { id: string; method: string }
expect(frame.method).toBe('session.create')
gatewaySocket.message(JSON.stringify({ id: frame.id, jsonrpc: '2.0', result: { ok: true } }))
await expect(req).resolves.toEqual({ ok: true })
gw.kill()
})
it('mirrors event frames to sidecar websocket when configured', async () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
process.env.HERMES_TUI_SIDECAR_URL = 'ws://gateway.test/api/pub?token=abc&channel=demo'
const gw = new GatewayClient()
const seen: string[] = []
gw.on('event', ev => seen.push(ev.type))
gw.start()
const gatewaySocket = FakeWebSocket.instances[0]!
gatewaySocket.open()
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2))
const sidecarSocket = FakeWebSocket.instances[1]!
sidecarSocket.open()
gw.drain()
const eventFrame = JSON.stringify({
jsonrpc: '2.0',
method: 'event',
params: { type: 'tool.start', payload: { tool_id: 't1' } }
})
gatewaySocket.message(eventFrame)
expect(seen).toContain('tool.start')
expect(sidecarSocket.sent).toContain(eventFrame)
gw.kill()
})
it('emits exit when attached websocket closes', () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
const exits: Array<null | number> = []
gw.on('exit', code => exits.push(code))
gw.start()
const gatewaySocket = FakeWebSocket.instances[0]!
gatewaySocket.open()
gw.drain()
gatewaySocket.close(1011)
expect(exits).toEqual([1011])
})
it('rejects pending RPCs with websocket wording when the attached socket closes', async () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
gw.start()
const gatewaySocket = FakeWebSocket.instances[0]!
gatewaySocket.open()
gw.drain()
const req = gw.request('session.create', {})
await vi.waitFor(() => expect(gatewaySocket.sent.length).toBeGreaterThan(0))
gatewaySocket.close(1011)
await expect(req).rejects.toThrow(/gateway websocket closed \(1011\)/)
})
it('rejects pending RPCs when kill() closes the attached websocket', async () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
gw.start()
const gatewaySocket = FakeWebSocket.instances[0]!
gatewaySocket.open()
gw.drain()
const req = gw.request('session.create', {})
await vi.waitFor(() => expect(gatewaySocket.sent.length).toBeGreaterThan(0))
gw.kill()
await expect(req).rejects.toThrow(/gateway closed/)
})
it('reattaches when HERMES_TUI_GATEWAY_URL rotates between requests', async () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway-old.test/api/ws?token=abc'
const gw = new GatewayClient()
gw.start()
const firstSocket = FakeWebSocket.instances[0]!
firstSocket.open()
gw.drain()
const stale = gw.request('session.create', {})
await vi.waitFor(() => expect(firstSocket.sent.length).toBeGreaterThan(0))
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway-new.test/api/ws?token=xyz'
const next = gw.request('session.create', {})
await expect(stale).rejects.toThrow(/gateway attach url changed/)
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2))
const secondSocket = FakeWebSocket.instances[1]!
expect(secondSocket.url).toContain('gateway-new.test')
secondSocket.open()
await vi.waitFor(() => expect(secondSocket.sent.length).toBeGreaterThan(0))
const frame = JSON.parse(secondSocket.sent[0] ?? '{}') as { id: string }
secondSocket.message(JSON.stringify({ id: frame.id, jsonrpc: '2.0', result: { ok: true } }))
await expect(next).resolves.toEqual({ ok: true })
gw.kill()
})
it('redacts query string secrets in attach failure logs and events', () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=hunter2&channel=secret'
delete (globalThis as { WebSocket?: unknown }).WebSocket
const gw = new GatewayClient()
const stderrLines: string[] = []
gw.on('event', ev => {
if (ev.type === 'gateway.stderr' && typeof ev.payload?.line === 'string') {
stderrLines.push(ev.payload.line)
}
})
gw.start()
gw.drain()
expect(stderrLines.length).toBeGreaterThan(0)
for (const line of stderrLines) {
expect(line).not.toContain('hunter2')
expect(line).not.toContain('channel=secret')
}
expect(gw.getLogTail(20)).not.toContain('hunter2')
expect(gw.getLogTail(20)).not.toContain('channel=secret')
gw.kill()
})
it('redacts attach URL secrets when the WebSocket constructor throws', () => {
const secretUrl = 'ws://gateway.test/api/ws?token=hunter2&channel=secret'
process.env.HERMES_TUI_GATEWAY_URL = secretUrl
;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingWebSocket extends FakeWebSocket {
constructor(url: string) {
throw new TypeError(`Invalid URL: ${url}`)
}
} as unknown as typeof WebSocket
const gw = new GatewayClient()
gw.start()
gw.drain()
const tail = gw.getLogTail(20)
expect(tail).not.toContain('hunter2')
expect(tail).not.toContain('channel=secret')
expect(tail).not.toContain(secretUrl)
expect(tail).toContain('ws://gateway.test/api/ws?***')
gw.kill()
})
it('redacts sidecar URL secrets when the WebSocket constructor throws', async () => {
const sidecarUrl = 'ws://gateway.test/api/pub?token=hunter2&channel=secret'
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
process.env.HERMES_TUI_SIDECAR_URL = sidecarUrl
;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingSidecarWebSocket extends FakeWebSocket {
constructor(url: string) {
if (url.includes('/api/pub')) {
throw new TypeError(`Invalid URL: ${url}`)
}
super(url)
}
} as unknown as typeof WebSocket
const gw = new GatewayClient()
gw.start()
const gatewaySocket = FakeWebSocket.instances[0]!
gatewaySocket.open()
await vi.waitFor(() => expect(gw.getLogTail(20)).toContain('[sidecar] failed to connect'))
const tail = gw.getLogTail(20)
expect(tail).not.toContain('hunter2')
expect(tail).not.toContain('channel=secret')
expect(tail).not.toContain(sidecarUrl)
expect(tail).toContain('ws://gateway.test/api/pub?***')
gw.kill()
})
it('redacts user-info credentials even on URLs the WHATWG parser rejects', () => {
// Port 99999 is outside the WHATWG URL parser's valid 065535
// range and survives `.trim()`, so the fixture deterministically
// exercises `redactUrl()`'s fallback branch across Node versions.
// (An earlier `%zz` user-info fixture did NOT actually throw in
// recent Node — WHATWG accepts malformed percent escapes there —
// which silently routed the test through the structured-URL path.)
const fixture = 'ws://alice:hunter2@gateway.test:99999/api/ws?token=secret'
expect(() => new URL(fixture)).toThrow()
process.env.HERMES_TUI_GATEWAY_URL = fixture
delete (globalThis as { WebSocket?: unknown }).WebSocket
const gw = new GatewayClient()
const stderrLines: string[] = []
gw.on('event', ev => {
if (ev.type === 'gateway.stderr' && typeof ev.payload?.line === 'string') {
stderrLines.push(ev.payload.line)
}
})
gw.start()
gw.drain()
expect(stderrLines.length).toBeGreaterThan(0)
for (const line of stderrLines) {
expect(line).not.toContain('alice')
expect(line).not.toContain('hunter2')
expect(line).not.toContain('token=secret')
}
const tail = gw.getLogTail(20)
expect(tail).not.toContain('alice')
expect(tail).not.toContain('hunter2')
expect(tail).not.toContain('token=secret')
gw.kill()
})
})

View file

@ -1,8 +1,47 @@
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { AUDIO_DIRECTIVE_RE, INLINE_RE, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const matches = (text: string) => [...text.matchAll(INLINE_RE)].map(m => m[0])
const BEL = String.fromCharCode(7)
const ESC = String.fromCharCode(27)
const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
const renderPlain = (node: React.ReactNode) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(node, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
instance.unmount()
instance.cleanup()
return output
.replace(OSC_RE, '')
.split('\n')
.map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd())
}
describe('INLINE_RE emphasis', () => {
it('matches word-boundary italic/bold', () => {
@ -144,3 +183,84 @@ describe('protocol sentinels', () => {
expect(AUDIO_DIRECTIVE_RE.test('audio_as_voice')).toBe(false)
})
})
describe('Md wrapping', () => {
it('trims spaces from word-wrap continuation lines', () => {
const lines = renderPlain(
React.createElement(Box, { width: 5 }, React.createElement(Md, { t: DEFAULT_THEME, text: 'Let me' }))
)
expect(lines).toContain('Let')
expect(lines).toContain('me')
expect(lines).not.toContain(' me')
})
it('keeps nested list and quote indentation out of trim-sensitive text', () => {
const lines = renderPlain(
React.createElement(
Box,
{ flexDirection: 'column', width: 24 },
React.createElement(Md, { t: DEFAULT_THEME, text: ' - nested bullet' }),
React.createElement(Md, { t: DEFAULT_THEME, text: '>> nested quote' })
)
)
expect(lines).toContain(' • nested bullet')
expect(lines).toContain(' │ nested quote')
})
it('preserves original inline-code edge spaces', () => {
const lines = renderPlain(
React.createElement(Box, { width: 24 }, React.createElement(Md, { t: DEFAULT_THEME, text: '` hi ` ok' }))
)
expect(lines.some(line => line.startsWith(' hi ok'))).toBe(true)
})
})
describe('renderTable CJK width alignment', () => {
it('column starts share the same display offset across CJK rows', async () => {
const { stringWidth } = await import('@hermes/ink')
const md = [
'| 配置 | Config | 状态 |',
'|------|--------|------|',
'| Vicuna (report) | dense | × |',
'| ChatGLM | chat | ✓ |',
'| 通义千问 | qwen | × |'
].join('\n')
// Pre-fix bug: ` `.repeat(w - stripInlineMarkup(...).length) used
// UTF-16 code units, so a CJK header cell padded to 2 cells while
// the body cell padded to 4, drifting subsequent columns by 2
// cells per CJK char.
//
// Post-fix contract: the prefix preceding the start of column N
// has the same display width across the header and every body row
// (deduped to skip the divider, which renders independently).
const lines = renderPlain(
React.createElement(Box, null, React.createElement(Md, { compact: true, t: DEFAULT_THEME, text: md }))
).filter(line => line.trim().length > 0)
// Heuristic: a "data row" line either contains 'Config' (header)
// or one of the body labels; a divider is all box-drawing. Use
// the substring 'Config' / 'dense' / 'chat' / 'qwen' as the
// unique anchor for column 2's start position on each row.
const colStarts = (line: string, anchor: string): number => {
const idx = line.indexOf(anchor)
return idx < 0 ? -1 : stringWidth(line.slice(0, idx))
}
const headerCol2 = lines.map(l => colStarts(l, 'Config')).find(v => v >= 0)
const denseCol2 = lines.map(l => colStarts(l, 'dense')).find(v => v >= 0)
const chatCol2 = lines.map(l => colStarts(l, 'chat')).find(v => v >= 0)
const qwenCol2 = lines.map(l => colStarts(l, 'qwen')).find(v => v >= 0)
expect(headerCol2).toBeDefined()
expect(denseCol2).toBe(headerCol2)
expect(chatCol2).toBe(headerCol2)
// The CJK row is the one that drifted before the fix. It must
// align with the rest now.
expect(qwenCol2).toBe(headerCol2)
})
})

View file

@ -1,7 +1,13 @@
import { renderSync } from '@hermes/ink'
import React from 'react'
import { PassThrough } from 'stream'
import { describe, expect, it } from 'vitest'
import { MessageLine } from '../components/messageLine.js'
import { toTranscriptMessages } from '../domain/messages.js'
import { upsert } from '../lib/messages.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
describe('toTranscriptMessages', () => {
it('preserves assistant tool-call rows so resume does not drop prior turns', () => {
@ -21,6 +27,50 @@ describe('toTranscriptMessages', () => {
})
})
describe('MessageLine', () => {
it('preserves a separator after compound user prompt glyphs in transcript rows', () => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const t = {
...DEFAULT_THEME,
brand: { ...DEFAULT_THEME.brand, prompt: 'Ψ >' }
}
const instance = renderSync(
React.createElement(MessageLine, {
cols: 80,
msg: { role: 'user', text: 'Okay' },
t
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
const renderedLine = stripAnsi(output)
.split('\n')
.find(line => line.includes('Okay'))
expect(renderedLine).toContain('Ψ > Okay')
})
})
describe('upsert', () => {
it('appends when last role differs', () => {
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)

View file

@ -67,11 +67,15 @@ describe('isVoiceToggleKey', () => {
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'B')).toBe(true)
})
it('matches Cmd+B on macOS (preserve platform muscle memory)', async () => {
it('matches kitty-style Cmd+B on macOS via key.super', async () => {
const { isVoiceToggleKey } = await importPlatform('darwin')
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b')).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b')).toBe(true)
// ``key.meta`` is NOT accepted as Cmd — hermes-ink uses meta for
// Alt too, so accepting it leaked Alt+B into the default binding
// (Copilot round-6 review on #19835). Legacy-terminal mac users
// get strict Ctrl+B.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b')).toBe(false)
})
it('matches Ctrl+B on non-macOS platforms', async () => {
@ -89,6 +93,449 @@ describe('isVoiceToggleKey', () => {
})
})
describe('parseVoiceRecordKey (#18994)', () => {
it('falls back to Ctrl+B for empty input', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
expect(parseVoiceRecordKey('')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
it('parses ctrl+<letter> bindings', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
expect(parseVoiceRecordKey('ctrl+o')).toEqual({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' })
expect(parseVoiceRecordKey('Ctrl+R')).toEqual({ ch: 'r', mod: 'ctrl', raw: 'ctrl+r' })
})
it('parses alt/super aliases', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
expect(parseVoiceRecordKey('alt+b').mod).toBe('alt')
expect(parseVoiceRecordKey('option+b').mod).toBe('alt')
expect(parseVoiceRecordKey('super+b').mod).toBe('super')
expect(parseVoiceRecordKey('win+b').mod).toBe('super')
})
it('treats ambiguous mac modifiers (meta / cmd / command) as unrecognised', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// ``meta`` / ``cmd`` / ``command`` are ambiguous on the wire:
// hermes-ink sets ``key.meta`` for plain Alt on every platform AND
// for Cmd on legacy macOS terminals. Accepting any of them would
// produce a display/binding mismatch (Copilot round-6 review on
// #19835). Users on modern kitty-style terminals spell the
// platform action modifier ``super`` / ``win``.
expect(parseVoiceRecordKey('meta+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('cmd+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('command+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
it('parses named keys (space, enter, tab, escape, backspace, delete)', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
// Every named token from the CLI's prompt_toolkit ``c-<name>`` set is
// accepted with both the canonical name and its common alias.
expect(parseVoiceRecordKey('ctrl+space')).toEqual({
ch: 'space',
mod: 'ctrl',
named: 'space',
raw: 'ctrl+space'
})
expect(parseVoiceRecordKey('alt+enter').named).toBe('enter')
expect(parseVoiceRecordKey('alt+return').named).toBe('enter') // ``return`` ↔ ``enter``
expect(parseVoiceRecordKey('ctrl+tab').named).toBe('tab')
expect(parseVoiceRecordKey('ctrl+escape').named).toBe('escape')
expect(parseVoiceRecordKey('ctrl+esc').named).toBe('escape') // ``esc`` alias
expect(parseVoiceRecordKey('ctrl+backspace').named).toBe('backspace')
expect(parseVoiceRecordKey('ctrl+delete').named).toBe('delete')
expect(parseVoiceRecordKey('ctrl+del').named).toBe('delete') // ``del`` alias
})
it('falls back to Ctrl+B for unrecognised multi-character tokens', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// Typos / unsupported names (``ctrl+spcae``, ``ctrl+f5``, …) fall back
// to the documented Ctrl+B default rather than silently disabling the
// binding.
expect(parseVoiceRecordKey('ctrl+spcae')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('ctrl+f5')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
// Round-3 Copilot review regressions on #19835.
it('does not throw on non-string YAML scalars — falls back instead', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// ``config.get full`` surfaces raw YAML values; ``voice.record_key: 1``
// or ``voice.record_key: true`` would otherwise crash ``.trim()``.
expect(parseVoiceRecordKey(1 as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey(true as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey(null as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey(undefined as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey({} as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
it('rejects multi-modifier chords rather than silently dropping extras', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// Previously ``ctrl+alt+r`` parsed as ``ctrl+r`` and ``cmd+ctrl+b`` as
// ``super+b`` — a typo silently bound a different shortcut. Now a
// multi-modifier spelling falls back to the documented default.
expect(parseVoiceRecordKey('ctrl+alt+r')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('cmd+ctrl+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('alt+ctrl+space')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
// Round-4 Copilot review regressions on #19835.
it('rejects bare-char configs without an explicit modifier', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// The classic CLI's prompt_toolkit binds raw-char configs to the key
// itself (``c-o`` requires an explicit modifier); rewriting ``o``
// → ``ctrl+o`` would silently diverge the two runtimes. Refuse.
expect(parseVoiceRecordKey('o')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('space')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('escape')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
it('rejects ctrl+c / ctrl+d / ctrl+l — reserved by the TUI input handler', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// ``useInputHandlers()`` intercepts these before the voice check,
// so a binding like ``ctrl+c`` would be advertised but never fire.
// Fall back to the documented default instead of lying to the user.
expect(parseVoiceRecordKey('ctrl+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('ctrl+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('ctrl+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
// Alt-modifier versions of those letters are NOT intercepted, so
// they remain usable.
expect(parseVoiceRecordKey('alt+c').mod).toBe('alt')
// ``ctrl+x`` is intentionally allowed — only intercepted during
// queue-edit (``queueEditIdx !== null``), so the voice binding
// works for most of the session (Copilot round-8 review).
expect(parseVoiceRecordKey('ctrl+x').mod).toBe('ctrl')
expect(parseVoiceRecordKey('ctrl+x').ch).toBe('x')
})
it('rejects super+{c,d,l,v} on macOS — action-mod chords are claimed before voice', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('darwin')
// On macOS super+c/d/l/v are copy / exit / clear / paste. Reject at
// parse time so /voice status doesn't advertise dead bindings.
expect(parseVoiceRecordKey('super+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('super+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('super+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('super+v')).toEqual(DEFAULT_VOICE_RECORD_KEY)
// Other super letters still work (no global chord claims them).
expect(parseVoiceRecordKey('super+b').mod).toBe('super')
expect(parseVoiceRecordKey('super+o').mod).toBe('super')
})
it('allows super+{c,d,l,v} on Linux/Windows — those globals key off Ctrl, not Super', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
// Kitty/CSI-u users on non-mac report Cmd/Super as ``key.super``,
// but the TUI's global shortcuts (copy/exit/clear/paste) key off
// Ctrl there, so ``super+<letter>`` doesn't collide. Reject would
// silently coerce valid configs to Ctrl+B (Copilot round-8 review).
expect(parseVoiceRecordKey('super+c').mod).toBe('super')
expect(parseVoiceRecordKey('super+d').mod).toBe('super')
expect(parseVoiceRecordKey('super+l').mod).toBe('super')
expect(parseVoiceRecordKey('super+v').mod).toBe('super')
})
it('rejects alt+{c,d,l} on macOS — meta-as-alt collides with isAction', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('darwin')
// hermes-ink reports Alt as ``key.meta`` on many terminals, and
// ``isActionMod`` on darwin accepts ``key.meta`` as the action
// modifier. So ``alt+c`` / ``alt+d`` / ``alt+l`` get claimed by
// isCopyShortcut / isAction('d') / isAction('l') before voice
// runs (Copilot round-12 on #19835).
expect(parseVoiceRecordKey('alt+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('alt+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('alt+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
// Other alt letters stay usable on darwin.
expect(parseVoiceRecordKey('alt+r').mod).toBe('alt')
expect(parseVoiceRecordKey('alt+space').mod).toBe('alt')
})
it('allows alt+{c,d,l} on Linux/Windows — non-mac isAction keys off Ctrl', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
// On Linux/Windows ``isActionMod`` ignores key.meta, so alt+<letter>
// doesn't collide with copy/exit/clear. Those configs stay usable.
expect(parseVoiceRecordKey('alt+c').mod).toBe('alt')
expect(parseVoiceRecordKey('alt+d').mod).toBe('alt')
expect(parseVoiceRecordKey('alt+l').mod).toBe('alt')
})
// Round-5 Copilot review regressions on #19835.
it('super+<key> does NOT fire on key.meta-only events (Alt+X false-fire guard)', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
// hermes-ink sets ``key.meta`` for Alt/Option AND for bare Esc on
// some macOS terminals. The super branch used to accept
// ``isMac && key.meta`` as a Cmd fallback, which made super+<key>
// bindings silently fire on Alt+<key> / bare Esc.
const superB = parseVoiceRecordKey('super+b')
const superSpace = parseVoiceRecordKey('super+space')
const superEscape = parseVoiceRecordKey('super+escape')
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', superB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, ' ', superSpace)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', superEscape)).toBe(false)
})
// Round-6 Copilot review regressions on #19835.
it('default ctrl+b does NOT fire on Alt+B via isActionMod meta leak', async () => {
const { DEFAULT_VOICE_RECORD_KEY, isVoiceToggleKey } = await importPlatform('darwin')
// ``isActionMod(key)`` on darwin was accepting ``key.meta`` as the
// action modifier, so Alt+B (key.meta=true) fired the default
// ctrl+b binding. Now the Cmd-fallback path requires literal
// ``key.super`` on macOS and rejects ``key.meta``.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(false)
// Literal Ctrl+B and Cmd+B (kitty-style) still work on darwin.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
})
it('ctrl+<key> rejects chords with extra alt / meta / super bits', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const ctrlO = parseVoiceRecordKey('ctrl+o')
// ``ctrl+o`` must fire ONLY on literal Ctrl+O, not on
// Ctrl+Alt+O / Ctrl+Cmd+O / Ctrl+Meta+O — otherwise the runtime
// matches a different chord than the parser would let you
// configure.
expect(isVoiceToggleKey({ alt: true, ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: true, super: false }, 'o', ctrlO)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'o', ctrlO)).toBe(false)
// Sanity: plain Ctrl+O still fires.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
})
it('super+<key> rejects chords with extra ctrl / alt / meta bits', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const superB = parseVoiceRecordKey('super+b')
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: true }, 'b', superB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: true }, 'b', superB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'b', superB)).toBe(false)
// Sanity: plain Super+B still fires.
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', superB)).toBe(true)
})
it('alt+escape does not fire on bare Esc meta-shape', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const altEscape = parseVoiceRecordKey('alt+escape')
// Some terminals surface bare Esc as meta=true + escape=true.
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', altEscape)).toBe(false)
// Explicit alt bit (kitty-style) still fires the configured chord.
expect(isVoiceToggleKey({ alt: true, ctrl: false, escape: true, meta: false, super: false }, '', altEscape)).toBe(true)
})
it('rejects matches when Shift is held (different chord than configured)', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
// Parser rejects multi-modifier configs like ``ctrl+shift+tab``,
// so the runtime matcher must also reject Shift-held events —
// otherwise ``ctrl+tab`` would fire on Ctrl+Shift+Tab.
const ctrlTab = parseVoiceRecordKey('ctrl+tab')
const altEnter = parseVoiceRecordKey('alt+enter')
const ctrlO = parseVoiceRecordKey('ctrl+o')
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false, tab: true }, '', ctrlTab)).toBe(false)
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, return: true, shift: true, super: false }, '', altEnter)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false }, 'o', ctrlO)).toBe(false)
// Sanity: same events without Shift still fire.
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: false, super: false, tab: true }, '', ctrlTab)).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: false, super: false }, 'o', ctrlO)).toBe(true)
})
})
describe('formatVoiceRecordKey (#18994)', () => {
it('renders as the user expects in /voice status', async () => {
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+b'))).toBe('Ctrl+B')
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+o'))).toBe('Ctrl+O')
expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+r'))).toBe('Alt+R')
// ``super``/``win`` render as ``Super`` on non-mac so the hint
// doesn't tell Linux/Windows users to press a Cmd key they don't
// have.
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Super+B')
})
it('renders named keys in title case (Ctrl+Space, Ctrl+Enter)', async () => {
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+space'))).toBe('Ctrl+Space')
expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+enter'))).toBe('Alt+Enter')
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+esc'))).toBe('Ctrl+Escape')
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+space'))).toBe('Super+Space')
})
})
describe('isVoiceToggleKey honours configured record key (#18994)', () => {
it('binds the configured letter, not hardcoded b', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const ctrlO = parseVoiceRecordKey('ctrl+o')
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
// The old hardcoded 'b' must NOT match when the user configured 'o'.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlO)).toBe(false)
})
it('alt+<letter> binding matches alt OR meta (terminal-protocol parity)', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const altR = parseVoiceRecordKey('alt+r')
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false }, 'r', altR)).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'r', altR)).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'r', altR)).toBe(false)
})
it('binds named keys via ink event flags (space → ch === " ", enter → key.return, …)', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const ctrlSpace = parseVoiceRecordKey('ctrl+space')
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true)
// Single-char ``b`` must NOT match a ``space``-configured binding.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlSpace)).toBe(false)
// Space without the configured modifier must not fire either.
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, ' ', ctrlSpace)).toBe(false)
const ctrlEnter = parseVoiceRecordKey('ctrl+enter')
expect(isVoiceToggleKey({ ctrl: true, meta: false, return: true, super: false }, '', ctrlEnter)).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, meta: false, return: false, super: false }, '', ctrlEnter)).toBe(false)
const altTab = parseVoiceRecordKey('alt+tab')
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(true)
expect(isVoiceToggleKey({ alt: false, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(false)
const ctrlEscape = parseVoiceRecordKey('ctrl+escape')
expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, escape: false, meta: false, super: false }, '', ctrlEscape)).toBe(false)
const ctrlBackspace = parseVoiceRecordKey('ctrl+backspace')
expect(isVoiceToggleKey({ backspace: true, ctrl: true, meta: false, super: false }, '', ctrlBackspace)).toBe(true)
const ctrlDelete = parseVoiceRecordKey('ctrl+delete')
expect(isVoiceToggleKey({ ctrl: true, delete: true, meta: false, super: false }, '', ctrlDelete)).toBe(true)
})
it('omitted configured key falls back to ctrl+b (back-compat)', async () => {
const { isVoiceToggleKey } = await importPlatform('linux')
// No third arg → DEFAULT_VOICE_RECORD_KEY → Ctrl+B behaviour.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o')).toBe(false)
})
// Regressions from Copilot review on #19835: the previous implementation
// accepted ``isActionMod(key)`` in the ``ctrl`` branch for every
// configured key, so bare Esc (which hermes-ink reports with
// ``key.meta`` on some macOS terminals) fired ``ctrl+escape``, and
// Alt+Space / Alt+Tab fired ``ctrl+space`` / ``ctrl+tab``. The fallback
// is now gated to the documented default (``ctrl+b``) only.
it('ctrl+escape does NOT fire on bare Esc via key.meta on macOS', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const ctrlEscape = parseVoiceRecordKey('ctrl+escape')
// Bare Esc on a legacy macOS terminal: ``key.meta: true``, ``key.escape: true``, no ctrl.
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', ctrlEscape)).toBe(false)
// Real Ctrl+Esc still fires.
expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true)
})
it('ctrl+space does NOT fire on Alt+Space on macOS', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const ctrlSpace = parseVoiceRecordKey('ctrl+space')
// Alt+Space surfaces as ``key.meta: true`` with space char.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, ' ', ctrlSpace)).toBe(false)
// Real Ctrl+Space still fires.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true)
})
it('default ctrl+b accepts raw Ctrl+B and kitty-style Cmd+B on macOS', async () => {
const { DEFAULT_VOICE_RECORD_KEY, isVoiceToggleKey } = await importPlatform('darwin')
// Raw Ctrl+B: always works.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
// Cmd+B via kitty-style ``key.super``: still works.
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
// Cmd+B via legacy ``key.meta`` NO LONGER works — ``key.meta`` is
// hermes-ink's Alt signal, so accepting it leaked Alt+B into the
// default binding (Copilot round-6 review on #19835).
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(false)
})
it('custom ctrl+<letter> does NOT accept Cmd fallback on macOS', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const ctrlO = parseVoiceRecordKey('ctrl+o')
// Only ``ctrl+b`` gets the action-modifier fallback; ``ctrl+o`` must
// be a literal Ctrl bit — otherwise Cmd+O would steal the shortcut.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'o', ctrlO)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'o', ctrlO)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
})
it('super+b renders "Cmd+B" on darwin and requires the literal key.super bit', async () => {
const { formatVoiceRecordKey, isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const superB = parseVoiceRecordKey('super+b')
expect(formatVoiceRecordKey(superB)).toBe('Cmd+B')
// Kitty-style: key.super fires the binding.
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', superB)).toBe(true)
// ``key.meta`` is NOT accepted — hermes-ink uses meta for Alt too,
// so accepting it here would make super+b silently fire on Alt+B
// (Copilot round-5 review on #19835).
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', superB)).toBe(false)
// Ctrl held at the same time → reject (different chord).
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'b', superB)).toBe(false)
})
// Round-2 Copilot review regressions on #19835.
it('super+b renders "Super+B" on Linux (not "Cmd+B")', async () => {
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Super+B')
expect(formatVoiceRecordKey(parseVoiceRecordKey('win+b'))).toBe('Super+B')
})
it('super+b still renders "Cmd+B" on macOS', async () => {
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('darwin')
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Cmd+B')
expect(formatVoiceRecordKey(parseVoiceRecordKey('win+b'))).toBe('Cmd+B')
})
it('ctrl+b aliases (control+b, "ctrl + b") still accept Cmd+B fallback on macOS', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const controlB = parseVoiceRecordKey('control+b')
const spacedB = parseVoiceRecordKey('ctrl + b')
// Both parse to the documented default semantically; both must keep
// the macOS Cmd+B muscle-memory fallback via kitty-style key.super.
// ``key.meta`` is NOT accepted — that's hermes-ink's Alt signal
// (round-6 review), so legacy-terminal users get strict Ctrl+B.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', controlB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', spacedB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', controlB)).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', spacedB)).toBe(true)
// Literal Ctrl+B still fires.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', controlB)).toBe(true)
// And still reject a ctrl bit on a different letter.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', controlB)).toBe(false)
})
})
describe('isMacActionFallback', () => {
it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => {
const { isMacActionFallback } = await importPlatform('darwin')

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
describe('precisionWheel', () => {
it('passes the first modifier-held wheel event', () => {
const s = initPrecisionWheel()
expect(computePrecisionWheelStep(s, 1, true, 1000)).toEqual({ active: true, entered: true, rows: 1 })
})
it('coalesces same-frame events without throttling line-by-line scroll', () => {
const s = initPrecisionWheel()
computePrecisionWheelStep(s, 1, true, 1000)
expect(computePrecisionWheelStep(s, 1, true, 1008).rows).toBe(0)
expect(computePrecisionWheelStep(s, 1, true, 1016).rows).toBe(1)
})
it('keeps queued momentum in precision mode briefly after modifier release', () => {
const s = initPrecisionWheel()
computePrecisionWheelStep(s, 1, true, 1000)
expect(computePrecisionWheelStep(s, 1, false, 1050)).toMatchObject({ active: true, rows: 1 })
})
it('leaves precision mode once modifier-free momentum goes idle', () => {
const s = initPrecisionWheel()
computePrecisionWheelStep(s, 1, true, 1000)
expect(computePrecisionWheelStep(s, 1, false, 1100)).toEqual({ active: false, entered: false, rows: 0 })
})
it('does not coalesce immediate reversals', () => {
const s = initPrecisionWheel()
computePrecisionWheelStep(s, 1, true, 1000)
expect(computePrecisionWheelStep(s, -1, true, 1008).rows).toBe(1)
})
})

View file

@ -3,9 +3,12 @@ import { describe, expect, it, vi } from 'vitest'
import { scrollWithSelectionBy } from '../app/scroll.js'
function makeScroll(overrides: Partial<Record<string, unknown>> = {}) {
const getScrollHeight = (overrides.getScrollHeight as (() => number) | undefined) ?? vi.fn(() => 100)
return {
getFreshScrollHeight: vi.fn(() => getScrollHeight()),
getPendingDelta: vi.fn(() => 0),
getScrollHeight: vi.fn(() => 100),
getScrollHeight,
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20),
getViewportTop: vi.fn(() => 0),
@ -34,6 +37,47 @@ describe('scrollWithSelectionBy', () => {
expect(s.scrollBy).toHaveBeenCalledWith(1)
})
it('uses fresh scroll height when cached height would swallow a down-scroll at a fake bottom', () => {
const s = makeScroll({
getFreshScrollHeight: vi.fn(() => 34),
getScrollHeight: vi.fn(() => 30),
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20)
})
const selection = {
captureScrolledRows: vi.fn(),
getState: vi.fn(() => null),
shiftAnchor: vi.fn(),
shiftSelection: vi.fn()
}
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
expect(s.scrollBy).toHaveBeenCalledWith(4)
})
it('uses fresh height when pending down-scroll reaches the cached fake bottom', () => {
const s = makeScroll({
getFreshScrollHeight: vi.fn(() => 38),
getPendingDelta: vi.fn(() => 2),
getScrollHeight: vi.fn(() => 32),
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20)
})
const selection = {
captureScrolledRows: vi.fn(),
getState: vi.fn(() => null),
shiftAnchor: vi.fn(),
shiftSelection: vi.fn()
}
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
expect(s.scrollBy).toHaveBeenCalledWith(6)
})
it('does nothing at the edge instead of queueing dead pending deltas', () => {
const s = makeScroll({
getScrollHeight: vi.fn(() => 30),

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { padVerb, VERB_PAD_LEN } from '../components/appChrome.js'
import { VERBS } from '../content/verbs.js'
describe('FaceTicker verb padding', () => {
it('pads every verb to the same width', () => {
for (const verb of VERBS) {
expect(padVerb(verb)).toHaveLength(VERB_PAD_LEN)
}
})
it('keeps trailing ellipsis attached', () => {
for (const verb of VERBS) {
expect(padVerb(verb).startsWith(`${verb}`)).toBe(true)
}
})
})

View file

@ -3,11 +3,19 @@ import { describe, expect, it, vi } from 'vitest'
import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js'
describe('terminal mode reset', () => {
it('includes the sticky input modes Hermes enables', () => {
it('includes common sticky input modes', () => {
expect(TERMINAL_MODE_RESET).toContain('\x1b[0\'z')
expect(TERMINAL_MODE_RESET).toContain('\x1b[0\'{')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2029l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1016l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1015l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1006l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1005l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1003l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1002l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1001l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1000l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?9l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1004l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2004l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1049l')

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { shouldPassThroughToGlobalHandler } from '../components/textInput.js'
import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js'
const key = (overrides: Record<string, unknown> = {}) =>
({ ctrl: false, meta: false, ...overrides }) as any
describe('shouldPassThroughToGlobalHandler', () => {
it('passes through the configured voice shortcut while composer is focused', () => {
expect(
shouldPassThroughToGlobalHandler('o', key({ ctrl: true }), parseVoiceRecordKey('ctrl+o'))
).toBe(true)
expect(
shouldPassThroughToGlobalHandler('r', key({ meta: true }), parseVoiceRecordKey('alt+r'))
).toBe(true)
expect(
shouldPassThroughToGlobalHandler(' ', key({ ctrl: true }), parseVoiceRecordKey('ctrl+space'))
).toBe(true)
expect(
shouldPassThroughToGlobalHandler('', key({ ctrl: true, return: true }), parseVoiceRecordKey('ctrl+enter'))
).toBe(true)
})
it('keeps the legacy default pass-through when no custom key is provided', () => {
expect(shouldPassThroughToGlobalHandler('b', key({ ctrl: true }), DEFAULT_VOICE_RECORD_KEY)).toBe(true)
expect(shouldPassThroughToGlobalHandler('b', key({ ctrl: true }))).toBe(true)
})
it('does not swallow ordinary typing keys', () => {
expect(shouldPassThroughToGlobalHandler('h', key(), parseVoiceRecordKey('ctrl+o'))).toBe(false)
expect(shouldPassThroughToGlobalHandler('o', key(), parseVoiceRecordKey('ctrl+o'))).toBe(false)
})
it('always passes through non-voice global control keys', () => {
expect(shouldPassThroughToGlobalHandler('c', key({ ctrl: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('x', key({ ctrl: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ escape: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ tab: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ pageUp: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ pageDown: true }))).toBe(true)
})
})

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { decideRightClickAction } from '../components/textInput.js'
describe('decideRightClickAction', () => {
it('returns paste when there is no selection', () => {
expect(decideRightClickAction('hello world', null)).toEqual({ action: 'paste' })
})
it('returns paste for a collapsed (empty) range', () => {
expect(decideRightClickAction('hello world', { end: 5, start: 5 })).toEqual({
action: 'paste'
})
})
it('copies the slice when range covers non-empty text', () => {
expect(decideRightClickAction('hello world', { end: 5, start: 0 })).toEqual({
action: 'copy',
text: 'hello'
})
})
it('copies a middle slice', () => {
expect(decideRightClickAction('hello world', { end: 11, start: 6 })).toEqual({
action: 'copy',
text: 'world'
})
})
it('falls back to paste when slice is empty (out-of-range indices)', () => {
expect(decideRightClickAction('', { end: 5, start: 0 })).toEqual({ action: 'paste' })
})
it('handles unicode (emoji, CJK) in the slice', () => {
const value = 'hi 你好 🎉'
expect(decideRightClickAction(value, { end: 5, start: 3 })).toEqual({
action: 'copy',
text: '你好'
})
})
it('preserves leading/trailing whitespace in the copied slice', () => {
expect(decideRightClickAction(' spaced ', { end: 10, start: 0 })).toEqual({
action: 'copy',
text: ' spaced '
})
})
})

View file

@ -209,6 +209,34 @@ describe('fromSkin', () => {
expect(theme.color.completionCurrentBg).toBe('#bfbfbf')
})
it('uses active completion color as the selection highlight fallback', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const theme = fromSkin({ completion_menu_current_bg: '#123456' }, {})
expect(theme.color.selectionBg).toBe('#123456')
})
it('maps completion meta background colors from skins', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const theme = fromSkin({
completion_menu_meta_bg: '#111111',
completion_menu_meta_current_bg: '#222222'
}, {})
expect(theme.color.completionMetaBg).toBe('#111111')
expect(theme.color.completionMetaCurrentBg).toBe('#222222')
})
it('lets selection_bg override completion highlight colors', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const theme = fromSkin({ completion_menu_current_bg: '#123456', selection_bg: '#654321' }, {})
expect(theme.color.selectionBg).toBe('#654321')
})
it('overrides branding', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' })

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { completionRequestForInput } from '../hooks/useCompletion.js'
describe('completionRequestForInput', () => {
it('routes real slash commands to slash completion', () => {
expect(completionRequestForInput('/help')).toMatchObject({
method: 'complete.slash',
params: { text: '/help' },
replaceFrom: 1
})
})
it('does not route absolute paths through slash completion', () => {
expect(
completionRequestForInput('/home/d/Desktop/agenda/CrimsonRed/.hermes/plans/2026-05-04-HANDOFF-NEXT.md')
).toMatchObject({
method: 'complete.path',
params: { word: '/home/d/Desktop/agenda/CrimsonRed/.hermes/plans/2026-05-04-HANDOFF-NEXT.md' },
replaceFrom: 0
})
})
it('keeps path completion for trailing absolute path tokens', () => {
expect(completionRequestForInput('read /home/d/Desktop/file.md')).toMatchObject({
method: 'complete.path',
params: { word: '/home/d/Desktop/file.md' },
replaceFrom: 5
})
})
it('leaves plain text alone', () => {
expect(completionRequestForInput('hello there')).toBeNull()
})
})

View file

@ -1,13 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $uiState, resetUiState } from '../app/uiStore.js'
import {
applyDisplay,
hydrateFullConfig,
normalizeBusyInputMode,
normalizeIndicatorStyle,
normalizeMouseTracking,
normalizeStatusBar
} from '../app/useConfigSync.js'
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
describe('applyDisplay', () => {
beforeEach(() => {
@ -292,3 +294,139 @@ describe('applyDisplay → tui_status_indicator', () => {
expect($uiState.get().indicatorStyle).toBe('kaomoji')
})
})
// Regressions from Copilot review on #19835: the config-hydration path
// for voice.record_key was untested, so a future regression in the
// hydration or mtime-reapply wiring would slip past the suite.
describe('applyDisplay → voice.record_key (#18994)', () => {
beforeEach(() => {
resetUiState()
})
it('parses voice.record_key and pushes it through the setter', () => {
const setBell = vi.fn()
const setVoiceRecordKey = vi.fn()
applyDisplay(
{ config: { display: {}, voice: { record_key: 'ctrl+space' } } },
setBell,
setVoiceRecordKey
)
expect(setVoiceRecordKey).toHaveBeenCalledWith(
expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space', raw: 'ctrl+space' })
)
})
it('falls back to the documented default when voice.record_key is missing', () => {
const setBell = vi.fn()
const setVoiceRecordKey = vi.fn()
applyDisplay({ config: { display: {} } }, setBell, setVoiceRecordKey)
expect(setVoiceRecordKey).toHaveBeenCalledWith(
expect.objectContaining({ ch: 'b', mod: 'ctrl', raw: 'ctrl+b' })
)
})
it('is a no-op when the voice setter is not passed (back-compat)', () => {
const setBell = vi.fn()
// applyDisplay is used in the setVoiceEnabled-less init path too;
// omitting the third arg must not throw.
expect(() =>
applyDisplay({ config: { display: {}, voice: { record_key: 'alt+r' } } }, setBell)
).not.toThrow()
})
it('does not reset voiceRecordKey when cfg is null (transient RPC failure)', () => {
const setBell = vi.fn()
const setVoiceRecordKey = vi.fn()
// quietRpc() collapses request failures to null. Resetting the
// cached shortcut on every null would clobber a custom binding
// after one transient error until the next successful poll
// (Copilot round-8 review on #19835).
applyDisplay(null, setBell, setVoiceRecordKey)
expect(setVoiceRecordKey).not.toHaveBeenCalled()
// bell is still applied (defaults to false on null), so the setter
// runs — we specifically only skip voiceRecordKey.
expect(setBell).toHaveBeenCalledWith(false)
})
})
// Round-12 Copilot review regression on #19835: the live mtime-reload
// path was previously untested, so a regression in the polling/RPC
// wiring to applyDisplay would only be visible at runtime. The fetch
// + apply body is now shared as ``hydrateFullConfig()``, exercised
// directly from both the initial hydration and the poll-tick body.
describe('hydrateFullConfig', () => {
beforeEach(() => {
resetUiState()
})
const makeFakeGw = (payload: unknown) =>
({
request: vi.fn(() => Promise.resolve(payload)),
on: vi.fn(),
off: vi.fn()
}) as any
it('re-applies voice.record_key from a fresh config.get full response', async () => {
const gw = makeFakeGw({ config: { display: {}, voice: { record_key: 'ctrl+o' } } })
const setBell = vi.fn()
const setVoiceRecordKey = vi.fn()
await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
expect(gw.request).toHaveBeenCalledWith('config.get', { key: 'full' })
expect(setVoiceRecordKey).toHaveBeenCalledWith(
expect.objectContaining({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' })
)
expect(setBell).toHaveBeenCalledWith(false)
})
it('reapplies the latest value on each invocation (mtime-reload semantics)', async () => {
const gw = makeFakeGw({ config: { display: {}, voice: { record_key: 'ctrl+b' } } })
const setBell = vi.fn()
const setVoiceRecordKey = vi.fn()
await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
expect(setVoiceRecordKey).toHaveBeenLastCalledWith(expect.objectContaining({ ch: 'b' }))
// Simulate a config edit: gw now returns a new shortcut.
gw.request = vi.fn(() => Promise.resolve({ config: { display: {}, voice: { record_key: 'alt+space' } } }))
await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
expect(setVoiceRecordKey).toHaveBeenLastCalledWith(
expect.objectContaining({ ch: 'space', mod: 'alt', named: 'space' })
)
})
it('leaves cached voiceRecordKey untouched when the RPC fails', async () => {
const gw = { request: vi.fn(() => Promise.reject(new Error('boom'))), on: vi.fn(), off: vi.fn() } as any
const setBell = vi.fn()
const setVoiceRecordKey = vi.fn()
const result = await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
// quietRpc() swallows the error and returns null; applyDisplay
// sees cfg=null and skips the voice setter (Copilot round-8).
expect(result).toBeNull()
expect(setVoiceRecordKey).not.toHaveBeenCalled()
// bell setter still fires — applyDisplay's null-cfg path applies
// the documented bell default (false).
expect(setBell).toHaveBeenCalledWith(false)
})
it('threads through without a voice setter (back-compat call sites)', async () => {
const gw = makeFakeGw({ config: { display: { bell_on_complete: true } } })
const setBell = vi.fn()
// No third arg — applyDisplay must not throw and must still apply
// display flags (round-2 / round-8 invariant).
await expect(hydrateFullConfig(gw, setBell)).resolves.toBeTruthy()
expect(setBell).toHaveBeenCalledWith(true)
})
})

View file

@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest'
import { applyVoiceRecordResponse } from '../app/useInputHandlers.js'
describe('applyVoiceRecordResponse', () => {
it('reverts optimistic REC state when the gateway reports voice busy', () => {
const setProcessing = vi.fn()
const setRecording = vi.fn()
const sys = vi.fn()
applyVoiceRecordResponse({ status: 'busy' }, true, { setProcessing, setRecording }, sys)
expect(setRecording).toHaveBeenCalledWith(false)
expect(setProcessing).toHaveBeenCalledWith(true)
expect(sys).toHaveBeenCalledWith('voice: still transcribing; try again shortly')
})
it('keeps optimistic REC state for successful recording starts', () => {
const setProcessing = vi.fn()
const setRecording = vi.fn()
applyVoiceRecordResponse({ status: 'recording' }, true, { setProcessing, setRecording }, vi.fn())
expect(setRecording).not.toHaveBeenCalled()
expect(setProcessing).not.toHaveBeenCalled()
})
it('reverts optimistic REC state when the gateway returns null', () => {
const setProcessing = vi.fn()
const setRecording = vi.fn()
applyVoiceRecordResponse(null, true, { setProcessing, setRecording }, vi.fn())
expect(setRecording).toHaveBeenCalledWith(false)
expect(setProcessing).toHaveBeenCalledWith(false)
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js'
import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js'
describe('viewportStore', () => {
it('normalizes absent scroll handles', () => {
@ -51,4 +51,35 @@ describe('viewportStore', () => {
expect(snap.atBottom).toBe(true)
expect(snap.scrollHeight).toBe(20)
})
it('keeps scrollbar position tied to committed scrollTop, not pending target', () => {
const handle = {
getPendingDelta: () => 24,
getScrollHeight: () => 100,
getScrollTop: () => 10,
getViewportHeight: () => 20,
isSticky: () => false
}
const viewport = getViewportSnapshot(handle as any)
const scrollbar = getScrollbarSnapshot(handle as any)
expect(viewport.top).toBe(34)
expect(scrollbar).toEqual({
scrollHeight: 100,
top: 10,
viewportHeight: 20
})
expect(scrollbarSnapshotKey(scrollbar)).toBe('10:20:100')
})
it('clamps scrollbar position to committed scroll bounds', () => {
const handle = {
getScrollHeight: () => 30,
getScrollTop: () => 50,
getViewportHeight: () => 20
}
expect(getScrollbarSnapshot(handle as any).top).toBe(10)
})
})

View file

@ -17,6 +17,13 @@ describe('virtual height estimates', () => {
expect(estimatedMsgHeight(msg, 35, { compact: false, details: false })).toBeGreaterThan(5)
})
it('uses compound user prompt width when estimating user message wrapping', () => {
const msg: Msg = { role: 'user', text: 'x'.repeat(21) }
expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: '' })).toBe(3)
expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4)
})
it('includes detail sections when visible', () => {
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] }
@ -24,4 +31,12 @@ describe('virtual height estimates', () => {
estimatedMsgHeight(msg, 80, { compact: false, details: false })
)
})
it('reserves two extra rows for the inter-turn separator on non-first user messages', () => {
const msg: Msg = { role: 'user', text: 'follow-up question' }
const base = estimatedMsgHeight(msg, 80, { compact: false, details: false })
const withSep = estimatedMsgHeight(msg, 80, { compact: false, details: false, withSeparator: true })
expect(withSep).toBe(base + 2)
})
})

View file

@ -0,0 +1,155 @@
import { PassThrough } from 'stream'
import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import React, { useLayoutEffect, useRef } from 'react'
import { describe, expect, it } from 'vitest'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
interface Item {
height: number
key: string
}
interface Exposed {
scroll: ScrollBoxHandle | null
virtualHistory: ReturnType<typeof useVirtualHistory>
}
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const makeStreams = () => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
Object.assign(stdout, { columns: 80, isTTY: false, rows: 20 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', () => {})
return { stderr, stdin, stdout }
}
const mountedSpan = (items: readonly Item[], virtualHistory: ReturnType<typeof useVirtualHistory>) => {
let height = 0
for (let index = virtualHistory.start; index < virtualHistory.end; index++) {
height += items[index]?.height ?? 0
}
return { bottom: virtualHistory.topSpacer + height, top: virtualHistory.topSpacer }
}
const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType<typeof useVirtualHistory>, scroll: ScrollBoxHandle) => {
const span = mountedSpan(items, virtualHistory)
const top = scroll.getScrollTop()
const bottom = top + scroll.getViewportHeight()
return top >= span.top && bottom <= span.bottom
}
function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | null>; items: readonly Item[] }) {
const scrollRef = useRef<ScrollBoxHandle | null>(null)
const virtualHistory = useVirtualHistory(scrollRef, items, 80, {
coldStartCount: 16,
estimateHeight: index => items[index]?.height ?? 1,
maxMounted: 16,
overscan: 2
})
useLayoutEffect(() => {
expose.current = { scroll: scrollRef.current, virtualHistory }
})
return React.createElement(
ScrollBox,
{ flexDirection: 'column', height: 10, ref: scrollRef, stickyScroll: true },
React.createElement(
Box,
{ flexDirection: 'column', width: '100%' },
virtualHistory.topSpacer > 0 ? React.createElement(Box, { height: virtualHistory.topSpacer }) : null,
...items
.slice(virtualHistory.start, virtualHistory.end)
.map(item =>
React.createElement(
Box,
{ height: item.height, key: item.key, ref: virtualHistory.measureRef(item.key) },
React.createElement(Text, null, item.key)
)
),
virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null
)
)
}
describe('useVirtualHistory offset cache reuse', () => {
it('recomputes offsets after a mounted row height changes', async () => {
const tall = [
{ height: 6, key: 'a' },
{ height: 6, key: 'b' },
{ height: 6, key: 'c' }
]
const short = tall.map(item => ({ ...item, height: 2 }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(React.createElement(Harness, { expose, items: tall }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
expect(expose.current!.virtualHistory.offsets[tall.length]).toBe(18)
instance.rerender(React.createElement(Harness, { expose, items: short }))
await delay(40)
expect(expose.current!.virtualHistory.offsets[short.length]).toBe(6)
expect(expose.current!.virtualHistory.bottomSpacer).toBe(0)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('ignores stale reused offset-array entries after the item count shrinks', async () => {
const beforeShrink = Array.from({ length: 1400 }, (_, index) => ({ height: 1, key: `old${index}` }))
const afterShrink = Array.from({ length: 800 }, (_, index) => ({ height: 7, key: `new${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(React.createElement(Harness, { expose, items: beforeShrink }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
instance.rerender(React.createElement(Harness, { expose, items: afterShrink }))
await delay(20)
const scroll = expose.current!.scroll!
const transcriptHeight = expose.current!.virtualHistory.offsets[afterShrink.length] ?? 0
expect(transcriptHeight).toBe(5600)
expect(scroll.getScrollTop()).toBe(transcriptHeight - scroll.getViewportHeight())
scroll.scrollBy(-1)
await delay(80)
expect(scroll.getPendingDelta()).toBe(0)
expect(viewportIsMounted(afterShrink, expose.current!.virtualHistory, scroll)).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
})

View file

@ -1,5 +1,6 @@
import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
import { STREAM_BATCH_MS } from '../config/timing.js'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { SETUP_REQUIRED_TITLE, buildSetupRequiredSections } from '../content/setup.js'
import type {
CommandsCatalogResponse,
ConfigFullResponse,
@ -64,6 +65,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
let pendingThinkingStatus = ''
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
let startupPromptSubmitted = false
// Inject the disk-save callback into turnController so recordMessageComplete
// can fire-and-forget a persist without having to plumb a gateway ref around.
@ -146,6 +148,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}, ms)
}
const scheduleStartupPrompt = () => {
if (startupPromptSubmitted || (!STARTUP_QUERY && !STARTUP_IMAGE)) {
return
}
startupPromptSubmitted = true
setTimeout(async () => {
let sid = getUiState().sid
for (let i = 0; !sid && i < 40; i += 1) {
await new Promise(resolve => setTimeout(resolve, 100))
sid = getUiState().sid
}
if (!sid) {
return sys('startup query skipped: no active session')
}
if (STARTUP_IMAGE) {
try {
await rpc('image.attach', { path: STARTUP_IMAGE, session_id: sid })
} catch (e) {
sys(`startup image attach failed: ${rpcErrorMessage(e)}`)
}
}
submitRef.current(STARTUP_QUERY || 'What do you see in this image?')
}, 0)
}
// Terminal statuses are never overwritten by late-arriving live events —
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
// `failed` or `interrupted` terminal state (Copilot review #14045).
@ -181,6 +213,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (STARTUP_RESUME_ID) {
patchUiState({ status: 'resuming…' })
resumeById(STARTUP_RESUME_ID)
scheduleStartupPrompt()
return
}
@ -196,6 +229,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (!cfg?.config?.display?.tui_auto_resume_recent) {
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
return
}
@ -206,17 +240,20 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (target) {
patchUiState({ status: 'resuming most recent…' })
resumeById(target)
scheduleStartupPrompt()
return
}
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
})
})
.catch(() => {
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
})
}
@ -287,6 +324,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
if (p.kind === 'goal') {
sys(p.text)
return
}
if (!p.kind || p.kind === 'status') {
return
}
@ -510,6 +552,20 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'review.summary': {
// Self-improvement background review emitted a persistent summary
// of what it saved to memory/skills. Surface it as a system line
// in the transcript so it never gets lost to a transient status
// flash. Python-side already formats it as "💾 Self-improvement
// review: …".
const text = String(ev.payload?.text ?? '').trim()
if (text) {
sys(text)
}
return
}
case 'subagent.spawn_requested':
// Child built but not yet running (waiting on ThreadPoolExecutor slot).
// Preserve completed state if a later event races in before this one.

View file

@ -114,6 +114,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
}
if (d.type === 'send') {
if (d.notice?.trim()) {
sys(d.notice)
}
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`)
}
})

View file

@ -4,6 +4,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { ImageAttachResponse } from '../gatewayTypes.js'
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import type {
@ -189,7 +190,7 @@ export interface InputHandlerActions {
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
newSession: (msg?: string, title?: string) => void
sys: (text: string) => void
}
@ -210,6 +211,7 @@ export interface InputHandlerContext {
}
voice: {
enabled: boolean
recordKey: ParsedVoiceRecordKey
recording: boolean
setProcessing: StateSetter<boolean>
setRecording: StateSetter<boolean>
@ -230,7 +232,7 @@ export interface GatewayEventHandlerContext {
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject<number>
newSession: (msg?: string) => void
newSession: (msg?: string, title?: string) => void
resetSession: () => void
resumeById: (id: string) => void
setCatalog: StateSetter<null | SlashCatalog>
@ -270,12 +272,13 @@ export interface SlashHandlerContext {
getHistoryItems: () => Msg[]
getLastUserMsg: () => string
maybeWarn: (value: unknown) => void
setCatalog: StateSetter<null | SlashCatalog>
}
session: {
closeSession: (targetSid?: null | string) => Promise<unknown>
die: () => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
newSession: (msg?: string, title?: string) => void
resetVisibleHistory: (info?: null | SessionInfo) => void
resumeById: (id: string) => void
setSessionStartedAt: StateSetter<number>
@ -291,6 +294,7 @@ export interface SlashHandlerContext {
}
voice: {
setVoiceEnabled: StateSetter<boolean>
setVoiceRecordKey: (v: ParsedVoiceRecordKey) => void
}
}
@ -318,6 +322,7 @@ export interface AppLayoutComposerProps {
queuedDisplay: string[]
submit: (value: string) => void
updateInput: StateSetter<string>
voiceRecordKey: ParsedVoiceRecordKey
}
export interface AppLayoutProgressProps {

View file

@ -13,6 +13,23 @@ export interface ScrollWithSelectionOptions {
readonly selection: SelectionApi
}
function scrollBoundsForDelta(s: ScrollBoxHandle, cur: number, delta: number) {
const viewport = Math.max(0, s.getViewportHeight())
const cachedHeight = Math.max(viewport, s.getScrollHeight())
let max = Math.max(0, cachedHeight - viewport)
// getScrollHeight() is render-time cached. After the streaming tail is
// committed into virtual history, the Yoga height can be fresher than the
// cached value; if we clamp only against the cached fake bottom, wheel-down
// becomes a no-op and no render is scheduled to reveal the real tail.
if (delta > 0 && cur + delta >= max - 1) {
const freshHeight = Math.max(viewport, s.getFreshScrollHeight())
max = Math.max(0, freshHeight - viewport)
}
return { max, viewport }
}
export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: ScrollWithSelectionOptions): void {
const s = scrollRef.current
@ -21,8 +38,7 @@ export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: S
}
const cur = s.getScrollTop() + s.getPendingDelta()
const viewport = Math.max(0, s.getViewportHeight())
const max = Math.max(0, s.getScrollHeight() - viewport)
const { max, viewport } = scrollBoundsForDelta(s, cur, delta)
const actual = Math.max(0, Math.min(max, cur + delta)) - cur
if (actual === 0) {

View file

@ -1,15 +1,19 @@
import { forceRedraw } from '@hermes/ink'
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import type {
ConfigGetValueResponse,
ConfigSetResponse,
SessionSaveResponse,
SessionStatusResponse,
SessionSteerResponse,
SessionTitleResponse,
SessionUndoResponse
} from '../../../gatewayTypes.js'
import { writeClipboardText } from '../../../lib/clipboard.js'
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
import type { Msg, PanelSection } from '../../../types.js'
@ -111,16 +115,17 @@ export const coreCommands: SlashCommand[] = [
aliases: ['new'],
help: 'start a new session',
name: 'clear',
run: (_arg, ctx, cmd) => {
run: (arg, ctx, cmd) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
const isNew = cmd.startsWith('/new')
const requestedTitle = isNew ? arg.trim() : ''
const commit = () => {
patchUiState({ status: 'forging session…' })
ctx.session.newSession(isNew ? 'new session started' : undefined)
ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined)
}
if (NO_CONFIRM_DESTRUCTIVE) {
@ -140,6 +145,30 @@ export const coreCommands: SlashCommand[] = [
}
},
{
help: 'force a full UI repaint',
name: 'redraw',
run: (_arg, ctx) => {
forceRedraw(process.stdout)
ctx.transcript.sys('ui redrawn')
}
},
{
help: 'show live session info',
name: 'status',
run: (_arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('no active session')
}
ctx.gateway
.rpc<SessionStatusResponse>('session.status', { session_id: ctx.sid })
.then(ctx.guarded<SessionStatusResponse>(r => ctx.transcript.page(r.output || '(no status)', 'Status')))
.catch(ctx.guardedErr)
}
},
{
help: 'resume a prior session',
name: 'resume',
@ -318,10 +347,27 @@ export const coreCommands: SlashCommand[] = [
const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
if (!target) {
return sys('nothing to copy')
return sys('nothing to copy — start a conversation first')
}
writeOsc52Clipboard(target.text)
void writeClipboardText(target.text)
.then(nativeOk => {
if (ctx.stale()) {
return
}
if (nativeOk) {
sys('copied to clipboard')
} else {
writeOsc52Clipboard(target.text)
sys('sent OSC52 copy sequence (terminal support required)')
}
})
.catch(error => {
if (!ctx.stale()) {
sys(`copy failed: ${String(error)}`)
}
})
}
},

View file

@ -1,5 +1,6 @@
import type {
BrowserManageResponse,
CommandsCatalogResponse,
DelegationPauseResponse,
ProcessStopResponse,
ReloadEnvResponse,
@ -56,6 +57,10 @@ interface SkillsBrowseResponse {
total_pages?: number
}
interface SkillsReloadResponse {
output?: string
}
export const opsCommands: SlashCommand[] = [
{
help: 'stop background processes',
@ -435,10 +440,44 @@ export const opsCommands: SlashCommand[] = [
}
},
{
aliases: ['reload_skills'],
help: 're-scan installed skills in the live TUI gateway',
name: 'reload-skills',
run: (_arg, ctx) => {
ctx.gateway
.rpc<SkillsReloadResponse>('skills.reload', {})
.then(
ctx.guarded<SkillsReloadResponse>(r => {
ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills')
ctx.gateway
.rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(
ctx.guarded<CommandsCatalogResponse>(catalog => {
if (!catalog?.pairs) {
return
}
ctx.local.setCatalog({
canon: (catalog.canon ?? {}) as Record<string, string>,
categories: catalog.categories ?? [],
pairs: catalog.pairs as [string, string][],
skillCount: (catalog.skill_count ?? 0) as number,
sub: (catalog.sub ?? {}) as Record<string, string[]>
})
})
)
.catch(() => {})
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'browse, inspect, install skills',
name: 'skills',
run: (arg, ctx) => {
run: (arg, ctx, cmd) => {
const text = arg.trim()
if (!text) {
@ -449,6 +488,22 @@ export const opsCommands: SlashCommand[] = [
const query = rest.join(' ').trim()
const { rpc } = ctx.gateway
const { panel, sys } = ctx.transcript
const runViaSlashWorker = () => {
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
const body = r?.output || '/skills: no output'
const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted)
})
.catch(ctx.guardedErr)
}
if (sub === 'list') {
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
@ -593,7 +648,7 @@ export const opsCommands: SlashCommand[] = [
return
}
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
runViaSlashWorker()
}
},

View file

@ -10,6 +10,7 @@ import type {
SessionUsageResponse,
VoiceToggleResponse
} from '../../../gatewayTypes.js'
import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
import { fmtK } from '../../../lib/text.js'
import type { PanelSection } from '../../../types.js'
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
@ -61,7 +62,6 @@ export const sessionCommands: SlashCommand[] = [
{
help: 'change or show model',
aliases: ['provider'],
name: 'model',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('change models')) {
@ -92,6 +92,19 @@ export const sessionCommands: SlashCommand[] = [
}
},
{
help: 'browse and resume previous sessions',
name: 'sessions',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
if (!arg.trim()) {
return patchOverlayState({ picker: true })
}
}
},
{
help: 'attach an image',
name: 'image',
@ -109,7 +122,7 @@ export const sessionCommands: SlashCommand[] = [
},
{
help: 'switch or reset personality (history reset on set)',
help: 'switch personality for this session',
name: 'personality',
run: (arg, ctx) => {
if (!arg) {
@ -221,6 +234,30 @@ export const sessionCommands: SlashCommand[] = [
ctx.guarded<VoiceToggleResponse>(r => {
ctx.voice.setVoiceEnabled(!!r.enabled)
// Render the configured record key (config.yaml ``voice.record_key``)
// instead of hardcoded "Ctrl+B" — the gateway response carries the
// current value so /voice status and /voice on stay in sync with
// both the CLI and the TUI's actual binding (#18994).
//
// Copilot review on #19835 caught that rendering from the fresh
// backend response WITHOUT updating the frontend ``voice.recordKey``
// state would skew display and binding between config-edit and
// the next ``mtime`` poll (~5s). Parse once, push into state so
// ``useInputHandlers()`` picks up the new binding immediately.
//
// Round-2 follow-up: only push state when the response actually
// carries ``record_key`` — otherwise an older gateway (or a future
// branch that forgets to include it) would clobber a custom user
// binding back to the default on every /voice invocation. The
// label still falls back to the documented default for display.
const parsed = r.record_key ? parseVoiceRecordKey(r.record_key) : undefined
if (parsed) {
ctx.voice.setVoiceRecordKey(parsed)
}
const recordKeyLabel = formatVoiceRecordKey(parsed ?? parseVoiceRecordKey('ctrl+b'))
// Match CLI's _show_voice_status / _enable_voice_mode /
// _toggle_voice_tts output shape so users don't have to learn
// two vocabularies.
@ -230,11 +267,11 @@ export const sessionCommands: SlashCommand[] = [
ctx.transcript.sys('Voice Mode Status')
ctx.transcript.sys(` Mode: ${mode}`)
ctx.transcript.sys(` TTS: ${tts}`)
ctx.transcript.sys(' Record key: Ctrl+B')
ctx.transcript.sys(` Record key: ${recordKeyLabel}`)
// CLI's "Requirements:" block — surfaces STT/audio setup issues
// so the user sees "STT provider: MISSING ..." instead of
// silently failing on every Ctrl+B press.
// silently failing on every record-key press.
if (r.details) {
ctx.transcript.sys('')
ctx.transcript.sys(' Requirements:')
@ -259,7 +296,7 @@ export const sessionCommands: SlashCommand[] = [
if (r.enabled) {
const tts = r.tts ? ' (TTS enabled)' : ''
ctx.transcript.sys(`Voice mode enabled${tts}`)
ctx.transcript.sys(' Ctrl+B to start/stop recording')
ctx.transcript.sys(` ${recordKeyLabel} to start/stop recording`)
ctx.transcript.sys(' /voice tts to toggle speech output')
ctx.transcript.sys(' /voice off to disable voice mode')
} else {

View file

@ -1,4 +1,4 @@
import { atom } from 'nanostores'
import { atom, computed } from 'nanostores'
import { MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js'
@ -30,6 +30,9 @@ const buildUiState = (): UiState => ({
export const $uiState = atom<UiState>(buildUiState())
export const $uiTheme = computed($uiState, state => state.theme)
export const $uiSessionId = computed($uiState, state => state.sid)
export const getUiState = () => $uiState.get()
export const patchUiState = (next: Partial<UiState> | ((state: UiState) => UiState)) =>

View file

@ -7,6 +7,11 @@ import type {
ConfigMtimeResponse,
ReloadMcpResponse
} from '../gatewayTypes.js'
import {
DEFAULT_VOICE_RECORD_KEY,
parseVoiceRecordKey,
type ParsedVoiceRecordKey
} from '../lib/platform.js'
import { asRpcResult } from '../lib/rpc.js'
import {
@ -89,10 +94,47 @@ const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
}
}
export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceRecordKey => {
const raw = cfg?.config?.voice?.record_key
return raw ? parseVoiceRecordKey(raw) : DEFAULT_VOICE_RECORD_KEY
}
/** Fetch ``config.get full`` and fan the result through ``applyDisplay``.
*
* Extracted so the mtime-reload path can be exercised by the test
* suite without a React runtime (Copilot round-12 review on #19835).
* Both the initial hydration and the mtime poller use this shared
* helper, so a regression in the fetch/apply plumbing now fails the
* useConfigSync tests instead of only being visible at runtime. */
export async function hydrateFullConfig(
gw: GatewayClient,
setBell: (v: boolean) => void,
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
): Promise<ConfigFullResponse | null> {
const cfg = await quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' })
applyDisplay(cfg, setBell, setVoiceRecordKey)
return cfg
}
export const applyDisplay = (
cfg: ConfigFullResponse | null,
setBell: (v: boolean) => void,
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
) => {
const d = cfg?.config?.display ?? {}
setBell(!!d.bell_on_complete)
// Only push the voice record key when the RPC actually returned a
// config payload. ``quietRpc()`` collapses failures to ``null``; if we
// reset the cached shortcut on every null we would clobber a custom
// binding after one transient RPC error until the next config edit
// (Copilot round-8 review on #19835). The mtime-poll loop advances
// ``mtimeRef`` before this call, so staying silent on null preserves
// the last-good state and lets the next successful poll refresh it.
if (setVoiceRecordKey && cfg) {
setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg))
}
patchUiState({
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
compact: !!d.tui_compact,
@ -109,7 +151,13 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
})
}
export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
export function useConfigSync({
gw,
setBellOnComplete,
setVoiceEnabled,
setVoiceRecordKey,
sid
}: UseConfigSyncOptions) {
const mtimeRef = useRef(0)
useEffect(() => {
@ -125,8 +173,8 @@ export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: U
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
})
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
}, [gw, setBellOnComplete, setVoiceEnabled, sid])
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
}, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid])
useEffect(() => {
if (!sid) {
@ -154,17 +202,18 @@ export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: U
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
})
}, MTIME_POLL_MS)
return () => clearInterval(id)
}, [gw, setBellOnComplete, sid])
}, [gw, setBellOnComplete, setVoiceRecordKey, sid])
}
export interface UseConfigSyncOptions {
gw: GatewayClient
setBellOnComplete: (v: boolean) => void
setVoiceEnabled: (v: boolean) => void
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
sid: null | string
}

View file

@ -11,6 +11,7 @@ import type {
VoiceRecordResponse
} from '../gatewayTypes.js'
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
import { getInputSelection } from './inputSelectionStore.js'
@ -21,8 +22,26 @@ import { patchTurnState } from './turnStore.js'
import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const PRECISION_WHEEL_MIN_GAP_MS = 80
const PRECISION_WHEEL_STICKY_MS = 80
export function applyVoiceRecordResponse(
response: null | VoiceRecordResponse,
starting: boolean,
voice: Pick<InputHandlerContext['voice'], 'setProcessing' | 'setRecording'>,
sys: (text: string) => void
) {
if (!starting || response?.status === 'recording') {
return
}
voice.setRecording(false)
if (response?.status === 'busy') {
voice.setProcessing(true)
sys('voice: still transcribing; try again shortly')
} else {
voice.setProcessing(false)
}
}
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
@ -38,9 +57,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
// rows = wheelStep × accelMult. State mutates in place across renders.
const wheelAccelRef = useRef(initWheelAccelForHost())
const precisionWheelRef = useRef<{ active: boolean; dir: 0 | -1 | 1; lastEventAtMs: number; lastScrollAtMs: number }>(
{ active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
)
const precisionWheelRef = useRef(initPrecisionWheel())
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
@ -160,11 +177,12 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
}
}
// CLI parity: Ctrl+B toggles the VAD-driven continuous recording loop
// CLI parity: Ctrl+B toggles a VAD-bounded push-to-talk capture
// (NOT the voice-mode umbrella bit). The mode is enabled via /voice on;
// Ctrl+B while the mode is off sys-nudges the user. While the mode is
// on, the first press starts a continuous loop (gateway → start_continuous,
// VAD auto-stop → transcribe → auto-restart), a subsequent press stops it.
// on, the first press starts a single VAD-bounded capture
// (gateway -> start_continuous(auto_restart=false), VAD auto-stop ->
// transcribe -> idle), a subsequent press stops and transcribes it.
// The gateway publishes voice.status + voice.transcript events that
// createGatewayEventHandler turns into UI badges and composer injection.
const voiceRecordToggle = () => {
@ -185,14 +203,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
voice.setProcessing(false)
}
gateway.rpc<VoiceRecordResponse>('voice.record', { action }).catch((e: Error) => {
// Revert optimistic UI on failure.
if (starting) {
voice.setRecording(false)
}
gateway
.rpc<VoiceRecordResponse>('voice.record', { action, session_id: getUiState().sid })
.then(r => applyVoiceRecordResponse(r, starting, voice, actions.sys))
.catch((e: Error) => {
// Revert optimistic UI on failure.
if (starting) {
voice.setRecording(false)
}
actions.sys(`voice error: ${e.message}`)
})
actions.sys(`voice error: ${e.message}`)
})
}
useInput((ch, key) => {
@ -291,40 +312,26 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (key.wheelUp || key.wheelDown) {
const dir: -1 | 1 = key.wheelUp ? -1 : 1
const now = Date.now()
// Modifier-held wheel = precision mode: at most one wheelStep per short
// interval. Smooth mice / trackpads emit many raw wheel events for one
// intended line step, so raw 1:1 still moves too far.
// Modifier-held wheel = precision mode: one row per frame, no accel.
// Smooth mice / trackpads emit tiny same-frame bursts; coalesce those
// without the old 80ms throttle that made opt-scroll feel stepped.
// SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
// macOS is intercepted by the terminal, so we honor Option (meta) on
// Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift
// is reserved for selection extension.
const hasModifier = key.meta || key.ctrl
const precision = precisionWheelRef.current
// Keep precision active through the current wheel burst after the
// modifier is released. Otherwise a stream of queued/momentum wheel
// events can hand off mid-burst into the accelerated path and jump.
const precisionSticky = now - precision.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now)
if (hasModifier || precisionSticky) {
if (!precision.active) {
precision.active = true
if (precision.active) {
// Entering precision mode must discard any accelerated wheel state;
// otherwise the next normal wheel event inherits stale momentum.
if (precision.entered) {
wheelAccelRef.current = initWheelAccelForHost()
}
precision.lastEventAtMs = now
if (dir === precision.dir && now - precision.lastScrollAtMs < PRECISION_WHEEL_MIN_GAP_MS) {
return
}
precision.lastScrollAtMs = now
precision.dir = dir
return scrollTranscript(dir * wheelStep)
return precision.rows ? scrollTranscript(dir * wheelStep) : undefined
}
precision.active = false
// 0 = direction-flip bounce deferred; skip the no-op scroll.
const rows = computeWheelStep(wheelAccelRef.current, dir, now)
@ -348,9 +355,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return scrollTranscript(key.pageUp ? -step : step)
}
// Queue-edit cancel beats selection-clear: the queue header explicitly
// promises "Esc cancel", so honoring it takes priority over the implicit
// selection-dismissal convention. Without an active edit, fall through.
// Escape-based voice bindings (ctrl/alt/super+escape) must win before the
// generic Esc handlers below; otherwise queue-edit cancel / selection-clear
// would swallow the chord and /voice would advertise a shortcut that never
// actually toggles recording in those UI states.
if (key.escape && isVoiceToggleKey(key, ch, voice.recordKey)) {
return voiceRecordToggle()
}
// Queue-edit cancel beats selection-clear for plain Esc: the queue header
// explicitly promises "Esc cancel", so honoring it takes priority over the
// implicit selection-dismissal convention. Without an active edit, fall through.
if (key.escape && cState.queueEditIdx !== null) {
return cActions.clearIn()
}
@ -439,7 +454,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return
}
if (isVoiceToggleKey(key, ch)) {
if (isVoiceToggleKey(key, ch, voice.recordKey)) {
return voiceRecordToggle()
}

View file

@ -1,4 +1,4 @@
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -16,8 +16,9 @@ import type {
} from '../gatewayTypes.js'
import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { composerPromptWidth } from '../lib/inputMetrics.js'
import { appendTranscriptMessage } from '../lib/messages.js'
import { isMac } from '../lib/platform.js'
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
@ -103,6 +104,7 @@ export function useMainApp(gw: GatewayClient) {
const [voiceEnabled, setVoiceEnabled] = useState(false)
const [voiceRecording, setVoiceRecording] = useState(false)
const [voiceProcessing, setVoiceProcessing] = useState(false)
const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
const [goodVibesTick, setGoodVibesTick] = useState(0)
@ -244,7 +246,8 @@ export function useMainApp(gw: GatewayClient) {
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt)
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
const heightCache = useMemo(() => {
let cache = heightCachesRef.current.get(heightCacheKey)
@ -261,14 +264,21 @@ export function useMainApp(gw: GatewayClient) {
return cache
}, [heightCacheKey])
// Index of the first user-role message — separator-rendering in
// appLayout.tsx skips this row, so the height estimator must skip it
// too. -1 when no user message exists yet (no row will gate true).
const firstUserIdx = useMemo(() => virtualRows.findIndex(r => r.msg.role === 'user'), [virtualRows])
const estimateRowHeight = useCallback(
(index: number) =>
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
compact: ui.compact,
details: detailsVisible,
limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS
limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS,
userPrompt: ui.theme.brand.prompt,
withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx
}),
[cols, detailsVisible, ui.compact, virtualRows]
[cols, detailsVisible, firstUserIdx, ui.compact, ui.theme.brand.prompt, virtualRows]
)
const syncHeightCache = useCallback(
@ -358,6 +368,13 @@ export function useMainApp(gw: GatewayClient) {
const die = useCallback(() => {
gw.kill()
exit()
// Ink's exit() calls unmount() which resets terminal modes but does NOT
// call process.exit(). Without an explicit exit the Node process stays
// alive (stdin listener keeps the event loop open), so the process.on('exit')
// handler in entry.tsx — which sends the final resetTerminalModes() — never
// fires. This leaves kitty keyboard protocol, mouse modes, etc. enabled
// in the parent shell. See issue #19194.
process.exit(0)
}, [exit, gw])
const session = useSessionLifecycle({
@ -384,7 +401,7 @@ export function useMainApp(gw: GatewayClient) {
}
}, [ui.busy])
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
@ -529,6 +546,7 @@ export function useMainApp(gw: GatewayClient) {
terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout },
voice: {
enabled: voiceEnabled,
recordKey: voiceRecordKey,
recording: voiceRecording,
setProcessing: setVoiceProcessing,
setRecording: setVoiceRecording,
@ -594,10 +612,10 @@ export function useMainApp(gw: GatewayClient) {
gw.on('exit', exitHandler)
gw.drain()
// entry.tsx's setupGracefulExit handles process cleanup on real exit.
return () => {
gw.off('event', handler)
gw.off('exit', exitHandler)
gw.kill()
}
}, [gw, sys])
@ -619,7 +637,8 @@ export function useMainApp(gw: GatewayClient) {
catalog,
getHistoryItems: () => historyItemsRef.current,
getLastUserMsg: () => lastUserMsgRef.current,
maybeWarn
maybeWarn,
setCatalog
},
session: {
closeSession: session.closeSession,
@ -632,7 +651,7 @@ export function useMainApp(gw: GatewayClient) {
},
slashFlightRef,
transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange },
voice: { setVoiceEnabled }
voice: { setVoiceEnabled, setVoiceRecordKey }
}),
[
catalog,
@ -711,9 +730,12 @@ export function useMainApp(gw: GatewayClient) {
const anyPanelVisible = SECTION_NAMES.some(
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
)
const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const thinkingPanelVisible =
sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const toolsPanelVisible =
sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const activityPanelVisible =
sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const showProgressArea = useTurnSelector(state =>
anyPanelVisible
@ -726,7 +748,9 @@ export function useMainApp(gw: GatewayClient) {
const hasTrailTools = Boolean(segment.tools?.length)
if (segment.kind === 'trail' && !segment.text) {
return (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
return (
(thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
)
}
return (
@ -772,9 +796,10 @@ export function useMainApp(gw: GatewayClient) {
queueEditIdx: composerState.queueEditIdx,
queuedDisplay: composerState.queuedDisplay,
submit,
updateInput: composerActions.setInput
updateInput: composerActions.setInput,
voiceRecordKey
}),
[cols, composerActions, composerState, empty, pagerPageSize, submit]
[cols, composerActions, composerState, empty, pagerPageSize, submit, voiceRecordKey]
)
// Pass current progress through unfrozen — streaming update throttling

View file

@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs'
import type { ScrollBoxHandle } from '@hermes/ink'
import { evictInkCaches } from '@hermes/ink'
import { type RefObject, useCallback } from 'react'
import { useCallback, type RefObject } from 'react'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
@ -12,6 +12,7 @@ import type {
SessionCloseResponse,
SessionCreateResponse,
SessionResumeResponse,
SessionTitleResponse,
SetupStatusResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
@ -122,7 +123,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
)
const newSession = useCallback(
async (msg?: string) => {
async (msg?: string, title?: string) => {
const setup = await rpc<SetupStatusResponse>('setup.status', {})
if (setup?.provider_configured === false) {
@ -141,6 +142,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
}
const info = r.info ?? null
const requestedTitle = title?.trim() ?? ''
resetSession()
setSessionStartedAt(Date.now())
@ -168,6 +170,30 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
if (msg) {
sys(msg)
}
if (requestedTitle) {
rpc<SessionTitleResponse>('session.title', {
session_id: r.session_id,
title: requestedTitle
})
.then(result => {
if (!result || getUiState().sid !== r.session_id) {
return
}
const nextTitle = (result.title ?? requestedTitle).trim()
const suffix = result.pending ? ' (queued while session initializes)' : ''
sys(`session title set: ${nextTitle}${suffix}`)
})
.catch((err: unknown) => {
if (getUiState().sid !== r.session_id) {
return
}
const message = err instanceof Error ? err.message : String(err)
sys(`warning: failed to set session title: ${message}`)
})
}
},
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
)

View file

@ -126,13 +126,9 @@ export function useSubmission(opts: UseSubmissionOptions) {
return sys('session not ready yet')
}
// Plain prompts are the common path and should not pay an extra RPC
// before prompt.submit. File-drop detection still runs for absolute,
// tilde, file://, and explicit relative paths.
if (!looksLikeSlashCommand(text) && !/(?:^|\s)(?:file:\/\/|~\/|\.?\.\/|\/)[^\s]+/.test(text)) {
return startSubmit(text, expand(text), showUserMessage)
}
// Always ask the backend whether this looks like a file drop.
// The backend's _detect_file_drop handles paths with spaces, quotes,
// Windows drive letters, and escaped characters correctly.
gw.request<InputDetectDropResponse>('input.detect_drop', { session_id: sid, text })
.then(r => {
if (!r?.matched) {

View file

@ -1,6 +1,6 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
import unicodeSpinners from 'unicode-animations'
import { $delegationState } from '../app/delegationStore.js'
@ -13,13 +13,18 @@ import { fmtDuration } from '../domain/messages.js'
import { stickyPromptFromViewport } from '../domain/viewport.js'
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
import { fmtK } from '../lib/text.js'
import { useViewportSnapshot } from '../lib/viewportStore.js'
import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
// Keep verb segment width stable so status-bar content to the right doesn't
// jitter when the ticker rotates between short/long verbs.
export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis
export const padVerb = (verb: string) => `${verb}`.padEnd(VERB_PAD_LEN, ' ')
// Compact alternates for the `emoji` and `ascii` indicator styles.
// Each entry is a fixed-width (display-width) glyph.
const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮']
@ -102,7 +107,11 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu
const { frame } = renderIndicator(style, tick)
const verb = VERBS[verbTick % VERBS.length] ?? ''
const verbSegment = showVerb ? ` ${verb}` : ''
const verbSegment = showVerb ? ` ${padVerb(verb)}` : ''
// Leading space keeps a gap between the frame and the duration when the
// verb segment is hidden (e.g. `unicode` spinner style). When the verb
// IS shown, its trailing padding already provides the gap, so the extra
// space is harmless.
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
return (
@ -314,6 +323,14 @@ export function StatusRule({
<SessionDuration startedAt={sessionStartedAt} />
</Text>
) : null}
{typeof usage.compressions === 'number' && usage.compressions > 0 ? (
<Text color={t.color.muted}>
{' │ '}
<Text color={usage.compressions >= 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted}>
cmp {usage.compressions}
</Text>
</Text>
) : null}
<SpawnHud t={t} />
{voiceLabel ? (
<Text
@ -366,7 +383,8 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }:
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<number | null>(null)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef)
const grabRef = useRef<number | null>(null)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)
if (!vp) {
return <Box width={1} />
@ -394,15 +412,20 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
onMouseDown={(e: { localRow?: number }) => {
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
grabRef.current = off
setGrab(off)
jump(row, off)
}}
onMouseDrag={(e: { localRow?: number }) =>
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => setGrab(null)}
onMouseUp={() => {
grabRef.current = null
setGrab(null)
}}
width={1}
>
{!scrollable ? (

View file

@ -76,6 +76,15 @@ const TranscriptPane = memo(function TranscriptPane({
return -1
}, [transcript.historyItems])
// Index of the first user-role message; every later user message gets a
// small dash above it so multi-turn transcripts visually segment by
// turn. -1 when no user message has been sent yet → no separator ever
// renders.
const firstUserIdx = useMemo(
() => transcript.historyItems.findIndex(m => m.role === 'user'),
[transcript.historyItems]
)
return (
<>
<ScrollBox
@ -95,6 +104,12 @@ const TranscriptPane = memo(function TranscriptPane({
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && (
<Box marginTop={1}>
<Text color={ui.theme.color.border}></Text>
</Box>
)}
{row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
@ -288,6 +303,7 @@ const ComposerPane = memo(function ComposerPane({
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
voiceRecordKey={composer.voiceRecordKey}
/>
</Box>

View file

@ -4,7 +4,7 @@ import { useStore } from '@nanostores/react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppOverlaysProps } from '../app/interfaces.js'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { $uiSessionId, $uiTheme } from '../app/uiStore.js'
import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
@ -24,12 +24,12 @@ export function PromptZone({
onSudoSubmit
}: Pick<AppOverlaysProps, 'cols' | 'onApprovalChoice' | 'onClarifyAnswer' | 'onSecretSubmit' | 'onSudoSubmit'>) {
const overlay = useStore($overlayState)
const ui = useStore($uiState)
const theme = useStore($uiTheme)
if (overlay.approval) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={ui.theme} />
<ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={theme} />
</Box>
)
}
@ -46,7 +46,7 @@ export function PromptZone({
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ConfirmPrompt onCancel={onCancel} onConfirm={onConfirm} req={req} t={ui.theme} />
<ConfirmPrompt onCancel={onCancel} onConfirm={onConfirm} req={req} t={theme} />
</Box>
)
}
@ -59,7 +59,7 @@ export function PromptZone({
onAnswer={onClarifyAnswer}
onCancel={() => onClarifyAnswer('')}
req={overlay.clarify}
t={ui.theme}
t={theme}
/>
</Box>
)
@ -68,7 +68,7 @@ export function PromptZone({
if (overlay.sudo) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={ui.theme} />
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={theme} />
</Box>
)
}
@ -82,7 +82,7 @@ export function PromptZone({
label={overlay.secret.prompt}
onSubmit={onSecretSubmit}
sub={`for ${overlay.secret.envVar}`}
t={ui.theme}
t={theme}
/>
</Box>
)
@ -101,7 +101,8 @@ export function FloatingOverlays({
}: Pick<AppOverlaysProps, 'cols' | 'compIdx' | 'completions' | 'onModelSelect' | 'onPickerSelect' | 'pagerPageSize'>) {
const { gw } = useGateway()
const overlay = useStore($overlayState)
const ui = useStore($uiState)
const sid = useStore($uiSessionId)
const theme = useStore($uiTheme)
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length
@ -119,40 +120,40 @@ export function FloatingOverlays({
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && (
<FloatBox color={ui.theme.color.border}>
<FloatBox color={theme.color.border}>
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={ui.theme}
t={theme}
/>
</FloatBox>
)}
{overlay.modelPicker && (
<FloatBox color={ui.theme.color.border}>
<FloatBox color={theme.color.border}>
<ModelPicker
gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={ui.sid}
t={ui.theme}
sessionId={sid}
t={theme}
/>
</FloatBox>
)}
{overlay.skillsHub && (
<FloatBox color={ui.theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
<FloatBox color={theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
</FloatBox>
)}
{overlay.pager && (
<FloatBox color={ui.theme.color.border}>
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={ui.theme.color.primary}>
<Text bold color={theme.color.primary}>
{overlay.pager.title}
</Text>
</Box>
@ -163,7 +164,7 @@ export function FloatingOverlays({
))}
<Box marginTop={1}>
<OverlayHint t={ui.theme}>
<OverlayHint t={theme}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
@ -174,23 +175,31 @@ export function FloatingOverlays({
)}
{!!completions.length && (
<FloatBox color={ui.theme.color.primary}>
<FloatBox color={theme.color.primary}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
return (
<Box
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
backgroundColor={active ? theme.color.completionCurrentBg : theme.color.completionBg}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Text bold color={ui.theme.color.label}>
<Text bold color={theme.color.label}>
{' '}
{item.display}
</Text>
{item.meta ? <Text color={ui.theme.color.muted}> {item.meta}</Text> : null}
{item.meta ? (
<Text
backgroundColor={active ? theme.color.completionMetaCurrentBg : theme.color.completionMetaBg}
color={theme.color.muted}
>
{' '}
{item.meta}
</Text>
) : null}
</Box>
)
})}

View file

@ -58,6 +58,44 @@ export function Banner({ t }: { t: Theme }) {
)
}
// ── Collapsible helpers ──────────────────────────────────────────────
function CollapseToggle({
count,
open,
suffix,
t,
title,
onToggle
}: {
count?: number
open: boolean
suffix?: string
t: Theme
title: string
onToggle: () => void
}) {
return (
<Box onClick={onToggle}>
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
<Text bold color={t.color.accent}>
{title}
</Text>
{typeof count === 'number' ? (
<Text color={t.color.muted}> ({count})</Text>
) : null}
{suffix ? (
<Text color={t.color.muted}> {suffix}</Text>
) : null}
</Box>
)
}
// ── SessionPanel ─────────────────────────────────────────────────────
const SKILLS_MAX = 8
const TOOLSETS_MAX = 8
export function SessionPanel({ info, sid, t }: SessionPanelProps) {
const cols = useStdout().stdout?.columns ?? 100
const heroLines = caduceus(t.color, t.bannerHero || undefined)
@ -67,6 +105,12 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
const lineBudget = Math.max(12, w - 2)
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
// ── Local collapse state for each section ──
const [toolsOpen, setToolsOpen] = useState(true)
const [skillsOpen, setSkillsOpen] = useState(false)
const [systemOpen, setSystemOpen] = useState(false)
const [mcpOpen, setMcpOpen] = useState(false)
const truncLine = (pfx: string, items: string[]) => {
let line = ''
let shown = 0
@ -85,35 +129,89 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
return line
}
const section = (title: string, data: Record<string, string[]>, max = 8, overflowLabel = 'more…') => {
const entries = Object.entries(data).sort()
const shown = entries.slice(0, max)
const overflow = entries.length - max
const skeleton = info.lazy && entries.length === 0
// ── Collapsible skills section ──
const skillEntries = Object.entries(info.skills).sort()
const skillsTotal = flat(info.skills).length
const skillsCatCount = skillEntries.length
const skillsBody = () => {
if (info.lazy && skillEntries.length === 0) {
return <InlineLoader label="scanning skills" t={t} />
}
const shown = skillEntries.slice(0, SKILLS_MAX)
const overflow = skillEntries.length - SKILLS_MAX
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.accent}>
Available {title}
</Text>
{skeleton ? (
<InlineLoader label={title === 'Tools' ? 'discovering tools' : 'scanning skills'} t={t} />
) : (
shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))
)}
{overflow > 0 && (
<Text color={t.color.muted}>
(and {overflow} {overflowLabel})
<>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && (
<Text color={t.color.muted}>(and {overflow} more categories)</Text>
)}
</Box>
</>
)
}
// ── Collapsible tools section ──
const toolEntries = Object.entries(info.tools).sort()
const toolsTotal = flat(info.tools).length
const toolsBody = () => {
const shown = toolEntries.slice(0, TOOLSETS_MAX)
const overflow = toolEntries.length - TOOLSETS_MAX
return (
<>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && (
<Text color={t.color.muted}>(and {overflow} more toolsets)</Text>
)}
</>
)
}
// ── Collapsible MCP section ──
const mcpBody = () => (
<>
{(info.mcp_servers ?? []).map(s => (
<Text key={s.name} wrap="truncate">
<Text color={t.color.muted}>{` ${s.name} `}</Text>
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
<Text color={t.color.muted}>: </Text>
{s.connected ? (
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : (
<Text color={t.color.error}>failed</Text>
)}
</Text>
))}
</>
)
// ── System prompt body ──
const sysPromptLen = (info.system_prompt ?? '').length
const systemBody = () => {
if (sysPromptLen === 0) {
return <Text color={t.color.muted}>No system prompt loaded.</Text>
}
return (
<Text color={t.color.muted}>
{info.system_prompt}
</Text>
)
}
@ -151,37 +249,64 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
</Text>
</Box>
{section('Tools', info.tools, 8, 'more toolsets…')}
{section('Skills', info.skills)}
{/* ── Tools (expanded by default) ── */}
<Box flexDirection="column" marginTop={1}>
<CollapseToggle
onToggle={() => setToolsOpen(v => !v)}
open={toolsOpen}
t={t}
title="Available Tools"
/>
{toolsOpen && toolsBody()}
</Box>
{/* ── Skills (collapsed by default) ── */}
<Box flexDirection="column" marginTop={1}>
<CollapseToggle
count={skillsTotal}
onToggle={() => setSkillsOpen(v => !v)}
open={skillsOpen}
suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined}
t={t}
title="Available Skills"
/>
{skillsOpen && skillsBody()}
</Box>
{/* ── System Prompt (collapsed by default) ── */}
{sysPromptLen > 0 && (
<Box flexDirection="column" marginTop={1}>
<CollapseToggle
onToggle={() => setSystemOpen(v => !v)}
open={systemOpen}
suffix={`${sysPromptLen.toLocaleString()} chars`}
t={t}
title="System Prompt"
/>
{systemOpen && systemBody()}
</Box>
)}
{/* ── MCP Servers (collapsed by default) ── */}
{info.mcp_servers && info.mcp_servers.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.accent}>
MCP Servers
</Text>
{info.mcp_servers.map(s => (
<Text key={s.name} wrap="truncate">
<Text color={t.color.muted}>{` ${s.name} `}</Text>
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
<Text color={t.color.muted}>: </Text>
{s.connected ? (
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : (
<Text color={t.color.error}>failed</Text>
)}
</Text>
))}
<CollapseToggle
count={info.mcp_servers.length}
onToggle={() => setMcpOpen(v => !v)}
open={mcpOpen}
suffix="connected"
t={t}
title="MCP Servers"
/>
{mcpOpen && mcpBody()}
</Box>
)}
<Text />
<Text color={t.color.text}>
{flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills
{toolsTotal} tools{' · '}
{skillsTotal} skills
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
{' · '}
<Text color={t.color.muted}>/help for commands</Text>

View file

@ -1,4 +1,4 @@
import { Box, Link, Text } from '@hermes/ink'
import { Box, Link, stringWidth, Text } from '@hermes/ink'
import { Fragment, memo, type ReactNode, useMemo } from 'react'
import { ensureEmojiPresentation } from '../lib/emoji.js'
@ -170,16 +170,22 @@ export const stripInlineMarkup = (v: string) =>
.replace(/\\\(([^\n]+?)\\\)/g, '$1')
const renderTable = (k: number, rows: string[][], t: Theme) => {
const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => stripInlineMarkup(r[ci] ?? '').length)))
// Column widths in *display cells*, not UTF-16 code units. CJK
// glyphs and most emoji render as two cells but `String#length`
// counts them as one, which collapses Chinese / Japanese / Korean
// tables into drift across rows. `stringWidth` (Bun.stringWidth
// fast path + an East-Asian-width-aware fallback, memoised in
// @hermes/ink) returns the actual cell count.
const cellWidth = (raw: string) => stringWidth(stripInlineMarkup(raw))
const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => cellWidth(r[ci] ?? ''))))
// Thin divider under the header. Without it tables look like prose
// with extra spacing because the header is just accent-coloured text
// (#15534). We avoid full borders on purpose — column widths come
// from `stripInlineMarkup(...).length` (UTF-16 code units, not
// display width), so a real outline often misaligns on emoji and
// East-Asian wide characters; one dim solid rule (`─`) under row 0
// plus tab-style column gaps reads cleanly on every terminal we
// tested.
// from `stringWidth(...)`, so the dividers and the row content stay
// in sync on CJK / emoji tables; tab-style column gaps still read
// cleanly without the boxed look.
const sep = widths.map(w => '─'.repeat(Math.max(1, w))).join(' ')
return (
@ -190,7 +196,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
{widths.map((w, ci) => (
<Text bold={ri === 0} color={ri === 0 ? t.color.accent : undefined} key={ci}>
<MdInline t={t} text={row[ci] ?? ''} />
{' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))}
{' '.repeat(Math.max(0, w - cellWidth(row[ci] ?? '')))}
{ci < widths.length - 1 ? ' ' : ''}
</Text>
))}
@ -323,7 +329,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
parts.push(<Text key={parts.length}>{text.slice(last)}</Text>)
}
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
return <Text wrap="wrap-trim">{parts.length ? parts : text}</Text>
}
// Cross-instance parsed-children cache: useMemo's per-instance cache dies
@ -420,7 +426,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (media) {
start('paragraph')
nodes.push(
<Text color={t.color.muted} key={key}>
<Text color={t.color.muted} key={key} wrap="wrap-trim">
{'▸ '}
<Link url={/^(?:\/|[a-z]:[\\/])/i.test(media) ? `file://${media}` : media}>
@ -594,7 +600,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (heading) {
start('heading')
nodes.push(
<Text bold color={t.color.accent} key={key}>
<Text bold color={t.color.accent} key={key} wrap="wrap-trim">
<MdInline t={t} text={heading} />
</Text>
)
@ -606,7 +612,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
start('heading')
nodes.push(
<Text bold color={t.color.accent} key={key}>
<Text bold color={t.color.accent} key={key} wrap="wrap-trim">
<MdInline t={t} text={line.trim()} />
</Text>
)
@ -632,7 +638,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (footnote) {
start('list')
nodes.push(
<Text color={t.color.muted} key={key}>
<Text color={t.color.muted} key={key} wrap="wrap-trim">
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
</Text>
)
@ -641,7 +647,7 @@ function MdImpl({ compact, t, text }: MdProps) {
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
nodes.push(
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
<Text color={t.color.muted}>
<Text color={t.color.muted} wrap="wrap-trim">
<MdInline t={t} text={lines[i]!.trim()} />
</Text>
</Box>
@ -655,7 +661,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
start('list')
nodes.push(
<Text bold key={key}>
<Text bold key={key} wrap="wrap-trim">
{line.trim()}
</Text>
)
@ -669,7 +675,7 @@ function MdImpl({ compact, t, text }: MdProps) {
}
nodes.push(
<Text key={`${key}-def-${i}`}>
<Text key={`${key}-def-${i}`} wrap="wrap-trim">
<Text color={t.color.muted}> · </Text>
<MdInline t={t} text={def} />
</Text>
@ -689,14 +695,12 @@ function MdImpl({ compact, t, text }: MdProps) {
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
nodes.push(
<Text key={key}>
<Text color={t.color.muted}>
{' '.repeat(indentDepth(bullet[1]!) * 2)}
{marker}{' '}
<Box key={key} paddingLeft={indentDepth(bullet[1]!) * 2}>
<Text wrap="wrap-trim">
<Text color={t.color.muted}>{marker} </Text>
<MdInline t={t} text={task ? task[2]! : bullet[2]!} />
</Text>
<MdInline t={t} text={task ? task[2]! : bullet[2]!} />
</Text>
</Box>
)
i++
@ -708,14 +712,12 @@ function MdImpl({ compact, t, text }: MdProps) {
if (numbered) {
start('list')
nodes.push(
<Text key={key}>
<Text color={t.color.muted}>
{' '.repeat(indentDepth(numbered[1]!) * 2)}
{numbered[2]}.{' '}
<Box key={key} paddingLeft={indentDepth(numbered[1]!) * 2}>
<Text wrap="wrap-trim">
<Text color={t.color.muted}>{numbered[2]}. </Text>
<MdInline t={t} text={numbered[3]!} />
</Text>
<MdInline t={t} text={numbered[3]!} />
</Text>
</Box>
)
i++
@ -737,11 +739,11 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push(
<Box flexDirection="column" key={key}>
{quoteLines.map((ql, qi) => (
<Text color={t.color.muted} key={qi}>
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
{'│ '}
<MdInline t={t} text={ql.text} />
</Text>
<Box key={qi} paddingLeft={Math.max(0, ql.depth - 1) * 2}>
<Text color={t.color.muted} wrap="wrap-trim">
<MdInline t={t} text={ql.text} />
</Text>
</Box>
))}
</Box>
)
@ -774,7 +776,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (summary) {
start('paragraph')
nodes.push(
<Text color={t.color.muted} key={key}>
<Text color={t.color.muted} key={key} wrap="wrap-trim">
{summary}
</Text>
)
@ -786,7 +788,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (/^<\/?[^>]+>$/.test(line.trim())) {
start('paragraph')
nodes.push(
<Text color={t.color.muted} key={key}>
<Text color={t.color.muted} key={key} wrap="wrap-trim">
{line.trim()}
</Text>
)

View file

@ -1,10 +1,11 @@
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
import { memo } from 'react'
import { memo, useState } from 'react'
import { LONG_MSG } from '../config/limits.js'
import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js'
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
import {
boundedHistoryRenderText,
boundedLiveRenderText,
@ -21,6 +22,9 @@ import { StreamingMd } from './streamingMarkdown.js'
import { ToolTrail } from './thinking.js'
import { TodoPanel } from './todoPanel.js'
// Collapse threshold for long system messages (system prompt etc.)
const SYSTEM_COLLAPSE_CHARS = 400
export const MessageLine = memo(function MessageLine({
cols,
compact,
@ -45,6 +49,10 @@ export const MessageLine = memo(function MessageLine({
const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride)
const thinking = msg.thinking?.trim() ?? ''
// Collapse toggle for long system messages
const systemIsLong = msg.role === 'system' && msg.text.length > SYSTEM_COLLAPSE_CHARS
const [systemOpen, setSystemOpen] = useState(false)
if (msg.kind === 'trail' && msg.todos?.length) {
return (
<TodoPanel
@ -95,6 +103,7 @@ export const MessageLine = memo(function MessageLine({
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt)
const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
@ -104,6 +113,27 @@ export const MessageLine = memo(function MessageLine({
return <Text color={t.color.muted}>{msg.text}</Text>
}
// ── Collapsible long system message (system prompt, AGENTS.md, etc.) ──
// MUST come before the hasAnsi check — system messages from the backend
// contain Rich markup escape codes that would otherwise hit <Ansi> full render.
if (systemIsLong) {
const firstLine = (msg.text.split('\n')[0] ?? '').trim().slice(0, 120) || '(system message)'
return (
<Box flexDirection="column">
<Box onClick={() => setSystemOpen(v => !v)}>
<Text color={t.color.accent}>{systemOpen ? '▾ ' : '▸ '}</Text>
<Text color={t.color.muted}>{firstLine}</Text>
<Text color={t.color.muted} dimColor>
{' — '}
{msg.text.length.toLocaleString()} chars
</Text>
</Box>
{systemOpen && <Ansi>{msg.text}</Ansi>}
</Box>
)
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{msg.text}</Ansi>
}
@ -163,13 +193,13 @@ export const MessageLine = memo(function MessageLine({
)}
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={3}>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</NoSelect>
<Box width={Math.max(20, cols - 5)}>{content}</Box>
<Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt)}>{content}</Box>
</Box>
</Box>
)

View file

@ -8,12 +8,14 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js'
const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
type Stage = 'provider' | 'key' | 'model' | 'disconnect'
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('')
@ -22,7 +24,10 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const [persistGlobal, setPersistGlobal] = useState(false)
const [providerIdx, setProviderIdx] = useState(0)
const [modelIdx, setModelIdx] = useState(0)
const [stage, setStage] = useState<'model' | 'provider'>('provider')
const [stage, setStage] = useState<Stage>('provider')
const [keyInput, setKeyInput] = useState('')
const [keySaving, setKeySaving] = useState(false)
const [keyError, setKeyError] = useState('')
const { stdout } = useStdout()
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
@ -68,9 +73,12 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const names = useMemo(() => providerDisplayNames(providers), [providers])
const back = () => {
if (stage === 'model') {
if (stage === 'model' || stage === 'key' || stage === 'disconnect') {
setStage('provider')
setModelIdx(0)
setKeyInput('')
setKeyError('')
setKeySaving(false)
return
}
@ -81,6 +89,118 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
useOverlayKeys({ onBack: back, onClose: onCancel })
useInput((ch, key) => {
// Key entry stage handles its own input
if (stage === 'key') {
if (keySaving) {
return
}
if (key.return) {
if (!keyInput.trim()) {
return
}
setKeySaving(true)
setKeyError('')
gw.request<{ provider?: ModelOptionProvider }>('model.save_key', {
slug: provider?.slug,
api_key: keyInput.trim(),
...(sessionId ? { session_id: sessionId } : {}),
})
.then(raw => {
const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw)
if (!r?.provider) {
setKeyError('failed to save key')
setKeySaving(false)
return
}
// Update the provider in our list with fresh data
setProviders(prev =>
prev.map(p => p.slug === r.provider!.slug ? r.provider! : p)
)
setKeyInput('')
setKeySaving(false)
setStage('model')
setModelIdx(0)
})
.catch((e: unknown) => {
setKeyError(rpcErrorMessage(e))
setKeySaving(false)
})
return
}
if (key.backspace || key.delete) {
setKeyInput(v => v.slice(0, -1))
return
}
// ctrl+u clears input
if (ch === '\u0015') {
setKeyInput('')
return
}
if (ch && !key.ctrl && !key.meta) {
setKeyInput(v => v + ch)
}
return
}
// Disconnect confirmation stage
if (stage === 'disconnect') {
if (ch.toLowerCase() === 'y' || key.return) {
if (!provider) {
setStage('provider')
return
}
setKeySaving(true)
gw.request<{ disconnected?: boolean }>('model.disconnect', {
slug: provider.slug,
...(sessionId ? { session_id: sessionId } : {}),
})
.then(raw => {
const r = asRpcResult<{ disconnected?: boolean }>(raw)
if (r?.disconnected) {
// Mark provider as unauthenticated in local state
setProviders(prev =>
prev.map(p => p.slug === provider.slug
? { ...p, authenticated: false, models: [], total_models: 0, warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure' }
: p
)
)
}
setKeySaving(false)
setStage('provider')
})
.catch(() => {
setKeySaving(false)
setStage('provider')
})
return
}
if (ch.toLowerCase() === 'n' || key.escape) {
setStage('provider')
return
}
return
}
const count = stage === 'provider' ? providers.length : models.length
const sel = stage === 'provider' ? providerIdx : modelIdx
const setSel = stage === 'provider' ? setProviderIdx : setModelIdx
@ -103,6 +223,18 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return
}
if (provider.authenticated === false) {
// api_key providers: prompt for key inline
if (provider.auth_type === 'api_key' && provider.key_env) {
setStage('key')
setKeyInput('')
setKeyError('')
}
// Other auth types: no-op (warning shown tells them to run hermes model)
return
}
setStage('model')
setModelIdx(0)
@ -126,22 +258,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return
}
const n = ch === '0' ? 10 : parseInt(ch, 10)
// Disconnect: only in provider stage, only for authenticated providers
if (ch.toLowerCase() === 'd' && stage === 'provider' && provider?.authenticated !== false) {
setStage('disconnect')
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
const offset = windowOffset(count, sel, VISIBLE)
if (stage === 'provider') {
const next = offset + n - 1
if (providers[next]) {
setProviderIdx(next)
}
} else if (provider && models[offset + n - 1]) {
onSelect(
`${models[offset + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`
)
}
return
}
})
@ -161,15 +282,96 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (!providers.length) {
return (
<Box flexDirection="column">
<Text color={t.color.muted}>no authenticated providers</Text>
<Text color={t.color.muted}>no providers available</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
// ── Key entry stage ──────────────────────────────────────────────────
if (stage === 'key' && provider) {
const masked = keyInput ? '•'.repeat(Math.min(keyInput.length, 40)) : ''
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Configure {provider.name}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
Paste your API key below (saved to ~/.hermes/.env)
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{provider.key_env}:
</Text>
<Text color={t.color.accent} wrap="truncate-end">
{' '}{masked || '(empty)'}{keySaving ? '' : '▎'}
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
{keyError ? (
<Text color={t.color.label} wrap="truncate-end">
error: {keyError}
</Text>
) : keySaving ? (
<Text color={t.color.muted} wrap="truncate-end">
saving
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end"> </Text>
)}
<OverlayHint t={t}>Enter save · Ctrl+U clear · Esc back</OverlayHint>
</Box>
)
}
// ── Disconnect confirmation stage ─────────────────────────────────────
if (stage === 'disconnect' && provider) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Disconnect {provider.name}?
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
This removes saved credentials for {provider.name}.
</Text>
<Text color={t.color.muted} wrap="truncate-end">
You can re-authenticate later by selecting it again.
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
{keySaving ? (
<Text color={t.color.muted} wrap="truncate-end">disconnecting</Text>
) : (
<OverlayHint t={t}>y/Enter confirm · n/Esc cancel</OverlayHint>
)}
</Box>
)
}
// ── Provider selection stage ─────────────────────────────────────────
if (stage === 'provider') {
const rows = providers.map(
(p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models`
(p, i) => {
const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●'
const modelCount = p.total_models ?? p.models?.length ?? 0
const suffix = p.authenticated === false
? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)')
: `${modelCount} models`
return `${authMark} ${names[i]} · ${suffix}`
}
)
const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
@ -197,17 +399,19 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
{Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
const p = providers[idx]
const dimmed = p?.authenticated === false
return row ? (
<Text
bold={providerIdx === idx}
color={providerIdx === idx ? t.color.accent : t.color.muted}
color={providerIdx === idx ? t.color.accent : dimmed ? t.color.label : t.color.muted}
inverse={providerIdx === idx}
key={providers[idx]?.slug ?? `row-${idx}`}
wrap="truncate-end"
>
{providerIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
{idx + 1}. {row}
</Text>
) : (
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
@ -223,11 +427,12 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
<Text color={t.color.muted} wrap="truncate-end">
persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text>
<OverlayHint t={t}>/ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayHint>
<OverlayHint t={t}>/ select · Enter choose · d disconnect · Esc/q cancel</OverlayHint>
</Box>
)
}
// ── Model selection stage ────────────────────────────────────────────
const { items, offset } = windowItems(models, modelIdx, VISIBLE)
return (
@ -273,7 +478,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
wrap="truncate-end"
>
{prefix}
{i + 1}. {row}
{idx + 1}. {row}
</Text>
)
})}
@ -286,7 +491,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text>
<OverlayHint t={t}>
{models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back · q close' : 'Enter/Esc back · q close'}
{models.length ? '↑/↓ select · Enter switch · Esc back · q close' : 'Enter/Esc back · q close'}
</OverlayHint>
</Box>
)

View file

@ -5,7 +5,14 @@ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'rea
import { setInputSelection } from '../app/inputSelectionStore.js'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
import { cursorLayout, offsetFromPosition } from '../lib/inputMetrics.js'
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
import {
DEFAULT_VOICE_RECORD_KEY,
isActionMod,
isMac,
isMacActionFallback,
isVoiceToggleKey,
type ParsedVoiceRecordKey
} from '../lib/platform.js'
type InkExt = typeof Ink & {
stringWidth: (s: string) => number
@ -239,6 +246,7 @@ export function TextInput({
onSubmit,
mask,
mouseApiRef,
voiceRecordKey = DEFAULT_VOICE_RECORD_KEY,
placeholder = '',
focus = true
}: TextInputProps) {
@ -699,6 +707,15 @@ export function TextInput({
(inp: string, k: Key, event: InputEvent) => {
const eventRaw = event.keypress.raw
// Configured voice shortcut wins over composer-level defaults like
// paste/copy so users who bind voice to ctrl+v / alt+v / cmd+v
// actually get voice toggled instead of a paste (Copilot round-7
// follow-up on #19835). The pass-through predicate is a no-op for
// ordinary typing and plain paste when voice is unbound to 'v'.
if (shouldPassThroughToGlobalHandler(inp, k, voiceRecordKey)) {
return
}
if (
eventRaw === '\x1bv' ||
eventRaw === '\x1bV' ||
@ -744,22 +761,6 @@ export function TextInput({
return
}
// Ctrl chords claimed by useInputHandlers — pass through instead of
// letting them fall into readline-style nav or a literal char insert.
// Ctrl+B = voice toggle, Ctrl+X = delete queued message while editing.
if (
(k.ctrl && inp === 'c') ||
(k.ctrl && inp === 'b') ||
(k.ctrl && inp === 'x') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||
k.pageDown ||
k.escape
) {
return
}
if (k.return) {
if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) {
flushParentChange()
@ -969,10 +970,15 @@ export function TextInput({
return
}
// Right-click → route through the same path as Alt+V so the composer
// clipboard RPC (text or image) handles it.
// Right-click → copy active selection if any, otherwise paste.
if (e.button === 2) {
e.stopImmediatePropagation?.()
const decision = decideRightClickAction(vRef.current, selRange())
if (decision.action === 'copy') {
void writeClipboardText(decision.text)
return
}
emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
return
@ -1041,8 +1047,51 @@ interface TextInputProps {
onSubmit?: (v: string) => void
placeholder?: string
value: string
voiceRecordKey?: ParsedVoiceRecordKey
}
export type RightClickDecision =
| { action: 'copy'; text: string }
| { action: 'paste' }
/**
* Decide what right-click should do on the composer:
* - non-empty selection copy that text to the clipboard
* - no selection (or empty/collapsed range) fall through to paste
*
* Mirrors terminal-native behavior (xterm, iTerm, gnome-terminal) where
* right-click pastes only when there is nothing selected to copy.
*
* Callers pass the already-normalized range from `selRange()` (start <= end,
* or null when collapsed), so this helper does not need to re-normalize.
*/
export function decideRightClickAction(
value: string,
range: { end: number; start: number } | null
): RightClickDecision {
if (range && range.end > range.start) {
const text = value.slice(range.start, range.end)
if (text) {
return { action: 'copy', text }
}
}
return { action: 'paste' }
}
export const shouldPassThroughToGlobalHandler = (
input: string,
key: Key,
voiceRecordKey: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY
): boolean =>
(key.ctrl && input === 'c') ||
(key.ctrl && input === 'x') ||
key.tab ||
(key.shift && key.tab) ||
key.pageUp ||
key.pageDown ||
key.escape ||
isVoiceToggleKey(key, input, voiceRecordKey)
export interface TextInputMouseApi {
dragAt: (row: number, col: number) => void
end: () => void

View file

@ -1,6 +1,8 @@
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim()
export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)

View file

@ -13,10 +13,26 @@ const MAX_BUFFERED_EVENTS = 2000
const MAX_LOG_PREVIEW = 240
const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000)
const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)
const WS_CONNECTING = 0
const WS_OPEN = 1
const WS_CLOSING = 2
const WS_CLOSED = 3
const truncateLine = (line: string) =>
line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line
const resolveGatewayAttachUrl = () => {
const raw = process.env.HERMES_TUI_GATEWAY_URL?.trim()
return raw ? raw : null
}
const resolveSidecarUrl = () => {
const raw = process.env.HERMES_TUI_SIDECAR_URL?.trim()
return raw ? raw : null
}
const resolvePython = (root: string) => {
const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()
@ -43,6 +59,60 @@ const asGatewayEvent = (value: unknown): GatewayEvent | null =>
? (value as GatewayEvent)
: null
// Hoisted decoder: attach mode can drive high-frequency binary frames
// (tool deltas, reasoning streams) and constructing a fresh TextDecoder
// per message creates avoidable GC pressure. One module-level instance
// is fine because UTF-8 is stateless and we always pass entire frames.
const _wireDecoder = new TextDecoder()
const asWireText = (raw: unknown): string | null => {
if (typeof raw === 'string') {
return raw
}
if (raw instanceof ArrayBuffer) {
return _wireDecoder.decode(raw)
}
if (ArrayBuffer.isView(raw)) {
return _wireDecoder.decode(raw)
}
return null
}
// Matches `<scheme>://user:pass@host…` style user-info segments in
// otherwise-malformed URLs that the WHATWG `URL` parser can't accept.
// Used by the `redactUrl` fallback so embedded credentials are
// scrubbed from log lines even when the URL is unparseable.
const _USERINFO_FALLBACK_RE = /^([a-z][a-z0-9+.\-]*:\/\/)[^/?#@]*@/i
// Connection URLs (gateway, sidecar) often carry bearer tokens in the query
// string. We surface them in user-facing log lines and the
// `gateway.start_timeout` payload, so always strip the query string and any
// embedded user-info before logging.
const redactUrl = (raw: string): string => {
if (!raw) {
return raw
}
try {
const url = new URL(raw)
const userInfo = url.username || url.password ? '***@' : ''
const query = url.search ? '?***' : ''
return `${url.protocol}//${userInfo}${url.host}${url.pathname}${query}`
} catch {
// WHATWG URL rejected the input. Best-effort: strip an embedded
// `user:pass@` segment AND the query string so a malformed token
// bearer can never escape into the log tail.
const noUserInfo = raw.replace(_USERINFO_FALLBACK_RE, '$1***@')
const queryIdx = noUserInfo.indexOf('?')
return queryIdx >= 0 ? `${noUserInfo.slice(0, queryIdx)}?***` : noUserInfo
}
}
interface Pending {
id: string
method: string
@ -53,6 +123,11 @@ interface Pending {
export class GatewayClient extends EventEmitter {
private proc: ChildProcess | null = null
private ws: WebSocket | null = null
private wsConnectPromise: Promise<void> | null = null
private sidecarWs: WebSocket | null = null
private attachUrl: null | string = null
private sidecarUrl: null | string = null
private reqId = 0
private logs = new CircularBuffer<string>(MAX_GATEWAY_LOG_LINES)
private pending = new Map<string, Pending>()
@ -88,14 +163,48 @@ export class GatewayClient extends EventEmitter {
this.bufferedEvents.push(ev)
}
start() {
const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../')
const python = resolvePython(root)
const cwd = process.env.HERMES_CWD || root
const env = { ...process.env }
const pyPath = env.PYTHONPATH?.trim()
env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root
private clearReadyTimer() {
if (this.readyTimer) {
clearTimeout(this.readyTimer)
this.readyTimer = null
}
}
private closeSidecarSocket() {
try {
this.sidecarWs?.close()
} catch {
// best effort
} finally {
this.sidecarWs = null
}
}
private closeGatewaySocket() {
// Null the active reference BEFORE invoking close(): real WebSocket
// implementations dispatch the 'close' event after a microtask hop,
// so by the time the handler runs `this.ws` should already be null
// and the identity guard will correctly classify the close as
// belonging to a discarded socket. (Test fakes emit synchronously,
// so doing the swap up front is also what makes the identity guard
// match real timing in tests.)
const ws = this.ws
this.ws = null
this.wsConnectPromise = null
try {
ws?.close()
} catch {
// best effort
}
}
private resetStartupState() {
// Reject any in-flight RPCs left over from the previous transport
// before we swap. Otherwise the old transport's stale exit/close
// handlers (now identity-gated to ignore unrelated transports)
// never fire `rejectPending`, leaving callers hanging on promises
// attached to a discarded child / socket.
this.rejectPending(new Error('gateway restarting'))
this.ready = false
this.bufferedEvents.clear()
this.pendingExit = undefined
@ -103,15 +212,10 @@ export class GatewayClient extends EventEmitter {
this.stderrRl?.close()
this.stdoutRl = null
this.stderrRl = null
this.clearReadyTimer()
}
if (this.proc && !this.proc.killed && this.proc.exitCode === null) {
this.proc.kill()
}
if (this.readyTimer) {
clearTimeout(this.readyTimer)
}
private startReadyTimer(python: string, cwd: string) {
this.readyTimer = setTimeout(() => {
if (this.ready) {
return
@ -130,7 +234,95 @@ export class GatewayClient extends EventEmitter {
payload: { cwd, python, stderr_tail: stderrTail }
})
}, STARTUP_TIMEOUT_MS)
}
private handleTransportExit(code: null | number, reason?: string) {
this.clearReadyTimer()
this.closeSidecarSocket()
this.rejectPending(new Error(reason || `gateway exited${code === null ? '' : ` (${code})`}`))
if (this.subscribed) {
this.emit('exit', code)
} else {
this.pendingExit = code
}
}
private connectSidecarMirror() {
this.closeSidecarSocket()
if (!this.sidecarUrl) {
return
}
if (typeof WebSocket === 'undefined') {
this.pushLog(`[sidecar] WebSocket unavailable; skipping mirror to ${redactUrl(this.sidecarUrl)}`)
return
}
try {
const ws = new WebSocket(this.sidecarUrl)
this.sidecarWs = ws
ws.addEventListener('close', () => {
if (this.sidecarWs === ws) {
this.sidecarWs = null
}
})
ws.addEventListener('error', () => {
this.pushLog('[sidecar] mirror connection error')
})
} catch (err) {
this.pushLog(`[sidecar] failed to connect ${redactUrl(this.sidecarUrl)} (constructor error)`)
this.sidecarWs = null
}
}
private mirrorEventToSidecar(rawFrame: string) {
const ws = this.sidecarWs
if (!ws || ws.readyState !== WS_OPEN) {
return
}
try {
ws.send(rawFrame)
} catch {
// best effort
}
}
private handleWebSocketFrame(raw: unknown) {
const text = asWireText(raw)
if (!text) {
return
}
try {
const frame = JSON.parse(text) as Record<string, unknown>
if (frame.method === 'event') {
this.mirrorEventToSidecar(text)
}
this.dispatch(frame)
} catch {
const preview = text.trim().slice(0, MAX_LOG_PREVIEW) || '(empty frame)'
this.pushLog(`[protocol] malformed websocket frame: ${preview}`)
this.publish({ type: 'gateway.protocol_error', payload: { preview } })
}
}
private startSpawnedGateway(root: string) {
const python = resolvePython(root)
const cwd = process.env.HERMES_CWD || root
const env = { ...process.env }
const pyPath = env.PYTHONPATH?.trim()
env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root
this.startReadyTimer(python, cwd)
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
this.stdoutRl = createInterface({ input: this.proc.stdout! })
@ -157,28 +349,154 @@ export class GatewayClient extends EventEmitter {
this.publish({ type: 'gateway.stderr', payload: { line } })
})
const ownedProc = this.proc
this.proc.on('error', err => {
this.pushLog(`[spawn] ${err.message}`)
this.rejectPending(new Error(`gateway error: ${err.message}`))
this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } })
})
// Skip stale errors on an already-replaced child.
if (this.proc !== ownedProc) {
return
}
const line = `[spawn] ${err.message}`
this.pushLog(line)
this.publish({ type: 'gateway.stderr', payload: { line } })
// Detach the reference up front so the late `exit` event for
// this same child is identity-skipped (we don't want to emit
// 'exit' twice). Then run the full teardown — clears the
// startup timer so we don't fire a misleading
// `gateway.start_timeout`, rejects pending RPCs, and emits or
// queues a single `exit`.
this.proc = null
this.handleTransportExit(1, `gateway error: ${err.message}`)
})
this.proc.on('exit', code => {
if (this.readyTimer) {
clearTimeout(this.readyTimer)
this.readyTimer = null
// start() can replace `this.proc` while an old child is still
// tearing down. Skip stale exits so we don't clear the new
// startup timer or reject newly-issued pending requests.
if (this.proc !== ownedProc) {
return
}
this.rejectPending(new Error(`gateway exited${code === null ? '' : ` (${code})`}`))
if (this.subscribed) {
this.emit('exit', code)
} else {
this.pendingExit = code
}
this.handleTransportExit(code)
})
}
private startAttachedGateway(attachUrl: string) {
const safeAttachUrl = redactUrl(attachUrl)
this.startReadyTimer('websocket', safeAttachUrl)
if (typeof WebSocket === 'undefined') {
const line = `[startup] WebSocket API unavailable; cannot attach to ${safeAttachUrl}`
this.pushLog(line)
this.publish({ type: 'gateway.stderr', payload: { line } })
this.handleTransportExit(1, 'gateway websocket unavailable')
return
}
try {
const ws = new WebSocket(attachUrl)
let settled = false
this.ws = ws
const connectPromise = new Promise<void>((resolve, reject) => {
ws.addEventListener(
'open',
() => {
if (!settled) {
settled = true
resolve()
}
this.connectSidecarMirror()
},
{ once: true }
)
ws.addEventListener(
'error',
() => {
if (!settled) {
this.pushLog('[startup] gateway websocket connect error')
settled = true
reject(new Error('gateway websocket connection failed'))
}
},
{ once: true }
)
ws.addEventListener(
'close',
ev => {
if (!settled) {
settled = true
reject(new Error(`gateway websocket closed (${ev.code}) during connect`))
}
},
{ once: true }
)
})
// The connect promise is only awaited by RPCs that arrive while
// the socket is still connecting. If no request races the open
// (or a teardown drops the reference before anyone observes it),
// a connect-error / early-close rejection would surface as an
// unhandled promise rejection in Node. Attach a no-op handler to
// ensure the rejection is always observed.
connectPromise.catch(() => {})
this.wsConnectPromise = connectPromise
ws.addEventListener('message', ev => this.handleWebSocketFrame(ev.data))
ws.addEventListener('close', ev => {
// Skip close events from sockets that have already been
// replaced — start() / closeGatewaySocket() can swap `this.ws`
// before an in-flight close lands, and we must not clear the
// new ready timer or reject the new pending requests on behalf
// of a stale socket.
if (this.ws !== ws) {
return
}
this.ws = null
this.wsConnectPromise = null
this.handleTransportExit(ev.code, `gateway websocket closed${ev.code ? ` (${ev.code})` : ''}`)
})
ws.addEventListener('error', () => {
const line = '[gateway] websocket transport error'
this.pushLog(line)
this.publish({ type: 'gateway.stderr', payload: { line } })
})
} catch (err) {
this.pushLog(`[startup] failed to connect websocket gateway ${safeAttachUrl} (constructor error)`)
this.handleTransportExit(1, 'gateway websocket startup failed')
}
}
start() {
const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../')
const attachUrl = resolveGatewayAttachUrl()
const sidecarUrl = resolveSidecarUrl()
this.attachUrl = attachUrl
this.sidecarUrl = sidecarUrl
this.resetStartupState()
if (this.proc && !this.proc.killed && this.proc.exitCode === null) {
this.proc.kill()
}
this.proc = null
this.closeGatewaySocket()
this.closeSidecarSocket()
if (attachUrl) {
this.startAttachedGateway(attachUrl)
return
}
this.startSpawnedGateway(root)
}
private dispatch(msg: Record<string, unknown>) {
const id = msg.id as string | undefined
const p = id ? this.pending.get(id) : undefined
@ -258,7 +576,78 @@ export class GatewayClient extends EventEmitter {
return this.logs.tail(Math.max(1, limit)).join('\n')
}
private async ensureAttachedWebSocket(method: string): Promise<WebSocket> {
if (!this.attachUrl) {
throw new Error('gateway not running')
}
if (!this.ws || this.ws.readyState === WS_CLOSED || this.ws.readyState === WS_CLOSING) {
this.start()
}
if (this.ws?.readyState === WS_CONNECTING) {
try {
await this.wsConnectPromise
} catch (err) {
throw err instanceof Error ? err : new Error(String(err))
}
}
if (!this.ws || this.ws.readyState !== WS_OPEN) {
throw new Error(`gateway not connected: ${method}`)
}
return this.ws
}
private requestOverWebSocket<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
return this.ensureAttachedWebSocket(method).then(
ws =>
new Promise<T>((resolve, reject) => {
const id = `r${++this.reqId}`
const timeout = setTimeout(this.onTimeout, REQUEST_TIMEOUT_MS, id)
timeout.unref?.()
this.pending.set(id, {
id,
method,
reject,
resolve: v => resolve(v as T),
timeout
})
try {
ws.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }))
} catch (e) {
const pending = this.pending.get(id)
if (pending) {
clearTimeout(pending.timeout)
this.pending.delete(id)
}
reject(e instanceof Error ? e : new Error(String(e)))
}
})
)
}
request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const attachUrl = resolveGatewayAttachUrl()
if (attachUrl) {
if (this.attachUrl !== attachUrl) {
// The env var rotated at runtime — restart the transport so
// switching from spawned-gateway mode to attach mode also
// tears down the old Python child. Merely closing `this.ws`
// would leave a previously spawned gateway process alive.
this.rejectPending(new Error('gateway attach url changed'))
this.start()
}
return this.requestOverWebSocket<T>(method, params)
}
if (!this.proc?.stdin || this.proc.killed || this.proc.exitCode !== null) {
this.start()
}
@ -299,5 +688,13 @@ export class GatewayClient extends EventEmitter {
kill() {
this.proc?.kill()
this.closeGatewaySocket()
this.closeSidecarSocket()
this.clearReadyTimer()
// The ws 'close' handler is identity-gated on `this.ws === ws`
// and we just nulled `this.ws`, so it will short-circuit and
// skip handleTransportExit. Reject pending RPCs explicitly so
// attach-mode promises do not hang after an intentional kill.
this.rejectPending(new Error('gateway closed'))
}
}

View file

@ -47,7 +47,7 @@ export type CommandDispatchResponse =
| { output?: string; type: 'exec' | 'plugin' }
| { target: string; type: 'alias' }
| { message?: string; name: string; type: 'skill' }
| { message: string; type: 'send' }
| { message: string; notice?: string; type: 'send' }
// ── Config ───────────────────────────────────────────────────────────
@ -75,8 +75,14 @@ export interface ConfigDisplayConfig {
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
}
export interface ConfigVoiceConfig {
// Raw `yaml.safe_load()` value from config; may be non-string if hand-edited.
// Callers must normalize/validate at runtime (parseVoiceRecordKey()).
record_key?: unknown
}
export interface ConfigFullResponse {
config?: { display?: ConfigDisplayConfig }
config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig }
}
export interface ConfigMtimeResponse {
@ -170,6 +176,10 @@ export interface SessionUsageResponse {
total?: number
}
export interface SessionStatusResponse {
output?: string
}
export interface SessionCompressResponse {
after_messages?: number
after_tokens?: number
@ -279,12 +289,13 @@ export interface VoiceToggleResponse {
available?: boolean
details?: string
enabled?: boolean
record_key?: string
stt_available?: boolean
tts?: boolean
}
export interface VoiceRecordResponse {
status?: string
status?: 'busy' | 'recording' | 'stopped'
text?: string
}
@ -302,7 +313,10 @@ export interface ToolsConfigureResponse {
// ── Model picker ─────────────────────────────────────────────────────
export interface ModelOptionProvider {
auth_type?: string
authenticated?: boolean
is_current?: boolean
key_env?: string
models?: string[]
name: string
slug: string
@ -493,6 +507,7 @@ export type GatewayEvent =
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
| { payload?: { text?: string }; session_id?: string; type: 'review.summary' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }

View file

@ -1,12 +1,43 @@
import { useEffect, useRef, useState } from 'react'
import type { CompletionItem } from '../app/interfaces.js'
import { looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { CompletionResponse } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/
export function completionRequestForInput(
input: string
):
| { method: 'complete.path'; params: { word: string }; replaceFrom: number }
| { method: 'complete.slash'; params: { text: string }; replaceFrom: number }
| null {
const isSlashCommand = looksLikeSlashCommand(input)
const pathWord = isSlashCommand ? null : (input.match(TAB_PATH_RE)?.[1] ?? null)
if (!isSlashCommand && !pathWord) {
return null
}
// `/model` uses the two-step ModelPicker (real curated IDs).
// Slash completion here only showed short aliases + vendor/family meta.
if (isSlashCommand && /^\/model(?:\s|$)/.test(input)) {
return null
}
if (isSlashCommand) {
return { method: 'complete.slash', params: { text: input }, replaceFrom: 1 }
}
return {
method: 'complete.path',
params: { word: pathWord! },
replaceFrom: input.length - pathWord!.length
}
}
export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) {
const [completions, setCompletions] = useState<CompletionItem[]>([])
const [compIdx, setCompIdx] = useState(0)
@ -33,35 +64,19 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
ref.current = input
const isSlash = input.startsWith('/')
const pathWord = isSlash ? null : (input.match(TAB_PATH_RE)?.[1] ?? null)
if (!isSlash && !pathWord) {
const request = completionRequestForInput(input)
if (!request) {
clear()
return
}
// `/model` / `/provider` use the two-step ModelPicker (real curated IDs).
// Slash completion here only showed short aliases + vendor/family meta.
if (isSlash && /^\/(?:model|provider)(?:\s|$)/.test(input)) {
clear()
return
}
const pathReplace = input.length - (pathWord?.length ?? 0)
const t = setTimeout(() => {
if (ref.current !== input) {
return
}
const req = isSlash
? gw.request<CompletionResponse>('complete.slash', { text: input })
: gw.request<CompletionResponse>('complete.path', { word: pathWord })
req
gw.request<CompletionResponse>(request.method, request.params)
.then(raw => {
if (ref.current !== input) {
return
@ -71,7 +86,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
setCompletions(r?.items ?? [])
setCompIdx(0)
setCompReplace(isSlash ? (r?.replace_from ?? 1) : pathReplace)
setCompReplace(request.method === 'complete.slash' ? (r?.replace_from ?? 1) : request.replaceFrom)
})
.catch((e: unknown) => {
if (ref.current !== input) {
@ -86,7 +101,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
}
])
setCompIdx(0)
setCompReplace(isSlash ? 1 : pathReplace)
setCompReplace(request.replaceFrom)
})
}, 60)

View file

@ -51,9 +51,9 @@ const SLIDE_STEP = 12
const NOOP = () => {}
const upperBound = (arr: ArrayLike<number>, target: number) => {
const upperBound = (arr: ArrayLike<number>, target: number, length = arr.length) => {
let lo = 0
let hi = arr.length
let hi = length
while (lo < hi) {
const mid = (lo + hi) >> 1
@ -130,6 +130,9 @@ export function useVirtualHistory(
})
const [hasScrollRef, setHasScrollRef] = useState(false)
// Height cache writes happen in layout effects; bump once so offsets and
// clamp bounds rebuild without waiting for the next scroll/input event.
const [measuredHeightVersion, bumpMeasuredHeightVersion] = useState(0)
const metrics = useRef({ sticky: true, top: 0, vp: 0 })
const lastScrollTopRef = useRef(0)
@ -282,8 +285,8 @@ export function useVirtualHistory(
// Binary search — offsets is monotone. Linear walk was O(n) at n=10k+,
// ~2ms per render during scroll.
start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo) - 1))
end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi)))
start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo, n + 1) - 1))
end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi, n + 1)))
}
}
@ -434,6 +437,7 @@ export function useVirtualHistory(
useLayoutEffect(() => {
const s = scrollRef.current
let dirty = false
let heightDirty = false
// Give the renderer the mounted-row coverage for passive scroll clamping.
// Clamp MUST use the EFFECTIVE (deferred) range, not the immediate one.
@ -474,6 +478,7 @@ export function useVirtualHistory(
if (h > 0 && heights.current.get(k) !== h) {
heights.current.set(k, h)
dirty = true
heightDirty = true
}
}
}
@ -499,7 +504,11 @@ export function useVirtualHistory(
offsetVersion.current++
onHeightsChangeRef.current?.(heights.current)
}
})
if (heightDirty) {
bumpMeasuredHeightVersion(n => n + 1)
}
}, [effEnd, effStart, items, liveTailActive, measuredHeightVersion, n, offsets, scrollRef, sticky, total, vp])
return {
bottomSpacer: Math.max(0, total - (offsets[effEnd] ?? total)),

View file

@ -44,7 +44,7 @@ function readClipboardCommands(
const attempts: Array<{ args: readonly string[]; cmd: string }> = []
if (env.WSL_INTEROP) {
if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS })
}
@ -91,32 +91,76 @@ export async function readClipboardText(
return null
}
function writeClipboardCommands(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv
): Array<{ args: readonly string[]; cmd: string }> {
if (platform === 'darwin') {
return [{ cmd: 'pbcopy', args: [] }]
}
if (platform === 'win32') {
return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }]
}
const attempts: Array<{ args: readonly string[]; cmd: string }> = []
if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
attempts.push({
cmd: 'powershell.exe',
args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input']
})
}
if (env.WAYLAND_DISPLAY) {
attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] })
}
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] })
attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] })
return attempts
}
/**
* Write plain text to the system clipboard.
*
* On macOS this uses `pbcopy`. On other platforms we intentionally return
* false for now; non-mac copy still falls back to OSC52.
* Tries native platform tools in fallback order:
* - macOS: pbcopy
* - Windows: PowerShell Set-Clipboard
* - WSL: powershell.exe Set-Clipboard
* - Linux Wayland: wl-copy --type text/plain
* - Linux X11: xclip -selection clipboard -in
* - Linux X11 alt: xsel --clipboard --input
*
* Returns true if at least one backend succeeded, false otherwise
* (callers should fall back to OSC52 on false).
*/
export async function writeClipboardText(
text: string,
platform: NodeJS.Platform = process.platform,
start: typeof spawn = spawn
start: typeof spawn = spawn,
env: NodeJS.ProcessEnv = process.env
): Promise<boolean> {
if (platform !== 'darwin') {
return false
const candidates = writeClipboardCommands(platform, env)
for (const { cmd, args } of candidates) {
try {
const ok = await new Promise<boolean>(resolve => {
const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
child.once('error', () => resolve(false))
child.once('close', code => resolve(code === 0))
child.stdin?.end(text)
})
if (ok) {
return true
}
} catch {
// Fall through to the next clipboard backend.
}
}
try {
const ok = await new Promise<boolean>(resolve => {
const child = start('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
child.once('error', () => resolve(false))
child.once('close', code => resolve(code === 0))
child.stdin.end(text)
})
return ok
} catch {
return false
}
return false
}

View file

@ -1,5 +1,7 @@
import { stringWidth } from '@hermes/ink'
import type { Role } from '../types.js'
export const COMPOSER_PROMPT_GAP_WIDTH = 1
let _seg: Intl.Segmenter | null = null
@ -162,6 +164,14 @@ export function composerPromptWidth(promptText: string) {
return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH
}
export function transcriptGutterWidth(role: Role, userPrompt: string) {
return role === 'user' ? composerPromptWidth(userPrompt) : 3
}
export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string) {
return Math.max(20, totalCols - transcriptGutterWidth(role, userPrompt) - 2)
}
export function stableComposerColumns(totalCols: number, promptWidth: number) {
// Physical render/wrap width. Always reserve outer composer padding and
// prompt prefix. Only reserve the transcript scrollbar gutter when the

View file

@ -51,13 +51,359 @@ export const isCopyShortcut = (
(isMac && key.ctrl && (key.meta || key.super === true)))
/**
* Voice recording toggle key (Ctrl+B).
* Voice recording toggle key configurable via ``voice.record_key`` in
* ``config.yaml`` (default ``ctrl+b``).
*
* Documented as "Ctrl+B" everywhere: tips.py, config.yaml's voice.record_key
* default, and the Python CLI prompt_toolkit handler. We accept raw Ctrl+B on
* every platform so the TUI matches those docs. On macOS we additionally
* accept Cmd+B (the platform action modifier) so existing macOS muscle memory
* keeps working.
* Documented in tips.py, the Python CLI prompt_toolkit handler, and the
* config.yaml default. The TUI honours the same config knob (#18994);
* when ``voice.record_key`` is e.g. ``ctrl+o`` the TUI binds Ctrl+O.
*
* Only the documented default (``ctrl+b``) additionally accepts the
* macOS action modifier (Cmd+B) custom bindings like ``ctrl+o``
* require the literal Ctrl bit so Cmd+O can't steal the shortcut.
*/
export const isVoiceToggleKey = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string): boolean =>
(key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b'
export type VoiceRecordKeyMod = 'alt' | 'ctrl' | 'super'
/** Named (multi-character) keys we support, matching the CLI's
* prompt_toolkit binding shape (``c-space``, ``c-enter``, etc.) so a
* config value like ``ctrl+space`` binds in both runtimes. */
export type VoiceRecordKeyNamed = 'backspace' | 'delete' | 'enter' | 'escape' | 'space' | 'tab'
export interface ParsedVoiceRecordKey {
/** Single character (``'b'``, ``'o'``) when ``named`` is undefined,
* otherwise the named-key token (``'space'``, ``'enter'``). Kept as
* one field for back-compat with the v1 ``{ ch, mod, raw }`` shape. */
ch: string
mod: VoiceRecordKeyMod
named?: VoiceRecordKeyNamed
raw: string
}
export const DEFAULT_VOICE_RECORD_KEY: ParsedVoiceRecordKey = {
ch: 'b',
mod: 'ctrl',
raw: 'ctrl+b'
}
/** Modifier aliases.
*
* ``meta`` / ``cmd`` / ``command`` are intentionally absent.
* hermes-ink sets ``key.meta`` for plain Alt/Option on every platform
* AND for Cmd on some legacy macOS terminals (Terminal.app without
* kitty-protocol passthrough). Accepting any of those as a literal
* modifier would produce a display/binding mismatch a config like
* ``cmd+b`` would render as ``Cmd+B`` but silently fire on Alt+B, or
* never fire at all on legacy terminals even though the UI advertises
* it (Copilot round-6 review on #19835). Users on modern kitty-style
* terminals (iTerm2 CSI-u, Ghostty, Kitty, WezTerm, Alacritty) spell
* the platform action modifier ``super`` / ``win``, which match the
* unambiguous ``key.super`` bit. macOS users on Terminal.app stick
* with the documented ``ctrl+b``.
*
* Cross-runtime parity: the ``ctrl`` / ``control`` / ``alt`` / ``option`` /
* ``opt`` spellings are normalized identically in the classic CLI
* (``hermes_cli/voice.py::normalize_voice_record_key_for_prompt_toolkit``)
* so one ``voice.record_key`` value binds the same shortcut in both
* runtimes (Copilot round-9 review on #19835). The ``super`` /
* ``win`` / ``windows`` spellings are TUI-only prompt_toolkit has no
* super modifier, so the CLI falls back to the documented default and
* logs a warning at startup (Copilot round-11 review on #19835). */
const _MOD_ALIASES: Record<string, VoiceRecordKeyMod> = {
alt: 'alt',
control: 'ctrl',
ctrl: 'ctrl',
option: 'alt',
opt: 'alt',
super: 'super',
win: 'super',
windows: 'super'
}
/** Map config-string named tokens to the canonical name used at match time.
*
* Aliases mirror what prompt_toolkit accepts (``return`` ``enter``,
* ``esc`` ``escape``) so a config that round-trips through the CLI also
* binds in the TUI. */
const _NAMED_KEY_ALIASES: Record<string, VoiceRecordKeyNamed> = {
backspace: 'backspace',
bs: 'backspace',
del: 'delete',
delete: 'delete',
enter: 'enter',
esc: 'escape',
escape: 'escape',
ret: 'enter',
return: 'enter',
space: 'space',
spc: 'space',
tab: 'tab'
}
/** ``useInputHandlers()`` intercepts these unconditionally before the
* voice check runs, so a binding like ``ctrl+c`` (interrupt),
* ``ctrl+d`` (quit), or ``ctrl+l`` (clear screen) would be advertised
* in /voice status but never fire push-to-talk. Reject at parse time
* so the user gets the documented Ctrl+B instead of a dead shortcut
* (Copilot round-4 review on #19835).
*
* ``ctrl+x`` is intentionally NOT here it's only claimed during
* queue-edit (``queueEditIdx !== null``), so the voice binding works
* for most of the session and matches CLI parity for ``ctrl+<letter>``
* bindings (Copilot round-8 review on #19835). */
const _RESERVED_CTRL_CHARS = new Set(['c', 'd', 'l'])
/** On macOS the action-modifier intercepts these editor chords via
* ``isCopyShortcut`` / ``isAction`` in ``useInputHandlers()``:
* - super+c copy
* - super+d exit
* - super+l clear screen
* - super+v paste (also claimed at the TextInput layer)
* On Linux/Windows those globals key off Ctrl instead of Super, so
* super+<letter> bindings don't collide. Gate the rejection to darwin
* at parse time so kitty/CSI-u ``super+<key>`` configs still work for
* non-mac users (Copilot round-8 review on #19835). */
const _RESERVED_SUPER_CHARS = new Set(['c', 'd', 'l', 'v'])
/** On macOS ``isActionMod`` accepts ``key.meta`` as the action
* modifier but hermes-ink reports Alt as ``key.meta`` on many
* terminals. So on darwin a configured ``alt+c`` / ``alt+d`` / ``alt+l``
* gets swallowed by ``isCopyShortcut`` / ``isAction`` before the voice
* check runs. Block at parse time so /voice status doesn't advertise
* a shortcut that actually copies / quits / clears (Copilot round-12
* review on #19835). */
const _RESERVED_ALT_CHARS_MAC = new Set(['c', 'd', 'l'])
interface RuntimeKeyEvent {
alt?: boolean
backspace?: boolean
ctrl: boolean
delete?: boolean
escape?: boolean
meta: boolean
return?: boolean
shift?: boolean
super?: boolean
tab?: boolean
}
/** Match an ink ``key`` event against a parsed named key. The ink runtime
* sets one boolean per named key; ``space`` is a printable char so it
* arrives as ``ch === ' '`` rather than a dedicated ``key.space`` flag. */
const _matchesNamedKey = (
named: VoiceRecordKeyNamed,
key: RuntimeKeyEvent,
ch: string
): boolean => {
switch (named) {
case 'backspace':
return key.backspace === true
case 'delete':
return key.delete === true
case 'enter':
return key.return === true
case 'escape':
return key.escape === true
case 'space':
return ch === ' '
case 'tab':
return key.tab === true
}
}
/**
* Parse a config-string voice record key like ``ctrl+b`` / ``alt+r`` /
* ``ctrl+space`` into ``{mod, ch, named?}``. Accepts single characters
* AND the named tokens declared in ``_NAMED_KEY_ALIASES`` (``space``,
* ``enter``/``return``, ``tab``, ``escape``/``esc``, ``backspace``,
* ``delete``) matching the keys prompt_toolkit accepts on the CLI
* side via the ``c-<name>`` rewrite in ``cli.py``.
*
* Accepts ``unknown`` because the source is raw YAML via
* ``config.get full`` a hand-edited ``voice.record_key: 1`` or
* ``voice.record_key: true`` would otherwise crash ``.trim()`` on a
* non-string scalar (Copilot round-3 review on #19835). Non-string /
* empty / unrecognised values fall back to the documented Ctrl+B
* default so a typo never silently disables the shortcut.
*/
export const parseVoiceRecordKey = (raw: unknown): ParsedVoiceRecordKey => {
if (typeof raw !== 'string') {
return DEFAULT_VOICE_RECORD_KEY
}
const lower = raw.trim().toLowerCase()
if (!lower) {
return DEFAULT_VOICE_RECORD_KEY
}
const parts = lower.split('+').map(p => p.trim()).filter(Boolean)
if (!parts.length) {
return DEFAULT_VOICE_RECORD_KEY
}
const last = parts[parts.length - 1]
const modCandidates = parts.slice(0, -1)
// Reject multi-modifier chords (``ctrl+alt+r``, ``cmd+ctrl+b``) rather
// than silently dropping the extra modifier — the previous
// single-token validator made a typo bind a different shortcut than
// the user configured (Copilot round-3 review on #19835). The classic
// CLI only supports single-modifier bindings via prompt_toolkit's
// ``c-x`` / ``a-x`` rewrite in ``cli.py``, so this matches CLI parity.
if (modCandidates.length > 1) {
return DEFAULT_VOICE_RECORD_KEY
}
// Require an explicit modifier. A bare ``o`` / ``space`` / ``escape``
// has no sensible mapping: the CLI's prompt_toolkit binds the raw
// key (no rewrite) so bare-char configs would silently diverge
// between the two runtimes (Copilot round-4 review on #19835).
// Fall back to the documented default.
if (modCandidates.length === 0) {
return DEFAULT_VOICE_RECORD_KEY
}
const norm = _MOD_ALIASES[modCandidates[0]]
// Unknown modifier token (e.g. bare ``meta+b`` which is ambiguous on
// the wire) falls back to the documented default rather than
// silently coercing to Ctrl and producing a misleading bind.
if (!norm) {
return DEFAULT_VOICE_RECORD_KEY
}
const mod = norm
// Block bindings the TUI input handler intercepts before the voice
// check — ``ctrl+c`` / ``ctrl+d`` / ``ctrl+l`` would never actually
// fire push-to-talk, so advertising them in /voice status is a lie.
if (mod === 'ctrl' && last.length === 1 && _RESERVED_CTRL_CHARS.has(last)) {
return DEFAULT_VOICE_RECORD_KEY
}
// Same for ``super+c`` / ``super+d`` / ``super+l`` / ``super+v`` on
// macOS only — those are copy / exit / clear / paste and get claimed
// by ``isCopyShortcut`` / ``isAction`` / the TextInput paste layer
// before voice has a chance to toggle. On Linux/Windows the TUI
// globals key off Ctrl (not Super), so kitty/CSI-u ``super+<letter>``
// bindings stay usable for non-mac users.
if (isMac && mod === 'super' && last.length === 1 && _RESERVED_SUPER_CHARS.has(last)) {
return DEFAULT_VOICE_RECORD_KEY
}
// On macOS hermes-ink reports Alt as ``key.meta``, which ``isActionMod``
// accepts as the mac action modifier. So ``alt+c`` / ``alt+d`` / ``alt+l``
// collide with copy / exit / clear in ``useInputHandlers()`` before the
// voice check. Reject at parse time on darwin only — non-mac ``alt+<letter>``
// bindings are still usable (Copilot round-12 review on #19835).
if (isMac && mod === 'alt' && last.length === 1 && _RESERVED_ALT_CHARS_MAC.has(last)) {
return DEFAULT_VOICE_RECORD_KEY
}
if (last.length === 1) {
return { ch: last, mod, raw: lower }
}
const named = _NAMED_KEY_ALIASES[last]
if (named) {
return { ch: named, mod, named, raw: lower }
}
// Unknown multi-character token (e.g. typo'd ``ctrl+spcae``) — fall back
// to the doc default rather than silently disabling the binding.
return DEFAULT_VOICE_RECORD_KEY
}
/** Render a parsed key back as ``Ctrl+B`` / ``Ctrl+Space`` for status text.
*
* Platform-aware for the ``super`` modifier: renders ``Cmd`` on macOS and
* ``Super`` elsewhere. Previously rendered ``Cmd`` universally, which told
* Linux/Windows users the wrong modifier to press (Copilot review, round
* 2 on #19835). */
export const formatVoiceRecordKey = (parsed: ParsedVoiceRecordKey): string => {
const modLabel =
parsed.mod === 'super' ? (isMac ? 'Cmd' : 'Super') : parsed.mod[0].toUpperCase() + parsed.mod.slice(1)
// Named tokens render in title case (Ctrl+Space, Ctrl+Enter); single
// chars render upper-case to match the existing Ctrl+B convention.
const keyLabel = parsed.named
? parsed.named[0].toUpperCase() + parsed.named.slice(1)
: parsed.ch.toUpperCase()
return `${modLabel}+${keyLabel}`
}
/** Whether the parsed binding is the documented default (ctrl+b).
*
* Compare on the parsed spec rather than ``raw`` so semantically-equal
* aliases (``control+b``, ``ctrl + b``) still get the macOS Cmd+B
* muscle-memory fallback (Copilot review, round 2 on #19835). */
const _isDefaultVoiceKey = (parsed: ParsedVoiceRecordKey): boolean =>
parsed.mod === DEFAULT_VOICE_RECORD_KEY.mod &&
parsed.ch === DEFAULT_VOICE_RECORD_KEY.ch &&
parsed.named === DEFAULT_VOICE_RECORD_KEY.named
export const isVoiceToggleKey = (
key: RuntimeKeyEvent,
ch: string,
configured: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY
): boolean => {
// Match the configured key first (single-char compare or named-key
// event-property check). Bail out before evaluating modifier shape
// so the wrong key never reaches the modifier guard.
if (configured.named) {
if (!_matchesNamedKey(configured.named, key, ch)) {
return false
}
} else if (ch.toLowerCase() !== configured.ch) {
return false
}
// The parser rejects multi-modifier configs (``ctrl+shift+b`` etc.),
// so at match time Shift must always be clear — otherwise
// ``ctrl+tab`` would also fire on Ctrl+Shift+Tab and ``alt+enter``
// on Alt+Shift+Enter, triggering a different chord than configured
// (Copilot round-5 review on #19835).
if (key.shift === true) {
return false
}
switch (configured.mod) {
case 'alt':
// Most terminals surface Alt as either ``alt`` or ``meta``; accept
// both so the binding works across xterm-style and kitty-style
// protocols. Guard against ctrl/super bits so a chord like
// Ctrl+Alt+<key> or Cmd+Alt+<key> doesn't spuriously fire the
// alt binding.
//
// Bare Escape on hermes-ink can arrive as ``key.meta=true`` on some
// terminals, so a configured ``alt+escape`` must not match that shape;
// require an explicit alt bit for escape chords (Copilot round-7
// follow-up on #19835).
return (key.alt === true || (key.meta && key.escape !== true)) && !key.ctrl && key.super !== true
case 'ctrl':
// Require the Ctrl bit AND a clear Alt/Super so a chord like
// Ctrl+Alt+<key> / Ctrl+Cmd+<key> doesn't spuriously match
// ``ctrl+<key>`` (Copilot round-6 review on #19835).
//
// The documented default (``ctrl+b``) additionally accepts the
// explicit ``key.super`` bit on macOS for Cmd+B muscle memory —
// but ONLY ``key.super`` (kitty-style), never ``key.meta``, since
// ``key.meta`` is hermes-ink's Alt signal and accepting it would
// fire the binding on Alt+B.
if (key.ctrl) {
return !key.alt && !key.meta && key.super !== true
}
return _isDefaultVoiceKey(configured) && isMac && key.super === true && !key.alt && !key.meta
case 'super':
// Require the explicit ``key.super`` bit (kitty-style protocol)
// AND clear Ctrl/Alt/Meta so Ctrl+Cmd+X or Alt+Cmd+X don't
// spuriously fire the super binding (Copilot round-6 review on
// #19835). Legacy-terminal users whose Cmd arrives as
// ``key.meta`` need a kitty-protocol terminal — see the
// _MOD_ALIASES doc-comment for the rationale.
return key.super === true && !key.ctrl && !key.alt && !key.meta
}
}

View file

@ -0,0 +1,48 @@
const PRECISION_WHEEL_FRAME_MS = 16
const PRECISION_WHEEL_STICKY_MS = 80
export type PrecisionWheelState = {
active: boolean
dir: 0 | -1 | 1
lastEventAtMs: number
lastScrollAtMs: number
}
export type PrecisionWheelStep = {
active: boolean
entered: boolean
rows: 0 | 1
}
export function initPrecisionWheel(): PrecisionWheelState {
return { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
}
export function computePrecisionWheelStep(
state: PrecisionWheelState,
dir: -1 | 1,
hasModifier: boolean,
now: number
): PrecisionWheelStep {
const active = hasModifier || now - state.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
if (!active) {
state.active = false
return { active: false, entered: false, rows: 0 }
}
const entered = !state.active
state.active = true
state.lastEventAtMs = now
if (dir === state.dir && now - state.lastScrollAtMs < PRECISION_WHEEL_FRAME_MS) {
return { active: true, entered, rows: 0 }
}
state.dir = dir
state.lastScrollAtMs = now
return { active: true, entered, rows: 1 }
}

View file

@ -27,7 +27,11 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul
}
if (t === 'send' && typeof o.message === 'string') {
return { type: 'send', message: o.message }
return {
type: 'send',
message: o.message,
notice: typeof o.notice === 'string' ? o.notice : undefined,
}
}
return null

View file

@ -1,10 +1,18 @@
import { writeSync } from 'node:fs'
export const TERMINAL_MODE_RESET =
'\x1b[0\'z' + // DEC locator reporting
'\x1b[0\'{' + // selectable locator events
'\x1b[?2029l' + // passive mouse
'\x1b[?1016l' + // SGR-pixels mouse
'\x1b[?1015l' + // urxvt decimal mouse
'\x1b[?1006l' + // SGR mouse
'\x1b[?1005l' + // UTF-8 extended mouse
'\x1b[?1003l' + // any-motion mouse
'\x1b[?1002l' + // button-motion mouse
'\x1b[?1001l' + // highlight mouse
'\x1b[?1000l' + // click mouse
'\x1b[?9l' + // X10 mouse
'\x1b[?1004l' + // focus events
'\x1b[?2004l' + // bracketed paste
'\x1b[?1049l' + // alternate screen

View file

@ -11,6 +11,12 @@ export interface ViewportSnapshot {
viewportHeight: number
}
export interface ScrollbarSnapshot {
scrollHeight: number
top: number
viewportHeight: number
}
const EMPTY: ViewportSnapshot = {
atBottom: true,
bottom: 0,
@ -20,6 +26,12 @@ const EMPTY: ViewportSnapshot = {
viewportHeight: 0
}
const EMPTY_SCROLLBAR: ScrollbarSnapshot = {
scrollHeight: 0,
top: 0,
viewportHeight: 0
}
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
if (!s) {
return EMPTY
@ -52,6 +64,26 @@ export function viewportSnapshotKey(v: ViewportSnapshot) {
return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}`
}
export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot {
if (!s) {
return EMPTY_SCROLLBAR
}
const viewportHeight = Math.max(0, s.getViewportHeight())
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
const maxTop = Math.max(0, scrollHeight - viewportHeight)
return {
scrollHeight,
top: Math.max(0, Math.min(maxTop, s.getScrollTop())),
viewportHeight
}
}
export function scrollbarSnapshotKey(v: ScrollbarSnapshot) {
return `${v.top}:${v.viewportHeight}:${v.scrollHeight}`
}
export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
@ -72,3 +104,21 @@ export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>
}
}, [key])
}
export function useScrollbarSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ScrollbarSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)),
() => scrollbarSnapshotKey(EMPTY_SCROLLBAR)
)
return useMemo(() => {
const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':')
return {
scrollHeight: Number(scrollHeight),
top: Number(top),
viewportHeight: Number(viewportHeight)
}
}, [key])
}

View file

@ -1,5 +1,6 @@
import type { Msg } from '../types.js'
import { transcriptBodyWidth } from './inputMetrics.js'
import { boundedHistoryRenderText } from './text.js'
const hashText = (text: string) => {
@ -38,7 +39,19 @@ export const wrappedLines = (text: string, width: number) => {
export const estimatedMsgHeight = (
msg: Msg,
cols: number,
{ compact, details, limitHistory = false }: { compact: boolean; details: boolean; limitHistory?: boolean }
{
compact,
details,
limitHistory = false,
userPrompt = '',
withSeparator = false
}: {
compact: boolean
details: boolean
limitHistory?: boolean
userPrompt?: string
withSeparator?: boolean
}
) => {
if (msg.kind === 'intro') {
return msg.info?.version ? 9 : 5
@ -56,7 +69,7 @@ export const estimatedMsgHeight = (
return Math.max(2, msg.todos.length + 2)
}
const bodyWidth = Math.max(20, cols - 5)
const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt)
const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text
let h = wrappedLines(text || ' ', bodyWidth)
@ -74,5 +87,12 @@ export const estimatedMsgHeight = (
h++
}
// Inter-turn separator above non-first user messages (1 rule row + 1
// top-margin row). The render-side gate is in appLayout.tsx; we trust
// the caller to pass `withSeparator` only when it matches that gate.
if (withSeparator) {
h += 2
}
return Math.max(1, h)
}

View file

@ -6,6 +6,8 @@ export interface ThemeColors {
muted: string
completionBg: string
completionCurrentBg: string
completionMetaBg: string
completionMetaCurrentBg: string
label: string
ok: string
@ -264,8 +266,10 @@ export const DARK_THEME: Theme = {
// new value sits ~60% luminance — readable without losing the "muted /
// secondary" semantic. Field labels still use `label` (65%) which
// stays brighter so hierarchy holds.
completionBg: '#FFFFFF',
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
completionBg: '#1a1a2e',
completionCurrentBg: '#333355',
completionMetaBg: '#1a1a2e',
completionMetaCurrentBg: '#333355',
label: '#DAA520',
ok: '#4caf50',
@ -312,6 +316,8 @@ export const LIGHT_THEME: Theme = {
muted: '#7A5A0F',
completionBg: '#F5F5F5',
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
completionMetaBg: '#F5F5F5',
completionMetaCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
label: '#7A5A0F',
ok: '#2E7D32',
@ -517,12 +523,20 @@ export function fromSkin(
): Theme {
const d = DEFAULT_THEME
const c = (k: string) => colors[k]
const hasSkinColors = Object.keys(colors).length > 0
const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent
const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent
const muted = c('banner_dim') ?? d.color.muted
const completionBg = c('completion_menu_bg') ?? d.color.completionBg
const completionCurrentBg =
c('completion_menu_current_bg') ??
(hasSkinColors ? mix(completionBg, bannerAccent, 0.25) : d.color.completionCurrentBg)
const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg
const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg
return normalizeThemeForAnsiLightTerminal({
color: {
primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
@ -531,7 +545,9 @@ export function fromSkin(
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
muted,
completionBg,
completionCurrentBg: c('completion_menu_current_bg') ?? mix(completionBg, bannerAccent, 0.25),
completionCurrentBg,
completionMetaBg,
completionMetaCurrentBg,
label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok,
@ -548,7 +564,7 @@ export function fromSkin(
statusWarn: c('ui_warn') ?? d.color.statusWarn,
statusBad: d.color.statusBad,
statusCritical: d.color.statusCritical,
selectionBg: c('selection_bg') ?? d.color.selectionBg,
selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? (hasSkinColors ? completionCurrentBg : d.color.selectionBg),
diffAdded: d.color.diffAdded,
diffRemoved: d.color.diffRemoved,

View file

@ -150,6 +150,7 @@ export interface SessionInfo {
release_date?: string
service_tier?: string
skills: Record<string, string[]>
system_prompt?: string
tools: Record<string, string[]>
update_behind?: number | null
update_command?: string
@ -159,12 +160,15 @@ export interface SessionInfo {
export interface Usage {
calls: number
compressions?: number
context_max?: number
context_percent?: number
context_used?: number
cost_status?: string
cost_usd?: number
input: number
output: number
reasoning?: number
total: number
}