Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled

This commit is contained in:
红尘
2026-05-31 09:36:58 +08:00
commit d73ff9b0fb
4188 changed files with 1614916 additions and 0 deletions
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_THEME } from '../theme.js'
import type { SessionActiveItem } from '../gatewayTypes.js'
import {
activeSessionCountLabel,
canTypeOrchestratorPrompt,
currentSessionSelectionIndex,
orchestratorContextHint,
orchestratorContextHintSegments,
orchestratorGlobalHotkeyHint,
orchestratorGlobalHotkeyHintSegments,
orchestratorHintSegmentColor,
clampOrchestratorSelection,
closeFallbackAfterClose,
draftModelArgFromPickerValue,
draftModelDisplayLabel,
fixedSessionColumnStyle,
draftTitleFromPrompt,
isNewSessionRow,
newSessionMarkerColor,
newSessionRowIndex,
orchestratorRowClickAction,
orchestratorVisibleRowIndexes,
selectedSessionRowStyle
} from '../components/activeSessionSwitcher.js'
describe('session orchestrator helpers', () => {
it('labels live sessions compactly for tight overlays', () => {
expect(activeSessionCountLabel(0)).toBe('0 live sessions')
expect(activeSessionCountLabel(1)).toBe('1 live session')
expect(activeSessionCountLabel(3)).toBe('3 live sessions')
expect(activeSessionCountLabel(1)).not.toContain('in this TUI')
})
it('keeps session orchestrator hotkey hints short and contextual', () => {
expect(orchestratorContextHint(false)).toBe('Session row: Enter switch · Ctrl+D close')
expect(orchestratorContextHint(true)).toBe('New row: type prompt · Enter start · Tab model')
expect(orchestratorGlobalHotkeyHint).toBe('↑↓ move · Ctrl+N new · Ctrl+R refresh · Esc close')
expect(orchestratorGlobalHotkeyHint.length).toBeLessThanOrEqual(56)
})
it('assigns themed colors consistently to orchestrator labels and hotkeys', () => {
expect(orchestratorContextHintSegments(false)).toEqual([
{ role: 'label', text: 'Session row:' },
{ role: 'text', text: ' ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' switch · ' },
{ role: 'hotkey', text: 'Ctrl+D' },
{ role: 'text', text: ' close' }
])
expect(orchestratorContextHintSegments(true)).toEqual([
{ role: 'label', text: 'New row:' },
{ role: 'text', text: ' type prompt · ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' start · ' },
{ role: 'hotkey', text: 'Tab' },
{ role: 'text', text: ' model' }
])
expect(orchestratorGlobalHotkeyHintSegments.filter(s => s.role === 'hotkey').map(s => s.text)).toEqual([
'↑↓',
'Ctrl+N',
'Ctrl+R',
'Esc'
])
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'hotkey')).toBe(DEFAULT_THEME.color.accent)
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'label')).toBe(DEFAULT_THEME.color.label)
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'text')).toBe(DEFAULT_THEME.color.muted)
expect(newSessionMarkerColor(DEFAULT_THEME, false)).toBe(DEFAULT_THEME.color.label)
expect(newSessionMarkerColor(DEFAULT_THEME, true)).toBe(DEFAULT_THEME.color.text)
})
it('uses a readable selected row style instead of accent-on-accent inverse text', () => {
const style = selectedSessionRowStyle(DEFAULT_THEME)
expect(style.backgroundColor).toBe(DEFAULT_THEME.color.selectionBg)
expect(style.color).toBe(DEFAULT_THEME.color.text)
expect(style.backgroundColor).not.toBe(DEFAULT_THEME.color.accent)
expect(style.color).not.toBe(DEFAULT_THEME.color.accent)
})
it('turns model picker values into session-scoped draft model args', () => {
expect(draftModelArgFromPickerValue('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe(
'kimi-k2.6 --provider ollama-cloud'
)
expect(draftModelArgFromPickerValue('openai/gpt-5.5 --provider openai-codex --global')).toBe(
'openai/gpt-5.5 --provider openai-codex'
)
})
it('highlights the current live session when the picker opens', () => {
const sessions = [
{ id: 'first', status: 'idle' },
{ id: 'second', status: 'working', current: true },
{ id: 'third', status: 'idle' }
] satisfies SessionActiveItem[]
expect(currentSessionSelectionIndex(sessions, 'second')).toBe(1)
expect(
currentSessionSelectionIndex([{ id: 'first', status: 'idle' }, { id: 'third', status: 'idle' }], 'third')
).toBe(1)
expect(currentSessionSelectionIndex(sessions, 'missing')).toBe(1)
expect(currentSessionSelectionIndex([], 'missing')).toBe(0)
})
it('adds a selectable New row after the live sessions and gates prompt typing to it', () => {
expect(newSessionRowIndex(0)).toBe(0)
expect(newSessionRowIndex(3)).toBe(3)
expect(clampOrchestratorSelection(-5, 2)).toBe(0)
expect(clampOrchestratorSelection(99, 2)).toBe(2)
expect(isNewSessionRow(0, 0)).toBe(true)
expect(isNewSessionRow(1, 2)).toBe(false)
expect(isNewSessionRow(2, 2)).toBe(true)
expect(canTypeOrchestratorPrompt(1, 2)).toBe(false)
expect(canTypeOrchestratorPrompt(2, 2)).toBe(true)
expect(orchestratorVisibleRowIndexes(3, 3, 12)).toEqual([0, 1, 2, 3])
expect(orchestratorVisibleRowIndexes(13, 13, 12)).toContain(13)
})
it('selects a safe fallback after closing the current live session', () => {
const remaining = [
{ id: 'next', status: 'idle' },
{ id: 'other', status: 'working' }
] satisfies SessionActiveItem[]
expect(closeFallbackAfterClose('other', 'current', remaining)).toEqual({ action: 'stay' })
expect(closeFallbackAfterClose('current', 'current', remaining)).toEqual({ action: 'activate', sessionId: 'next' })
expect(closeFallbackAfterClose('current', 'current', [])).toEqual({ action: 'new' })
})
it('shows clean draft model labels without picker flags or provider params', () => {
expect(draftModelDisplayLabel('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe('kimi-k2.6')
expect(draftModelDisplayLabel('openai/gpt-5.5 --provider openai-codex --global')).toBe('gpt-5.5')
expect(draftModelDisplayLabel('')).toBe('current/default')
})
it('maps row clicks to existing-session activation or New-row focus', () => {
const sessions = [
{ id: 'a', status: 'idle' },
{ id: 'b', status: 'idle' }
] satisfies SessionActiveItem[]
expect(orchestratorRowClickAction(1, sessions)).toEqual({ action: 'activate', sessionId: 'b' })
expect(orchestratorRowClickAction(2, sessions)).toEqual({ action: 'select-new' })
expect(orchestratorRowClickAction(99, sessions)).toEqual({ action: 'select-new' })
})
it('keeps fixed table columns from shrinking into adjacent columns', () => {
expect(fixedSessionColumnStyle().flexShrink).toBe(0)
})
it('builds a compact title from the orchestrator prompt', () => {
expect(draftTitleFromPrompt(' Build the websocket orchestrator panel and make it robust. ', 24)).toBe(
'Build the websocket orc…'
)
})
})
@@ -0,0 +1,84 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { StatusRule } from '../components/appChrome.js'
import { DEFAULT_THEME } from '../theme.js'
type ReactNodeLike = React.ReactNode
const textContent = (node: ReactNodeLike): string => {
if (node === null || node === undefined || typeof node === 'boolean') {
return ''
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(textContent).join('')
}
if (React.isValidElement(node)) {
return textContent(node.props.children)
}
return ''
}
const findClickableWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => {
if (node === null || node === undefined || typeof node === 'boolean') {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findClickableWithText(child, needle)
if (found) {
return found
}
}
return null
}
if (!React.isValidElement(node)) {
return null
}
if (typeof node.props.onClick === 'function' && textContent(node).includes(needle)) {
return node
}
return findClickableWithText(node.props.children, needle)
}
describe('StatusRule session count click target', () => {
it('makes the live session count itself clickable', () => {
const openSwitcher = vi.fn()
const element = StatusRule({
bgCount: 0,
busy: false,
cols: 100,
cwdLabel: '~/repo',
liveSessionCount: 1,
model: 'kimi-k2.6',
onSessionCountClick: openSwitcher,
sessionStartedAt: null,
showCost: false,
status: 'ready',
statusColor: DEFAULT_THEME.color.ok,
t: DEFAULT_THEME,
turnStartedAt: null,
usage: { total: 0 },
voiceLabel: ''
})
const clickableSessionCount = findClickableWithText(element, '1 session')
expect(clickableSessionCount).not.toBeNull()
clickableSessionCount!.props.onClick({ stopImmediatePropagation: vi.fn() })
expect(openSwitcher).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { approvalAction } from '../components/prompts.js'
describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
it('maps Esc to deny — parity with global Ctrl+C cancellation', () => {
expect(approvalAction('', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('', { escape: true }, 2)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('maps number keys 1..4 to once/session/always/deny in registration order', () => {
expect(approvalAction('1', {}, 0)).toEqual({ kind: 'choose', choice: 'once' })
expect(approvalAction('2', {}, 0)).toEqual({ kind: 'choose', choice: 'session' })
expect(approvalAction('3', {}, 0)).toEqual({ kind: 'choose', choice: 'always' })
expect(approvalAction('4', {}, 0)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('ignores out-of-range numbers', () => {
expect(approvalAction('0', {}, 1)).toEqual({ kind: 'noop' })
expect(approvalAction('5', {}, 1)).toEqual({ kind: 'noop' })
expect(approvalAction('9', {}, 1)).toEqual({ kind: 'noop' })
})
it('confirms the current selection on Enter', () => {
expect(approvalAction('', { return: true }, 0)).toEqual({ kind: 'choose', choice: 'once' })
expect(approvalAction('', { return: true }, 3)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('moves selection up/down within bounds', () => {
expect(approvalAction('', { upArrow: true }, 2)).toEqual({ kind: 'move', delta: -1 })
expect(approvalAction('', { downArrow: true }, 1)).toEqual({ kind: 'move', delta: 1 })
})
it('clamps selection movement at the edges', () => {
expect(approvalAction('', { upArrow: true }, 0)).toEqual({ kind: 'noop' })
expect(approvalAction('', { downArrow: true }, 3)).toEqual({ kind: 'noop' })
})
it('Esc beats numeric/return — denying is always the first interpretation', () => {
// If a terminal somehow delivers Esc + a digit in the same event, deny
// wins. Documents the precedence so a future refactor doesn't flip it.
expect(approvalAction('1', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('', { escape: true, return: true }, 1)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('returns noop for unrelated keystrokes (printable letters etc.)', () => {
expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })
expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })
})
})
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { asCommandDispatch } from '../lib/rpc.js'
describe('asCommandDispatch', () => {
it('parses exec, alias, skill, and send', () => {
expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' })
expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' })
expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({
type: 'skill',
name: 'x',
message: 'do'
})
expect(asCommandDispatch({ type: 'send', message: 'hello world' })).toEqual({
type: 'send',
message: 'hello world'
})
})
it('rejects malformed payloads', () => {
expect(asCommandDispatch(null)).toBeNull()
expect(asCommandDispatch({ type: 'alias' })).toBeNull()
expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull()
expect(asCommandDispatch({ type: 'send' })).toBeNull()
expect(asCommandDispatch({ type: 'send', message: 42 })).toBeNull()
})
})
+369
View File
@@ -0,0 +1,369 @@
import { describe, expect, it, vi } from 'vitest'
import { isUsableClipboardText, readClipboardText, writeClipboardText } from '../lib/clipboard.js'
describe('readClipboardText', () => {
it('reads text from pbpaste on macOS', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'hello world\n' })
await expect(readClipboardText('darwin', run)).resolves.toBe('hello world\n')
expect(run).toHaveBeenCalledWith(
'pbpaste',
[],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
it('reads text from PowerShell on Windows', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'from windows\r\n' })
await expect(readClipboardText('win32', run)).resolves.toBe('from windows\r\n')
expect(run).toHaveBeenCalledWith(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
it('tries powershell.exe first on WSL', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'from wsl\n' })
await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe(
'from wsl\n'
)
expect(run).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
it('uses wl-paste on Wayland Linux', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'from wayland\n' })
await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe(
'from wayland\n'
)
expect(run).toHaveBeenCalledWith(
'wl-paste',
['--type', 'text'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
it('falls back to xclip on Linux when wl-paste fails', async () => {
const run = vi
.fn()
.mockRejectedValueOnce(new Error('wl-paste missing'))
.mockResolvedValueOnce({ stdout: 'from xclip\n' })
await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe(
'from xclip\n'
)
expect(run).toHaveBeenNthCalledWith(
1,
'wl-paste',
['--type', 'text'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
expect(run).toHaveBeenNthCalledWith(
2,
'xclip',
['-selection', 'clipboard', '-out'],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
it('returns null when every clipboard backend fails', async () => {
const run = vi.fn().mockRejectedValue(new Error('clipboard failed'))
await expect(
readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)
).resolves.toBeNull()
})
})
describe('isUsableClipboardText', () => {
it('accepts normal text', () => {
expect(isUsableClipboardText('hello world\n')).toBe(true)
})
it('rejects empty or whitespace-only content', () => {
expect(isUsableClipboardText('')).toBe(false)
expect(isUsableClipboardText(' \n\t')).toBe(false)
})
it('rejects binary-looking clipboard payloads', () => {
expect(isUsableClipboardText('PNG\u0000\u0001\u0002\u0003IHDR')).toBe(false)
expect(isUsableClipboardText('TIFF\ufffd\ufffd\ufffdmetadata')).toBe(false)
})
})
describe('writeClipboardText', () => {
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
}
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 () => {
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('hello world', 'darwin', start as any)).resolves.toBe(true)
expect(start).toHaveBeenCalledWith(
'pbcopy',
[],
expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
)
expect(stdin.end).toHaveBeenCalledWith('hello world')
})
it('returns false when pbcopy fails', async () => {
const child = {
once: vi.fn((event: string, cb: () => void) => {
if (event === 'error') {
cb()
}
return child
}),
stdin: { end: vi.fn() }
}
const start = vi.fn().mockReturnValue(child)
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()
)
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
expect(commandIdx).toBeGreaterThan(-1)
const script = calledArgs[commandIdx + 1]
expect(script).toContain('FromBase64String')
expect(script).toContain(Buffer.from('wsl text', 'utf8').toString('base64'))
})
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()
)
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
const script = calledArgs[commandIdx + 1]
expect(script).toContain('FromBase64String')
expect(script).toContain(Buffer.from('wslg text', 'utf8').toString('base64'))
})
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()
)
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
})
it('preserves CJK text via base64 encoding in PowerShell on WSL', 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)
const cjkText = '你好世界,测试中文 🎉'
await expect(writeClipboardText(cjkText, 'linux', start as any, { WSL_INTEROP: '/tmp/socket' })).resolves.toBe(true)
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
const script = calledArgs[commandIdx + 1]
expect(script).toContain(Buffer.from(cjkText, 'utf8').toString('base64'))
expect(script).toContain('UTF8.GetString')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { FACES } from '../content/faces.js'
import { HOTKEYS } from '../content/hotkeys.js'
import { PLACEHOLDERS } from '../content/placeholders.js'
import { TOOL_VERBS, VERBS } from '../content/verbs.js'
import { ROLE } from '../domain/roles.js'
import { ZERO } from '../domain/usage.js'
import { INTERPOLATION_RE } from '../protocol/interpolation.js'
import { DEFAULT_THEME } from '../theme.js'
describe('constants', () => {
it('ZERO', () => expect(ZERO).toEqual({ calls: 0, input: 0, output: 0, total: 0 }))
it('string arrays are populated', () => {
for (const arr of [FACES, PLACEHOLDERS, VERBS]) {
expect(arr.length).toBeGreaterThan(0)
arr.forEach(s => expect(typeof s).toBe('string'))
}
})
it('HOTKEYS are [key, desc] pairs', () => {
HOTKEYS.forEach(([k, d]) => {
expect(typeof k).toBe('string')
expect(typeof d).toBe('string')
})
})
it('documents Ctrl/Cmd+L as non-destructive redraw', () => {
const hotkey = HOTKEYS.find(([k]) => k.endsWith('+L'))
expect(hotkey).toBeDefined()
expect(hotkey?.[1]).toBe('redraw / repaint')
})
it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => {
expect(TOOL_VERBS.terminal).toBe('terminal')
expect(TOOL_VERBS.read_file).toBe('reading')
})
it('INTERPOLATION_RE matches {!cmd}', () => {
INTERPOLATION_RE.lastIndex = 0
expect(INTERPOLATION_RE.test('{!date}')).toBe(true)
INTERPOLATION_RE.lastIndex = 0
expect(INTERPOLATION_RE.test('plain')).toBe(false)
})
it('ROLE produces glyph/body/prefix per role', () => {
for (const role of ['assistant', 'system', 'tool', 'user'] as const) {
expect(ROLE[role](DEFAULT_THEME)).toHaveProperty('glyph')
}
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,822 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSlashHandler } from '../app/createSlashHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
describe('createSlashHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
})
it('opens the resume picker locally', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/resume')).toBe(true)
expect(getOverlayState().picker).toBe(true)
})
it('opens the live session switcher locally even when the current session is busy', () => {
patchUiState({ busy: true, sid: 'sid-abc' })
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/sessions')).toBe(true)
expect(getOverlayState().sessions).toBe(true)
expect(ctx.session.guardBusySessionSwitch).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()
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('handles /update locally and exits with code 42 via dieWithCode', () => {
vi.useFakeTimers()
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/update')).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('exiting TUI to run update...')
// Advance past the 100ms setTimeout
vi.advanceTimersByTime(150)
expect(ctx.session.dieWithCode).toHaveBeenCalledWith(42)
vi.useRealTimers()
})
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 () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'x-model' }))
}
})
expect(createSlashHandler(ctx)('/model x-model')).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'model',
session_id: 'sid-abc',
value: 'x-model'
})
})
it('honors TUI picker session scope without adding --global', async () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'anthropic/claude-sonnet-4.6' }))
}
})
expect(
createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`)
).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'model',
session_id: 'sid-abc',
value: 'anthropic/claude-sonnet-4.6 --provider openrouter'
})
})
it('does not duplicate --global for explicit persistent model switches', () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx()
createSlashHandler(ctx)('/model x-model --global')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'model',
session_id: 'sid-abc',
value: 'x-model --global'
})
})
it('applies /reasoning hide to the thinking section immediately', async () => {
patchUiState({ sections: { thinking: 'expanded' }, showReasoning: true, sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'hide' }))
}
})
expect(createSlashHandler(ctx)('/reasoning hide')).toBe(true)
await vi.waitFor(() => {
expect(getUiState().showReasoning).toBe(false)
expect(getUiState().sections.thinking).toBe('hidden')
})
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'reasoning',
session_id: 'sid-abc',
value: 'hide'
})
})
it('applies /reasoning show to the thinking section immediately', async () => {
patchUiState({ sections: { thinking: 'hidden' }, showReasoning: false, sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'show' }))
}
})
expect(createSlashHandler(ctx)('/reasoning show')).toBe(true)
await vi.waitFor(() => {
expect(getUiState().showReasoning).toBe(true)
expect(getUiState().sections.thinking).toBe('expanded')
})
})
it('opens the skills hub locally for bare /skills', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/skills')).toBe(true)
expect(getOverlayState().skillsHub).toBe(true)
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('routes /skills install <name> to skills.manage without opening overlay', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
expect(getOverlayState().skillsHub).toBe(false)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'install',
query: 'foo'
})
})
it('routes /skills inspect <name> to skills.manage', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills inspect my-skill')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'inspect',
query: 'my-skill'
})
})
it('routes /skills search <query> to skills.manage', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills search vibe')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'search',
query: 'vibe'
})
})
it('routes /skills browse [page] to skills.manage with a numeric page', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills browse 3')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'browse',
page: 3
})
})
it('delegates non-native /skills subcommands to slash.exec', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills check')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
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('keeps visible scrollback when branching a TUI session', async () => {
patchUiState({ sid: 'sid-parent' })
const rpc = vi.fn(() => Promise.resolve({ session_id: 'sid-branch', title: 'branch title' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/branch branch title')).toBe(true)
expect(rpc).toHaveBeenCalledWith('session.branch', { name: 'branch title', session_id: 'sid-parent' })
await vi.waitFor(() => {
expect(getUiState().sid).toBe('sid-branch')
expect(ctx.transcript.sys).toHaveBeenCalledWith('branched → branch title')
})
expect(ctx.transcript.setHistoryItems).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 () => {
const ctx = buildCtx()
expect(getUiState().detailsMode).toBe('collapsed')
expect(createSlashHandler(ctx)('/details toggle')).toBe(true)
expect(getUiState().detailsMode).toBe('expanded')
expect(getUiState().detailsModeCommandOverride).toBe(true)
expect(getUiState().sections).toEqual({
thinking: 'expanded',
tools: 'expanded',
subagents: 'expanded',
activity: 'expanded'
})
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'details_mode',
value: 'expanded'
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded')
})
it('sets a per-section override and persists it under details_mode.<section>', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/details activity hidden')).toBe(true)
expect(getUiState().sections.activity).toBe('hidden')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'details_mode.activity',
value: 'hidden'
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('details activity: hidden')
})
it('clears a per-section override on /details <section> reset', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/details tools expanded')
expect(getUiState().sections.tools).toBe('expanded')
createSlashHandler(ctx)('/details tools reset')
expect(getUiState().sections.tools).toBeUndefined()
expect(ctx.gateway.rpc).toHaveBeenLastCalledWith('config.set', {
key: 'details_mode.tools',
value: ''
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('details tools: reset')
})
it('rejects unknown section modes with a usage hint', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/details tools blink')
expect(getUiState().sections.tools).toBeUndefined()
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /details <section> [hidden|collapsed|expanded|reset]')
})
it('shows tool enable usage when names are missing', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/tools enable')).toBe(true)
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(1, 'usage: /tools enable <name> [name ...]')
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(2, 'built-in toolset: /tools enable web')
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue')
})
it.each([
['/browser status', 'browser.manage', { action: 'status', session_id: null }],
['/browser connect', 'browser.manage', { action: 'connect', session_id: null, url: 'http://127.0.0.1:9222' }],
['/reload-mcp', 'reload.mcp', { session_id: null }],
['/reload', 'reload.env', {}],
['/stop', 'process.stop', {}],
['/fast status', 'config.get', { key: 'fast', session_id: null }],
['/busy status', 'config.get', { key: 'busy' }],
['/indicator', 'config.get', { key: 'indicator' }]
])('routes %s through native RPC (no slash worker)', (command, method, params) => {
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)(command)).toBe(true)
expect(rpc).toHaveBeenCalledWith(method, params)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('renders browser connect progress messages from the gateway', async () => {
const rpc = vi.fn(() =>
Promise.resolve({
connected: false,
messages: [
"Chromium-family browser isn't running with remote debugging — attempting to launch...",
'Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect'
],
url: 'http://127.0.0.1:9222'
})
)
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/browser connect')).toBe(true)
expect(ctx.transcript.sys).toHaveBeenCalledWith('checking Chromium-family browser remote debugging at http://127.0.0.1:9222...')
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith(
"Chromium-family browser isn't running with remote debugging — attempting to launch..."
)
expect(ctx.transcript.sys).toHaveBeenCalledWith(
'Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect'
)
expect(ctx.transcript.sys).not.toHaveBeenCalledWith('browser connect failed')
})
})
it('routes /rollback through native RPC when a session is active', () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/rollback')).toBe(true)
expect(rpc).toHaveBeenCalledWith('rollback.list', { session_id: 'sid-abc' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('hot-swaps the live indicator when /indicator <style> succeeds', async () => {
const rpc = vi.fn(() => Promise.resolve({ value: 'emoji' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/indicator emoji')).toBe(true)
expect(rpc).toHaveBeenCalledWith('config.set', { key: 'indicator', value: 'emoji' })
await vi.waitFor(() => expect(getUiState().indicatorStyle).toBe('emoji'))
})
it('rejects unknown indicator styles before hitting the gateway', () => {
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/indicator sparkle')).toBe(true)
expect(rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /indicator [ascii|emoji|kaomoji|unicode]')
})
it('drops stale slash.exec output after a newer slash', async () => {
let resolveLate: (v: { output?: string }) => void
let slashExecCalls = 0
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
slashExecCalls += 1
if (slashExecCalls === 1) {
return new Promise<{ output?: string }>(res => {
resolveLate = res
})
}
return Promise.resolve({ output: 'fresh' })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/slow')).toBe(true)
expect(h('/later')).toBe(true)
resolveLate!({ output: 'too late' })
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalled()
})
expect(ctx.transcript.sys).not.toHaveBeenCalledWith('too late')
})
it('dispatches command.dispatch with typed alias', async () => {
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('no'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'alias', target: 'help' })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/zzz')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
})
})
it('resolves unique local aliases through the catalog', () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/h': '/help',
'/help': '/help'
}
}
}
})
expect(createSlashHandler(ctx)('/h')).toBe(true)
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
})
it('lets exact catalog commands win over longer prefix matches', async () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/profile': '/profile',
'/plugins': '/plugins'
}
}
}
})
expect(createSlashHandler(ctx)('/profile')).toBe(true)
await vi.waitFor(() => {
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'profile',
session_id: null
})
})
expect(ctx.transcript.sys).not.toHaveBeenCalledWith(expect.stringContaining('ambiguous command'))
})
it('keeps ambiguous prefix handling when there is no exact catalog match', () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/status': '/status',
'/statusbar': '/statusbar'
}
}
}
})
expect(createSlashHandler(ctx)('/stat')).toBe(true)
expect(ctx.transcript.sys).toHaveBeenCalledWith('ambiguous command: /status, /statusbar')
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('falls through to command.dispatch for skill commands and sends the message', async () => {
const skillMessage = 'Use this skill to do X.\n\n## Steps\n1. First step'
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('skill command: use command.dispatch'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'skill', message: skillMessage, name: 'hermes-agent-dev' })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/hermes-agent-dev')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev')
})
expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage)
})
it('/history pages the current TUI transcript (user + assistant)', () => {
const ctx = buildCtx({
local: {
...buildLocal(),
getHistoryItems: vi.fn(() => [
{ role: 'user', text: 'hello' },
{ role: 'system', text: 'ignore me' },
{ role: 'assistant', text: 'hi there' },
{ role: 'user', text: 'test' }
])
}
})
createSlashHandler(ctx)('/history')
expect(ctx.transcript.page).toHaveBeenCalledTimes(1)
const [body, title] = ctx.transcript.page.mock.calls[0]!
expect(title).toBe('History')
expect(body).toContain('[You #1]')
expect(body).toContain('hello')
expect(body).toContain('[Hermes #2]')
expect(body).toContain('hi there')
expect(body).toContain('[You #3]')
expect(body).not.toContain('ignore me')
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('/history reports empty state without paging', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/history')
expect(ctx.transcript.page).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet')
})
it('/save forwards to session.save RPC and reports the returned file', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ file: '/tmp/hermes_conversation_test.json' }))
const ctx = buildCtx({
gateway: { ...buildGateway(), rpc },
local: {
...buildLocal(),
getHistoryItems: vi.fn(() => [
{ role: 'system', text: 'intro' },
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'hi there' }
])
}
})
createSlashHandler(ctx)('/save')
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(rpc).toHaveBeenCalledWith('session.save', { session_id: 'sid-abc' })
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('conversation saved to: /tmp/hermes_conversation_test.json')
})
})
it('/save reports empty state without calling the RPC or slash worker', () => {
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/save')
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet')
})
it('/save without an active session tells the user instead of hitting the RPC', () => {
// sid stays null (default) but there IS visible conversation
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({
gateway: { ...buildGateway(), rpc },
local: {
...buildLocal(),
getHistoryItems: vi.fn(() => [{ role: 'user', text: 'hello' }])
}
})
createSlashHandler(ctx)('/save')
expect(rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('no active session — nothing to save')
})
it('/rollback without an active session tells the user instead of hitting the RPC', () => {
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/rollback')
expect(rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('no active session — nothing to rollback')
})
it('/title <name> uses session.title RPC and bypasses slash.exec', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ pending: false, title: 'my title' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/title my title')
expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc', title: 'my title' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('session title set: my title')
})
})
it('/title with no args fetches and displays the current title', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ title: 'demo title' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/title')
expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('title: demo title')
})
})
})
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
...overrides,
slashFlightRef: overrides.slashFlightRef ?? { current: 0 },
composer: { ...buildComposer(), ...overrides.composer },
gateway: { ...buildGateway(), ...overrides.gateway },
local: { ...buildLocal(), ...overrides.local },
session: { ...buildSession(), ...overrides.session },
transcript: { ...buildTranscript(), ...overrides.transcript },
voice: { ...buildVoice(), ...overrides.voice }
})
const buildComposer = () => ({
enqueue: vi.fn(),
hasSelection: false,
paste: vi.fn(),
queueRef: { current: [] as string[] },
selection: { copySelection: vi.fn(async () => '') },
setInput: vi.fn()
})
const buildGateway = () => ({
gw: {
getLogTail: vi.fn(() => ''),
kill: vi.fn(),
request: vi.fn(() => Promise.resolve({}))
},
rpc: vi.fn(() => Promise.resolve({}))
})
const buildLocal = () => ({
catalog: null,
getHistoryItems: vi.fn(() => []),
getLastUserMsg: vi.fn(() => ''),
maybeWarn: vi.fn(),
setCatalog: vi.fn()
})
const buildSession = () => ({
closeSession: vi.fn(() => Promise.resolve(null)),
die: vi.fn(),
dieWithCode: vi.fn(),
guardBusySessionSwitch: vi.fn(() => false),
newLiveSession: vi.fn(),
newSession: vi.fn(),
resetVisibleHistory: vi.fn(),
resumeById: vi.fn(),
setSessionStartedAt: vi.fn()
})
const buildTranscript = () => ({
page: vi.fn(),
panel: vi.fn(),
send: vi.fn(),
setHistoryItems: vi.fn(),
sys: vi.fn(),
trimLastExchange: vi.fn(items => items)
})
const buildVoice = () => ({
setVoiceEnabled: vi.fn(),
setVoiceRecordKey: vi.fn(),
setVoiceTts: vi.fn()
})
interface Ctx {
slashFlightRef: { current: number }
composer: ReturnType<typeof buildComposer>
gateway: ReturnType<typeof buildGateway>
local: ReturnType<typeof buildLocal>
session: ReturnType<typeof buildSession>
transcript: ReturnType<typeof buildTranscript>
voice: ReturnType<typeof buildVoice>
}
@@ -0,0 +1,114 @@
/**
* Pinned regression for the multi-line composer cursor-drift bug.
*
* Symptom: in `hermes --tui`, typing into the composer until the input
* wraps across multiple visual rows would leave several blank cells
* between the last typed character and the (hardware) cursor block.
* Worse on narrow terminals (the Cursor IDE built-in terminal in
* particular).
*
* Root cause: the composer's `cursorLayout` (used by `useDeclaredCursor`
* to place the hardware cursor) ran a hand-rolled word-wrap algorithm,
* while Ink's `<Text wrap="wrap">` renders via `wrap-ansi`. The two
* disagreed on many real inputs — wrap-ansi would keep "branch
* investigate" on one row while cursorLayout claimed it had wrapped,
* etc. — so the declared cursor position drifted from where the text
* was actually rendered. The fix sources cursorLayout's line breaks
* directly from wrap-ansi, guaranteeing agreement.
*
* This test pins the contract: for every char that would be typed into
* the composer, the cursor position reported by cursorLayout MUST equal
* the end-of-text position that wrap-ansi would render. Any future
* regression that lets the two diverge re-introduces the drift.
*/
import { wrapAnsi } from '@hermes/ink'
import { describe, expect, it } from 'vitest'
import { cursorLayout, inputVisualHeight } from '../lib/inputMetrics.js'
function wrapAnsiEnd(text: string, cols: number): { line: number; column: number } {
const wrapped = wrapAnsi(text, cols, { hard: true, trim: false })
const lines = wrapped.split('\n')
const last = lines[lines.length - 1] ?? ''
return { line: lines.length - 1, column: last.length }
}
const USER_REPORT_MESSAGE =
// Paraphrase of the user's actual bug report, included verbatim so the
// test is grounded in a realistic typing pattern (long single line,
// mixed-length words, punctuation, no hard newlines).
'im in cursor terminal using hermes --tui and as i type multiline my caret at the end will often ' +
'go.. randomly.. like multiple spaces away lol and idk why. theres no rhyme/reason really but ' +
'there should literally never be a non-user added space at the end of my composer input right? ' +
'i dont think it happens on new sessions but only existing ones. there have been a few prs to ' +
'try to fix this and all not working. ok it just happened, to me, nowso attaching screenshot ' +
'and you can see its multiline, new session. on a new bb/<xxx> branch investigate'
describe('cursor-drift regression — composer cursorLayout matches Ink rendering', () => {
it('agrees with wrap-ansi at every typing-prefix of the user-reported message', () => {
// Walks the message char-by-char (mirroring what the TUI sees when a
// user types). At every prefix, cursorLayout must place the cursor
// exactly where wrap-ansi would render the end of the text.
//
// Pre-fix: this failed on most narrow widths because the hand-rolled
// wrap algorithm broke at slightly different points than wrap-ansi.
for (const cols of [40, 50, 55, 60, 65, 70, 80]) {
let acc = ''
for (const ch of USER_REPORT_MESSAGE) {
acc += ch
const layout = cursorLayout(acc, acc.length, cols)
const expected = wrapAnsiEnd(acc, cols)
expect(
layout,
`mismatch at cols=${cols}, len=${acc.length}, last-char=${JSON.stringify(ch)}, ` +
`tail=${JSON.stringify(acc.slice(-30))}`
).toEqual(expected)
}
}
})
it('keeps cursor on the same row when text exactly fills the terminal width', () => {
// wrap-ansi does NOT push exact-fill text onto a phantom next line.
// The previous algorithm did — that's what produced the visible
// "cursor parked one row below the last char" symptom on narrow
// terminals at certain message lengths.
for (const cols of [8, 12, 18, 24]) {
const text = 'a'.repeat(cols)
const layout = cursorLayout(text, text.length, cols)
const inkLines = wrapAnsi(text, cols, { hard: true, trim: false }).split('\n')
expect(layout.line).toBe(0)
expect(layout.column).toBe(cols)
expect(inkLines).toHaveLength(1)
expect(inputVisualHeight(text, cols)).toBe(1)
}
})
it('does not stuff a trailing whitespace word onto a phantom line', () => {
// "branch investigate" at cols=20 fits on one row in wrap-ansi. The
// bug claimed otherwise, parking the cursor at (line=1, col=?) and
// leaving the user's "branch investigate" rendered alone on row 0
// with the cursor block several cells past it.
const text = 'branch investigate'
const cols = 20
expect(cursorLayout(text, text.length, cols)).toEqual({ column: text.length, line: 0 })
expect(cursorLayout(text, text.length, cols)).toEqual(wrapAnsiEnd(text, cols))
})
it('agrees with wrap-ansi for word-wrap that pushes a word onto the next line', () => {
// "hello world" at cols=8 wraps to ["hello ", "world"] in wrap-ansi.
// The cursor at end-of-text must land at line=1, col=5 — where Ink
// actually renders the last 'd'. The previous algorithm reported
// (line=2, col=0) here (phantom extra wrap), which parked the
// cursor on a row Ink never painted.
const text = 'hello world'
const cols = 8
expect(cursorLayout(text, text.length, cols)).toEqual({ column: 5, line: 1 })
expect(cursorLayout(text, text.length, cols)).toEqual(wrapAnsiEnd(text, cols))
})
})
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { isSectionName, parseDetailsMode, resolveSections, SECTION_NAMES, sectionMode } from '../domain/details.js'
describe('parseDetailsMode', () => {
it('accepts the canonical modes case-insensitively', () => {
expect(parseDetailsMode('hidden')).toBe('hidden')
expect(parseDetailsMode(' COLLAPSED ')).toBe('collapsed')
expect(parseDetailsMode('Expanded')).toBe('expanded')
})
it('rejects junk', () => {
expect(parseDetailsMode('truncated')).toBeNull()
expect(parseDetailsMode('')).toBeNull()
expect(parseDetailsMode(undefined)).toBeNull()
expect(parseDetailsMode(42)).toBeNull()
})
})
describe('isSectionName', () => {
it('only lets the four canonical sections through', () => {
expect(isSectionName('thinking')).toBe(true)
expect(isSectionName('tools')).toBe(true)
expect(isSectionName('subagents')).toBe(true)
expect(isSectionName('activity')).toBe(true)
expect(isSectionName('Thinking')).toBe(false) // case-sensitive on purpose
expect(isSectionName('bogus')).toBe(false)
expect(isSectionName('')).toBe(false)
expect(isSectionName(7)).toBe(false)
})
it('SECTION_NAMES exposes them all', () => {
expect([...SECTION_NAMES].sort()).toEqual(['activity', 'subagents', 'thinking', 'tools'])
})
})
describe('resolveSections', () => {
it('parses a well-formed sections object', () => {
expect(
resolveSections({
thinking: 'expanded',
tools: 'expanded',
subagents: 'collapsed',
activity: 'hidden'
})
).toEqual({
thinking: 'expanded',
tools: 'expanded',
subagents: 'collapsed',
activity: 'hidden'
})
})
it('drops unknown section names and unknown modes', () => {
expect(
resolveSections({
thinking: 'expanded',
tools: 'maximised',
bogus: 'hidden',
activity: 'hidden'
})
).toEqual({ thinking: 'expanded', activity: 'hidden' })
})
it('treats nullish/non-objects as empty overrides', () => {
expect(resolveSections(undefined)).toEqual({})
expect(resolveSections(null)).toEqual({})
expect(resolveSections('hidden')).toEqual({})
expect(resolveSections([])).toEqual({})
})
})
describe('sectionMode', () => {
it('falls back to the global mode for sections without a built-in default', () => {
expect(sectionMode('subagents', 'collapsed', {})).toBe('collapsed')
expect(sectionMode('subagents', 'expanded', undefined)).toBe('expanded')
expect(sectionMode('subagents', 'hidden', {})).toBe('hidden')
})
it('streams thinking + tools expanded by default for persisted config values', () => {
expect(sectionMode('thinking', 'collapsed', {})).toBe('expanded')
expect(sectionMode('thinking', 'hidden', undefined)).toBe('expanded')
expect(sectionMode('tools', 'collapsed', {})).toBe('expanded')
expect(sectionMode('tools', 'hidden', undefined)).toBe('expanded')
})
it('hides the activity panel by default for persisted config values', () => {
expect(sectionMode('activity', 'collapsed', {})).toBe('hidden')
expect(sectionMode('activity', 'expanded', undefined)).toBe('hidden')
expect(sectionMode('activity', 'hidden', {})).toBe('hidden')
})
it('applies in-session /details mode globally over built-in defaults', () => {
expect(sectionMode('thinking', 'collapsed', {}, true)).toBe('collapsed')
expect(sectionMode('tools', 'hidden', {}, true)).toBe('hidden')
expect(sectionMode('activity', 'expanded', undefined, true)).toBe('expanded')
})
it('honours per-section overrides over both the section default and global mode', () => {
expect(sectionMode('thinking', 'collapsed', { thinking: 'collapsed' })).toBe('collapsed')
expect(sectionMode('tools', 'collapsed', { tools: 'hidden' })).toBe('hidden')
expect(sectionMode('activity', 'collapsed', { activity: 'expanded' })).toBe('expanded')
expect(sectionMode('activity', 'expanded', { activity: 'collapsed' })).toBe('collapsed')
})
it('lets per-section overrides escape the global hidden mode', () => {
// Regression for the case where global details_mode: hidden used to
// short-circuit the entire accordion and prevent overrides from
// surfacing — `sections.tools: expanded` must still resolve to expanded.
expect(sectionMode('subagents', 'hidden', { subagents: 'expanded' })).toBe('expanded')
expect(sectionMode('thinking', 'hidden', { thinking: 'collapsed' })).toBe('collapsed')
expect(sectionMode('activity', 'hidden', { activity: 'expanded' })).toBe('expanded')
})
})
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { ensureEmojiPresentation } from '../lib/emoji.js'
const VS16 = '\uFE0F'
describe('ensureEmojiPresentation', () => {
it('passes through ASCII unchanged', () => {
expect(ensureEmojiPresentation('hello world')).toBe('hello world')
expect(ensureEmojiPresentation('')).toBe('')
})
it('passes through emoji that already defaults to emoji presentation', () => {
expect(ensureEmojiPresentation('🚀 rocket')).toBe('🚀 rocket')
expect(ensureEmojiPresentation('😀')).toBe('😀')
})
it('injects VS16 after text-default emoji codepoints', () => {
expect(ensureEmojiPresentation('⚠ careful')).toBe(`${VS16} careful`)
expect(ensureEmojiPresentation(' info')).toBe(`${VS16} info`)
expect(ensureEmojiPresentation('love ❤ you')).toBe(`love ❤${VS16} you`)
expect(ensureEmojiPresentation('✔ done')).toBe(`${VS16} done`)
})
it('is idempotent when VS16 is already present', () => {
const already = `${VS16} ${VS16}${VS16}`
expect(ensureEmojiPresentation(already)).toBe(already)
expect(ensureEmojiPresentation(ensureEmojiPresentation('⚠'))).toBe(`${VS16}`)
})
it('leaves keycap sequences alone when the base is not a text-default emoji', () => {
expect(ensureEmojiPresentation('1\u20e3')).toBe('1\u20e3')
})
it('injects VS16 before ZWJ so text-default bases participate in emoji sequences', () => {
// ❤ + ZWJ + 🔥 → ❤️‍🔥 (heart on fire). Without VS16 between the heart
// and the ZWJ, terminals render the heart in text/monochrome form and
// the ZWJ ligature can fail to form.
const heartFire = '\u2764\u200d\ud83d\udd25'
expect(ensureEmojiPresentation(heartFire)).toBe(`\u2764\uFE0F\u200d\ud83d\udd25`)
})
it('leaves explicit text-presentation selector (VS15) alone', () => {
// `❤︎` (U+2764 + U+FE0E) asks for text presentation — injecting VS16
// would create an invalid double-variation sequence.
const explicitText = '\u2764\ufe0e'
expect(ensureEmojiPresentation(explicitText)).toBe(explicitText)
})
it('returns the original reference when no change is needed', () => {
const already = `${VS16} ${VS16}${VS16}`
// Reference equality — the lazy allocator should short-circuit to the
// input when nothing needed injection.
expect(ensureEmojiPresentation(already)).toBe(already)
})
it('handles mixed content', () => {
expect(ensureEmojiPresentation('⚠ path: /tmp/x ❤ done')).toBe(`${VS16} path: /tmp/x ❤${VS16} done`)
})
})
+144
View File
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
__resetLinkTitleCache,
fetchLinkTitle,
hostPathLabel,
isTitleFetchable,
normalizeExternalUrl,
urlSlugTitleLabel
} from '../lib/externalLink.js'
afterEach(() => {
__resetLinkTitleCache()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('external link helpers', () => {
it('formats URL fallbacks as host + path', () => {
expect(
hostPathLabel(
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
)
).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894')
})
it('derives readable title fallbacks from URL slugs', () => {
expect(
urlSlugTitleLabel('https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/')
).toBe('From Fajardo Icacos Island Full Day Catamaran Trip')
})
it('keeps x.com status fallbacks link-like instead of generic Status labels', () => {
expect(urlSlugTitleLabel('https://x.com/grok/status/2056065022749479209')).toBe(
'x.com/grok/status/2056065022749479209'
)
})
it('normalizes scheme-less links', () => {
expect(normalizeExternalUrl(' expedia.com/things-to-do/puerto-rico-el-yunque ')).toBe(
'https://expedia.com/things-to-do/puerto-rico-el-yunque'
)
})
it('filters out local/non-http targets for title fetches', () => {
expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true)
expect(isTitleFetchable('http://localhost:5174')).toBe(false)
expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false)
expect(isTitleFetchable('mailto:hello@example.com')).toBe(false)
})
it('blocks private, link-local, and intranet hosts', () => {
expect(isTitleFetchable('http://10.0.0.12/path')).toBe(false)
expect(isTitleFetchable('http://172.22.5.4/path')).toBe(false)
expect(isTitleFetchable('http://192.168.1.22/path')).toBe(false)
expect(isTitleFetchable('http://169.254.169.254/latest/meta-data')).toBe(false)
expect(isTitleFetchable('http://[fd00::1]/')).toBe(false)
expect(isTitleFetchable('http://[fe80::1]/')).toBe(false)
expect(isTitleFetchable('http://printer.local/status')).toBe(false)
expect(isTitleFetchable('http://intranet/status')).toBe(false)
expect(isTitleFetchable('https://8.8.8.8/status')).toBe(true)
})
it('deduplicates in-flight title fetches and caches results', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('<html><head><title>El Yunque Tour Water Slide, Rope Swing & Pickup</title></head></html>', {
headers: { 'content-type': 'text/html; charset=utf-8' },
status: 200
})
)
vi.stubGlobal('fetch', fetchMock)
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure.a46272756.activity-details'
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(fetchMock).toHaveBeenCalledTimes(1)
const third = await fetchLinkTitle(url)
expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('shares cache across protocol/www URL variants', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('<html><head><title>Shared Canonical Title</title></head></html>', {
headers: { 'content-type': 'text/html' },
status: 200
})
)
vi.stubGlobal('fetch', fetchMock)
const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)])
expect(a).toBe('Shared Canonical Title')
expect(b).toBe('Shared Canonical Title')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('ignores error-like fetched titles', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('<html><head><title>Just a moment...</title></head></html>', {
headers: { 'content-type': 'text/html' },
status: 200
})
)
vi.stubGlobal('fetch', fetchMock)
const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
await expect(fetchLinkTitle(url)).resolves.toBe('')
})
it('decodes HTML entities in fetched titles', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('<html><head><title>AT&amp;T &#39;Deals&#39;</title></head></html>', {
headers: { 'content-type': 'text/html' },
status: 200
})
)
vi.stubGlobal('fetch', fetchMock)
await expect(fetchLinkTitle('https://example.com/offers')).resolves.toBe("AT&T 'Deals'")
})
it('skips network fetch for non-fetchable targets', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(fetchLinkTitle('http://localhost:3000/path')).resolves.toBe('')
await expect(fetchLinkTitle('mailto:hello@example.com')).resolves.toBe('')
await expect(fetchLinkTitle('file:///tmp/demo.html')).resolves.toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
})
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest'
const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR', 'TERM', 'TERM_PROGRAM'] as const
let importId = 0
async function withCleanEnv(setup: () => void, body: () => Promise<void>) {
const saved: Record<string, string | undefined> = {}
for (const k of ENV_KEYS) {
saved[k] = process.env[k]
delete process.env[k]
}
try {
setup()
await body()
} finally {
for (const k of ENV_KEYS) {
if (saved[k] === undefined) {
delete process.env[k]
} else {
process.env[k] = saved[k]
}
}
}
}
describe('forceTruecolor', () => {
it('does not force truecolor by default', async () => {
await withCleanEnv(
() => {},
async () => {
await import('../lib/forceTruecolor.js?t=default-' + importId++)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('does not infer truecolor from Apple Terminal on pre-Tahoe macOS', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.TERM = 'xterm-256color'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=apple-' + importId++)
expect(mod.shouldForceTruecolor({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('downgrades Apple Terminal when truecolor is only advertised by env', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=downgrade-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'Apple_Terminal',
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
} as NodeJS.ProcessEnv)
).toBe(true)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('keeps non-Apple terminals untouched when they advertise truecolor', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'vscode'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=keep-non-apple-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'vscode',
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
} as NodeJS.ProcessEnv)
).toBe(false)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => {
await withCleanEnv(
() => {
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
await import('../lib/forceTruecolor.js?t=enabled-' + importId++)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('respects HERMES_TUI_TRUECOLOR=0 opt-out', async () => {
await withCleanEnv(
() => {
process.env.HERMES_TUI_TRUECOLOR = '0'
process.env.TERM_PROGRAM = 'Apple_Terminal'
},
async () => {
await import('../lib/forceTruecolor.js?t=optout-' + importId++)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('lets explicit opt-in keep Apple truecolor advertisement', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=apple-explicit-on-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'Apple_Terminal',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',
HERMES_TUI_TRUECOLOR: '1'
} as NodeJS.ProcessEnv)
).toBe(false)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('respects NO_COLOR', async () => {
await withCleanEnv(
() => {
process.env.NO_COLOR = '1'
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
await import('../lib/forceTruecolor.js?t=no-color-' + importId++)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('respects existing FORCE_COLOR unless Hermes truecolor is explicit', async () => {
await withCleanEnv(
() => {
process.env.FORCE_COLOR = ''
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=force-color-' + importId++)
expect(mod.shouldForceTruecolor(process.env)).toBe(false)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBe('')
}
)
})
it('lets explicit Hermes truecolor override existing FORCE_COLOR', async () => {
await withCleanEnv(
() => {
process.env.FORCE_COLOR = '0'
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
await import('../lib/forceTruecolor.js?t=explicit-force-' + importId++)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
})
+394
View File
@@ -0,0 +1,394 @@
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])
expect(gw.getLogTail(20)).toContain('[lifecycle] websocket close code=1011')
expect(gw.getLogTail(20)).toContain('[lifecycle] transport exit code=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('test.shutdown')
await expect(req).rejects.toThrow(/gateway closed/)
expect(gw.getLogTail(20)).toContain('[lifecycle] GatewayClient.kill reason=test.shutdown')
})
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()
})
})
+331
View File
@@ -0,0 +1,331 @@
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, 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', () => {
expect(matches('say _hi_ there')).toEqual(['_hi_'])
expect(matches('very __bold move__ today')).toEqual(['__bold move__'])
expect(matches('(_paren_) and [_bracket_]')).toEqual(['_paren_', '_bracket_'])
})
it('keeps intraword underscores literal', () => {
const path = '/home/me/.hermes/cache/screenshots/browser_screenshot_ecc1c3feab.png'
expect(matches(path)).toEqual([])
expect(matches('snake_case_var and MY_CONST')).toEqual([])
expect(matches('foo__bar__baz')).toEqual([])
})
it('keeps Python dunder identifiers literal', () => {
expect(matches('if __name__ == "__main__":')).toEqual([])
expect(matches('def __init__(self):')).toEqual([])
expect(matches('print(__file__)')).toEqual([])
})
it('still matches asterisk emphasis intraword', () => {
expect(matches('a*b*c')).toEqual(['*b*'])
expect(matches('a**bold**c')).toEqual(['**bold**'])
})
it('matches short alphanumeric subscript (H~2~O, CO~2~, X~n~)', () => {
expect(matches('H~2~O')).toEqual(['~2~'])
expect(matches('CO~2~ levels')).toEqual(['~2~'])
expect(matches('the X~n~ term')).toEqual(['~n~'])
})
it('ignores kaomoji-style ~! and ~? punctuation', () => {
// Kimi / Qwen / GLM emit these as decorators and the whole span between
// two tildes used to get collapsed into one dim blob.
expect(matches('Aww ~! Building step by step, I love it ~!')).toEqual([])
expect(matches('cool ~? yeah ~?')).toEqual([])
expect(matches('mixed ~! and ~? flow')).toEqual([])
})
it('ignores tilde spans that contain spaces or punctuation', () => {
// Real subscript doesn't contain spaces; a tilde followed by words-then-
// tilde is almost always conversational. Matching it swallows text.
expect(matches('hello ~good idea~ there')).toEqual([])
expect(matches('x ~oh no!~ y')).toEqual([])
})
it('does not let strikethrough eat subscript', () => {
expect(matches('~~strike~~ and H~2~O')).toEqual(['~~strike~~', '~2~'])
})
})
describe('stripInlineMarkup', () => {
it('strips word-boundary emphasis only', () => {
expect(stripInlineMarkup('say _hi_ there')).toBe('say hi there')
expect(stripInlineMarkup('browser_screenshot_ecc.png')).toBe('browser_screenshot_ecc.png')
expect(stripInlineMarkup('__bold move__ and foo__bar__')).toBe('bold move and foo__bar__')
})
it('preserves Python dunder identifiers', () => {
expect(stripInlineMarkup('if __name__ == "__main__":')).toBe('if __name__ == "__main__":')
expect(stripInlineMarkup('class X: def __init__(self): pass')).toBe('class X: def __init__(self): pass')
})
it('leaves ~!/~? kaomoji alone and still handles real subscript', () => {
expect(stripInlineMarkup('Yay ~! nice work ~!')).toBe('Yay ~! nice work ~!')
expect(stripInlineMarkup('H~2~O and CO~2~')).toBe('H_2O and CO_2')
})
it('strips inline math delimiters but keeps the formula text', () => {
expect(stripInlineMarkup('$\\mathbb{Z}$ is a ring')).toBe('\\mathbb{Z} is a ring')
expect(stripInlineMarkup('see \\(a + b\\) ok')).toBe('see a + b ok')
})
})
describe('INLINE_RE inline math', () => {
it('matches single-dollar math and beats emphasis at the same start', () => {
// Without math handling, `*b*` would have matched as italics and
// corrupted the formula. With math added to INLINE_RE, the leftmost
// match at column 0 (`$P=a*b*c$`) wins.
expect(matches('$P=a*b*c$')).toEqual(['$P=a*b*c$'])
expect(matches('see $\\mathbb{Z}$ here')).toEqual(['$\\mathbb{Z}$'])
})
it('does not match currency-style prose', () => {
expect(matches('it costs $5 and $10')).toEqual([])
expect(matches('paid $5')).toEqual([])
})
it('does not let inline math swallow a $$ display fence', () => {
// `$$x$$` is a display block, not two abutting inline-math spans.
expect(matches('$$x$$')).toEqual([])
})
it('matches \\(...\\) inline math', () => {
expect(matches('foo \\(x + y\\) bar')).toEqual(['\\(x + y\\)'])
})
it('does not corrupt subscripts/superscripts inside math', () => {
// `_n` and `^r` are markdown emphasis/superscript markers in prose, but
// inside a `$...$` span the entire formula is captured as a single
// inline-math token so the inner regexes never see those characters.
expect(matches('$P=a_n x^n + a_0$')).toEqual(['$P=a_n x^n + a_0$'])
expect(matches('$\\beta_1,\\dots,\\beta_r$')).toEqual(['$\\beta_1,\\dots,\\beta_r$'])
})
it('places math content in the correct capture group (regression: m[16] is bare URL)', () => {
// When `m[16]` was the bare URL group AND the inline-math `$...$`
// group simultaneously (because the bare URL pattern lacked its own
// capturing parens), MdInline rendered `$\\mathbb{R}$` as an
// underlined autolink instead of italic amber math. Lock down the
// numbering: math goes in m[17] / m[18], URLs go in m[16].
const url = [...'see https://example.com here'.matchAll(INLINE_RE)][0]!
const dollarMath = [...'$\\mathbb{R}$'.matchAll(INLINE_RE)][0]!
const parenMath = [...'\\(\\pi\\)'.matchAll(INLINE_RE)][0]!
expect(url[16]).toBe('https://example.com')
expect(url[17]).toBeUndefined()
expect(url[18]).toBeUndefined()
expect(dollarMath[16]).toBeUndefined()
expect(dollarMath[17]).toBe('\\mathbb{R}')
expect(dollarMath[18]).toBeUndefined()
expect(parenMath[16]).toBeUndefined()
expect(parenMath[17]).toBeUndefined()
expect(parenMath[18]).toBe('\\pi')
})
})
describe('protocol sentinels', () => {
it('captures MEDIA: paths with surrounding quotes or backticks', () => {
expect('MEDIA:/tmp/a.png'.match(MEDIA_LINE_RE)?.[1]).toBe('/tmp/a.png')
expect(' MEDIA: /home/me/.hermes/cache/screenshots/browser_screenshot_ecc.png '.match(MEDIA_LINE_RE)?.[1]).toBe(
'/home/me/.hermes/cache/screenshots/browser_screenshot_ecc.png'
)
expect('`MEDIA:/tmp/a.png`'.match(MEDIA_LINE_RE)?.[1]).toBe('/tmp/a.png')
expect('"MEDIA:C:\\files\\a.png"'.match(MEDIA_LINE_RE)?.[1]).toBe('C:\\files\\a.png')
})
it('ignores MEDIA: tokens embedded in prose', () => {
expect('here is MEDIA:/tmp/a.png for you'.match(MEDIA_LINE_RE)).toBeNull()
expect('the media: section is empty'.match(MEDIA_LINE_RE)).toBeNull()
})
it('matches the [[audio_as_voice]] directive', () => {
expect(AUDIO_DIRECTIVE_RE.test('[[audio_as_voice]]')).toBe(true)
expect(AUDIO_DIRECTIVE_RE.test(' [[audio_as_voice]] ')).toBe(true)
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)
})
it('renders Python dunder identifiers literally outside code fences', () => {
const lines = renderPlain(
React.createElement(
Box,
{ width: 80 },
React.createElement(Md, {
t: DEFAULT_THEME,
text: 'if __name__ == "__main__":\n obj.__init__()'
})
)
)
const rendered = lines.join('\n')
expect(rendered).toContain('if __name__ == "__main__":')
expect(rendered).toContain('obj.__init__()')
})
})
describe('Md link labels', () => {
it('renders bare URLs with readable slug labels', () => {
const lines = renderPlain(
React.createElement(
Box,
{ width: 120 },
React.createElement(Md, {
t: DEFAULT_THEME,
text: 'see https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure for details'
})
)
)
const rendered = lines.join('\n')
expect(rendered).toContain('Puerto Rico El Yunque Rainforest Adventure')
expect(rendered).not.toContain('https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure')
})
it('keeps explicit markdown labels as the immediate fallback', () => {
const lines = renderPlain(
React.createElement(
Box,
{ width: 80 },
React.createElement(Md, {
t: DEFAULT_THEME,
text: '[Trip details](https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure)'
})
)
)
expect(lines.join('\n')).toContain('Trip details')
})
})
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)
})
})
+293
View File
@@ -0,0 +1,293 @@
import { describe, expect, it } from 'vitest'
import { BOX_CLOSE, BOX_OPEN, BOX_RE, texToUnicode } from '../lib/mathUnicode.js'
const stripBox = (s: string) => s.replace(BOX_RE, '$1')
describe('texToUnicode — symbols', () => {
it('substitutes lowercase Greek', () => {
expect(texToUnicode('\\alpha + \\beta + \\pi')).toBe('α + β + π')
expect(texToUnicode('\\omega')).toBe('ω')
})
it('substitutes uppercase Greek', () => {
expect(texToUnicode('\\Sigma \\Omega \\Pi')).toBe('Σ Ω Π')
})
it('substitutes set theory and logic operators', () => {
expect(texToUnicode('A \\cup B \\cap C')).toBe('A B ∩ C')
expect(texToUnicode('\\forall x \\in \\emptyset')).toBe('∀ x ∈ ∅')
expect(texToUnicode('p \\implies q \\iff r')).toBe('p ⟹ q ⟺ r')
})
it('substitutes relations and arrows', () => {
expect(texToUnicode('a \\le b \\ge c \\ne d')).toBe('a ≤ b ≥ c ≠ d')
expect(texToUnicode('f: A \\to B')).toBe('f: A → B')
})
it('uses longest-match-first so \\leq beats \\le', () => {
expect(texToUnicode('\\leq')).toBe('≤')
})
it('preserves unknown commands that share a prefix with known ones', () => {
// `\leqq` is a real LaTeX command (≦) we don't have in our table.
// The word-boundary lookahead prevents `\le` from matching, so the
// whole thing is preserved verbatim — much better than `≤qq`.
expect(texToUnicode('\\leqq')).toBe('\\leqq')
})
it('refuses to substitute a partial command (word boundary)', () => {
expect(texToUnicode('\\alphabet')).toBe('\\alphabet')
expect(texToUnicode('\\pin')).toBe('\\pin')
})
})
describe('texToUnicode — blackboard / calligraphic / fraktur', () => {
it('renders \\mathbb capitals', () => {
expect(texToUnicode('\\mathbb{R}')).toBe('')
expect(texToUnicode('\\mathbb{N} \\subset \\mathbb{Z} \\subset \\mathbb{Q} \\subset \\mathbb{R}')).toBe('')
})
it('renders \\mathcal and \\mathfrak', () => {
expect(texToUnicode('\\mathcal{F} \\subset \\mathfrak{A}')).toBe('𝔄')
})
it('preserves \\mathbb{...} when argument is multi-letter or non-letter', () => {
expect(texToUnicode('\\mathbb{NN}')).toBe('\\mathbb{NN}')
expect(texToUnicode('\\mathbb{1}')).toBe('\\mathbb{1}')
})
it('strips \\mathbf / \\mathit / \\mathrm / \\text wrappers (no Unicode bold/italic in monospace)', () => {
expect(texToUnicode('\\mathbf{x}')).toBe('x')
expect(texToUnicode('\\text{if } x > 0')).toBe('if x > 0')
expect(texToUnicode('\\operatorname{rank}(A)')).toBe('rank(A)')
})
})
describe('texToUnicode — sub / superscripts', () => {
it('converts simple superscripts', () => {
expect(texToUnicode('x^2 + y^2')).toBe('x² + y²')
expect(texToUnicode('e^{n}')).toBe('eⁿ')
})
it('converts simple subscripts', () => {
expect(texToUnicode('a_1 + a_2 + a_n')).toBe('a₁ + a₂ + aₙ')
expect(texToUnicode('x_{0}')).toBe('x₀')
})
it('converts mixed-content scripts when every glyph has a Unicode form', () => {
// `+`, digits, and lowercase letters all have superscript glyphs,
// so `n+1` → `ⁿ⁺¹`. Comma has no subscript form, so `i,j` falls
// back to `_(i,j)` (parens) rather than partially substituting —
// parens read as ordinary grouping while braces look like leftover
// unrendered LaTeX.
expect(texToUnicode('x^{n+1}')).toBe('xⁿ⁺¹')
expect(texToUnicode('a_{i,j}')).toBe('a_(i,j)')
})
it('uses parens (not braces) when the body has Greek with no superscript form', () => {
// π has no Unicode superscript, so `e^{i\pi}` after symbol pass is
// `e^{iπ}` and the script fallback emits `e^(iπ)` — much more
// readable than the LaTeX-looking `e^{iπ}`.
expect(texToUnicode('e^{i\\pi}')).toBe('e^(iπ)')
})
it('strips braces on script fallback when body collapses to a single char', () => {
// `^{\infty}` → symbol pass produces `^{∞}` → convertScript can't
// find ∞ in SUPERSCRIPT, but the body is one char so we drop the
// braces and emit `^∞` (much more readable than `^{∞}`).
expect(texToUnicode('e^{\\infty}')).toBe('e^∞')
})
it('handles a real-world sum', () => {
expect(texToUnicode('\\sum_{n=0}^{\\infty} \\frac{1}{n!}')).toBe('∑ₙ₌₀^∞ 1/n!')
})
})
describe('texToUnicode — fractions', () => {
it('collapses \\frac to a/b', () => {
expect(texToUnicode('\\frac{1}{2}')).toBe('1/2')
expect(texToUnicode('\\frac{a}{b}')).toBe('a/b')
})
it('parenthesises multi-token numerator / denominator', () => {
expect(texToUnicode('\\frac{n+1}{2}')).toBe('(n+1)/2')
expect(texToUnicode('\\frac{a + b}{c - d}')).toBe('(a + b)/(c - d)')
})
it('handles nested fractions', () => {
expect(texToUnicode('\\frac{1}{\\frac{1}{x}}')).toBe('1/(1/x)')
})
it('handles braces inside numerator / denominator (regression: regex \\frac couldn\'t)', () => {
// The regex-only `\frac` matcher used `[^{}]*` for each arg, which
// failed the moment a numerator contained its own braces (here the
// `{p-1}` from a superscript). The balanced-brace parser handles it.
expect(texToUnicode('\\frac{|t|^{p-1}|P(t)|^p}{(p-1)!}')).toBe('(|t|ᵖ⁻¹|P(t)|ᵖ)/((p-1)!)')
})
it('preserves \\frac when arguments are malformed', () => {
expect(texToUnicode('\\frac{a}')).toBe('\\frac{a}')
expect(texToUnicode('\\fraction{a}{b}')).toBe('\\fraction{a}{b}')
})
})
describe('texToUnicode — typography no-ops', () => {
it('strips \\displaystyle / \\textstyle / \\scriptstyle / \\scriptscriptstyle', () => {
expect(texToUnicode('\\displaystyle\\sum_{i=1}^n x_i')).toBe('∑ᵢ₌₁ⁿ xᵢ')
expect(texToUnicode('f(x) = \\displaystyle \\frac{1}{2}')).toBe('f(x) = 1/2')
expect(texToUnicode('\\textstyle x + y')).toBe('x + y')
})
it('strips \\limits / \\nolimits which only affect bound positioning', () => {
expect(texToUnicode('\\sum\\limits_{k=1}^n a_k')).toBe('∑ₖ₌₁ⁿ aₖ')
expect(texToUnicode('\\int\\nolimits_0^1 f(x) dx')).toBe('∫₀¹ f(x) dx')
})
it('does not eat letter-continuation commands like \\limit_inf', () => {
// The `(?![A-Za-z])` lookahead protects hypothetical commands that
// start with `\limit` / `\display` / etc. The bare names are stripped
// but anything longer is preserved verbatim.
expect(texToUnicode('\\limitinf x')).toBe('\\limitinf x')
})
})
describe('texToUnicode — sizing wrappers', () => {
it('strips \\big / \\Big / \\bigg / \\Bigg before delimiters', () => {
expect(texToUnicode('\\bigl[ x \\bigr]')).toBe('[ x ]')
expect(texToUnicode('\\Big( y \\Big)')).toBe('( y )')
expect(texToUnicode('\\bigg| z \\bigg|')).toBe('| z |')
expect(texToUnicode('\\Biggl\\{ a \\Biggr\\}')).toBe('{ a }')
})
it('does not eat \\bigtriangleup or other letter-continuations', () => {
expect(texToUnicode('A \\bigtriangleup B')).toBe('A \\bigtriangleup B')
})
})
describe('texToUnicode — modular arithmetic and tags', () => {
it('renders \\pmod{p} as " (mod p)"', () => {
expect(texToUnicode('a \\equiv b \\pmod{p}')).toBe('a ≡ b (mod p)')
})
it('renders \\bmod / \\mod inline', () => {
expect(texToUnicode('a \\bmod n')).toBe('a mod n')
})
it('collapses \\tag{n} to " (n)"', () => {
expect(texToUnicode('x = y \\tag{24}')).toBe('x = y (24)')
})
})
describe('texToUnicode — newly added symbols', () => {
it('renders \\nmid, \\blacksquare, \\qed', () => {
expect(texToUnicode('p \\nmid q')).toBe('p ∤ q')
expect(texToUnicode('Therefore \\blacksquare')).toBe('Therefore ■')
expect(texToUnicode('done \\qed')).toBe('done ∎')
})
})
describe('texToUnicode — \\boxed / \\fbox', () => {
// `\boxed` produces non-printable U+0001 / U+0002 sentinels around its
// content so the markdown renderer can apply highlight styling. These
// tests assert both the sentinel form and the human-readable
// strip-fallback (BOX_RE).
it('wraps simple boxed content in BOX_OPEN/BOX_CLOSE sentinels', () => {
expect(texToUnicode('\\boxed{x = 0}')).toBe(`${BOX_OPEN}x = 0${BOX_CLOSE}`)
expect(stripBox(texToUnicode('\\boxed{x = 0}'))).toBe('x = 0')
expect(stripBox(texToUnicode('\\fbox{answer}'))).toBe('answer')
})
it('handles boxed expressions with nested braces (regression: regex couldn\'t)', () => {
// A `[^{}]*` regex would stop at the first `{` inside the body. The
// balanced-brace parser walks past it.
expect(stripBox(texToUnicode('\\boxed{x^{n+1}}'))).toBe('xⁿ⁺¹')
expect(stripBox(texToUnicode('\\boxed{\\frac{a}{b}}'))).toBe('a/b')
})
it('handles real-world boxed final answer', () => {
expect(stripBox(texToUnicode('\\boxed{J = -\\sum_{k=0}^n a_k F(k)}'))).toBe('J = -∑ₖ₌₀ⁿ aₖ F(k)')
})
it('preserves \\boxed without a brace argument', () => {
expect(texToUnicode('\\boxed something')).toBe('\\boxed something')
})
})
describe('texToUnicode — combining marks', () => {
it('applies \\overline / \\bar / \\hat / \\vec / \\tilde', () => {
expect(texToUnicode('\\overline{x}')).toBe('x\u0305')
expect(texToUnicode('\\hat{y}')).toBe('y\u0302')
expect(texToUnicode('\\vec{v}')).toBe('v\u20D7')
})
})
describe('texToUnicode — left/right delimiters', () => {
it('strips \\left and \\right keeping the delimiter character', () => {
expect(texToUnicode('\\left( x + y \\right)')).toBe('( x + y )')
expect(texToUnicode('\\left| x \\right|')).toBe('| x |')
})
it('handles escaped delimiters \\left\\{ ... \\right\\}', () => {
expect(texToUnicode('\\left\\{p/q \\mid q \\neq 0\\right\\}')).toBe('{p/q q ≠ 0}')
})
it('handles named delimiters via \\left\\langle / \\right\\rangle', () => {
expect(texToUnicode('\\left\\langle u, v \\right\\rangle')).toBe('⟨ u, v ⟩')
})
it('drops \\left. and \\right. (which are explicit "no delimiter")', () => {
expect(texToUnicode('\\left. f \\right|')).toBe(' f |')
})
it('preserves \\leftarrow / \\rightarrow (word boundary blocks the strip)', () => {
expect(texToUnicode('A \\leftarrow B \\rightarrow C')).toBe('A ← B → C')
})
})
describe('texToUnicode — labelled arrows', () => {
it('renders \\xrightarrow{label} as ─label→', () => {
expect(texToUnicode('a \\xrightarrow{x=1} b')).toBe('a ─x=1→ b')
})
it('renders \\xleftarrow{label} as ←label─', () => {
expect(texToUnicode('a \\xleftarrow{n} b')).toBe('a ←n─ b')
})
it('still applies symbol substitution inside the label', () => {
expect(texToUnicode('a \\xrightarrow{n \\to \\infty} L')).toBe('a ─n → ∞→ L')
})
})
describe('texToUnicode — punctuation commands without lookahead', () => {
it('substitutes \\{ even when immediately followed by a letter', () => {
// Regression: with a global `(?![A-Za-z])` lookahead, `\{p` refused
// to substitute (because `p` is a letter) and rendered as `\{p`.
expect(texToUnicode('\\{p, q\\}')).toBe('{p, q}')
})
it('substitutes thin-space \\, before a letter', () => {
expect(texToUnicode('a\\,b')).toBe('a b')
})
})
describe('texToUnicode — round-trip realism', () => {
it('renders a typical model-emitted formula', () => {
expect(texToUnicode('\\alpha \\in \\mathbb{R}, \\alpha \\notin \\mathbb{Q}')).toBe('α, α')
})
it('preserves unknown commands verbatim', () => {
expect(texToUnicode('\\bigtriangleup \\circledast')).toBe('\\bigtriangleup \\circledast')
})
it('handles commands without delimiters between', () => {
// Word-boundary lookahead means `\alpha\beta` doesn't accidentally
// match `\alphabeta` as one ungrouped token.
expect(texToUnicode('\\alpha\\beta')).toBe('αβ')
})
it('leaves plain text alone', () => {
expect(texToUnicode('hello world')).toBe('hello world')
expect(texToUnicode('')).toBe('')
})
})
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { shouldShowResponseSeparator } from '../components/messageLine.js'
describe('shouldShowResponseSeparator', () => {
it('separates assistant response text from visible details', () => {
expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final', thinking: 'plan' }, true)).toBe(true)
})
it('does not add a response separator without details or body text', () => {
expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final' }, false)).toBe(false)
expect(shouldShowResponseSeparator({ role: 'assistant', text: ' ', thinking: 'plan' }, true)).toBe(false)
})
it('does not add response separators to non-assistant transcript rows', () => {
expect(shouldShowResponseSeparator({ role: 'user', text: 'prompt' }, true)).toBe(false)
expect(shouldShowResponseSeparator({ role: 'system', text: 'note' }, true)).toBe(false)
})
})
+92
View File
@@ -0,0 +1,92 @@
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', () => {
const rows = [
{ role: 'user', text: 'first prompt' },
{ role: 'tool', context: 'repo', name: 'search_files', text: 'ignored raw result' },
{ role: 'assistant', text: 'first answer' },
{ role: 'user', text: 'second prompt' }
]
expect(toTranscriptMessages(rows).map(msg => [msg.role, msg.text])).toEqual([
['user', 'first prompt'],
['assistant', 'first answer'],
['user', 'second prompt']
])
expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toContain('Search Files')
})
})
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)
})
it('replaces when last role matches', () => {
expect(upsert([{ role: 'assistant', text: 'partial' }], 'assistant', 'full')[0]!.text).toBe('full')
})
it('appends to empty', () => {
expect(upsert([], 'user', 'first')).toEqual([{ role: 'user', text: 'first' }])
})
it('does not mutate', () => {
const prev = [{ role: 'user' as const, text: 'hi' }]
upsert(prev, 'assistant', 'yo')
expect(prev).toHaveLength(1)
})
})
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { startPromptLiveSession } from '../app/useMainApp.js'
describe('startPromptLiveSession', () => {
it('starts a kept-live session with generated id/title, applies selected model, then dispatches the prompt', async () => {
const calls: Array<[string, unknown]> = []
const sid = await startPromptLiveSession({
dispatchSubmission: prompt => calls.push(['dispatch', prompt]),
maybeWarn: value => calls.push(['warn', value]),
modelArg: 'kimi-k2.6 --provider ollama-cloud',
newLiveSession: async (message, title) => {
calls.push(['new', { message, title }])
return 'abc123'
},
onModelSwitched: (value, result) => calls.push(['model-switched', { result, value }]),
prompt: ' Build the thing ',
rpc: async (method, params) => {
calls.push(['rpc', { method, params }])
return { value: 'kimi-k2.6', warning: '' }
},
sys: text => calls.push(['sys', text])
})
expect(sid).toBe('abc123')
expect(calls).toEqual([
['new', { message: 'new live session started', title: undefined }],
[
'rpc',
{
method: 'config.set',
params: { key: 'model', session_id: 'abc123', value: 'kimi-k2.6 --provider ollama-cloud' }
}
],
['sys', 'model → kimi-k2.6'],
['warn', { value: 'kimi-k2.6', warning: '' }],
['model-switched', { result: { value: 'kimi-k2.6', warning: '' }, value: 'kimi-k2.6' }],
['dispatch', 'Build the thing']
])
})
it('does not start a session for an empty prompt', async () => {
const calls: string[] = []
const sid = await startPromptLiveSession({
dispatchSubmission: () => calls.push('dispatch'),
maybeWarn: () => calls.push('warn'),
newLiveSession: async () => {
calls.push('new')
return 'abc123'
},
prompt: ' ',
rpc: async () => ({ value: 'unused' }),
sys: () => calls.push('sys')
})
expect(sid).toBeNull()
expect(calls).toEqual([])
})
})
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildOsc52ClipboardQuery,
OSC52_CLIPBOARD_QUERY,
parseOsc52ClipboardData,
readOsc52Clipboard
} from '../lib/osc52.js'
const envBackup = { ...process.env }
afterEach(() => {
process.env = { ...envBackup }
})
describe('buildOsc52ClipboardQuery', () => {
it('returns the raw OSC52 query outside multiplexers', () => {
delete process.env.TMUX
delete process.env.STY
expect(buildOsc52ClipboardQuery()).toBe(OSC52_CLIPBOARD_QUERY)
})
it('wraps the query for tmux passthrough', () => {
process.env.TMUX = '/tmp/tmux-123/default,1,0'
expect(buildOsc52ClipboardQuery()).toContain('\x1bPtmux;')
expect(buildOsc52ClipboardQuery()).toContain(']52;c;?')
})
})
describe('parseOsc52ClipboardData', () => {
it('decodes clipboard payloads', () => {
const encoded = Buffer.from('hello from osc52', 'utf8').toString('base64')
expect(parseOsc52ClipboardData(`c;${encoded}`)).toBe('hello from osc52')
})
it('returns null for empty or query payloads', () => {
expect(parseOsc52ClipboardData('c;?')).toBeNull()
expect(parseOsc52ClipboardData('c;')).toBeNull()
})
})
describe('readOsc52Clipboard', () => {
it('returns decoded text from a terminal OSC52 response', async () => {
const send = vi.fn().mockResolvedValue({
code: 52,
data: `c;${Buffer.from('queried text', 'utf8').toString('base64')}`,
type: 'osc'
})
const flush = vi.fn().mockResolvedValue(undefined)
await expect(readOsc52Clipboard({ flush, send })).resolves.toBe('queried text')
expect(send).toHaveBeenCalled()
expect(flush).toHaveBeenCalled()
})
it('returns null when the querier is missing or unsupported', async () => {
await expect(readOsc52Clipboard(null)).resolves.toBeNull()
const send = vi.fn().mockResolvedValue(undefined)
const flush = vi.fn().mockResolvedValue(undefined)
await expect(readOsc52Clipboard({ flush, send })).resolves.toBeNull()
})
})
+70
View File
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
describe('shortCwd', () => {
const origHome = process.env.HOME
beforeEach(() => {
process.env.HOME = '/Users/bb'
})
afterEach(() => {
process.env.HOME = origHome
})
it('collapses HOME to ~', () => {
expect(shortCwd('/Users/bb/proj/repo')).toBe('~/proj/repo')
})
it('leaves non-HOME paths alone', () => {
expect(shortCwd('/tmp/work')).toBe('/tmp/work')
})
it('truncates long paths from the left with ellipsis', () => {
const out = shortCwd('/var/long/deeply/nested/workspace/here', 10)
expect(out.startsWith('…')).toBe(true)
expect(out.length).toBe(10)
expect('/var/long/deeply/nested/workspace/here'.endsWith(out.slice(1))).toBe(true)
})
it('keeps paths shorter than max intact', () => {
expect(shortCwd('/a/b', 10)).toBe('/a/b')
})
})
describe('fmtCwdBranch', () => {
const origHome = process.env.HOME
beforeEach(() => {
process.env.HOME = '/Users/bb'
})
afterEach(() => {
process.env.HOME = origHome
})
it('returns bare cwd when branch is null', () => {
expect(fmtCwdBranch('/Users/bb/proj', null)).toBe('~/proj')
})
it('returns bare cwd when branch is empty', () => {
expect(fmtCwdBranch('/Users/bb/proj', '')).toBe('~/proj')
})
it('appends branch in parens', () => {
expect(fmtCwdBranch('/Users/bb/proj', 'main')).toBe('~/proj (main)')
})
it('truncates the path to keep the branch tag readable', () => {
const out = fmtCwdBranch('/Users/bb/very/deeply/nested/project/folder', 'feature-branch', 30)
expect(out).toMatch(/ \(feature-branch\)$/)
expect(out.length).toBeLessThanOrEqual(30)
})
it('truncates very long branch names from the right', () => {
const out = fmtCwdBranch('/Users/bb/p', 'a-very-long-feature-branch-name')
expect(out).toMatch(/^~\/p \(…/)
expect(out).toContain(')')
})
})
+556
View File
@@ -0,0 +1,556 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalPlatform = process.platform
async function importPlatform(platform: NodeJS.Platform) {
vi.resetModules()
Object.defineProperty(process, 'platform', { value: platform })
return import('../lib/platform.js')
}
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.resetModules()
})
describe('platform action modifier', () => {
it('treats kitty Cmd sequences as the macOS action modifier', async () => {
const { isActionMod } = await importPlatform('darwin')
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(true)
expect(isActionMod({ ctrl: false, meta: true, super: false })).toBe(true)
expect(isActionMod({ ctrl: true, meta: false, super: false })).toBe(false)
})
it('still uses Ctrl as the action modifier on non-macOS', async () => {
const { isActionMod } = await importPlatform('linux')
expect(isActionMod({ ctrl: true, meta: false, super: false })).toBe(true)
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(false)
})
})
describe('isCopyShortcut', () => {
it('keeps Ctrl+C as the local non-macOS copy chord', async () => {
const { isCopyShortcut } = await importPlatform('linux')
expect(isCopyShortcut({ ctrl: true, meta: false, super: false }, 'c', {})).toBe(true)
})
it('accepts client Cmd+C over SSH even when running on Linux', async () => {
const { isCopyShortcut } = await importPlatform('linux')
const env = { SSH_CONNECTION: '1 2 3 4' } as NodeJS.ProcessEnv
expect(isCopyShortcut({ ctrl: false, meta: false, super: true }, 'c', env)).toBe(true)
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', env)).toBe(true)
})
it('does not treat local Linux Alt+C as copy', async () => {
const { isCopyShortcut } = await importPlatform('linux')
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false)
})
it('accepts the VS Code/Cursor forwarded Cmd+C copy sequence on macOS', async () => {
const { isCopyShortcut } = await importPlatform('darwin')
expect(isCopyShortcut({ ctrl: true, meta: false, super: true }, 'c', {})).toBe(true)
})
})
describe('isVoiceToggleKey', () => {
it('matches raw Ctrl+B on macOS (doc-default across platforms)', async () => {
const { isVoiceToggleKey } = await importPlatform('darwin')
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'B')).toBe(true)
})
it('matches kitty-style Cmd+B on macOS via key.super', async () => {
const { isVoiceToggleKey } = await importPlatform('darwin')
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 () => {
const { isVoiceToggleKey } = await importPlatform('linux')
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
})
it('does not match unmodified b or other Ctrl combos', async () => {
const { isVoiceToggleKey } = await importPlatform('darwin')
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'b')).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'a')).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'c')).toBe(false)
})
})
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')
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'k', 'k')).toBe(true)
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'w', 'w')).toBe(true)
// Must not fire when Cmd (meta/super) is held — those are distinct chords.
expect(isMacActionFallback({ ctrl: true, meta: true, super: false }, 'k', 'k')).toBe(false)
expect(isMacActionFallback({ ctrl: true, meta: false, super: true }, 'w', 'w')).toBe(false)
})
it('is a no-op on non-macOS (Linux routes Ctrl+K/W through isActionMod directly)', async () => {
const { isMacActionFallback } = await importPlatform('linux')
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'k', 'k')).toBe(false)
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'w', 'w')).toBe(false)
})
})
@@ -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)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { composerPromptText } from '../lib/prompt.js'
describe('composerPromptText', () => {
it('returns shell prompt for ! commands', () => {
expect(composerPromptText('', 'coder', true)).toBe('$')
})
it('prefixes named profiles onto the normal prompt', () => {
expect(composerPromptText('', 'coder')).toBe('coder ')
})
it('does not prefix default or custom profiles', () => {
expect(composerPromptText('', 'default')).toBe('')
expect(composerPromptText('', 'custom')).toBe('')
expect(composerPromptText('')).toBe('')
})
it('uses a Termux-safe ASCII prompt marker in normal mode', () => {
expect(composerPromptText('', 'coder', false, true, 50)).toBe('>')
})
it('keeps profile prefix suppressed on narrow Termux widths', () => {
expect(composerPromptText('', 'upstr', false, true, 72)).toBe('>')
})
it('allows profile prefix on very wide Termux panes', () => {
expect(composerPromptText('', 'upstr', false, true, 120)).toBe('upstr >')
})
})
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { providerDisplayNames } from '../domain/providers.js'
describe('providerDisplayNames', () => {
it('returns bare names when all are unique', () => {
expect(
providerDisplayNames([
{ name: 'Anthropic', slug: 'anthropic' },
{ name: 'OpenAI', slug: 'openai' }
])
).toEqual(['Anthropic', 'OpenAI'])
})
it('appends slug to every collision so the disambiguation is symmetric', () => {
expect(
providerDisplayNames([
{ name: 'Kimi For Coding', slug: 'kimi-coding' },
{ name: 'Kimi For Coding', slug: 'kimi-coding-cn' }
])
).toEqual(['Kimi For Coding (kimi-coding)', 'Kimi For Coding (kimi-coding-cn)'])
})
it('only disambiguates the colliding group', () => {
expect(
providerDisplayNames([
{ name: 'Anthropic', slug: 'anthropic' },
{ name: 'Foo', slug: 'foo-a' },
{ name: 'Foo', slug: 'foo-b' }
])
).toEqual(['Anthropic', 'Foo (foo-a)', 'Foo (foo-b)'])
})
it('falls back to plain name if slug is empty', () => {
expect(
providerDisplayNames([
{ name: 'Foo', slug: '' },
{ name: 'Foo', slug: '' }
])
).toEqual(['Foo', 'Foo'])
})
it('skips disambiguation when slug equals name', () => {
expect(
providerDisplayNames([
{ name: 'foo', slug: 'foo' },
{ name: 'foo', slug: 'foo' }
])
).toEqual(['foo', 'foo'])
})
it('handles empty input', () => {
expect(providerDisplayNames([])).toEqual([])
})
it('preserves order', () => {
const input = [
{ name: 'Z', slug: 'z' },
{ name: 'A', slug: 'a1' },
{ name: 'A', slug: 'a2' }
]
expect(providerDisplayNames(input)).toEqual(['Z', 'A (a1)', 'A (a2)'])
})
})
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import { cleanThinkingText } from '../lib/text.js'
describe('splitReasoning', () => {
it('extracts <think>…</think> and strips it from text', () => {
const { reasoning, text } = splitReasoning('<think>plotting</think>\n\nhere is the answer')
expect(reasoning).toBe('plotting')
expect(text).toBe('here is the answer')
})
it('handles multiple tag shapes', () => {
const input = '<reasoning>a</reasoning> <THINKING>b</THINKING> <thought>c</thought> body'
const { reasoning, text } = splitReasoning(input)
expect(reasoning).toContain('a')
expect(reasoning).toContain('b')
expect(reasoning).toContain('c')
expect(text).toBe('body')
})
it('treats unclosed leading <think>… as reasoning (real reasoning-model stream)', () => {
const { reasoning, text } = splitReasoning('<think>still deciding')
expect(reasoning).toBe('still deciding')
expect(text).toBe('')
})
it('does not strip trailing prose after a stray mid-text <think> mention', () => {
// Regression for "TUI eats last paragraph of output": when the model
// emits a literal `<think>` somewhere in prose (quoted explanation, code
// example, partial stream-mid-tag), the trailing greedy unclosed-tag
// regex used to consume every paragraph after it. Real unclosed
// reasoning blocks always lead the message — anchor to ^ so prose
// mentions are preserved.
const { reasoning, text } = splitReasoning(
'final answer paragraph one.\n\n<think>internal note never closed\n\nfinal answer paragraph two.'
)
expect(reasoning).toBe('')
expect(text).toBe('final answer paragraph one.\n\n<think>internal note never closed\n\nfinal answer paragraph two.')
})
it('returns empty reasoning and untouched text when no tags present', () => {
const { reasoning, text } = splitReasoning('plain body with no tags')
expect(reasoning).toBe('')
expect(text).toBe('plain body with no tags')
})
it('preserves text when reasoning block is empty', () => {
const { reasoning, text } = splitReasoning('<think></think>only body')
expect(reasoning).toBe('')
expect(text).toBe('only body')
})
it('detects presence of any supported tag', () => {
expect(hasReasoningTag('pre <think>x</think> post')).toBe(true)
expect(hasReasoningTag('pre <reasoning>x</reasoning>')).toBe(true)
expect(hasReasoningTag('<REASONING_SCRATCHPAD>x</REASONING_SCRATCHPAD>')).toBe(true)
expect(hasReasoningTag('no tags at all')).toBe(false)
})
})
describe('cleanThinkingText', () => {
it('removes face/status ticker fragments while preserving real reasoning', () => {
expect(
cleanThinkingText(
'(¬_¬) synthesizing...**Resolving comments on GitHub**\n( ͡° ͜ʖ ͡°) musing...\nActual step\n٩(๑❛ᴗ❛๑)۶ contemplating...next step'
)
).toBe('**Resolving comments on GitHub**\nActual step\nnext step')
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
describe('asRpcResult', () => {
it('keeps plain object payloads', () => {
expect(asRpcResult({ ok: true, value: 'x' })).toEqual({ ok: true, value: 'x' })
})
it('rejects missing or non-object payloads', () => {
expect(asRpcResult(undefined)).toBeNull()
expect(asRpcResult(null)).toBeNull()
expect(asRpcResult('oops')).toBeNull()
expect(asRpcResult(['bad'])).toBeNull()
})
})
describe('rpcErrorMessage', () => {
it('prefers Error messages', () => {
expect(rpcErrorMessage(new Error('boom'))).toBe('boom')
})
it('falls back for unknown errors', () => {
expect(rpcErrorMessage('broken')).toBe('broken')
expect(rpcErrorMessage({ code: 500 })).toBe('request failed')
})
})
+99
View File
@@ -0,0 +1,99 @@
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,
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20),
getViewportTop: vi.fn(() => 0),
scrollBy: vi.fn(),
...overrides
}
}
describe('scrollWithSelectionBy', () => {
it('clamps to the actual remaining scroll distance before calling scrollBy', () => {
const s = makeScroll({
getScrollHeight: vi.fn(() => 30),
getScrollTop: vi.fn(() => 9),
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(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),
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).not.toHaveBeenCalled()
})
})
+123
View File
@@ -0,0 +1,123 @@
import { execFileSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { findSlashCommand, SLASH_COMMANDS } from '../app/slash/registry.js'
type CommandRoute = 'fallback' | 'local' | 'native'
interface CommandRegistryLoad {
error?: string
names: string[]
}
const NATIVE_MUTATING_COMMANDS = new Set(['browser', 'busy', 'fast', 'reload-mcp', 'rollback', 'stop'])
const MUTATING_COMMANDS = [
'background',
'branch',
'browser',
'busy',
'clear',
'compress',
'fast',
'model',
'new',
'personality',
'queue',
'reasoning',
'reload-mcp',
'retry',
'rollback',
'steer',
'stop',
'title',
'tools',
'undo',
'verbose',
'voice',
'yolo'
] as const
const loadCommandRegistryNames = (): CommandRegistryLoad => {
const here = dirname(fileURLToPath(import.meta.url))
try {
const names = JSON.parse(
execFileSync(
process.env.PYTHON ?? 'python3',
[
'-c',
'import json; from hermes_cli.commands import COMMAND_REGISTRY; print(json.dumps([c.name for c in COMMAND_REGISTRY]))'
],
{ cwd: resolve(here, '../../..'), encoding: 'utf8' }
)
) as string[]
return { names: [...new Set(names)] }
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
names: []
}
}
}
const commandRegistry = loadCommandRegistryNames()
const registryIt = commandRegistry.error ? it.skip : it
const skipReason = commandRegistry.error ? commandRegistry.error.split('\n')[0] : ''
const LOCAL_COMMAND_NAMES = new Set(
SLASH_COMMANDS.flatMap(command => [command.name, ...(command.aliases ?? [])].map(name => name.toLowerCase()))
)
const classifyRoute = (name: string): CommandRoute => {
const normalized = name.toLowerCase()
if (NATIVE_MUTATING_COMMANDS.has(normalized)) {
return 'native'
}
if (LOCAL_COMMAND_NAMES.has(normalized)) {
return 'local'
}
return 'fallback'
}
describe('slash parity matrix', () => {
if (commandRegistry.error) {
it.skip(`Python command registry unavailable: ${skipReason}`, () => {})
}
registryIt('classifies each command registry command as local/native/fallback', () => {
const routes = Object.fromEntries(commandRegistry.names.map(name => [name, classifyRoute(name)]))
expect(routes['model']).toBe('local')
expect(routes['browser']).toBe('native')
expect(routes['reload-mcp']).toBe('native')
expect(routes['rollback']).toBe('native')
expect(routes['stop']).toBe('native')
})
registryIt('keeps every mutating command off slash-worker fallback', () => {
const routes = Object.fromEntries(commandRegistry.names.map(name => [name, classifyRoute(name)]))
for (const name of MUTATING_COMMANDS) {
expect(routes[name], `missing command in registry: ${name}`).toBeDefined()
expect(routes[name], `mutating command must not fallback: ${name}`).not.toBe('fallback')
}
})
it('/q alias resolves to queue, not quit (#31983)', () => {
// Regression for #31983: the TUI `quit` command used to carry alias `q`,
// which collided with the Python-side `/queue` alias. TUI-local commands
// dispatch before the backend, so `/q` resolved to /quit (session.die)
// instead of queueing a prompt.
const cmd = findSlashCommand('q')
expect(cmd, '/q must resolve to a command').toBeDefined()
expect(cmd!.name).toBe('queue')
})
})
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { clearSpawnHistory, getSpawnHistory, pushDiskSnapshot } from '../app/spawnHistoryStore.js'
describe('spawnHistoryStore status normalization', () => {
beforeEach(() => {
clearSpawnHistory()
})
it('keeps timeout/error statuses from disk snapshots', () => {
pushDiskSnapshot(
{
finished_at: 1_700_000_001,
label: 'status test',
session_id: 'sess-1',
started_at: 1_700_000_000,
subagents: [
{ goal: 'timeout child', id: 'sa-timeout', index: 0, status: 'timeout' },
{ goal: 'error child', id: 'sa-error', index: 1, status: 'error' }
]
},
'/tmp/snap-timeout-error.json'
)
const statuses = getSpawnHistory()[0]?.subagents.map(s => s.status)
expect(statuses).toEqual(['timeout', 'error'])
})
it('falls back unknown disk statuses to completed', () => {
pushDiskSnapshot(
{
finished_at: 1_700_000_011,
label: 'unknown status test',
session_id: 'sess-2',
started_at: 1_700_000_010,
subagents: [{ goal: 'mystery child', id: 'sa-unknown', index: 0, status: 'mystery_status' }]
},
'/tmp/snap-unknown.json'
)
const status = getSpawnHistory()[0]?.subagents[0]?.status
expect(status).toBe('completed')
})
})
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { patchTurnState, resetTurnState } from '../app/turnStore.js'
import { $uiState, resetUiState } from '../app/uiStore.js'
const shallowEqual = <T extends Record<string, unknown>>(a: T, b: T) =>
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is(a[key], b[key]))
const subscribeSelected = <T extends Record<string, unknown>>(selector: () => T) => {
let current = selector()
let calls = 0
const unsubscribe = $uiState.listen(() => {
const next = selector()
if (shallowEqual(next, current)) {
return
}
current = next
calls++
})
return { calls: () => calls, unsubscribe }
}
describe('TUI state isolation', () => {
beforeEach(() => {
resetUiState()
resetTurnState()
})
it('does not notify ui/composer subscribers for high-frequency turn updates', () => {
const composerRelevant = subscribeSelected(() => ({ busy: $uiState.get().busy, sid: $uiState.get().sid }))
try {
for (let i = 0; i < 50; i++) {
patchTurnState({ streaming: `token ${i}` })
}
} finally {
composerRelevant.unsubscribe()
}
expect(composerRelevant.calls()).toBe(0)
})
})
@@ -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)
}
})
})
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { statusRuleWidths } from '../components/appChrome.js'
describe('statusRuleWidths', () => {
it('keeps the status rule within the terminal width', () => {
for (const cols of [8, 12, 20, 40, 100]) {
const widths = statusRuleWidths(cols, '~/src/hermes-agent/main (some-long-branch-name)')
expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(cols)
expect(widths.leftWidth).toBeGreaterThan(0)
}
})
it('truncates the cwd segment before it can wrap in skinny terminals', () => {
const widths = statusRuleWidths(24, '~/src/hermes-agent/main (bb/some-extremely-long-branch)')
expect(widths.rightWidth).toBeLessThan('~/src/hermes-agent/main (bb/some-extremely-long-branch)'.length)
expect(widths.leftWidth).toBeGreaterThanOrEqual(8)
})
it('omits the cwd segment when there is no room for it', () => {
expect(statusRuleWidths(2, 'abcdef')).toEqual({ leftWidth: 2, rightWidth: 0, separatorWidth: 0 })
})
it('budgets the cwd segment by display width, not utf-16 length', () => {
const widths = statusRuleWidths(30, '目录/分支')
expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(30)
expect(widths.rightWidth).toBeGreaterThan('目录/分支'.length)
})
})
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { findStableBoundary } from '../components/streamingMarkdown.js'
// We test the pure boundary logic by rendering the component's ref
// behaviour through repeated calls. Since React isn't being rendered here,
// we reach into the module to test findStableBoundary via its exported
// behaviour — but the pure helper isn't exported. So test the component's
// observable output: pass sequential text values and verify the stable
// prefix never retreats.
//
// Strategy: mount StreamingMd in isolation and observe which <Md>
// instances it renders (by text prop). Without a DOM renderer that's
// heavy, so we validate the helper behaviour by directly invoking the
// fence/boundary logic via a re-exported surface.
import { DEFAULT_THEME } from '../theme.js'
describe('findStableBoundary', () => {
it('returns -1 when no blank line exists yet', () => {
expect(findStableBoundary('partial line with no newline yet')).toBe(-1)
})
it('returns -1 when only single newlines exist', () => {
expect(findStableBoundary('line one\nline two\nline three')).toBe(-1)
})
it('splits after the last blank line separator', () => {
// 'first\n\nsecond\n\nthird' → last blank = before 'third'
const text = 'first paragraph\n\nsecond paragraph\n\nthird'
const idx = findStableBoundary(text)
expect(text.slice(0, idx)).toBe('first paragraph\n\nsecond paragraph\n\n')
expect(text.slice(idx)).toBe('third')
})
it('refuses to split inside an open fenced block', () => {
// Fence opens, contains a blank line inside the code, no close yet.
const text = '```ts\nfn();\n\nmore code here'
expect(findStableBoundary(text)).toBe(-1)
})
it('splits before an open fenced block but not inside', () => {
const text = 'intro paragraph\n\n```ts\nfn();\n\nmore code'
const idx = findStableBoundary(text)
expect(text.slice(0, idx)).toBe('intro paragraph\n\n')
expect(text.slice(idx).startsWith('```ts')).toBe(true)
})
it('allows splitting after a fenced block closes', () => {
const text = '```ts\nfn();\n```\n\nnarration continues'
const idx = findStableBoundary(text)
expect(text.slice(0, idx)).toBe('```ts\nfn();\n```\n\n')
expect(text.slice(idx)).toBe('narration continues')
})
it('walks backwards through nested fence boundaries safely', () => {
// Two closed fences + narration + one new open fence. The only legal
// split is before the open fence, not between the closed ones.
const text = '```js\na\n```\n\nmid text\n\n```python\nstill open'
const idx = findStableBoundary(text)
expect(text.slice(0, idx)).toBe('```js\na\n```\n\nmid text\n\n')
})
it('handles empty input', () => {
expect(findStableBoundary('')).toBe(-1)
})
it('refuses to split inside an open $$ math block', () => {
// Display math has been opened but not closed; the only blank line
// sits inside the open block, so there's no safe boundary yet.
const text = '$$\nx + y\n\nmore math'
expect(findStableBoundary(text)).toBe(-1)
})
it('allows splitting after a $$ math block closes', () => {
const text = '$$\nx + y = z\n$$\n\nnarration continues'
const idx = findStableBoundary(text)
expect(text.slice(0, idx)).toBe('$$\nx + y = z\n$$\n\n')
expect(text.slice(idx)).toBe('narration continues')
})
it('splits before an open $$ block but not inside', () => {
// Mirror of the existing fenced-code test: prose, then an unclosed
// math block. The only safe boundary is the blank line BEFORE `$$`.
const text = 'intro paragraph\n\n$$\nx + y\n\nmore'
const idx = findStableBoundary(text)
expect(text.slice(0, idx)).toBe('intro paragraph\n\n')
expect(text.slice(idx).startsWith('$$')).toBe(true)
})
it('treats single-line $$x$$ as zero net toggle', () => {
// `$$x = y$$` opens AND closes on one line, so the stable boundary
// after it is allowed.
const text = 'intro\n\n$$x = y$$\n\nnarration'
const idx = findStableBoundary(text)
expect(text.slice(0, idx)).toBe('intro\n\n$$x = y$$\n\n')
expect(text.slice(idx)).toBe('narration')
})
it('refuses to split inside an open \\[ math block', () => {
const text = '\\[\nx + y\n\nmore'
expect(findStableBoundary(text)).toBe(-1)
})
})
describe('streaming theme assumption', () => {
it('theme is exportable (component import sanity check)', () => {
// Sanity that the theme we pass doesn't change shape. Component import
// already happens above — this is a smoke test that the module graph
// for streamingMarkdown wires up without cycles.
expect(DEFAULT_THEME.color.accent).toBeTruthy()
})
})
+407
View File
@@ -0,0 +1,407 @@
import { describe, expect, it } from 'vitest'
import {
buildSubagentTree,
descendantIds,
flattenTree,
fmtCost,
fmtDuration,
fmtTokens,
formatSummary,
hotnessBucket,
peakHotness,
sparkline,
topLevelSubagents,
treeTotals,
widthByDepth
} from '../lib/subagentTree.js'
import type { SubagentProgress } from '../types.js'
const makeItem = (overrides: Partial<SubagentProgress> & Pick<SubagentProgress, 'id' | 'index'>): SubagentProgress => ({
depth: 0,
goal: overrides.id,
notes: [],
parentId: null,
status: 'running',
taskCount: 1,
thinking: [],
toolCount: 0,
tools: [],
...overrides
})
describe('aggregate: tokens, cost, files, hotness', () => {
it('sums tokens and cost across subtree', () => {
const items = [
makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }),
makeItem({
costUsd: 0.005,
depth: 1,
id: 'c1',
index: 0,
inputTokens: 500,
outputTokens: 100,
parentId: 'p'
}),
makeItem({
costUsd: 0.008,
depth: 1,
id: 'c2',
index: 1,
inputTokens: 300,
outputTokens: 200,
parentId: 'p'
})
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate).toMatchObject({
costUsd: 0.023,
inputTokens: 1800,
outputTokens: 800
})
})
it('counts files read + written across subtree', () => {
const items = [
makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }),
makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.filesTouched).toBe(3)
})
it('hotness = totalTools / totalDuration', () => {
const items = [
makeItem({
durationSeconds: 10,
id: 'p',
index: 0,
status: 'completed',
toolCount: 20
})
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.hotness).toBeCloseTo(2)
})
it('hotness is zero when duration is zero', () => {
const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.hotness).toBe(0)
})
})
describe('hotnessBucket + peakHotness', () => {
it('peakHotness walks subtree', () => {
const items = [
makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }),
makeItem({
depth: 1,
durationSeconds: 1,
id: 'c',
index: 0,
parentId: 'p',
status: 'completed',
toolCount: 5
})
]
const tree = buildSubagentTree(items)
expect(peakHotness(tree)).toBeGreaterThan(2)
})
it('hotnessBucket clamps and normalizes', () => {
expect(hotnessBucket(0, 10, 4)).toBe(0)
expect(hotnessBucket(10, 10, 4)).toBe(3)
expect(hotnessBucket(5, 10, 4)).toBe(2)
expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped
expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero
})
})
describe('fmtCost + fmtTokens', () => {
it('fmtCost handles ranges', () => {
expect(fmtCost(0)).toBe('')
expect(fmtCost(0.001)).toBe('<$0.01')
expect(fmtCost(0.42)).toBe('$0.42')
expect(fmtCost(1.23)).toBe('$1.23')
expect(fmtCost(12.5)).toBe('$12.5')
})
it('fmtTokens handles ranges', () => {
expect(fmtTokens(0)).toBe('0')
expect(fmtTokens(542)).toBe('542')
expect(fmtTokens(1234)).toBe('1.2k')
expect(fmtTokens(45678)).toBe('46k')
})
})
describe('formatSummary with tokens + cost', () => {
it('includes token + cost when present', () => {
expect(
formatSummary({
activeCount: 0,
costUsd: 0.42,
descendantCount: 3,
filesTouched: 0,
hotness: 0,
inputTokens: 8000,
maxDepthFromHere: 2,
outputTokens: 2000,
totalDuration: 30,
totalTools: 14
})
).toBe('d2 · 3 agents · 14 tools · 30s · 10k tok · $0.42')
})
})
describe('buildSubagentTree', () => {
it('returns empty list for empty input', () => {
expect(buildSubagentTree([])).toEqual([])
})
it('treats flat list as top-level when no parentId is given', () => {
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 }), makeItem({ id: 'c', index: 2 })]
const tree = buildSubagentTree(items)
expect(tree).toHaveLength(3)
expect(tree.map(n => n.item.id)).toEqual(['a', 'b', 'c'])
expect(tree.every(n => n.children.length === 0)).toBe(true)
})
it('nests children under their parent by subagent_id', () => {
const items = [
makeItem({ id: 'parent', index: 0 }),
makeItem({ depth: 1, id: 'child-1', index: 0, parentId: 'parent' }),
makeItem({ depth: 1, id: 'child-2', index: 1, parentId: 'parent' })
]
const tree = buildSubagentTree(items)
expect(tree).toHaveLength(1)
expect(tree[0]!.children).toHaveLength(2)
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['child-1', 'child-2'])
})
it('builds multi-level nesting', () => {
const items = [
makeItem({ id: 'p', index: 0 }),
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }),
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.children[0]!.children[0]!.item.id).toBe('gc')
expect(tree[0]!.aggregate.maxDepthFromHere).toBe(2)
expect(tree[0]!.aggregate.descendantCount).toBe(2)
})
it('promotes orphaned children (missing parent) to top level', () => {
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
const tree = buildSubagentTree(items)
expect(tree).toHaveLength(2)
expect(tree.map(n => n.item.id)).toEqual(['a', 'orphan'])
})
it('stable sort: children ordered by (depth, index) not insert order', () => {
const items = [
makeItem({ id: 'p', index: 0 }),
makeItem({ depth: 1, id: 'c3', index: 2, parentId: 'p' }),
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['c1', 'c2', 'c3'])
})
})
describe('aggregate', () => {
it('sums tool counts and durations across subtree', () => {
const items = [
makeItem({ durationSeconds: 10, id: 'p', index: 0, status: 'completed', toolCount: 5 }),
makeItem({ depth: 1, durationSeconds: 4, id: 'c1', index: 0, parentId: 'p', status: 'completed', toolCount: 3 }),
makeItem({ depth: 1, durationSeconds: 2, id: 'c2', index: 1, parentId: 'p', status: 'completed', toolCount: 1 })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate).toMatchObject({
activeCount: 0,
descendantCount: 2,
totalDuration: 16,
totalTools: 9
})
})
it('counts queued + running as active', () => {
const items = [
makeItem({ id: 'p', index: 0, status: 'running' }),
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p', status: 'queued' }),
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p', status: 'completed' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.activeCount).toBe(2)
})
})
describe('widthByDepth', () => {
it('returns empty array for empty tree', () => {
expect(widthByDepth([])).toEqual([])
})
it('tallies nodes at each depth', () => {
const items = [
makeItem({ id: 'p1', index: 0 }),
makeItem({ id: 'p2', index: 1 }),
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p1' }),
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p1' }),
makeItem({ depth: 1, id: 'c3', index: 0, parentId: 'p2' }),
makeItem({ depth: 2, id: 'gc1', index: 0, parentId: 'c1' })
]
expect(widthByDepth(buildSubagentTree(items))).toEqual([2, 3, 1])
})
})
describe('treeTotals', () => {
it('folds a full tree into a single rollup', () => {
const items = [
makeItem({ id: 'p1', index: 0, toolCount: 5 }),
makeItem({ id: 'p2', index: 1, toolCount: 2 }),
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p1', toolCount: 3 })
]
const totals = treeTotals(buildSubagentTree(items))
expect(totals.descendantCount).toBe(3)
expect(totals.totalTools).toBe(10)
expect(totals.maxDepthFromHere).toBe(2)
})
it('returns zeros for empty tree', () => {
expect(treeTotals([])).toEqual({
activeCount: 0,
costUsd: 0,
descendantCount: 0,
filesTouched: 0,
hotness: 0,
inputTokens: 0,
maxDepthFromHere: 0,
outputTokens: 0,
totalDuration: 0,
totalTools: 0
})
})
})
describe('flattenTree + descendantIds', () => {
const items = [
makeItem({ id: 'p', index: 0 }),
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c1' }),
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
]
it('flattens in visit order (depth-first, pre-order)', () => {
const tree = buildSubagentTree(items)
expect(flattenTree(tree).map(n => n.item.id)).toEqual(['p', 'c1', 'gc', 'c2'])
})
it('collects descendant ids excluding the node itself', () => {
const tree = buildSubagentTree(items)
expect(descendantIds(tree[0]!)).toEqual(['c1', 'gc', 'c2'])
})
})
describe('sparkline', () => {
it('returns empty string for empty input', () => {
expect(sparkline([])).toBe('')
})
it('renders zeroes as spaces (not bottom glyph)', () => {
expect(sparkline([0, 0])).toBe(' ')
})
it('scales to the max value', () => {
const out = sparkline([1, 8])
expect(out).toHaveLength(2)
expect(out[1]).toBe('█')
})
it('sparse widths render as expected', () => {
const out = sparkline([2, 3, 7, 4])
expect(out).toHaveLength(4)
expect([...out].every(ch => /[\s▁-█]/.test(ch))).toBe(true)
})
})
describe('formatSummary', () => {
const emptyTotals = {
activeCount: 0,
costUsd: 0,
descendantCount: 0,
filesTouched: 0,
hotness: 0,
inputTokens: 0,
maxDepthFromHere: 0,
outputTokens: 0,
totalDuration: 0,
totalTools: 0
}
it('collapses zero-valued components', () => {
expect(formatSummary({ ...emptyTotals, descendantCount: 1 })).toBe('d0 · 1 agent')
})
it('emits rich summary with all pieces', () => {
expect(
formatSummary({
...emptyTotals,
activeCount: 2,
descendantCount: 7,
maxDepthFromHere: 3,
totalDuration: 134,
totalTools: 124
})
).toBe('d3 · 7 agents · 124 tools · 2m 14s · ⚡2')
})
})
describe('fmtDuration', () => {
it('formats under a minute as plain seconds', () => {
expect(fmtDuration(0)).toBe('0s')
expect(fmtDuration(42)).toBe('42s')
expect(fmtDuration(59.4)).toBe('59s')
})
it('formats whole minutes without trailing seconds', () => {
expect(fmtDuration(60)).toBe('1m')
expect(fmtDuration(180)).toBe('3m')
})
it('mixes minutes and seconds', () => {
expect(fmtDuration(134)).toBe('2m 14s')
expect(fmtDuration(605)).toBe('10m 5s')
})
})
describe('topLevelSubagents', () => {
it('returns items with no parent', () => {
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 })]
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'b'])
})
it('excludes children whose parent is present', () => {
const items = [makeItem({ id: 'p', index: 0 }), makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })]
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p'])
})
it('promotes orphans whose parent is missing', () => {
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'orphan'])
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { highlightLine, isHighlightable } from '../lib/syntax.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
describe('syntax highlighter', () => {
it('recognizes supported langs and aliases', () => {
expect(isHighlightable('ts')).toBe(true)
expect(isHighlightable('js')).toBe(true)
expect(isHighlightable('python')).toBe(true)
expect(isHighlightable('rs')).toBe(true)
expect(isHighlightable('bash')).toBe(true)
expect(isHighlightable('whatever')).toBe(false)
expect(isHighlightable('')).toBe(false)
})
it('paints a whole-line comment dim', () => {
const tokens = highlightLine('// hello', 'ts', t)
expect(tokens).toEqual([[t.color.muted, '// hello']])
})
it('paints keywords, strings, and numbers in a ts line', () => {
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
const colors = tokens.map(tok => tok[0])
expect(colors).toContain(t.color.border) // const
expect(colors).toContain(t.color.accent) // 'hi'
expect(colors).toContain(t.color.text) // 42
})
it('falls through unchanged for unknown langs', () => {
const tokens = highlightLine(`const x = 1`, 'zzz', t)
expect(tokens).toEqual([['', 'const x = 1']])
})
it('treats `#` as a python comment, not a selector', () => {
const tokens = highlightLine('# comment', 'py', t)
expect(tokens).toEqual([[t.color.muted, '# comment']])
})
})
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js'
describe('terminal mode reset', () => {
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')
expect(TERMINAL_MODE_RESET).toContain('\x1b[<u')
expect(TERMINAL_MODE_RESET).toContain('\x1b[>4m')
})
it('writes reset sequence to TTY streams without fds', () => {
const write = vi.fn()
expect(resetTerminalModes({ isTTY: true, write } as unknown as NodeJS.WriteStream)).toBe(true)
expect(write).toHaveBeenCalledWith(TERMINAL_MODE_RESET)
})
it('skips non-TTY streams', () => {
const write = vi.fn()
expect(resetTerminalModes({ isTTY: false, write } as unknown as NodeJS.WriteStream)).toBe(false)
expect(write).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import { terminalParityHints } from '../lib/terminalParity.js'
describe('terminalParityHints', () => {
it('warns for Apple Terminal and SSH/tmux sessions', async () => {
const hints = await terminalParityHints({
TERM_PROGRAM: 'Apple_Terminal',
TERM_SESSION_ID: 'w0t0p0:123',
SSH_CONNECTION: '1',
TMUX: '/tmp/tmux-1/default,1,0'
} as NodeJS.ProcessEnv)
expect(hints.map(h => h.key)).toEqual(expect.arrayContaining(['apple-terminal', 'remote', 'tmux']))
})
it('suggests IDE setup only for VS Code-family terminals that still need bindings', async () => {
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, {
fileOps: { readFile },
homeDir: '/tmp/fake-home'
})
expect(hints.some(h => h.key === 'ide-setup')).toBe(true)
})
it('suppresses IDE setup hint when keybindings are already configured', async () => {
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: '\u001b[99;13u' }
},
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\\\r\n' }
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\\\r\n' }
},
{
key: 'cmd+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\\\r\n' }
},
{
key: 'cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;9u' }
},
{
key: 'shift+cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;10u' }
}
])
)
const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, {
fileOps: { readFile },
homeDir: '/tmp/fake-home'
})
expect(hints.some(h => h.key === 'ide-setup')).toBe(false)
})
})
+386
View File
@@ -0,0 +1,386 @@
import { describe, expect, it, vi } from 'vitest'
import {
configureDetectedTerminalKeybindings,
configureTerminalKeybindings,
detectVSCodeLikeTerminal,
getVSCodeStyleConfigDir,
shouldPromptForTerminalSetup,
stripJsonComments
} from '../lib/terminalSetup.js'
describe('terminalSetup helpers', () => {
it('detects VS Code family terminals from environment', () => {
expect(detectVSCodeLikeTerminal({ CURSOR_TRACE_ID: 'x' } as NodeJS.ProcessEnv)).toBe('cursor')
expect(detectVSCodeLikeTerminal({ VSCODE_GIT_ASKPASS_MAIN: '/tmp/windsurf' } as NodeJS.ProcessEnv)).toBe('windsurf')
expect(detectVSCodeLikeTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe('vscode')
expect(detectVSCodeLikeTerminal({} as NodeJS.ProcessEnv)).toBeNull()
})
it('computes VS Code style config dirs cross-platform', () => {
expect(getVSCodeStyleConfigDir('Code', 'darwin', {} as NodeJS.ProcessEnv, '/home/me')).toBe(
'/home/me/Library/Application Support/Code/User'
)
expect(getVSCodeStyleConfigDir('Code', 'linux', {} as NodeJS.ProcessEnv, '/home/me')).toBe(
'/home/me/.config/Code/User'
)
expect(
getVSCodeStyleConfigDir(
'Code',
'win32',
{ APPDATA: 'C:/Users/me/AppData/Roaming' } as NodeJS.ProcessEnv,
'/home/me'
)
).toBe('C:/Users/me/AppData/Roaming/Code/User')
})
it('strips line comments from keybindings JSON', () => {
expect(stripJsonComments('// comment\n[{"key":"shift+enter"}]')).toBe('\n[{"key":"shift+enter"}]')
})
it('strips inline comments and block comments', () => {
expect(stripJsonComments('[{"key":"a"} // inline\n]')).toBe('[{"key":"a"} \n]')
expect(stripJsonComments('[/* block */{"key":"a"}]')).toBe('[{"key":"a"}]')
})
it('removes trailing commas before ] or }', () => {
expect(JSON.parse(stripJsonComments('[{"key":"a"},]'))).toEqual([{ key: 'a' }])
expect(JSON.parse(stripJsonComments('[{"key":"a",}]'))).toEqual([{ key: 'a' }])
})
it('preserves comment-like sequences inside strings', () => {
const input = '[{"key":"a","args":{"text":"// not a comment"}}]'
expect(JSON.parse(stripJsonComments(input))).toEqual([{ key: 'a', args: { text: '// not a comment' } }])
})
it('handles unterminated block comments gracefully', () => {
const input = '[{"key":"a"} /* never closed'
const stripped = stripJsonComments(input)
// The unterminated comment is consumed to end-of-file; the remainder is parseable
expect(stripped).toBe('[{"key":"a"} ')
})
})
describe('configureTerminalKeybindings', () => {
it('writes missing bindings into a VS Code style keybindings file', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(result.requiresRestart).toBe(true)
expect(writeFile).toHaveBeenCalledTimes(1)
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
const written = writeFile.mock.calls[0]?.[1] as string
expect(written).toContain('cmd+c')
expect(written).toContain('terminalTextSelected')
expect(written).toContain('\\u001b[99;13u')
expect(written).toContain('shift+enter')
expect(written).toContain('cmd+enter')
expect(written).toContain('cmd+z')
})
it('only adds the Cmd+C forwarding binding on macOS', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/home/me',
platform: 'linux'
})
expect(result.success).toBe(true)
const written = writeFile.mock.calls[0]?.[1] as string
expect(written).not.toContain('cmd+c')
expect(written).not.toContain('terminalTextSelected')
expect(written).not.toContain('\\u001b[99;13u')
expect(written).toContain('shift+enter')
})
it('reports conflicts without overwriting existing bindings', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+z',
command: 'something.else',
when: 'terminalFocus',
args: { text: 'noop' }
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('cursor', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('cmd+z')
expect(writeFile).not.toHaveBeenCalled()
expect(copyFile).not.toHaveBeenCalled() // no backup when not writing
})
it('flags a global (when-less) binding on the same key as a conflict', async () => {
// A user's keybindings.json `cmd+c` with no `when` clause is global —
// it overlaps any context, including our terminal scope. We must NOT
// silently add a terminal-scoped cmd+c that would shadow it.
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'myExtension.smartCopy'
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('cmd+c')
expect(writeFile).not.toHaveBeenCalled()
})
it('flags an overlapping terminal-context binding as a conflict', async () => {
// Existing `cmd+c` scoped to plain `terminalFocus` overlaps with our
// `terminalFocus && terminalTextSelected` — both fire when the
// terminal is focused with text selected, so the existing binding
// would shadow ours. Treat as a conflict even though the strings
// aren't identical.
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.copySelection',
when: 'terminalFocus'
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('cmd+c')
expect(writeFile).not.toHaveBeenCalled()
})
it('does not flag a negated terminalTextSelected binding as a conflict', async () => {
// A binding scoped to "terminal focused but no selected text" is
// logically disjoint from our copy-forwarding binding, which requires
// terminalTextSelected.
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && !terminalTextSelected',
args: { text: '\u0003' }
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(writeFile).toHaveBeenCalledTimes(1)
})
it('does not flag a disjoint-when binding on the same key as a conflict', async () => {
// VS Code allows multiple bindings for the same key when their `when`
// clauses don't overlap. A user's pre-existing cmd+c binding scoped to
// editor focus should NOT block our terminal-scoped cmd+c binding.
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'editor.action.clipboardCopyAction',
when: 'editorFocus'
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(writeFile).toHaveBeenCalledTimes(1)
})
it('backs up existing keybindings.json only when writing changes', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(JSON.stringify([]))
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(writeFile).toHaveBeenCalledTimes(1)
expect(copyFile).toHaveBeenCalledTimes(1) // backup created before writing
})
it('reports error when keybindings.json is not readable (EACCES)', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }))
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('Failed to read')
expect(writeFile).not.toHaveBeenCalled()
})
it('auto-detects the current IDE terminal', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureDetectedTerminalKeybindings({
env: { CURSOR_TRACE_ID: 'trace' } as NodeJS.ProcessEnv,
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(writeFile).toHaveBeenCalled()
})
it('refuses to configure IDE bindings from an SSH session', async () => {
const result = await configureDetectedTerminalKeybindings({
env: { SSH_CONNECTION: '1 2 3 4', TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('local machine')
})
it('prompts for setup when bindings are missing and suppresses prompt when complete', async () => {
const readMissing = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
await expect(
shouldPromptForTerminalSetup({
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
fileOps: { readFile: readMissing }
})
).resolves.toBe(true)
const readComplete = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: '\u001b[99;13u' }
},
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\\\r\n' }
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\\\r\n' }
},
{
key: 'cmd+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\\\r\n' }
},
{
key: 'cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;9u' }
},
{
key: 'shift+cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;10u' }
}
])
)
await expect(
shouldPromptForTerminalSetup({
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
fileOps: { readFile: readComplete }
})
).resolves.toBe(false)
})
it('suppresses terminal setup prompts inside SSH sessions', async () => {
await expect(
shouldPromptForTerminalSetup({
env: { SSH_CONNECTION: '1 2 3 4', TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv
})
).resolves.toBe(false)
})
})
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { isTermuxEnv, isTermuxTuiMode } from '../lib/termux.js'
describe('isTermuxEnv', () => {
it('detects TERMUX_VERSION marker', () => {
expect(isTermuxEnv({ TERMUX_VERSION: '0.118.0' } as NodeJS.ProcessEnv)).toBe(true)
})
it('detects Termux PREFIX path marker', () => {
expect(
isTermuxEnv({ PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv)
).toBe(true)
})
it('returns false for generic Linux envs', () => {
expect(isTermuxEnv({ PREFIX: '/usr' } as NodeJS.ProcessEnv)).toBe(false)
})
})
describe('isTermuxTuiMode', () => {
it('defaults to true inside Termux', () => {
expect(isTermuxTuiMode({ TERMUX_VERSION: '0.118.0' } as NodeJS.ProcessEnv)).toBe(true)
})
it('allows explicit opt-out override', () => {
expect(
isTermuxTuiMode({ TERMUX_VERSION: '0.118.0', HERMES_TUI_TERMUX_MODE: '0' } as NodeJS.ProcessEnv)
).toBe(false)
})
it('stays false outside Termux even if override is set', () => {
expect(isTermuxTuiMode({ HERMES_TUI_TERMUX_MODE: '1', PREFIX: '/usr' } as NodeJS.ProcessEnv)).toBe(false)
})
})
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { stableComposerColumns, transcriptBodyWidth } from '../lib/inputMetrics.js'
import { composerPromptText } from '../lib/prompt.js'
describe('Termux composer prompt + width guards', () => {
it('uses a single-cell ASCII prompt marker in Termux mode', () => {
expect(composerPromptText('', 'coder', false, true, 50)).toBe('>')
})
it('suppresses profile prefixes on narrow Termux panes', () => {
expect(composerPromptText('', 'upstr', false, true, 72)).toBe('>')
})
it('keeps profile context on very wide Termux panes', () => {
expect(composerPromptText('', 'upstr', false, true, 120)).toBe('upstr >')
})
it('reserves fewer columns for gutter on narrow Termux widths', () => {
// 32 columns after prompt: desktop reserves 2 for transcript scrollbar,
// Termux keeps those 2 columns for the active composer.
expect(stableComposerColumns(40, 8, false)).toBe(28)
expect(stableComposerColumns(40, 8, true)).toBe(30)
// With ample room, Termux still reserves the gutter for alignment.
expect(stableComposerColumns(60, 8, true)).toBe(48)
})
it('never over-allocates transcript body width on narrow panes', () => {
// Old behavior hard-minned to 20 columns and overflowed narrow layouts.
expect(transcriptBodyWidth(24, 'assistant', '>', true)).toBe(19)
expect(transcriptBodyWidth(24, 'user', 'upstr >', true)).toBe(14)
expect(transcriptBodyWidth(10, 'user', '>', true)).toBeGreaterThanOrEqual(1)
})
it('keeps legacy desktop floor outside Termux mode', () => {
expect(transcriptBodyWidth(24, 'assistant', '>')).toBe(20)
expect(transcriptBodyWidth(24, 'user', 'upstr >')).toBe(20)
})
})
+233
View File
@@ -0,0 +1,233 @@
import { describe, expect, it } from 'vitest'
import {
boundedLiveRenderText,
buildToolTrailLine,
buildVerboseToolTrailLine,
edgePreview,
estimateRows,
estimateTokensRough,
fmtK,
hasAnsi,
isToolTrailResultLine,
lastCotTrailIndex,
parseToolTrailResultLine,
pasteTokenLabel,
sameToolTrailGroup,
sanitizeAnsiForRender,
splitToolDuration,
stripAnsi,
thinkingPreview
} from '../lib/text.js'
describe('isToolTrailResultLine', () => {
it('detects completion markers', () => {
expect(isToolTrailResultLine('foo ✓')).toBe(true)
expect(isToolTrailResultLine('foo ✗')).toBe(true)
expect(isToolTrailResultLine('drafting x…')).toBe(false)
})
})
describe('buildToolTrailLine', () => {
it('puts completion duration inline before the result marker', () => {
const line = buildToolTrailLine('read_file', 'x', false, '', 0.94)
expect(line).toBe('Read File("x") (0.9s) ✓')
expect(parseToolTrailResultLine(line)).toEqual({ call: 'Read File("x") (0.9s)', detail: '', mark: '✓' })
expect(splitToolDuration('Read File("x") (0.9s)')).toEqual({ label: 'Read File("x")', duration: ' (0.9s)' })
})
})
describe('buildVerboseToolTrailLine', () => {
it('preserves multiline args and result details', () => {
const line = buildVerboseToolTrailLine(
'terminal',
'npm test',
false,
1.25,
'{\n "cmd": "npm test"\n}',
'first line\nsecond :: line'
)
expect(line).toContain('Args:\n{')
expect(line).toContain('Result:\nfirst line\nsecond :: line')
expect(parseToolTrailResultLine(line)).toEqual({
call: 'Terminal("npm test") (1.3s)',
detail: 'Args:\n{\n "cmd": "npm test"\n}\nResult:\nfirst line\nsecond :: line',
mark: '✓'
})
})
it('labels verbose failures as errors', () => {
const line = buildVerboseToolTrailLine('terminal', 'npm test', true, 0.5, undefined, 'command failed')
expect(line).toContain('Error:\ncommand failed')
expect(line).not.toContain('Result:\ncommand failed')
expect(parseToolTrailResultLine(line)).toEqual({
call: 'Terminal("npm test") (0.5s)',
detail: 'Error:\ncommand failed',
mark: '✗'
})
})
})
describe('lastCotTrailIndex', () => {
it('finds last non-result line', () => {
expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1)
expect(lastCotTrailIndex(['only result ✓'])).toBe(-1)
})
})
describe('sameToolTrailGroup', () => {
it('matches bare check lines', () => {
expect(sameToolTrailGroup('searching', 'searching ✓')).toBe(true)
expect(sameToolTrailGroup('searching', 'searching ✗')).toBe(true)
})
it('matches contextual lines', () => {
expect(sameToolTrailGroup('searching', 'searching: * ✓')).toBe(true)
expect(sameToolTrailGroup('searching', 'searching: foo ✓')).toBe(true)
})
it('rejects other tools', () => {
expect(sameToolTrailGroup('searching', 'reading ✓')).toBe(false)
expect(sameToolTrailGroup('searching', 'searching extra ✓')).toBe(false)
})
})
describe('fmtK', () => {
it('keeps small numbers plain', () => {
expect(fmtK(999)).toBe('999')
})
it('formats thousands as lowercase k', () => {
expect(fmtK(1000)).toBe('1k')
expect(fmtK(1500)).toBe('1.5k')
})
it('formats millions and billions with lowercase suffixes', () => {
expect(fmtK(1_000_000)).toBe('1m')
expect(fmtK(1_000_000_000)).toBe('1b')
})
})
describe('estimateTokensRough', () => {
it('uses 4 chars per token rounding up', () => {
expect(estimateTokensRough('')).toBe(0)
expect(estimateTokensRough('a')).toBe(1)
expect(estimateTokensRough('abcd')).toBe(1)
expect(estimateTokensRough('abcde')).toBe(2)
})
})
describe('ANSI sanitizers', () => {
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
it('strips CSI/OSC/control bytes from plain previews', () => {
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD`
expect(stripAnsi(sample)).toBe('ABCD')
})
it('strips incomplete CSI prefixes and carriage returns', () => {
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[CD\rE`
expect(stripAnsi(sample)).toBe('ABDE')
})
it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => {
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC`
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`)
})
it('keeps valid SGR while removing dangling CSI and carriage returns', () => {
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[39mC\rD`
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`)
})
it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => {
const sample = `A${ESC}(0B${ESC}%GC${ESC})0D`
expect(stripAnsi(sample)).toBe('ABCD')
expect(sanitizeAnsiForRender(sample)).toBe('ABCD')
})
it('detects non-CSI escape prefixes too', () => {
expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true)
})
})
describe('thinkingPreview', () => {
it('adds paragraph breaks before markdown thinking headings', () => {
const raw =
'**Considering user instructions**\nI need to answer.**Planning tool execution**\nI can run tools.**Determining weather search parameters**\nUse SF.'
expect(thinkingPreview(raw, 'full')).toBe(
'**Considering user instructions**\nI need to answer.\n\n**Planning tool execution**\nI can run tools.\n\n**Determining weather search parameters**\nUse SF.'
)
})
})
describe('boundedLiveRenderText', () => {
it('preserves short live text verbatim', () => {
expect(boundedLiveRenderText('one\ntwo', { maxChars: 100, maxLines: 10 })).toBe('one\ntwo')
})
it('keeps the live tail by character budget', () => {
const out = boundedLiveRenderText('abcdefghij', { maxChars: 4, maxLines: 10 })
expect(out).toContain('ghij')
expect(out).toContain('omitted')
expect(out).not.toContain('abcdef')
})
it('keeps the live tail by line budget', () => {
const out = boundedLiveRenderText(['a', 'b', 'c', 'd'].join('\n'), { maxChars: 100, maxLines: 2 })
expect(out).toContain('c\nd')
expect(out).toContain('omitted 2 lines')
expect(out).not.toContain('a\nb')
})
})
describe('edgePreview', () => {
it('keeps both ends for long text', () => {
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(
'Vampire.. stained with blood'
)
})
})
describe('pasteTokenLabel', () => {
it('builds readable long-paste labels with counts', () => {
const label = pasteTokenLabel('Vampire Bondage ropes slipped from her neck, still stained with blood', 250)
expect(label.startsWith('[[ ')).toBe(true)
expect(label).toContain('[250 lines]')
expect(label.endsWith(' ]]')).toBe(true)
})
})
describe('estimateRows', () => {
it('handles tilde code fences', () => {
const md = ['~~~markdown', '# heading', '~~~'].join('\n')
expect(estimateRows(md, 40)).toBeGreaterThanOrEqual(2)
})
it('handles checklist bullets as list rows', () => {
const md = ['- [x] done', '- [ ] todo'].join('\n')
expect(estimateRows(md, 40)).toBe(2)
})
it('keeps intraword underscores when sizing snake_case identifiers', () => {
const w = 80
const snake = 'look at test_case_with_underscores now'
const plain = 'look at test case with underscores now'
expect(estimateRows(snake, w)).toBe(estimateRows(plain, w))
})
})
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { applyPrintableInsert, shouldRouteMultiCharInputAsPaste } from '../components/textInput.js'
describe('applyPrintableInsert', () => {
it('applies non-bracketed multi-character bursts immediately', () => {
const burst = applyPrintableInsert('abc', 3, 'xxxxx')
const repeated = [...'xxxxx'].reduce(
(state, ch) => applyPrintableInsert(state.value, state.cursor, ch)!,
{ cursor: 3, value: 'abc' }
)
expect(burst).toEqual({ cursor: 8, value: 'abcxxxxx' })
expect(burst).toEqual(repeated)
})
it('replaces the selected range for burst input', () => {
expect(applyPrintableInsert('abZZef', 4, 'cd', { end: 4, start: 2 })).toEqual({
cursor: 4,
value: 'abcdef'
})
})
it('rejects control or escape-bearing input', () => {
expect(applyPrintableInsert('abc', 3, '\x1b[200~pasted')).toBeNull()
expect(applyPrintableInsert('abc', 3, '\t')).toBeNull()
})
})
describe('shouldRouteMultiCharInputAsPaste', () => {
it('keeps newline-bearing chunks on the paste path', () => {
expect(shouldRouteMultiCharInputAsPaste('hello\nworld')).toBe(true)
expect(shouldRouteMultiCharInputAsPaste('hello\r\nworld'.replace(/\r\n/g, '\n'))).toBe(true)
})
it('treats repeated printable key bursts as immediate input', () => {
expect(shouldRouteMultiCharInputAsPaste('xxxxx')).toBe(false)
})
})
@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
// Locate textInput.tsx relative to this test file so the assertion
// survives moves of the test fixture itself.
const TEXT_INPUT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'textInput.tsx')
const source = readFileSync(TEXT_INPUT_PATH, 'utf8')
// Closes Copilot follow-up on PR #26717: the original cursor-drift
// fix bumped Ink's displayCursor / cursorDeclaration on fast-echo, but
// if TextInput itself re-renders before the deferred 16ms `setCur`
// flushes (parent state change, status-bar tick, spinner) the layout
// effect inside `useDeclaredCursor` re-publishes a declaration
// computed from the STALE React `cur` state and clobbers the Ink-level
// bump. The fix is structural: read `curRef.current` (always
// up-to-date) when computing the layout, not the `cur` state.
//
// This file pins that invariant. Switching back to `cur` state — or
// re-introducing a memo keyed on `cur` that uses `curRef.current`
// inside but stops re-computing on rerender — is a regression and
// should be caught here, not via a flaky integration test that mounts
// Ink + stdin.
describe('textInput cursor-layout source of truth', () => {
it('reads curRef.current (not the cur React state) for cursorLayout', () => {
// The line we care about. We allow whitespace / formatting drift,
// but the call itself must use `curRef.current`.
expect(source).toMatch(/cursorLayout\(\s*display\s*,\s*curRef\.current\s*,\s*columns\s*\)/)
})
it('does not pass the bare `cur` React state into cursorLayout', () => {
// Any `cursorLayout(display, cur, columns)` invocation would
// reintroduce the stale-declaration window.
expect(source).not.toMatch(/cursorLayout\(\s*display\s*,\s*cur\s*,\s*columns\s*\)/)
})
it('keeps the fast-echo notifier calls paired with the stdout writes', () => {
// Both fast-echo paths must call noteCursorAdvance, otherwise Ink
// never learns about the out-of-band write and drifts again. We
// tolerate explanatory comments in between (the rationale block is
// intentionally long), but the pairing itself must hold.
const backspacePattern = /stdout!\.write\(['"`]\\b \\b['"`]\)[\s\S]{0,1000}?noteCursorAdvance\(-1\)/
expect(source).toMatch(backspacePattern)
const appendPattern = /stdout!\.write\(text\)[\s\S]{0,1000}?noteCursorAdvance\(text\.length\)/
expect(source).toMatch(appendPattern)
})
})
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest'
import { canFastAppendShape, canFastBackspaceShape, supportsFastEchoTerminal } from '../components/textInput.js'
// The fast-echo path bypasses Ink and writes characters directly to stdout
// for the common case of typing plain English at the end of the line. These
// tests pin the shape preconditions that make that bypass safe.
//
// Regression intent: any non-ASCII text — Vietnamese precomposed letters
// (one grapheme, `text.length === 1`, `stringWidth === 1`, but produced
// via IME composition across multiple keystrokes), combining marks
// (zero width), CJK (double width), emoji (variable width), or anything
// that could be produced by an in-flight IME composition — must NOT
// take the bypass. Closes:
// - "TUI is experiencing font errors when using Unicode to type Vietnamese"
// - #5221 TUI input box renders incorrectly for CJK / East-Asian wide
// - #7443 CLI TUI renders and deletes Chinese characters incorrectly
// - #17602 / #17603 Chinese text scattering / ghosting
describe('canFastAppendShape', () => {
const COLS = 40
it('accepts plain ASCII appended at end of single-line input', () => {
expect(canFastAppendShape('hello', 5, 'x', COLS, 5)).toBe(true)
expect(canFastAppendShape('hello', 5, ' world', COLS, 5)).toBe(true)
})
it('rejects when cursor is not at end of line', () => {
expect(canFastAppendShape('hello', 3, 'x', COLS, 5)).toBe(false)
})
it('rejects when current is empty (placeholder render path needed)', () => {
expect(canFastAppendShape('', 0, 'x', COLS, 0)).toBe(false)
})
it('rejects when current contains a newline (multi-line layout)', () => {
expect(canFastAppendShape('hi\nthere', 8, 'x', COLS, 5)).toBe(false)
})
it('rejects when appending would hit the wrap column', () => {
// Reaching cols on append must trigger a wrap, which the bypass
// cannot draw. Stay strictly below cols.
expect(canFastAppendShape('hello', 5, 'x', 6, 5)).toBe(false)
})
// -- Regression coverage: Vietnamese / combining marks / IME --
it('rejects Vietnamese precomposed letter ề (U+1EC1) — IME composition path', () => {
// 'ề' is one grapheme, length 1, width 1, but Vietnamese Telex/IME
// produces it via a multi-key composition. Fast-echo would commit the
// intermediate state to stdout and desync once the final commit
// arrives.
expect(canFastAppendShape('hello', 5, 'ề', COLS, 5)).toBe(false)
})
it('rejects Vietnamese tone marks ă, ơ, ư (Latin-Extended-A/B)', () => {
for (const ch of ['ă', 'ắ', 'ơ', 'ờ', 'ư', 'ự']) {
expect(canFastAppendShape('hello', 5, ch, COLS, 5)).toBe(false)
}
})
it('rejects NFD combining marks (U+0300 grave, U+0301 acute, U+0302 circumflex)', () => {
// Decomposed Vietnamese: 'e' + combining circumflex + combining grave
// = 'ề'. Each combining mark is zero-width but length 1; without the
// ASCII guard the second/third keypress would be fast-echoed and
// desync the cell column.
expect(canFastAppendShape('hello', 5, '\u0300', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '\u0301', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '\u0302', COLS, 5)).toBe(false)
})
it('rejects CJK (East-Asian wide) characters', () => {
expect(canFastAppendShape('hello', 5, '你', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '日本', COLS, 5)).toBe(false)
})
it('rejects emoji', () => {
expect(canFastAppendShape('hello', 5, '🙂', COLS, 5)).toBe(false)
})
it('rejects ANSI-bearing or control text', () => {
expect(canFastAppendShape('hello', 5, '\x1b[31m', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '\t', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '\x7f', COLS, 5)).toBe(false)
})
it('rejects NBSP and Latin-1 letters that would change the line shape', () => {
expect(canFastAppendShape('hello', 5, '\u00a0', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, 'é', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, 'ñ', COLS, 5)).toBe(false)
})
})
describe('canFastBackspaceShape', () => {
it('accepts deleting the last ASCII char', () => {
expect(canFastBackspaceShape('hello', 5)).toBe(true)
})
it('rejects when cursor is not at end', () => {
expect(canFastBackspaceShape('hello', 3)).toBe(false)
})
it('rejects when there is nothing to delete', () => {
expect(canFastBackspaceShape('', 0)).toBe(false)
expect(canFastBackspaceShape('hello', 0)).toBe(false)
})
it('rejects when value contains a newline', () => {
expect(canFastBackspaceShape('hi\nthere', 8)).toBe(false)
})
it('rejects deleting Vietnamese precomposed letter ề', () => {
// The "\b \b" shortcut clears one terminal cell; that's fine for a
// 1-cell ASCII char but if the previous grapheme is a Vietnamese
// letter that the IME may still be holding open, we want Ink to
// re-render so composition state stays consistent.
expect(canFastBackspaceShape('helloề', 'helloề'.length)).toBe(false)
})
it('rejects deleting a CJK character (2 cells)', () => {
expect(canFastBackspaceShape('hi你', 'hi你'.length)).toBe(false)
})
it('rejects deleting a NFD-composed grapheme with combining marks', () => {
// 'e' + U+0302 (circumflex) + U+0300 (grave) — final grapheme is one
// cluster but the previous-grapheme slice is multi-codepoint. Width
// is 1 but the bypass would be unsafe because the rendered cell
// already contained the combined glyph.
const s = 'hello' + 'e\u0302\u0300'
expect(canFastBackspaceShape(s, s.length)).toBe(false)
})
it('rejects deleting an emoji', () => {
expect(canFastBackspaceShape('hi🙂', 'hi🙂'.length)).toBe(false)
})
// Closes Copilot PR #26717 round 3: the "\b \b" sequence cannot move
// the terminal cursor onto the previous visual row across a
// soft-wrap boundary. When the caret sits at visual column 0 of a
// wrapped row (column == 0 in the computed cursor layout), backspace
// would leave the physical cursor in place while the logical caret
// moves up to the end of the previous visual line — desyncing both
// Ink's displayCursor model and the user-visible position. The fast
// path must fall through in that case so the normal Ink render path
// can lay out the correct cursor position.
it('rejects fast-backspace at a soft-wrap boundary when columns is known', () => {
// value width 6 in a column of 6 → cursorLayout produces (line 1, col 0)
// i.e. the caret has overflowed onto the next visual line.
const value = 'hello '
expect(canFastBackspaceShape(value, value.length, 6)).toBe(false)
})
it('rejects fast-backspace at an exact multiple of columns (wide wrap)', () => {
// 12 chars at width 6 → two full visual rows, caret at (line 2, col 0).
const value = 'abcdefghijkl'
expect(canFastBackspaceShape(value, value.length, 6)).toBe(false)
})
it('still accepts fast-backspace inside a wrapped line', () => {
// Caret mid-visual-line — "\b \b" can move the cursor one cell left
// without crossing a wrap boundary.
expect(canFastBackspaceShape('hello world', 'hello world'.length, 20)).toBe(true)
expect(canFastBackspaceShape('abcdefghi', 9, 6)).toBe(true) // visual line 1, col 3 → ok
})
it('skips the wrap-boundary check when columns is omitted (legacy contract)', () => {
// Callers that don't pass `columns` fall back to the pre-wrap-aware
// behavior — the function does NOT magically reject anything that
// could be a wrap boundary without the width. Production callers
// must always pass `columns`; this case is for unit tests of the
// pre-wrap shape contract.
expect(canFastBackspaceShape('hello ', 'hello '.length)).toBe(true)
})
})
describe('supportsFastEchoTerminal', () => {
it('disables fast-echo in Apple Terminal', () => {
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
})
it('disables fast-echo by default in Termux mode', () => {
expect(
supportsFastEchoTerminal({ TERMUX_VERSION: '0.118.0', PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv)
).toBe(false)
})
it('allows explicit Termux fast-echo opt-in via env override', () => {
expect(
supportsFastEchoTerminal({
HERMES_TUI_TERMUX_FAST_ECHO: '1',
TERMUX_VERSION: '0.118.0'
} as NodeJS.ProcessEnv)
).toBe(true)
})
it('keeps fast-echo enabled in VS Code and unknown non-Termux terminals', () => {
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
})
})
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { lineNav } from '../components/textInput.js'
describe('lineNav', () => {
it('returns null for single-line input (up)', () => {
expect(lineNav('hello world', 6, -1)).toBeNull()
})
it('returns null for single-line input (down)', () => {
expect(lineNav('hello world', 6, 1)).toBeNull()
})
it('returns null when cursor already on first line of a multiline block', () => {
expect(lineNav('one\ntwo\nthree', 2, -1)).toBeNull()
})
it('returns null when cursor on last line of a multiline block', () => {
expect(lineNav('one\ntwo\nthree', 10, 1)).toBeNull()
})
it('moves cursor up one line preserving column', () => {
// "hello\nworld" — cursor at col 3 of line 1 ('l' in world) → col 3 of line 0 ('l' in hello)
expect(lineNav('hello\nworld', 9, -1)).toBe(3)
})
it('moves cursor down one line preserving column', () => {
// cursor at col 2 of line 0 → col 2 of line 1
expect(lineNav('hello\nworld', 2, 1)).toBe(8)
})
it('clamps to end of shorter destination line on up', () => {
// col 10 on long line → clamp to end of short line "abc"
const s = 'abc\nlong long text'
const from = 14
expect(lineNav(s, from, -1)).toBe(3)
})
it('clamps to end of shorter destination line on down', () => {
// col 10 on line 0 → clamp to end of "abc" on line 1
const s = 'long long text\nabc'
expect(lineNav(s, 10, 1)).toBe(18)
})
it('handles empty lines correctly', () => {
// "a\n\nb" — cursor at line 2 (b) → up to empty line 1
expect(lineNav('a\n\nb', 3, -1)).toBe(2)
})
it('handles leading newline without crashing', () => {
expect(lineNav('\nfoo', 2, -1)).toBe(0)
})
})
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { shouldPassThroughToGlobalHandler, shouldPreserveCtrlJNewline } 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('shouldPreserveCtrlJNewline', () => {
it('preserves Ctrl+J as newline in Ghostty even when tmux masks TERM/TERM_PROGRAM', () => {
expect(
shouldPreserveCtrlJNewline({
GHOSTTY_RESOURCES_DIR: '/usr/share/ghostty',
TERM: 'tmux-256color',
TERM_PROGRAM: 'tmux'
})
).toBe(true)
})
it('keeps bare local POSIX LF-compatible prompts submitting on Ctrl+J', () => {
expect(shouldPreserveCtrlJNewline({ TERM: 'xterm-256color' })).toBe(false)
})
})
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)
})
})
@@ -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 '
})
})
})
+151
View File
@@ -0,0 +1,151 @@
import { wrapAnsi } from '@hermes/ink'
import { describe, expect, it } from 'vitest'
import { offsetFromPosition } from '../components/textInput.js'
import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
// Helper: compute the "end of text" position that wrap-ansi would render
// the input to. This is what Ink's <Text wrap="wrap"> uses, so cursorLayout
// MUST agree. Disagreement is the cursor-drift bug.
function wrapAnsiEndPosition(text: string, cols: number): { line: number; column: number } {
const wrapped = wrapAnsi(text, cols, { hard: true, trim: false })
const lines = wrapped.split('\n')
const last = lines[lines.length - 1] ?? ''
return { line: lines.length - 1, column: last.length }
}
describe('cursorLayout — word-wrap parity with wrap-ansi', () => {
it('places cursor mid-line at its column', () => {
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
})
it('places cursor at end of a non-full line', () => {
expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 })
})
it('does not push exact-fill text onto a phantom next line', () => {
// Regression: the previous hand-rolled wrap algorithm forced the cursor
// onto (line+1, 0) when the text exactly filled the row. wrap-ansi keeps
// it on the same row (no soft-wrap), so the cursor must too — otherwise
// useDeclaredCursor parks the hardware cursor below the last char and
// the user sees several blank cells between text and cursor block
// (#cursor-drift-multiline).
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 8, line: 0 })
expect(cursorLayout('abcdefgh', 8, 8)).toEqual(wrapAnsiEndPosition('abcdefgh', 8))
})
it('keeps short words on the current line when they fit (no phantom wrap)', () => {
// wrap-ansi: "hello wo" at cols=8 stays as one line "hello wo".
// The old cursorLayout incorrectly pushed to (1,0) because column=8 hit
// the column>=width check, but that disagreed with what Ink actually
// rendered.
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 8, line: 0 })
expect(cursorLayout('hello wo', 8, 8)).toEqual(wrapAnsiEndPosition('hello wo', 8))
})
it('moves words across wrap boundaries instead of splitting them', () => {
// "hello wor" at cols=8: wrap-ansi breaks at the space, "hello \nwor".
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 3, line: 1 })
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 4, line: 1 })
expect(cursorLayout('hello world', 11, 8)).toEqual({ column: 5, line: 1 })
// Each must match what wrap-ansi would actually render.
expect(cursorLayout('hello wor', 9, 8)).toEqual(wrapAnsiEndPosition('hello wor', 8))
expect(cursorLayout('hello worl', 10, 8)).toEqual(wrapAnsiEndPosition('hello worl', 8))
expect(cursorLayout('hello world', 11, 8)).toEqual(wrapAnsiEndPosition('hello world', 8))
})
it('wraps the next word instead of splitting it at the right edge', () => {
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
expect(cursorLayout(text, text.length, 70)).toEqual({ column: 4, line: 1 })
expect(inputVisualHeight(text, 70)).toBe(2)
})
it('honours explicit newlines', () => {
expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 })
expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 })
})
it('does not wrap when cursor is before the right edge', () => {
expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 })
})
it('matches wrap-ansi end-position for typing-style incremental input', () => {
// Pins the actual fix: type a long message char-by-char at a narrow
// width and assert the cursor follows wrap-ansi every step of the way.
// Before the fix, ~5 boundary positions per pass disagreed and Ink
// parked the cursor several cells past the last rendered character.
const MSG = 'on a new bb branch investigate and fix the cursor drift bug here'
for (const cols of [10, 14, 20, 30, 50, 80]) {
let acc = ''
for (const ch of MSG) {
acc += ch
expect(cursorLayout(acc, acc.length, cols)).toEqual(wrapAnsiEndPosition(acc, cols))
}
}
})
})
describe('input metrics helpers', () => {
it('computes visual height matching wrap-ansi line count', () => {
// Exact-fill text stays on one line in wrap-ansi (no phantom wrap), so
// visual height is 1. The previous implementation reported 2 here.
expect(inputVisualHeight('abcdefgh', 8)).toBe(1)
expect(inputVisualHeight('one\ntwo', 40)).toBe(2)
// Multi-line wrap case sanity
expect(inputVisualHeight('hello world', 8)).toBe(2)
})
it('counts the prompt gap as its own cell', () => {
expect(composerPromptWidth('>')).toBe(2)
expect(composerPromptWidth('')).toBe(2)
expect(composerPromptWidth('Ψ >')).toBe(4)
})
it('reserves gutters on wide panes without starving narrow composer width', () => {
expect(stableComposerColumns(100, 3)).toBe(93)
expect(stableComposerColumns(100, 5)).toBe(91)
expect(stableComposerColumns(10, 3)).toBe(5)
expect(stableComposerColumns(6, 3)).toBe(1)
})
})
describe('offsetFromPosition — word-wrap inverse of cursorLayout', () => {
it('returns 0 for empty input', () => {
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
})
it('maps clicks within a single line', () => {
expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3)
})
it('maps clicks past end to value length', () => {
expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2)
})
it('maps clicks on a wrapped second row at cols boundary', () => {
// Long words still hard-wrap when there is no word boundary.
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
})
it('maps clicks on a word-wrapped second row', () => {
// "hello world" at cols=8 wraps to "hello \nworld".
expect(offsetFromPosition('hello world', 1, 0, 8)).toBe(6)
expect(offsetFromPosition('hello world', 1, 3, 8)).toBe(9)
})
it('maps clicks on the moved final word', () => {
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
expect(offsetFromPosition(text, 1, 0, 70)).toBe(text.indexOf('wish'))
expect(offsetFromPosition(text, 1, 3, 70)).toBe(text.indexOf('wish') + 3)
})
it('maps clicks past a \\n into the target line', () => {
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
})
})
+311
View File
@@ -0,0 +1,311 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
// `theme.js` reads `process.env` at module-load to compute DEFAULT_THEME,
// and `fromSkin` closes over DEFAULT_THEME. A developer shell with
// HERMES_TUI_THEME=light (or HERMES_TUI_BACKGROUND set to something
// bright) would flip the base and turn these assertions into a local-
// only failure. We sterilize the relevant env vars + dynamically
// import the module fresh so EVERY symbol that closes over the env
// (DEFAULT_THEME, DARK_THEME, LIGHT_THEME, fromSkin) is loaded against
// a known-empty environment.
//
// `detectLightMode` takes env as an explicit arg, so it's safe to import
// statically — but we stay consistent and dynamic-import it too.
const RELEVANT_ENV = [
'HERMES_TUI_LIGHT',
'HERMES_TUI_THEME',
'HERMES_TUI_BACKGROUND',
'COLORFGBG',
'COLORTERM',
'TERM_PROGRAM'
] as const
async function importThemeWithEnv(env: Partial<Record<(typeof RELEVANT_ENV)[number], string>> = {}) {
for (const key of RELEVANT_ENV) {
vi.stubEnv(key, env[key] ?? '')
}
vi.resetModules()
return import('../theme.js')
}
async function importThemeWithCleanEnv() {
return importThemeWithEnv()
}
afterEach(() => {
vi.unstubAllEnvs()
vi.resetModules()
})
describe('DEFAULT_THEME', () => {
it('has brand defaults', async () => {
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent')
expect(DEFAULT_THEME.brand.prompt).toBe('')
expect(DEFAULT_THEME.brand.tool).toBe('┊')
})
it('has color palette', async () => {
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
expect(DEFAULT_THEME.color.primary).toBe('#FFD700')
expect(DEFAULT_THEME.color.error).toBe('#ef5350')
})
})
describe('LIGHT_THEME', () => {
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', async () => {
const { LIGHT_THEME } = await importThemeWithCleanEnv()
expect(LIGHT_THEME.color.primary).not.toBe('#FFD700')
expect(LIGHT_THEME.color.accent).not.toBe('#FFBF00')
expect(LIGHT_THEME.color.muted).not.toBe('#B8860B')
expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700')
})
it('keeps the same shape as DARK_THEME', async () => {
const { DARK_THEME, LIGHT_THEME } = await importThemeWithCleanEnv()
expect(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort())
expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand)
})
})
describe('DEFAULT_THEME aliasing', () => {
it('defaults to DARK_THEME when nothing signals light', async () => {
const { DEFAULT_THEME, DARK_THEME: DARK } = await importThemeWithCleanEnv()
expect(DEFAULT_THEME).toBe(DARK)
})
})
describe('detectLightMode', () => {
it('returns false on empty env', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({})).toBe(false)
})
it('defaults Apple Terminal to light when no stronger signal is present', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(true)
})
it('honors HERMES_TUI_LIGHT on/off', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ HERMES_TUI_LIGHT: '1' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_LIGHT: 'true' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_LIGHT: 'on' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_LIGHT: '0' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_LIGHT: 'off' })).toBe(false)
})
it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ COLORFGBG: '0;15' })).toBe(true)
expect(detectLightMode({ COLORFGBG: '0;default;15' })).toBe(true)
expect(detectLightMode({ COLORFGBG: '0;7' })).toBe(true)
expect(detectLightMode({ COLORFGBG: '15;0' })).toBe(false)
expect(detectLightMode({ COLORFGBG: '7;default;0' })).toBe(false)
})
it('falls through on malformed COLORFGBG with empty/non-numeric trailing field', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
// `Number('')` is 0, so `'15;'` would have been read as bg=0
// (authoritative dark) and incorrectly blocked TERM_PROGRAM.
// The strict /^\d+$/ guard makes these fall through instead.
const allowList = new Set(['Apple_Terminal'])
expect(detectLightMode({ COLORFGBG: '15;', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
expect(detectLightMode({ COLORFGBG: 'default;default', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
// Without an allow-list match, fall-through still defaults to dark.
expect(detectLightMode({ COLORFGBG: '15;' })).toBe(false)
})
it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_LIGHT: '0' })).toBe(false)
})
it('honors HERMES_TUI_THEME=light/dark as a symmetric explicit override', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ HERMES_TUI_THEME: 'light' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_THEME: 'dark' })).toBe(false)
expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_THEME: 'dark' })).toBe(false)
expect(detectLightMode({ COLORFGBG: '15;0', HERMES_TUI_THEME: 'light' })).toBe(true)
})
it('uses HERMES_TUI_BACKGROUND luminance when COLORFGBG is missing', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#ffffff' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#000000' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#1e1e1e' })).toBe(false)
// Three-char hex normalises like CSS.
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fff' })).toBe(true)
// Garbage falls through to the default-dark path.
expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'not-a-colour' })).toBe(false)
})
it('rejects partially-invalid hex instead of silently truncating', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
// `parseInt('fffgff'.slice(2,4), 16)` would return 15 — the strict
// regex must reject these inputs so they fall through to default-
// dark instead of producing a false-positive light reading.
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffgff' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'ffggff' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#xyz' })).toBe(false)
// Wrong length also rejected (no implicit padding/truncation).
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffff' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffffff' })).toBe(false)
})
it('treats COLORFGBG as authoritative when present so it dominates the TERM_PROGRAM allow-list', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
// Injecting the allow-list keeps this precedence rule explicit even if
// production defaults change.
const allowList = new Set(['Apple_Terminal'])
// Sanity: the allow-list alone WOULD turn this terminal light.
expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
// Dark COLORFGBG must beat the allow-list.
expect(detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(false)
})
})
describe('fromSkin', () => {
// `fromSkin` closes over DEFAULT_THEME (which is env-derived), so we
// must dynamic-import it after sterilizing env — otherwise an ambient
// HERMES_TUI_THEME=light would flip the base palette and make these
// assertions order-dependent on the developer's shell.
it('overrides banner colors', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.primary).toBe('#FF0000')
})
it('preserves unset colors', async () => {
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent)
})
it('derives completion current background from resolved completion background', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
expect(theme.color.completionBg).toBe('#ffffff')
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: '$' })
expect(brand.name).toBe('TestBot')
expect(brand.prompt).toBe('$')
})
it('normalizes skin prompt symbols to trimmed single-line text', async () => {
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({}, { prompt_symbol: ' ⚔ \n' }).brand.prompt).toBe('⚔ ')
expect(fromSkin({}, { prompt_symbol: ' Ψ > \n' }).brand.prompt).toBe('Ψ >')
expect(fromSkin({}, { prompt_symbol: '\n\t' }).brand.prompt).toBe(DEFAULT_THEME.brand.prompt)
})
it('defaults for empty skin', async () => {
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color)
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon)
})
it('normalizes non-banner foregrounds on light Apple Terminal', async () => {
const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' })
const theme = fromSkin({
banner_accent: '#FFBF00',
banner_border: '#CD7F32',
banner_dim: '#B8860B',
banner_text: '#FFF8DC',
banner_title: '#FFD700',
prompt: '#FFF8DC'
}, {})
expect(theme.color.primary).toBe('#FFD700')
expect(theme.color.accent).toBe('#FFBF00')
expect(theme.color.border).toBe('#CD7F32')
expect(theme.color.muted).toBe('ansi256(245)')
expect(theme.color.text).toBe('ansi256(136)')
expect(theme.color.prompt).toBe('ansi256(136)')
})
it('does not normalize light Apple Terminal when truecolor is advertised', async () => {
const { fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' })
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
expect(theme.color.text).toBe('#FFF8DC')
})
it('normalizes Apple Terminal names before matching', async () => {
const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: ' Apple_Terminal ' })
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
expect(theme.color.text).toBe('ansi256(136)')
})
it('passes banner logo/hero', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerLogo).toBe('LOGO')
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO')
})
it('maps ui_ color keys + cascades to status', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ ui_ok: '#008000' }, {})
expect(color.ok).toBe('#008000')
expect(color.statusGood).toBe('#008000')
})
})
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
archiveDoneTodos,
archiveTodosAtTurnEnd,
getTurnState,
patchTurnState,
resetTurnState,
toggleTodoCollapsed
} from '../app/turnStore.js'
describe('turnStore live progress helpers', () => {
beforeEach(() => resetTurnState())
it('archives completed todos into a transcript trail and clears the live anchor', () => {
patchTurnState({
todos: [
{ content: 'prep', id: 'prep', status: 'completed' },
{ content: 'serve', id: 'serve', status: 'completed' }
]
})
expect(archiveTodosAtTurnEnd()).toEqual([
{
kind: 'trail',
role: 'system',
text: '',
todoCollapsedByDefault: true,
todos: [
{ content: 'prep', id: 'prep', status: 'completed' },
{ content: 'serve', id: 'serve', status: 'completed' }
]
}
])
expect(getTurnState().todos).toEqual([])
})
it('archives incomplete todos with an incomplete flag so the hint renders', () => {
patchTurnState({
todos: [
{ content: 'cook', id: 'cook', status: 'completed' },
{ content: 'serve', id: 'serve', status: 'in_progress' },
{ content: 'eat', id: 'eat', status: 'pending' }
]
})
const archived = archiveTodosAtTurnEnd()
expect(archived).toHaveLength(1)
expect(archived[0]!.todoIncomplete).toBe(true)
expect(archived[0]!.todos?.map(t => t.id)).toEqual(['cook', 'serve', 'eat'])
expect(getTurnState().todos).toEqual([])
})
it('returns nothing when there are no todos at turn end', () => {
expect(archiveTodosAtTurnEnd()).toEqual([])
expect(archiveDoneTodos()).toEqual([])
})
it('tracks collapsed state independently of todo content', () => {
toggleTodoCollapsed()
expect(getTurnState().todoCollapsed).toBe(true)
toggleTodoCollapsed()
expect(getTurnState().todoCollapsed).toBe(false)
})
})
@@ -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()
})
})
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { looksLikeDroppedPath } from '../app/useComposerState.js'
describe('looksLikeDroppedPath', () => {
it('recognizes macOS screenshot temp paths and file URIs', () => {
expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot\\ 2026-04-21\\ at\\ 1.04.43 PM.png')).toBe(
true
)
expect(
looksLikeDroppedPath('file:///var/folders/x/T/TemporaryItems/Screenshot%202026-04-21%20at%201.04.43%20PM.png')
).toBe(true)
})
it('rejects normal multiline or plain text paste', () => {
expect(looksLikeDroppedPath('hello world')).toBe(false)
expect(looksLikeDroppedPath('line one\nline two')).toBe(false)
})
it('recognizes common image file extensions', () => {
expect(looksLikeDroppedPath('/Users/me/Desktop/photo.jpg')).toBe(true)
expect(looksLikeDroppedPath('/Users/me/Desktop/diagram.png')).toBe(true)
expect(looksLikeDroppedPath('/tmp/capture.webp')).toBe(true)
expect(looksLikeDroppedPath('/tmp/image.gif')).toBe(true)
})
it('recognizes file:// URIs with various extensions', () => {
expect(looksLikeDroppedPath('file:///home/user/doc.pdf')).toBe(true)
expect(looksLikeDroppedPath('file:///tmp/screenshot.png')).toBe(true)
})
it('recognizes paths with spaces (not backslash-escaped)', () => {
expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot 2026-04-21 at 1.04.43 PM.png')).toBe(true)
})
it('rejects empty/whitespace-only input', () => {
expect(looksLikeDroppedPath('')).toBe(false)
expect(looksLikeDroppedPath(' ')).toBe(false)
expect(looksLikeDroppedPath('\n')).toBe(false)
})
it('rejects URLs that are not file:// URIs', () => {
expect(looksLikeDroppedPath('https://example.com/image.png')).toBe(false)
expect(looksLikeDroppedPath('http://localhost/file.pdf')).toBe(false)
})
it('rejects short slash-like strings without path structure', () => {
// No second '/' or '.' → not a plausible file path
expect(looksLikeDroppedPath('/help')).toBe(false)
expect(looksLikeDroppedPath('/model sonnet')).toBe(false)
expect(looksLikeDroppedPath('/api')).toBe(false)
})
it('accepts absolute paths with directory separators or extensions', () => {
expect(looksLikeDroppedPath('/usr/bin/test')).toBe(true)
expect(looksLikeDroppedPath('/tmp/file.txt')).toBe(true)
expect(looksLikeDroppedPath('/etc/hosts')).toBe(true) // has second /
})
})
+460
View File
@@ -0,0 +1,460 @@
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(() => {
resetUiState()
})
it('fans every display flag out to $uiState and the bell callback', () => {
const setBell = vi.fn()
applyDisplay(
{
config: {
display: {
bell_on_complete: true,
details_mode: 'expanded',
inline_diffs: false,
show_cost: true,
show_reasoning: true,
streaming: false,
tui_compact: true,
tui_statusbar: false
}
}
},
setBell
)
const s = $uiState.get()
expect(setBell).toHaveBeenCalledWith(true)
expect(s.compact).toBe(true)
expect(s.detailsMode).toBe('expanded')
expect(s.inlineDiffs).toBe(false)
expect(s.showCost).toBe(true)
expect(s.showReasoning).toBe(true)
expect(s.statusBar).toBe('off')
expect(s.streaming).toBe(false)
})
it('coerces legacy true + "on" alias to top', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { tui_statusbar: true as unknown as 'on' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
applyDisplay({ config: { display: { tui_statusbar: 'on' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
})
it('applies v1 parity defaults when display fields are missing', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: {} } }, setBell)
const s = $uiState.get()
expect(setBell).toHaveBeenCalledWith(false)
expect(s.inlineDiffs).toBe(true)
expect(s.showCost).toBe(false)
expect(s.showReasoning).toBe(false)
expect(s.statusBar).toBe('top')
expect(s.streaming).toBe(true)
expect(s.sections).toEqual({})
})
it('uses documented mouse_tracking with legacy tui_mouse fallback', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { mouse_tracking: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe('off')
applyDisplay({ config: { display: { mouse_tracking: true, tui_mouse: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe('all')
applyDisplay({ config: { display: { tui_mouse: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe('off')
})
it('threads mouse_tracking presets through to $uiState', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { mouse_tracking: 'wheel' } } }, setBell)
expect($uiState.get().mouseTracking).toBe('wheel')
applyDisplay({ config: { display: { mouse_tracking: 'buttons' } } }, setBell)
expect($uiState.get().mouseTracking).toBe('buttons')
applyDisplay({ config: { display: { mouse_tracking: 'all' } } }, setBell)
expect($uiState.get().mouseTracking).toBe('all')
})
it('parses display.sections into per-section overrides', () => {
const setBell = vi.fn()
applyDisplay(
{
config: {
display: {
details_mode: 'collapsed',
sections: {
activity: 'hidden',
tools: 'expanded',
thinking: 'expanded',
bogus: 'expanded'
}
}
}
},
setBell
)
const s = $uiState.get()
expect(s.detailsMode).toBe('collapsed')
expect(s.sections).toEqual({
activity: 'hidden',
tools: 'expanded',
thinking: 'expanded'
})
})
it('drops invalid section modes', () => {
const setBell = vi.fn()
applyDisplay(
{
config: {
display: {
sections: { tools: 'maximised' as unknown as string, activity: 'hidden' }
}
}
},
setBell
)
expect($uiState.get().sections).toEqual({ activity: 'hidden' })
})
it('treats a null config like an empty display block', () => {
const setBell = vi.fn()
applyDisplay(null, setBell)
const s = $uiState.get()
expect(setBell).toHaveBeenCalledWith(false)
expect(s.inlineDiffs).toBe(true)
expect(s.streaming).toBe(true)
})
it('accepts the new string statusBar modes', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { tui_statusbar: 'bottom' } } }, setBell)
expect($uiState.get().statusBar).toBe('bottom')
applyDisplay({ config: { display: { tui_statusbar: 'top' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
})
})
describe('normalizeStatusBar', () => {
it('maps legacy bool + on alias to top/off', () => {
expect(normalizeStatusBar(true)).toBe('top')
expect(normalizeStatusBar(false)).toBe('off')
expect(normalizeStatusBar('on')).toBe('top')
})
it('passes through the canonical enum', () => {
expect(normalizeStatusBar('off')).toBe('off')
expect(normalizeStatusBar('top')).toBe('top')
expect(normalizeStatusBar('bottom')).toBe('bottom')
})
it('defaults missing/unknown values to top', () => {
expect(normalizeStatusBar(undefined)).toBe('top')
expect(normalizeStatusBar(null)).toBe('top')
expect(normalizeStatusBar('sideways')).toBe('top')
expect(normalizeStatusBar(42)).toBe('top')
})
it('trims whitespace and folds case', () => {
expect(normalizeStatusBar(' Bottom ')).toBe('bottom')
expect(normalizeStatusBar('TOP')).toBe('top')
expect(normalizeStatusBar(' on ')).toBe('top')
expect(normalizeStatusBar('OFF')).toBe('off')
})
})
describe('normalizeMouseTracking', () => {
it('defaults to all and prefers canonical mouse_tracking over legacy tui_mouse', () => {
expect(normalizeMouseTracking({})).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: false })).toBe('off')
expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe('off')
expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe('off')
expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe('off')
expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe('all')
expect(normalizeMouseTracking({ tui_mouse: false })).toBe('off')
})
it('accepts preset strings (wheel/buttons/all) and their aliases', () => {
expect(normalizeMouseTracking({ mouse_tracking: 'wheel' })).toBe('wheel')
expect(normalizeMouseTracking({ mouse_tracking: 'scroll' })).toBe('wheel')
expect(normalizeMouseTracking({ mouse_tracking: 'buttons' })).toBe('buttons')
expect(normalizeMouseTracking({ mouse_tracking: 'click' })).toBe('buttons')
expect(normalizeMouseTracking({ mouse_tracking: 'all' })).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: 'full' })).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: 'on' })).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: ' WHEEL ' })).toBe('wheel')
})
it('falls back to all for unknown strings', () => {
expect(normalizeMouseTracking({ mouse_tracking: 'rainbows' })).toBe('all')
})
})
describe('normalizeBusyInputMode', () => {
it('passes through the canonical CLI parity values', () => {
expect(normalizeBusyInputMode('queue')).toBe('queue')
expect(normalizeBusyInputMode('steer')).toBe('steer')
expect(normalizeBusyInputMode('interrupt')).toBe('interrupt')
})
it('trims and lowercases input', () => {
expect(normalizeBusyInputMode(' Queue ')).toBe('queue')
expect(normalizeBusyInputMode('STEER')).toBe('steer')
})
it('defaults to queue for missing/unknown values (TUI-only override)', () => {
// CLI / messaging adapters keep `interrupt` as the framework default
// (see hermes_cli/config.py + tui_gateway/server.py::_load_busy_input_mode);
// the TUI ships `queue` because typing a follow-up while the agent
// streams is the common authoring pattern and an unintended interrupt
// loses work.
expect(normalizeBusyInputMode(undefined)).toBe('queue')
expect(normalizeBusyInputMode(null)).toBe('queue')
expect(normalizeBusyInputMode('')).toBe('queue')
expect(normalizeBusyInputMode('drop')).toBe('queue')
expect(normalizeBusyInputMode(42)).toBe('queue')
})
})
describe('normalizeIndicatorStyle', () => {
it('passes through the canonical enum', () => {
expect(normalizeIndicatorStyle('kaomoji')).toBe('kaomoji')
expect(normalizeIndicatorStyle('emoji')).toBe('emoji')
expect(normalizeIndicatorStyle('unicode')).toBe('unicode')
expect(normalizeIndicatorStyle('ascii')).toBe('ascii')
})
it('trims and lowercases input', () => {
expect(normalizeIndicatorStyle(' Emoji ')).toBe('emoji')
expect(normalizeIndicatorStyle('UNICODE')).toBe('unicode')
})
it('defaults to kaomoji for missing/unknown values', () => {
expect(normalizeIndicatorStyle(undefined)).toBe('kaomoji')
expect(normalizeIndicatorStyle(null)).toBe('kaomoji')
expect(normalizeIndicatorStyle('')).toBe('kaomoji')
expect(normalizeIndicatorStyle('sparkle')).toBe('kaomoji')
expect(normalizeIndicatorStyle(42)).toBe('kaomoji')
})
})
describe('applyDisplay → busy_input_mode', () => {
beforeEach(() => {
resetUiState()
})
it('threads display.busy_input_mode into $uiState', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { busy_input_mode: 'queue' } } }, setBell)
expect($uiState.get().busyInputMode).toBe('queue')
applyDisplay({ config: { display: { busy_input_mode: 'steer' } } }, setBell)
expect($uiState.get().busyInputMode).toBe('steer')
})
it('falls back to queue when value is missing or invalid (TUI-only default)', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: {} } }, setBell)
expect($uiState.get().busyInputMode).toBe('queue')
applyDisplay({ config: { display: { busy_input_mode: 'drop' } } }, setBell)
expect($uiState.get().busyInputMode).toBe('queue')
})
})
describe('applyDisplay → tui_status_indicator', () => {
beforeEach(() => {
resetUiState()
})
it('threads display.tui_status_indicator into $uiState', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { tui_status_indicator: 'emoji' } } }, setBell)
expect($uiState.get().indicatorStyle).toBe('emoji')
applyDisplay({ config: { display: { tui_status_indicator: 'unicode' } } }, setBell)
expect($uiState.get().indicatorStyle).toBe('unicode')
})
it('falls back to kaomoji default when missing or invalid', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: {} } }, setBell)
expect($uiState.get().indicatorStyle).toBe('kaomoji')
applyDisplay({ config: { display: { tui_status_indicator: 'rainbow' } } }, setBell)
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)
})
})
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import { applyVoiceRecordResponse, shouldFallThroughForScroll } from '../app/useInputHandlers.js'
const baseKey = {
downArrow: false,
pageDown: false,
pageUp: false,
shift: false,
upArrow: false,
wheelDown: false,
wheelUp: false
}
describe('shouldFallThroughForScroll — keep transcript scrolling alive during prompt overlays', () => {
it('falls through for wheel scrolls', () => {
expect(shouldFallThroughForScroll({ ...baseKey, wheelUp: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, wheelDown: true })).toBe(true)
})
it('falls through for PageUp / PageDown', () => {
expect(shouldFallThroughForScroll({ ...baseKey, pageUp: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, pageDown: true })).toBe(true)
})
it('falls through for Shift+ArrowUp / Shift+ArrowDown', () => {
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, upArrow: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, downArrow: true })).toBe(true)
})
it('does NOT fall through for plain arrows — those drive in-prompt selection', () => {
expect(shouldFallThroughForScroll({ ...baseKey, upArrow: true })).toBe(false)
expect(shouldFallThroughForScroll({ ...baseKey, downArrow: true })).toBe(false)
})
it('does NOT fall through for plain Shift — without an arrow it is a no-op', () => {
expect(shouldFallThroughForScroll({ ...baseKey, shift: true })).toBe(false)
})
it('does NOT fall through for unrelated state (no scroll keys held)', () => {
expect(shouldFallThroughForScroll(baseKey)).toBe(false)
})
})
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)
})
})
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { removeAtInPlace } from '../hooks/useQueue.js'
describe('removeAtInPlace', () => {
it('removes the item at the given index in place', () => {
const arr = ['a', 'b', 'c']
removeAtInPlace(arr, 1)
expect(arr).toEqual(['a', 'c'])
})
it('is a no-op when the index is out of bounds', () => {
const arr = ['a', 'b']
removeAtInPlace(arr, -1)
removeAtInPlace(arr, 5)
expect(arr).toEqual(['a', 'b'])
})
it('returns the same reference (mutates in place)', () => {
const arr = ['x']
const same = removeAtInPlace(arr, 0)
expect(same).toBe(arr)
expect(arr).toEqual([])
})
})
@@ -0,0 +1,60 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
import { hydrateLiveSessionInflight, liveSessionInflightMessages, writeActiveSessionFile } from '../app/useSessionLifecycle.js'
describe('writeActiveSessionFile', () => {
let dir = ''
afterEach(() => {
if (dir) {
rmSync(dir, { force: true, recursive: true })
dir = ''
}
})
it('writes the actual resumed session id for the shell exit summary', () => {
dir = mkdtempSync(join(tmpdir(), 'hermes-tui-active-'))
const path = join(dir, 'active.json')
writeActiveSessionFile('actual_session', path)
expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ session_id: 'actual_session' })
})
})
describe('live session activation in-flight state', () => {
beforeEach(() => {
resetUiState()
resetTurnState()
turnController.fullReset()
patchUiState({ streaming: true })
})
it('keeps the in-flight user prompt in history and hydrates partial assistant text', () => {
const inflight = { assistant: 'partial answer', streaming: true, user: 'write a long answer' }
expect(liveSessionInflightMessages(inflight)).toEqual([{ role: 'user', text: 'write a long answer' }])
hydrateLiveSessionInflight(inflight)
expect(turnController.bufRef).toBe('partial answer')
expect(getTurnState().streaming).toBe('partial answer')
})
it('ignores empty in-flight payloads', () => {
expect(liveSessionInflightMessages({ assistant: '', streaming: false, user: ' ' })).toEqual([])
hydrateLiveSessionInflight({ assistant: '', streaming: false, user: '' })
expect(turnController.bufRef).toBe('')
expect(getTurnState().streaming).toBe('')
})
})
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureVirtualItemHeight } from '../hooks/useVirtualHistory.js'
describe('ensureVirtualItemHeight', () => {
it('reuses cached heights without invoking the estimator', () => {
const heights = new Map([['a', 7]])
const estimateHeight = vi.fn(() => 99)
expect(ensureVirtualItemHeight(heights, 'a', 0, 4, estimateHeight)).toBe(7)
expect(estimateHeight).not.toHaveBeenCalled()
expect(heights.get('a')).toBe(7)
})
it('lazily seeds missing heights from the estimator', () => {
const heights = new Map<string, number>()
const estimateHeight = vi.fn((index: number) => 10 + index)
expect(ensureVirtualItemHeight(heights, 'b', 2, 4, estimateHeight)).toBe(12)
expect(estimateHeight).toHaveBeenCalledTimes(1)
expect(estimateHeight).toHaveBeenCalledWith(2, 'b')
expect(heights.get('b')).toBe(12)
})
it('falls back to the default estimate when no estimator is provided', () => {
const heights = new Map<string, number>()
expect(ensureVirtualItemHeight(heights, 'c', 0, 4)).toBe(4)
expect(heights.get('c')).toBe(4)
})
it('normalizes non-positive estimates to a minimum of one row', () => {
const heights = new Map<string, number>()
const estimateHeight = vi.fn(() => 0)
expect(ensureVirtualItemHeight(heights, 'd', 0, 0, estimateHeight)).toBe(1)
expect(heights.get('d')).toBe(1)
})
})
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { stickyPromptFromViewport } from '../domain/viewport.js'
describe('stickyPromptFromViewport', () => {
it('hides the sticky prompt when a newer user message is already visible', () => {
const messages = [
{ role: 'user' as const, text: 'older prompt' },
{ role: 'assistant' as const, text: 'older answer' },
{ role: 'user' as const, text: 'current prompt' },
{ role: 'assistant' as const, text: 'current answer' }
]
const offsets = [0, 2, 10, 12, 20]
expect(stickyPromptFromViewport(messages, offsets, 8, 16, false)).toBe('')
})
it('shows the latest user message above the viewport when no user message is visible', () => {
const messages = [
{ role: 'user' as const, text: 'older prompt' },
{ role: 'assistant' as const, text: 'older answer' },
{ role: 'user' as const, text: 'current prompt' },
{ role: 'assistant' as const, text: 'current answer' }
]
const offsets = [0, 2, 10, 12, 20]
expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt')
})
it('shows the last prompt once the viewport starts after the history tail', () => {
const messages = [
{ role: 'user' as const, text: 'current prompt' },
{ role: 'assistant' as const, text: 'completed answer' }
]
expect(stickyPromptFromViewport(messages, [0, 2, 5], 8, 14, false)).toBe('current prompt')
})
it('shows a prompt as soon as its full row is above the viewport', () => {
const messages = [
{ role: 'user' as const, text: 'current prompt' },
{ role: 'assistant' as const, text: 'current answer' }
]
expect(stickyPromptFromViewport(messages, [0, 2, 10], 2, 8, false)).toBe('current prompt')
})
it('hides the sticky prompt at the bottom', () => {
const messages = [
{ role: 'user' as const, text: 'current prompt' },
{ role: 'assistant' as const, text: 'current answer' }
]
expect(stickyPromptFromViewport(messages, [0, 2, 10], 8, 10, true)).toBe('')
})
})
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js'
describe('viewportStore', () => {
it('normalizes absent scroll handles', () => {
expect(getViewportSnapshot(null)).toEqual({
atBottom: true,
bottom: 0,
pending: 0,
scrollHeight: 0,
top: 0,
viewportHeight: 0
})
})
it('includes pending scroll delta in snapshot math and keying', () => {
const handle = {
getPendingDelta: () => 3,
getScrollHeight: () => 40,
getScrollTop: () => 10,
getViewportHeight: () => 5,
isSticky: () => false
}
const snap = getViewportSnapshot(handle as any)
expect(snap).toMatchObject({
atBottom: false,
bottom: 18,
pending: 3,
scrollHeight: 40,
top: 13,
viewportHeight: 5
})
expect(viewportSnapshotKey(snap)).toBe('0:16:5:40:3')
})
it('uses fresh scroll height to clear stale non-bottom state', () => {
const handle = {
getFreshScrollHeight: () => 20,
getPendingDelta: () => 0,
getScrollHeight: () => 40,
getScrollTop: () => 15,
getViewportHeight: () => 5,
isSticky: () => false
}
const snap = getViewportSnapshot(handle as any)
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)
})
})
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { estimatedMsgHeight, messageHeightKey, wrappedLines } from '../lib/virtualHeights.js'
import type { Msg } from '../types.js'
describe('virtual height estimates', () => {
it('uses stable content keys across resumed message objects', () => {
const msg: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
expect(messageHeightKey(msg)).toBe(messageHeightKey({ ...msg }))
})
it('accounts for wrapping and preserved blank-block rhythm', () => {
const msg: Msg = { role: 'assistant', text: `one\n\n${'x'.repeat(90)}` }
expect(wrappedLines(msg.text, 30)).toBe(5)
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'] }
expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBeGreaterThan(
estimatedMsgHeight(msg, 80, { compact: false, details: false })
)
})
it('accounts for the response separator when assistant details are visible', () => {
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' }
expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe(
estimatedMsgHeight(msg, 80, { compact: false, details: false }) + 3
)
})
it('does not account for a response separator without visible details', () => {
const msg: Msg = { role: 'assistant', text: 'ok' }
expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe(
estimatedMsgHeight(msg, 80, { compact: false, details: false })
)
})
it('honors per-section visibility when estimating response separators', () => {
const thinkingOnly: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' }
const toolsOnly: Msg = { role: 'assistant', text: 'ok', tools: ['Tool A'] }
expect(
estimatedMsgHeight(thinkingOnly, 80, {
compact: false,
details: true,
thinkingVisible: false,
toolsVisible: true
})
).toBe(estimatedMsgHeight(thinkingOnly, 80, { compact: false, details: false }))
expect(
estimatedMsgHeight(toolsOnly, 80, {
compact: false,
details: true,
thinkingVisible: true,
toolsVisible: false
})
).toBe(estimatedMsgHeight(toolsOnly, 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)
})
it('caps wrapped-line counting so giant assistant turns do not block offset rebuilds', () => {
// wrappedLines is invoked once per uncached message during
// useVirtualHistory's offset rebuild. Unbounded counting on a long
// assistant response (10k+ chars × every row × every rebuild) blocks
// the UI on cold mount. Cap is ~800 rows; post-mount Yoga
// measurement converges to the true height regardless.
const giant = 'x'.repeat(1_000_000)
const t0 = performance.now()
const rows = wrappedLines(giant, 80)
const elapsed = performance.now() - t0
expect(rows).toBeLessThanOrEqual(800)
expect(elapsed).toBeLessThan(50)
})
})
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { shouldSetVirtualClamp } from '../hooks/useVirtualHistory.js'
describe('virtual history clamp bounds', () => {
it('does not clamp sticky live tail content', () => {
expect(shouldSetVirtualClamp({ itemCount: 20, sticky: true, viewportHeight: 10 })).toBe(false)
})
it('sets clamp bounds after manual scroll breaks sticky mode', () => {
expect(shouldSetVirtualClamp({ itemCount: 20, sticky: false, viewportHeight: 10 })).toBe(true)
})
it('does not clamp while a live tail is growing below virtual history', () => {
expect(shouldSetVirtualClamp({ itemCount: 20, liveTailActive: true, sticky: false, viewportHeight: 10 })).toBe(
false
)
})
})
@@ -0,0 +1,282 @@
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, virtualHistorySnapshotKey } from '../hooks/useVirtualHistory.js'
interface Item {
height: number
heightAfterResize?: 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
}
const itemHeightForColumns = (item: Item | undefined, columns: number) =>
columns >= 80 ? (item?.heightAfterResize ?? item?.height ?? 1) : (item?.height ?? 1)
function Harness({
columns = 80,
expose,
height = 10,
items,
maxMounted = 16
}: {
columns?: number
expose: React.MutableRefObject<Exposed | null>
height?: number
items: readonly Item[]
maxMounted?: number
}) {
const scrollRef = useRef<ScrollBoxHandle | null>(null)
const virtualHistory = useVirtualHistory(scrollRef, items, columns, {
coldStartCount: 16,
estimateHeight: index => itemHeightForColumns(items[index], columns),
maxMounted,
overscan: 2
})
useLayoutEffect(() => {
expose.current = { scroll: scrollRef.current, virtualHistory }
})
return React.createElement(
ScrollBox,
{ flexDirection: 'column', height, 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: itemHeightForColumns(item, columns),
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('includes viewport height in the external-store snapshot key', () => {
const base = {
getPendingDelta: () => 0,
getScrollTop: () => 20,
isSticky: () => false
}
const short = virtualHistorySnapshotKey({
...base,
getViewportHeight: () => 5
} as ScrollBoxHandle)
const tall = virtualHistorySnapshotKey({
...base,
getViewportHeight: () => 25
} as ScrollBoxHandle)
expect(short).not.toBe(tall)
})
it('remounts enough tail rows after the scroll viewport grows', async () => {
const items = Array.from({ length: 100 }, (_, index) => ({ height: 1, key: `item-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(React.createElement(Harness, { expose, height: 4, items, maxMounted: 80 }), {
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, height: 9, items, maxMounted: 80 }))
await delay(80)
expect(viewportIsMounted(items, expose.current!.virtualHistory, expose.current!.scroll!)).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('recomputes tail coverage when wrapped rows shrink after a width resize', async () => {
const items = Array.from({ length: 100 }, (_, index) => ({
height: 4,
heightAfterResize: 1,
key: `item-${index}`
}))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(
React.createElement(Harness, { columns: 40, expose, height: 10, items, maxMounted: 80 }),
{
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, { columns: 80, expose, height: 10, items, maxMounted: 80 }))
await delay(80)
const resizedItems = items.map(item => ({ height: item.heightAfterResize!, key: item.key }))
expect(viewportIsMounted(resizedItems, expose.current!.virtualHistory, expose.current!.scroll!)).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('keeps sticky scroll at the bottom when one tall tail row resizes', async () => {
const items = [{ height: 90, heightAfterResize: 50, key: 'tail' }]
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(
React.createElement(Harness, { columns: 70, expose, height: 18, items, maxMounted: 80 }),
{
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, { columns: 120, expose, height: 36, items, maxMounted: 80 }))
await delay(80)
const scroll = expose.current!.scroll!
expect(scroll.getScrollTop()).toBe(scroll.getScrollHeight() - scroll.getViewportHeight())
} finally {
instance.unmount()
instance.cleanup()
}
})
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()
}
})
})
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import { computeWheelStep, initWheelAccel } from '../lib/wheelAccel.js'
describe('wheelAccel — native path', () => {
it('first click after init returns base', () => {
const s = initWheelAccel(false, 1)
expect(computeWheelStep(s, 1, 1000)).toBe(1)
})
it('same-direction fast events ramp mult (window-mode)', () => {
const s = initWheelAccel(false, 1)
computeWheelStep(s, 1, 1000)
computeWheelStep(s, 1, 1020)
computeWheelStep(s, 1, 1040)
// Key property: doesn't shrink below base.
expect(computeWheelStep(s, 1, 1060)).toBeGreaterThanOrEqual(1)
})
it('gap beyond window resets mult to base', () => {
const s = initWheelAccel(false, 1)
for (let t = 1000; t < 1100; t += 20) {
computeWheelStep(s, 1, t)
}
expect(computeWheelStep(s, 1, 2000)).toBe(1)
})
it('direction flip defers one event for bounce detection', () => {
const s = initWheelAccel(false, 1)
computeWheelStep(s, 1, 1000)
expect(computeWheelStep(s, -1, 1050)).toBe(0)
})
it('flip-back within bounce window engages wheelMode', () => {
const s = initWheelAccel(false, 1)
computeWheelStep(s, 1, 1000)
computeWheelStep(s, -1, 1050)
computeWheelStep(s, 1, 1100)
expect(s.wheelMode).toBe(true)
})
it('flip-back outside bounce window is a real reversal (no wheelMode)', () => {
const s = initWheelAccel(false, 1)
computeWheelStep(s, 1, 1000)
computeWheelStep(s, -1, 1050)
computeWheelStep(s, 1, 1400)
expect(s.wheelMode).toBe(false)
})
it('5 consecutive sub-5ms events disengage wheelMode (trackpad signature)', () => {
const s = initWheelAccel(false, 1)
s.wheelMode = true
s.dir = 1
s.time = 1000
for (let t = 1002; t <= 1010; t += 2) {
computeWheelStep(s, 1, t)
}
expect(s.wheelMode).toBe(false)
})
it('1.5s idle disengages wheelMode', () => {
const s = initWheelAccel(false, 1)
s.wheelMode = true
s.dir = 1
s.time = 1000
computeWheelStep(s, 1, 3000)
expect(s.wheelMode).toBe(false)
})
})
describe('wheelAccel — xterm.js path', () => {
it('first click returns 2 after long idle', () => {
const s = initWheelAccel(true, 1)
expect(computeWheelStep(s, 1, 1000)).toBeGreaterThanOrEqual(1)
})
it('sub-5ms burst returns 1 (same-direction, same-batch)', () => {
const s = initWheelAccel(true, 1)
computeWheelStep(s, 1, 1000)
expect(computeWheelStep(s, 1, 1002)).toBe(1)
})
it('slow steady scroll stays in precision range', () => {
const s = initWheelAccel(true, 1)
for (let t = 1000; t < 2000; t += 33) {
const r = computeWheelStep(s, 1, t)
expect(r).toBeGreaterThanOrEqual(1)
expect(r).toBeLessThanOrEqual(6)
}
})
it('direction reversal resets mult', () => {
const s = initWheelAccel(true, 1)
for (let t = 1000; t < 1100; t += 20) {
computeWheelStep(s, 1, t)
}
const beforeFlip = s.mult
computeWheelStep(s, -1, 1200)
expect(s.mult).toBeLessThanOrEqual(beforeFlip)
expect(s.mult).toBe(2)
})
it('frac stays in [0,1) across events', () => {
const s = initWheelAccel(true, 1)
// Correctness invariant of fractional carry: never negative, never reaches 1.
for (let t = 1000; t < 1200; t += 30) {
computeWheelStep(s, 1, t)
expect(s.frac).toBeGreaterThanOrEqual(0)
expect(s.frac).toBeLessThan(1)
}
})
})
+25
View File
@@ -0,0 +1,25 @@
import { useStore } from '@nanostores/react'
import { GatewayProvider } from './app/gatewayContext.js'
import { $uiState } from './app/uiStore.js'
import { useMainApp } from './app/useMainApp.js'
import { AppLayout } from './components/appLayout.js'
import type { GatewayClient } from './gatewayClient.js'
export function App({ gw }: { gw: GatewayClient }) {
const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw)
const { mouseTracking } = useStore($uiState)
return (
<GatewayProvider value={gateway}>
<AppLayout
actions={appActions}
composer={appComposer}
mouseTracking={mouseTracking}
progress={appProgress}
status={appStatus}
transcript={appTranscript}
/>
</GatewayProvider>
)
}
+833
View File
@@ -0,0 +1,833 @@
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 type {
CommandsCatalogResponse,
ConfigFullResponse,
DelegationStatusResponse,
GatewayEvent,
GatewaySkin,
SessionMostRecentResponse
} from '../gatewayTypes.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatToolCall, stripAnsi } from '../lib/text.js'
import { fromSkin } from '../theme.js'
import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { getOverlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
const applySkin = (s: GatewaySkin) =>
patchUiState({
theme: fromSkin(
s.colors ?? {},
s.branding ?? {},
s.banner_logo ?? '',
s.banner_hero ?? '',
s.tool_prefix ?? '',
s.help_header ?? ''
)
})
const dropBgTask = (taskId: string) =>
patchUiState(state => {
const next = new Set(state.bgTasks)
next.delete(taskId)
return { ...state, bgTasks: next }
})
const pushUnique =
(max: number) =>
<T>(xs: T[], x: T): T[] =>
xs.at(-1) === x ? xs : [...xs, x].slice(-max)
const pushThinking = pushUnique(6)
const pushNote = pushUnique(6)
const pushTool = pushUnique(8)
const KNOWN_SUBAGENT_STATUSES = new Set<SubagentStatus>([
'completed',
'error',
'failed',
'interrupted',
'queued',
'running',
'timeout'
])
const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => {
if (typeof status !== 'string') {
return fallback
}
const normalized = status.toLowerCase() as SubagentStatus
return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback
}
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
const { rpc } = ctx.gateway
const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session
const { bellOnComplete, stdout, sys } = ctx.system
const { appendMessage, panel, setHistoryItems } = ctx.transcript
const { setInput } = ctx.composer
const { submitRef } = ctx.submission
const { setProcessing: setVoiceProcessing, setRecording: setVoiceRecording, setVoiceEnabled } = ctx.voice
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.
turnController.persistSpawnTree = async (subagents, sessionId) => {
try {
const startedAt = subagents.reduce<number>((min, s) => {
if (!s.startedAt) {
return min
}
return min === 0 ? s.startedAt : Math.min(min, s.startedAt)
}, 0)
const top = topLevelSubagents(subagents)
.map(s => s.goal)
.filter(Boolean)
.slice(0, 2)
const label = top.length ? top.join(' · ') : `${subagents.length} subagents`
await rpc('spawn_tree.save', {
finished_at: Date.now() / 1000,
label: label.slice(0, 120),
session_id: sessionId ?? 'default',
started_at: startedAt ? startedAt / 1000 : null,
subagents
})
} catch {
// Persistence is best-effort; in-memory history is the authoritative
// same-session source. A write failure doesn't block the turn.
}
}
// Refresh delegation caps at most every 5s so the status bar HUD can
// render a /warning close to the configured cap without spamming the RPC.
let lastDelegationFetchAt = 0
// ── Shared full-config read ──────────────────────────────────────────
//
// Several concerns need `display.*` flags at startup (the /agents nudge
// gate below, the auto-resume check in the `gateway.ready` handler).
// Memoize the `config.get full` RPC so we make exactly one round-trip
// instead of one per concern. Resolves to null on RPC failure; callers
// treat null as "use defaults".
let fullConfigPromise: null | Promise<ConfigFullResponse | null> = null
const getFullConfigOnce = (): Promise<ConfigFullResponse | null> => {
fullConfigPromise ??= rpc<ConfigFullResponse>('config.get', { key: 'full' }).catch(() => null)
return fullConfigPromise
}
// ── Nudge toward /agents on delegation ───────────────────────────────
//
// When `display.tui_agents_nudge` is enabled (default true), the first
// time a turn starts delegating we drop a single transient activity hint
// ("subagents working · /agents to watch live") so the user discovers the
// spawn-tree dashboard instead of staring at a quiet transcript — without
// hijacking the screen by force-opening an overlay. Guards:
// • fires at most once per turn (`agentsNudgedThisTurn`)
// • silent if the overlay is already open (nothing to advertise)
// Reset on `message.start`. The config flag is fetched once, lazily;
// until it resolves we assume the default (on).
let agentsNudgeEnabled = true
let agentsNudgeConfigFetched = false
let agentsNudgedThisTurn = false
const ensureAgentsNudgeConfig = () => {
if (agentsNudgeConfigFetched) {
return
}
agentsNudgeConfigFetched = true
getFullConfigOnce().then(cfg => {
// Only an explicit `false` disables it; absent/unknown keeps default on.
if (cfg?.config?.display?.tui_agents_nudge === false) {
agentsNudgeEnabled = false
}
})
}
const maybeNudgeAgents = () => {
ensureAgentsNudgeConfig()
if (!agentsNudgeEnabled || agentsNudgedThisTurn) {
return
}
// Already watching → no point advertising the dashboard. Don't burn the
// turn's nudge credit here: if the user closes the overlay later in the
// same turn while delegation is still ongoing, a subsequent event should
// still be allowed to nudge. The flag is only set once we actually push.
if (getOverlayState().agents) {
return
}
agentsNudgedThisTurn = true
turnController.pushActivity('subagents working · /agents to watch live', 'info')
}
const resetAgentsNudgeTurnState = () => {
agentsNudgedThisTurn = false
}
// Kick off the config fetch eagerly at handler creation so the flag is
// resolved well before the first delegation of any real session (which
// only happens after gateway.ready + a user turn).
ensureAgentsNudgeConfig()
const refreshDelegationStatus = (force = false) => {
const now = Date.now()
if (!force && now - lastDelegationFetchAt < 5000) {
return
}
lastDelegationFetchAt = now
rpc<DelegationStatusResponse>('delegation.status', {})
.then(r => applyDelegationStatus(r))
.catch(() => {})
}
const setStatus = (status: string) => {
pendingThinkingStatus = ''
if (thinkingStatusTimer) {
clearTimeout(thinkingStatusTimer)
thinkingStatusTimer = null
}
patchUiState({ status })
}
const scheduleThinkingStatus = (status: string) => {
pendingThinkingStatus = status
if (thinkingStatusTimer) {
return
}
thinkingStatusTimer = setTimeout(() => {
thinkingStatusTimer = null
patchUiState({ status: pendingThinkingStatus || statusFromBusy() })
}, STREAM_BATCH_MS)
}
const restoreStatusAfter = (ms: number) => {
turnController.clearStatusTimer()
turnController.statusTimer = setTimeout(() => {
turnController.statusTimer = null
patchUiState({ status: statusFromBusy() })
}, 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
// terminal state from complete (failed/interrupted/timeout/error).
const isTerminalStatus = (s: SubagentProgress['status']) =>
s === 'completed' || s === 'error' || s === 'failed' || s === 'interrupted' || s === 'timeout'
const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running')
const handleReady = (skin?: GatewaySkin) => {
if (skin) {
applySkin(skin)
}
rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
return
}
setCatalog({
canon: (r.canon ?? {}) as Record<string, string>,
categories: r.categories ?? [],
pairs: r.pairs as [string, string][],
skillCount: (r.skill_count ?? 0) as number,
sub: (r.sub ?? {}) as Record<string, string[]>
})
if (r.warning) {
turnController.pushActivity(String(r.warning), 'warn')
}
})
.catch((e: unknown) => turnController.pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'info'))
if (STARTUP_RESUME_ID) {
patchUiState({ status: 'resuming…' })
resumeById(STARTUP_RESUME_ID)
scheduleStartupPrompt()
return
}
// Opt-in: when `display.tui_auto_resume_recent` is true, look up
// the most recent human-facing session and resume it instead of
// forging a brand-new one. Mirrors classic CLI's `hermes -c` /
// `hermes --tui` muscle memory and addresses the audit's "session
// unrecoverable after disconnection" gap. Default off so existing
// users aren't surprised. (Shares the memoized full-config read.)
getFullConfigOnce()
.then(cfg => {
if (!cfg?.config?.display?.tui_auto_resume_recent) {
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
return
}
return rpc<SessionMostRecentResponse>('session.most_recent', {}).then(r => {
const target = r?.session_id
if (target) {
patchUiState({ status: 'resuming most recent…' })
resumeById(target)
scheduleStartupPrompt()
return
}
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
})
})
.catch(() => {
patchUiState({ status: 'forging session…' })
newSession()
scheduleStartupPrompt()
})
}
return (ev: GatewayEvent) => {
const sid = getUiState().sid
if (ev.session_id && sid && ev.session_id !== sid && !ev.type.startsWith('gateway.')) {
return
}
switch (ev.type) {
case 'gateway.ready':
handleReady(ev.payload?.skin)
return
case 'skin.changed':
if (ev.payload) {
applySkin(ev.payload)
}
return
case 'session.info': {
const info = ev.payload
patchUiState(state => ({
...state,
info,
status: state.status === 'starting agent…' ? 'ready' : state.status,
usage: info.usage ? { ...state.usage, ...info.usage } : state.usage
}))
setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info } : m)))
return
}
case 'thinking.delta': {
if (!getUiState().busy) {
return
}
const text = ev.payload?.text
if (text !== undefined) {
const value = String(text)
scheduleThinkingStatus(value || statusFromBusy())
if (value) {
turnController.recordReasoningDelta(value)
}
}
return
}
case 'message.start':
resetAgentsNudgeTurnState()
turnController.startMessage()
return
case 'status.update': {
const p = ev.payload
if (!p?.text) {
return
}
if (p.kind === 'goal') {
sys(p.text)
const brief = p.text.startsWith('✓')
? '✓ goal complete'
: p.text.startsWith('↻')
? '↻ goal continuing'
: p.text.startsWith('⏸')
? '⏸ goal paused'
: 'ready'
setStatus(brief)
restoreStatusAfter(6000)
return
}
setStatus(p.text)
if (p.kind === 'compressing') {
sys(p.text)
return
}
if (!p.kind || p.kind === 'status') {
return
}
if (turnController.lastStatusNote !== p.text) {
turnController.lastStatusNote = p.text
turnController.pushActivity(
p.text,
p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info'
)
}
restoreStatusAfter(4000)
return
}
case 'gateway.stderr': {
const line = String(ev.payload.line).slice(0, 120)
turnController.pushActivity(line, 'info')
return
}
case 'browser.progress': {
const message = String(ev.payload?.message ?? '').trim()
if (message) {
sys(message)
}
return
}
case 'voice.status': {
// Continuous VAD loop reports its internal state so the status bar
// can show listening / transcribing / idle without polling.
const state = String(ev.payload?.state ?? '')
if (state === 'listening') {
setVoiceRecording(true)
setVoiceProcessing(false)
} else if (state === 'transcribing') {
setVoiceRecording(false)
setVoiceProcessing(true)
} else {
setVoiceRecording(false)
setVoiceProcessing(false)
}
return
}
case 'voice.transcript': {
// CLI parity: the 3-strikes silence detector flipped off automatically.
// Mirror that on the UI side and tell the user why the mode is off.
if (ev.payload?.no_speech_limit) {
setVoiceEnabled(false)
setVoiceRecording(false)
setVoiceProcessing(false)
sys('voice: no speech detected 3 times, continuous mode stopped')
return
}
const text = String(ev.payload?.text ?? '').trim()
if (!text) {
return
}
// CLI parity: _pending_input.put(transcript) unconditionally feeds
// the transcript to the agent as its next turn — draft handling
// doesn't apply because voice-mode users are speaking, not typing.
//
// We can't branch on composer input from inside a setInput updater
// (React strict mode double-invokes it, duplicating the submit).
// Just clear + defer submit so the cleared input is committed before
// submit reads it.
setInput('')
setTimeout(() => submitRef.current(text), 0)
return
}
case 'gateway.start_timeout': {
const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {}
const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : ''
setStatus('gateway startup timeout')
turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error')
// Surface the most useful stderr lines inline so users can tell
// "wrong python", "missing dep", and "config parse failure"
// apart without leaving the TUI. Filter blank rows BEFORE
// taking the last N so trailing empty lines in the buffer
// don't crowd out actual content; truncate to match the
// 120-char clip used for `gateway.stderr` activity entries.
const STDERR_LINE_CAP = 120
const STDERR_LINES_MAX = 8
const tailLines = (stderrTail ?? '')
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.slice(-STDERR_LINES_MAX)
for (const line of tailLines) {
turnController.pushActivity(line.slice(0, STDERR_LINE_CAP), 'error')
}
return
}
case 'gateway.protocol_error':
setStatus('protocol warning')
restoreStatusAfter(4000)
if (!turnController.protocolWarned) {
turnController.protocolWarned = true
turnController.pushActivity('protocol noise detected · /logs to inspect', 'info')
}
if (ev.payload?.preview) {
turnController.pushActivity(`protocol noise: ${String(ev.payload.preview).slice(0, 120)}`, 'info')
}
return
case 'reasoning.delta':
if (ev.payload?.text) {
turnController.recordReasoningDelta(ev.payload.text, Boolean(ev.payload.verbose))
}
return
case 'reasoning.available':
turnController.recordReasoningAvailable(String(ev.payload?.text ?? ''), Boolean(ev.payload?.verbose))
return
case 'tool.progress':
if (ev.payload?.preview && ev.payload.name) {
turnController.recordToolProgress(ev.payload.name, ev.payload.preview)
}
return
case 'tool.generating':
if (ev.payload?.name) {
turnController.pushTrail(`drafting ${ev.payload.name}`)
}
return
case 'tool.start':
turnController.recordTodos(ev.payload.todos)
turnController.recordToolStart(
ev.payload.tool_id,
ev.payload.name ?? 'tool',
ev.payload.context ?? '',
ev.payload.args_text ? stripAnsi(String(ev.payload.args_text)) : undefined
)
return
case 'tool.complete': {
const inlineDiffText =
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
const resultText = ev.payload.result_text ? stripAnsi(String(ev.payload.result_text)) : undefined
if (inlineDiffText) {
turnController.recordInlineDiffToolComplete(
inlineDiffText,
ev.payload.tool_id,
ev.payload.name,
ev.payload.error,
ev.payload.duration_s,
resultText
)
} else {
turnController.recordToolComplete(
ev.payload.tool_id,
ev.payload.name,
ev.payload.error,
ev.payload.summary,
ev.payload.duration_s,
ev.payload.todos,
resultText
)
}
return
}
case 'clarify.request':
patchOverlayState({
clarify: { choices: ev.payload.choices, question: ev.payload.question, requestId: ev.payload.request_id }
})
setStatus('waiting for input…')
return
case 'approval.request': {
const description = String(ev.payload.description ?? 'dangerous command')
patchOverlayState({ approval: { command: String(ev.payload.command ?? ''), description } })
setStatus('approval needed')
return
}
case 'sudo.request':
patchOverlayState({ sudo: { requestId: ev.payload.request_id } })
setStatus('sudo password needed')
return
case 'secret.request':
patchOverlayState({
secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id }
})
setStatus('secret input needed')
return
case 'background.complete':
dropBgTask(ev.payload.task_id)
sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`)
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.
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' }))
// First sign of delegation this turn → nudge toward /agents.
maybeNudgeAgents()
// Prime the status-bar HUD: fetch caps (once every 5s) so we can
// warn as depth/concurrency approaches the configured ceiling.
if (getDelegationState().maxSpawnDepth === null) {
refreshDelegationStatus(true)
} else {
refreshDelegationStatus()
}
return
case 'subagent.start':
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' }))
// `subagent.start` is the first delegation event the TUI reliably
// receives (the delegate callback drops `spawn_requested` in the
// CLI→gateway path), so nudge here too. Once-per-turn guarded, so
// hooking both events is safe.
maybeNudgeAgents()
return
case 'subagent.thinking': {
const text = String(ev.payload.text ?? '').trim()
if (!text) {
return
}
// Update-only: never resurrect subagents whose spawn_requested/start
// we missed or that already flushed via message.complete.
turnController.upsertSubagent(
ev.payload,
c => ({
status: keepTerminalElseRunning(c.status),
thinking: pushThinking(c.thinking, text)
}),
{ createIfMissing: false }
)
return
}
case 'subagent.tool': {
const line = formatToolCall(
ev.payload.tool_name ?? 'delegate_task',
ev.payload.tool_preview ?? ev.payload.text ?? ''
)
turnController.upsertSubagent(
ev.payload,
c => ({
status: keepTerminalElseRunning(c.status),
tools: pushTool(c.tools, line)
}),
{ createIfMissing: false }
)
return
}
case 'subagent.progress': {
const text = String(ev.payload.text ?? '').trim()
if (!text) {
return
}
turnController.upsertSubagent(
ev.payload,
c => ({
notes: pushNote(c.notes, text),
status: keepTerminalElseRunning(c.status)
}),
{ createIfMissing: false }
)
return
}
case 'subagent.complete':
turnController.upsertSubagent(
ev.payload,
c => ({
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
status: normalizeSubagentStatus(ev.payload.status, 'completed'),
summary: ev.payload.summary || ev.payload.text || c.summary
}),
{ createIfMissing: false }
)
return
case 'message.delta':
turnController.recordMessageDelta(ev.payload ?? {})
return
case 'message.complete': {
const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {})
if (!wasInterrupted) {
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
msgs.forEach(appendMessage)
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')
}
}
setStatus('ready')
if (ev.payload?.usage) {
patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } }))
}
return
}
case 'error':
turnController.recordError()
{
const message = String(ev.payload?.message || 'unknown error')
turnController.pushActivity(message, 'error')
if (NO_PROVIDER_RE.test(message)) {
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
setStatus('setup required')
return
}
sys(`error: ${message}`)
setStatus('ready')
}
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import { parseSlashCommand } from '../domain/slash.js'
import type { SlashExecResponse } from '../gatewayTypes.js'
import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js'
import type { SlashHandlerContext } from './interfaces.js'
import { findSlashCommand } from './slash/registry.js'
import type { SlashRunCtx } from './slash/types.js'
import { getUiState } from './uiStore.js'
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
const { gw } = ctx.gateway
const { catalog } = ctx.local
const { page, send, sys } = ctx.transcript
const handler = (cmd: string): boolean => {
const flight = ++ctx.slashFlightRef.current
const ui = getUiState()
const sid = ui.sid
const parsed = parseSlashCommand(cmd)
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
const stale = () => flight !== ctx.slashFlightRef.current || getUiState().sid !== sid
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T): void => {
if (!stale() && r) {
fn(r)
}
}
const guardedErr = (e: unknown) => {
if (!stale()) {
sys(`error: ${rpcErrorMessage(e)}`)
}
}
const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, sid, stale, ui }
const found = findSlashCommand(parsed.name)
if (found) {
found.run(parsed.arg, runCtx, cmd)
return true
}
if (catalog?.canon) {
const needle = `/${parsed.name}`.toLowerCase()
const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1]
if (exact) {
if (exact.toLowerCase() !== needle) {
return handler(`${exact}${argTail}`)
}
} else {
const matches = [
...new Set(
Object.entries(catalog.canon)
.filter(([alias]) => alias.startsWith(needle))
.map(([, canon]) => canon)
)
]
if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) {
return handler(`${matches[0]}${argTail}`)
}
if (matches.length > 1) {
sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`)
return true
}
}
}
gw.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: sid })
.then(r => {
if (stale()) {
return
}
const body = r?.output || `/${parsed.name}: no output`
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text)
})
.catch(() => {
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
.then((raw: unknown) => {
if (stale()) {
return
}
const d = asCommandDispatch(raw)
if (!d) {
return sys('error: invalid response: command.dispatch')
}
if (d.type === 'exec' || d.type === 'plugin') {
return sys(d.output || '(no output)')
}
if (d.type === 'alias') {
return handler(`/${d.target}${argTail}`)
}
if (d.type === 'skill') {
sys(`⚡ loading skill: ${d.name}`)
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`)
}
if (d.type === 'send') {
if (d.notice?.trim()) {
sys(d.notice)
}
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`)
}
})
.catch(guardedErr)
})
return true
}
return handler
}
+77
View File
@@ -0,0 +1,77 @@
import { atom } from 'nanostores'
import type { DelegationStatusResponse } from '../gatewayTypes.js'
export interface DelegationState {
// Last known caps from `delegation.status` RPC. null until fetched.
maxConcurrentChildren: null | number
maxSpawnDepth: null | number
// True when spawning is globally paused (see tools/delegate_tool.py).
paused: boolean
// Monotonic clock of the last successful status fetch.
updatedAt: null | number
}
const buildState = (): DelegationState => ({
maxConcurrentChildren: null,
maxSpawnDepth: null,
paused: false,
updatedAt: null
})
export const $delegationState = atom<DelegationState>(buildState())
export const getDelegationState = () => $delegationState.get()
export const patchDelegationState = (next: Partial<DelegationState>) =>
$delegationState.set({ ...$delegationState.get(), ...next })
export const resetDelegationState = () => $delegationState.set(buildState())
// ── Overlay accordion open-state ──────────────────────────────────────
//
// Lifted out of OverlaySection's local useState so collapse choices
// survive:
// - navigating to a different subagent (Detail remounts)
// - switching list ↔ detail mode (Detail unmounts in list mode)
// - walking history (←/→)
// Keyed by section title; missing entries fall back to the section's
// `defaultOpen` prop.
export const $overlaySectionsOpen = atom<Record<string, boolean>>({})
export const toggleOverlaySection = (title: string, defaultOpen: boolean) => {
const state = $overlaySectionsOpen.get()
const current = title in state ? state[title]! : defaultOpen
$overlaySectionsOpen.set({ ...state, [title]: !current })
}
export const getOverlaySectionOpen = (title: string, defaultOpen: boolean): boolean => {
const state = $overlaySectionsOpen.get()
return title in state ? state[title]! : defaultOpen
}
/** Merge a raw RPC response into the store. Tolerant of partial/omitted fields. */
export const applyDelegationStatus = (r: DelegationStatusResponse | null | undefined) => {
if (!r) {
return
}
const patch: Partial<DelegationState> = { updatedAt: Date.now() }
if (typeof r.max_spawn_depth === 'number') {
patch.maxSpawnDepth = r.max_spawn_depth
}
if (typeof r.max_concurrent_children === 'number') {
patch.maxConcurrentChildren = r.max_concurrent_children
}
if (typeof r.paused === 'boolean') {
patch.paused = r.paused
}
patchDelegationState(patch)
}
+19
View File
@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react'
import type { GatewayProviderProps, GatewayServices } from './interfaces.js'
const GatewayContext = createContext<GatewayServices | null>(null)
export function GatewayProvider({ children, value }: GatewayProviderProps) {
return <GatewayContext.Provider value={value}>{children}</GatewayContext.Provider>
}
export function useGateway() {
const value = useContext(GatewayContext)
if (!value) {
throw new Error('GatewayContext missing')
}
return value
}
+15
View File
@@ -0,0 +1,15 @@
import { atom } from 'nanostores'
export interface InputSelection {
clear: () => void
collapseToEnd: () => void
end: number
start: number
value: string
}
export const $inputSelection = atom<InputSelection | null>(null)
export const setInputSelection = (next: InputSelection | null) => $inputSelection.set(next)
export const getInputSelection = () => $inputSelection.get()
+394
View File
@@ -0,0 +1,394 @@
import type { MouseTrackingMode, ScrollBoxHandle } from '@hermes/ink'
import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { ImageAttachResponse, SessionCloseResponse } 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 {
ApprovalReq,
ClarifyReq,
ConfirmReq,
DetailsMode,
Msg,
PanelSection,
SecretReq,
SectionVisibility,
SessionInfo,
SlashCatalog,
SudoReq,
Usage
} from '../types.js'
export interface StateSetter<T> {
(value: SetStateAction<T>): void
}
export type StatusBarMode = 'bottom' | 'off' | 'top'
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
// Single source of truth for indicator style names. Union type is
// derived from this tuple so adding/removing a style only touches one
// line — `useConfigSync` (validation) and `session.ts` (slash arg
// validation + usage hint) both import it.
export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const
export type IndicatorStyle = (typeof INDICATOR_STYLES)[number]
export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji'
export interface SelectionApi {
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
clearSelection: () => void
copySelection: () => Promise<string>
copySelectionNoClear: () => Promise<string>
getState: () => unknown
version: () => number
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
}
export interface CompletionItem {
display: string
meta?: string
text: string
}
export interface GatewayRpc {
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<null | T>
}
export interface GatewayServices {
gw: GatewayClient
rpc: GatewayRpc
}
export interface GatewayProviderProps {
children: ReactNode
value: GatewayServices
}
export interface OverlayState {
agents: boolean
agentsInitialHistoryIndex: number
approval: ApprovalReq | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
modelPicker: boolean
pager: null | PagerState
picker: boolean
secret: null | SecretReq
sessions: boolean
skillsHub: boolean
sudo: null | SudoReq
}
export interface PagerState {
lines: string[]
offset: number
title?: string
}
export interface TranscriptRow {
index: number
key: string
msg: Msg
}
export interface UiState {
bgTasks: Set<string>
busy: boolean
busyInputMode: BusyInputMode
compact: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
info: null | SessionInfo
liveSessionCount: number
inlineDiffs: boolean
mouseTracking: MouseTrackingMode
pasteCollapseLines: number
pasteCollapseChars: number
sections: SectionVisibility
showCost: boolean
showReasoning: boolean
indicatorStyle: IndicatorStyle
sid: null | string
status: string
statusBar: StatusBarMode
streaming: boolean
theme: Theme
usage: Usage
}
export interface VirtualHistoryState {
bottomSpacer: number
end: number
measureRef: (key: string) => (el: unknown) => void
offsets: ArrayLike<number>
start: number
topSpacer: number
}
export interface ComposerPasteResult {
cursor: number
value: string
}
export type MaybePromise<T> = Promise<T> | T
export interface ComposerActions {
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
openEditor: () => Promise<void>
pushHistory: (text: string) => void
removeQueue: (index: number) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setPasteSnips: StateSetter<PasteSnippet[]>
setQueueEdit: (index: null | number) => void
syncQueue: () => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
}
export interface ComposerState {
compIdx: number
compReplace: number
completions: CompletionItem[]
historyIdx: null | number
input: string
inputBuf: string[]
pasteSnips: PasteSnippet[]
queueEditIdx: null | number
queuedDisplay: string[]
}
export interface UseComposerStateOptions {
gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
onImageAttached?: (info: ImageAttachResponse) => void
submitRef: MutableRefObject<(value: string) => void>
}
export interface UseComposerStateResult {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
export interface InputHandlerActions {
answerClarify: (answer: string) => void
appendMessage: (msg: Msg) => void
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string, title?: string) => void
sys: (text: string) => void
}
export interface InputHandlerContext {
actions: InputHandlerActions
composer: {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
gateway: GatewayServices
terminal: {
hasSelection: boolean
scrollRef: RefObject<null | ScrollBoxHandle>
scrollWithSelection: (delta: number) => void
selection: SelectionApi
stdout?: NodeJS.WriteStream
}
voice: {
enabled: boolean
recordKey: ParsedVoiceRecordKey
recording: boolean
setProcessing: StateSetter<boolean>
setRecording: StateSetter<boolean>
setVoiceEnabled: StateSetter<boolean>
setVoiceTts: StateSetter<boolean>
}
wheelStep: number
}
export interface InputHandlerResult {
pagerPageSize: number
}
export interface GatewayEventHandlerContext {
composer: {
setInput: StateSetter<string>
}
gateway: GatewayServices
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject<number>
newSession: (msg?: string, title?: string) => void
resetSession: () => void
resumeById: (id: string) => void
setCatalog: StateSetter<null | SlashCatalog>
}
submission: {
submitRef: MutableRefObject<(value: string) => void>
}
system: {
bellOnComplete: boolean
stdout?: NodeJS.WriteStream
sys: (text: string) => void
}
transcript: {
appendMessage: (msg: Msg) => void
panel: (title: string, sections: PanelSection[]) => void
setHistoryItems: StateSetter<Msg[]>
}
voice: {
setProcessing: StateSetter<boolean>
setRecording: StateSetter<boolean>
setVoiceEnabled: StateSetter<boolean>
setVoiceTts: StateSetter<boolean>
}
}
export interface SlashHandlerContext {
composer: {
enqueue: (text: string) => void
hasSelection: boolean
paste: (quiet?: boolean) => void
queueRef: MutableRefObject<string[]>
selection: SelectionApi
setInput: StateSetter<string>
}
gateway: GatewayServices
local: {
catalog: null | SlashCatalog
getHistoryItems: () => Msg[]
getLastUserMsg: () => string
maybeWarn: (value: unknown) => void
setCatalog: StateSetter<null | SlashCatalog>
}
session: {
closeSession: (targetSid?: null | string) => Promise<unknown>
die: () => void
dieWithCode: (code: number) => void
guardBusySessionSwitch: (what?: string) => boolean
newLiveSession: (msg?: string, title?: string) => void
newSession: (msg?: string, title?: string) => void
resetVisibleHistory: (info?: null | SessionInfo) => void
resumeById: (id: string) => void
setSessionStartedAt: StateSetter<number>
}
slashFlightRef: MutableRefObject<number>
transcript: {
page: (text: string, title?: string) => void
panel: (title: string, sections: PanelSection[]) => void
send: (text: string) => void
setHistoryItems: StateSetter<Msg[]>
sys: (text: string) => void
trimLastExchange: (items: Msg[]) => Msg[]
}
voice: {
setVoiceEnabled: StateSetter<boolean>
setVoiceRecordKey: (v: ParsedVoiceRecordKey) => void
setVoiceTts: StateSetter<boolean>
}
}
export interface AppLayoutActions {
answerApproval: (choice: string) => void
answerClarify: (answer: string) => void
answerSecret: (value: string) => void
answerSudo: (pw: string) => void
clearSelection: () => void
activateLiveSession: (id: string) => void
closeLiveSession: (id: string) => Promise<null | SessionCloseResponse>
newLiveSession: () => void
newPromptSession: (prompt: string, modelArg?: string) => void
onModelSelect: (value: string) => void
resumeById: (id: string) => void
setStickyPrompt: (value: string) => void
}
export interface AppLayoutComposerProps {
cols: number
compIdx: number
completions: CompletionItem[]
empty: boolean
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
input: string
inputBuf: string[]
pagerPageSize: number
queueEditIdx: null | number
queuedDisplay: string[]
submit: (value: string) => void
updateInput: StateSetter<string>
voiceRecordKey: ParsedVoiceRecordKey
}
export interface AppLayoutProgressProps {
showProgressArea: boolean
}
export interface AppLayoutStatusProps {
cwdLabel: string
goodVibesTick: number
sessionStartedAt: null | number
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string
turnStartedAt: null | number
voiceLabel: string
}
export interface AppLayoutTranscriptProps {
historyItems: Msg[]
scrollRef: RefObject<null | ScrollBoxHandle>
virtualHistory: VirtualHistoryState
virtualRows: TranscriptRow[]
}
export interface AppLayoutProps {
actions: AppLayoutActions
composer: AppLayoutComposerProps
mouseTracking: MouseTrackingMode
progress: AppLayoutProgressProps
status: AppLayoutStatusProps
transcript: AppLayoutTranscriptProps
}
export interface AppOverlaysProps {
cols: number
compIdx: number
completions: CompletionItem[]
onApprovalChoice: (choice: string) => void
onClarifyAnswer: (value: string) => void
onActiveSessionSelect: (sessionId: string) => void
onActiveSessionClose: (sessionId: string) => Promise<null | SessionCloseResponse>
onModelSelect: (value: string) => void
onNewLiveSession: () => void
onNewPromptSession: (prompt: string, modelArg?: string) => void
onPickerSelect: (sessionId: string) => void
onSecretSubmit: (value: string) => void
onSudoSubmit: (pw: string) => void
pagerPageSize: number
}
export interface PasteSnippet {
label: string
path?: string
text: string
}
+53
View File
@@ -0,0 +1,53 @@
import { atom, computed } from 'nanostores'
import type { OverlayState } from './interfaces.js'
const buildOverlayState = (): OverlayState => ({
agents: false,
agentsInitialHistoryIndex: 0,
approval: null,
clarify: null,
confirm: null,
modelPicker: false,
pager: null,
picker: false,
secret: null,
sessions: false,
skillsHub: false,
sudo: null
})
export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, sessions, skillsHub, sudo }) =>
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || sessions || skillsHub || sudo)
)
export const getOverlayState = () => $overlayState.get()
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
/** Full reset — used by session/turn teardown and tests. */
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
/**
* Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo
* / secret / pager) but PRESERVE user-toggled ones agents dashboard, model
* picker, skills hub, session picker. Those are opened deliberately and
* shouldn't vanish when a turn ends. Called from turnController.idle() on
* every turn completion / interrupt; the old "reset everything" behaviour
* silently closed /agents the moment delegation finished.
*/
export const resetFlowOverlays = () =>
$overlayState.set({
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
modelPicker: $overlayState.get().modelPicker,
picker: $overlayState.get().picker,
sessions: $overlayState.get().sessions,
skillsHub: $overlayState.get().skillsHub
})
+71
View File
@@ -0,0 +1,71 @@
import type { ScrollBoxHandle } from '@hermes/ink'
import type { SelectionApi } from './interfaces.js'
export interface SelectionSnap {
anchor?: { row: number } | null
focus?: { row: number } | null
isDragging?: boolean
}
export interface ScrollWithSelectionOptions {
readonly scrollRef: { readonly current: ScrollBoxHandle | null }
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
if (!s) {
return
}
const cur = s.getScrollTop() + s.getPendingDelta()
const { max, viewport } = scrollBoundsForDelta(s, cur, delta)
const actual = Math.max(0, Math.min(max, cur + delta)) - cur
if (actual === 0) {
return
}
const sel = selection.getState() as null | SelectionSnap
const top = s.getViewportTop()
const bottom = top + viewport - 1
if (
sel?.anchor &&
sel.focus &&
sel.anchor.row >= top &&
sel.anchor.row <= bottom &&
(sel.isDragging || (sel.focus.row >= top && sel.focus.row <= bottom))
) {
const shift = sel.isDragging ? selection.shiftAnchor : selection.shiftSelection
if (actual > 0) {
selection.captureScrolledRows(top, top + actual - 1, 'above')
} else {
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
}
shift(-actual, top, bottom)
}
s.scrollBy(actual)
}
+54
View File
@@ -0,0 +1,54 @@
import type { RunExternalProcess } from '@hermes/ink'
import type { SetupStatusResponse } from '../gatewayTypes.js'
import type { LaunchResult } from '../lib/externalCli.js'
import type { SlashHandlerContext } from './interfaces.js'
import { patchUiState } from './uiStore.js'
export interface RunExternalSetupOptions {
args: string[]
ctx: Pick<SlashHandlerContext, 'gateway' | 'session' | 'transcript'>
done: string
launcher: (args: string[]) => Promise<LaunchResult>
suspend: (run: RunExternalProcess) => Promise<void>
}
export async function runExternalSetup({ args, ctx, done, launcher, suspend }: RunExternalSetupOptions) {
const { gateway, session, transcript } = ctx
transcript.sys(`launching \`hermes ${args.join(' ')}\``)
patchUiState({ status: 'setup running…' })
let result: LaunchResult = { code: null }
await suspend(async () => {
result = await launcher(args)
})
if (result.error) {
transcript.sys(`error launching hermes: ${result.error}`)
patchUiState({ status: 'setup required' })
return
}
if (result.code !== 0) {
transcript.sys(`hermes ${args[0]} exited with code ${result.code}`)
patchUiState({ status: 'setup required' })
return
}
const setup = await gateway.rpc<SetupStatusResponse>('setup.status', {})
if (setup?.provider_configured === false) {
transcript.sys('still no provider configured')
patchUiState({ status: 'setup required' })
return
}
transcript.sys(done)
session.newSession()
}
+648
View File
@@ -0,0 +1,648 @@
import { forceRedraw, type MouseTrackingMode } 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 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'
import type { StatusBarMode } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const flagFromArg = (arg: string, current: boolean): boolean | null => {
if (!arg) {
return !current
}
const mode = arg.trim().toLowerCase()
if (mode === 'on') {
return true
}
if (mode === 'off') {
return false
}
if (mode === 'toggle') {
return !current
}
return null
}
// `/mouse` toggles between full tracking and off when called bare so the
// old binary muscle-memory still works. Explicit presets (wheel / buttons /
// all) target the tmux-friendly hover-free subsets.
const MOUSE_MODE_ALIASES: Record<string, MouseTrackingMode> = {
all: 'all',
any: 'all',
button: 'buttons',
buttons: 'buttons',
click: 'buttons',
full: 'all',
off: 'off',
on: 'all',
scroll: 'wheel',
wheel: 'wheel'
}
const mouseModeFromArg = (arg: string, current: MouseTrackingMode): MouseTrackingMode | null => {
if (!arg || arg.trim().toLowerCase() === 'toggle') {
return current === 'off' ? 'all' : 'off'
}
return MOUSE_MODE_ALIASES[arg.trim().toLowerCase()] ?? null
}
const RESET_WORDS = new Set(['reset', 'clear', 'default'])
const CYCLE_WORDS = new Set(['cycle', 'toggle'])
const DETAILS_USAGE =
'usage: /details [hidden|collapsed|expanded|cycle] or /details <section> [hidden|collapsed|expanded|reset]'
const DETAILS_SECTION_USAGE = 'usage: /details <section> [hidden|collapsed|expanded|reset]'
export const coreCommands: SlashCommand[] = [
{
help: 'list commands + hotkeys',
name: 'help',
run: (_arg, ctx) => {
const sections: PanelSection[] = (ctx.local.catalog?.categories ?? []).map(cat => ({
rows: cat.pairs,
title: cat.name
}))
if (ctx.local.catalog?.skillCount) {
sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` })
}
sections.push(
{
rows: [
['/details [hidden|collapsed|expanded|cycle]', 'set global agent detail visibility mode'],
[
'/details <section> [hidden|collapsed|expanded|reset]',
'override one section (thinking/tools/subagents/activity)'
],
['/fortune [random|daily]', 'show a random or daily local fortune']
],
title: 'TUI'
},
{ rows: HOTKEYS, title: 'Hotkeys' }
)
ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections)
}
},
{
aliases: ['exit'],
help: 'exit hermes',
name: 'quit',
run: (_arg, ctx) => ctx.session.die()
},
{
help: 'update Hermes Agent to the latest version (exits TUI)',
name: 'update',
run: (_arg, ctx) => {
ctx.transcript.sys('exiting TUI to run update...')
// Exit code 42 signals the Python wrapper to exec `hermes update`.
// Use dieWithCode for proper cleanup (gateway kill + Ink unmount).
setTimeout(() => ctx.session.dieWithCode(42), 100)
}
},
{
aliases: ['scroll'],
help: 'set mouse tracking preset [on|off|toggle|wheel|buttons|all]',
name: 'mouse',
run: (arg, ctx) => {
const current = ctx.ui.mouseTracking
const next = mouseModeFromArg(arg, current)
if (next === null) {
return ctx.transcript.sys('usage: /mouse [on|off|toggle|wheel|buttons|all]')
}
patchUiState({ mouseTracking: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next}`))
}
},
{
aliases: ['new'],
help: 'start a new session',
name: 'clear',
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, requestedTitle || undefined)
}
if (NO_CONFIRM_DESTRUCTIVE) {
return commit()
}
patchOverlayState({
confirm: {
cancelLabel: 'No, keep going',
confirmLabel: isNew ? 'Yes, start a new session' : 'Yes, clear the session',
danger: true,
detail: 'This ends the current conversation and clears the transcript.',
onConfirm: commit,
title: isNew ? 'Start a new session?' : 'Clear the current session?'
}
})
}
},
{
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',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
arg ? ctx.session.resumeById(arg) : patchOverlayState({ picker: true })
}
},
{
help: 'set or show current session title',
name: 'title',
run: (arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('no active session')
}
const title = arg.trim()
if (!arg) {
ctx.gateway
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid })
.then(
ctx.guarded<SessionTitleResponse>(r => {
const current = (r?.title ?? '').trim()
ctx.transcript.sys(current ? `title: ${current}` : 'no title set')
})
)
.catch(ctx.guardedErr)
return
}
if (!title) {
return ctx.transcript.sys('usage: /title <your session title>')
}
ctx.gateway
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid, title })
.then(
ctx.guarded<SessionTitleResponse>(r => {
const next = (r?.title ?? title).trim()
const suffix = r?.pending ? ' (queued while session initializes)' : ''
ctx.transcript.sys(`session title set: ${next}${suffix}`)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'toggle compact transcript',
name: 'compact',
run: (arg, ctx) => {
const next = flagFromArg(arg, ctx.ui.compact)
if (next === null) {
return ctx.transcript.sys('usage: /compact [on|off|toggle]')
}
patchUiState({ compact: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`compact ${next ? 'on' : 'off'}`))
}
},
{
aliases: ['detail'],
help: 'control agent detail visibility (global or per-section)',
name: 'details',
run: (arg, ctx) => {
const { gateway, transcript, ui } = ctx
if (!arg) {
gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'details_mode' })
.then(r => {
if (ctx.stale()) {
return
}
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
patchUiState({ detailsMode: mode, detailsModeCommandOverride: false })
const overrides = SECTION_NAMES.filter(s => ui.sections[s])
.map(s => `${s}=${ui.sections[s]}`)
.join(' ')
transcript.sys(`details: ${mode}${overrides ? ` (${overrides})` : ''}`)
})
.catch(() => !ctx.stale() && transcript.sys(`details: ${ui.detailsMode}`))
return
}
const [first, second] = arg.trim().toLowerCase().split(/\s+/)
if (second && isSectionName(first)) {
const reset = RESET_WORDS.has(second)
const mode = reset ? null : parseDetailsMode(second)
if (!reset && !mode) {
return transcript.sys(DETAILS_SECTION_USAGE)
}
const { [first]: _drop, ...rest } = ui.sections
patchUiState({ sections: mode ? { ...rest, [first]: mode } : rest })
gateway
.rpc<ConfigSetResponse>('config.set', { key: `details_mode.${first}`, value: mode ?? '' })
.catch(() => {})
transcript.sys(`details ${first}: ${mode ?? 'reset'}`)
return
}
const next = CYCLE_WORDS.has(first ?? '') ? nextDetailsMode(ui.detailsMode) : parseDetailsMode(first)
if (!next) {
return transcript.sys(DETAILS_USAGE)
}
const sections = Object.fromEntries(SECTION_NAMES.map(section => [section, next]))
patchUiState({ detailsMode: next, detailsModeCommandOverride: true, sections })
gateway.rpc<ConfigSetResponse>('config.set', { key: 'details_mode', value: next }).catch(() => {})
transcript.sys(`details: ${next}`)
}
},
{
help: 'local fortune',
name: 'fortune',
run: (arg, ctx) => {
const key = arg.trim().toLowerCase()
if (!arg || key === 'random') {
return ctx.transcript.sys(randomFortune())
}
if (['daily', 'stable', 'today'].includes(key)) {
return ctx.transcript.sys(dailyFortune(ctx.sid))
}
ctx.transcript.sys('usage: /fortune [random|daily]')
}
},
{
help: 'copy selection or assistant message',
name: 'copy',
run: async (arg, ctx) => {
const { sys } = ctx.transcript
if (!arg && ctx.composer.hasSelection) {
const text = await ctx.composer.selection.copySelection()
if (text) {
return sys(`copied ${text.length} characters`)
} else {
return sys(
'clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence'
)
}
}
if (arg && Number.isNaN(parseInt(arg, 10))) {
return sys('usage: /copy [number]')
}
const all = ctx.local.getHistoryItems().filter(m => m.role === 'assistant')
const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
if (!target) {
return sys('nothing to copy — start a conversation first')
}
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)}`)
}
})
}
},
{
help: 'attach clipboard image',
name: 'paste',
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste())
},
{
help: 'configure IDE terminal keybindings for multiline + undo/redo',
name: 'terminal-setup',
run: (arg, ctx) => {
const target = arg.trim().toLowerCase()
if (target && !['auto', 'cursor', 'vscode', 'windsurf'].includes(target)) {
return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]')
}
const runner =
!target || target === 'auto'
? configureDetectedTerminalKeybindings()
: configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf')
void runner
.then(result => {
if (ctx.stale()) {
return
}
ctx.transcript.sys(result.message)
if (result.success && result.requiresRestart) {
ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect')
}
})
.catch(error => {
if (!ctx.stale()) {
ctx.transcript.sys(`terminal setup failed: ${String(error)}`)
}
})
}
},
{
help: 'view gateway logs',
name: 'logs',
run: (arg, ctx) => {
const text = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
text ? ctx.transcript.page(text, 'Logs') : ctx.transcript.sys('no gateway logs')
}
},
{
help: 'view current transcript (user + assistant messages)',
name: 'history',
run: (arg, ctx) => {
// The CLI-side `/history` runs in a detached slash-worker subprocess
// that never sees the TUI's turns — it only surfaces whatever was
// persisted before this process started. Render the TUI's own
// transcript so `/history` actually reflects what the user just did.
const items = ctx.local.getHistoryItems().filter(m => m.role === 'user' || m.role === 'assistant')
if (!items.length) {
return ctx.transcript.sys('no conversation yet')
}
const preview = Math.max(80, parseInt(arg, 10) || 400)
const lines = items.map((m, i) => {
const tag = m.role === 'user' ? `You #${i + 1}` : `Hermes #${i + 1}`
const body = m.text.trim() || (m.tools?.length ? `(${m.tools.length} tool calls)` : '(empty)')
const clipped = body.length > preview ? `${body.slice(0, preview).trimEnd()}` : body
return `[${tag}]\n${clipped}`
})
ctx.transcript.page(lines.join('\n\n'), 'History')
}
},
{
help: 'save the current transcript to JSON',
name: 'save',
run: (_arg, ctx) => {
const hasConversation = ctx.local
.getHistoryItems()
.some(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool')
if (!hasConversation) {
return ctx.transcript.sys('no conversation yet')
}
if (!ctx.sid) {
return ctx.transcript.sys('no active session — nothing to save')
}
ctx.gateway
.rpc<SessionSaveResponse>('session.save', { session_id: ctx.sid })
.then(
ctx.guarded<SessionSaveResponse>(r => {
const file = r?.file
if (file) {
ctx.transcript.sys(`conversation saved to: ${file}`)
} else {
ctx.transcript.sys('failed to save')
}
})
)
.catch(ctx.guardedErr)
}
},
{
aliases: ['sb'],
help: 'status bar position (on|off|top|bottom)',
name: 'statusbar',
run: (arg, ctx) => {
const mode = arg.trim().toLowerCase()
const toggle: StatusBarMode = ctx.ui.statusBar === 'off' ? 'top' : 'off'
const next: null | StatusBarMode =
!mode || mode === 'toggle'
? toggle
: mode === 'on' || mode === 'top'
? 'top'
: mode === 'off' || mode === 'bottom'
? mode
: null
if (!next) {
return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]')
}
patchUiState({ statusBar: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`))
}
},
{
aliases: ['q'],
help: 'inspect or enqueue a message',
name: 'queue',
run: (arg, ctx) => {
if (!arg) {
return ctx.transcript.sys(`${ctx.composer.queueRef.current.length} queued message(s)`)
}
ctx.composer.enqueue(arg)
ctx.transcript.sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`)
}
},
{
help: 'inject a message after the next tool call (no interrupt)',
name: 'steer',
run: (arg, ctx) => {
const payload = arg?.trim() ?? ''
if (!payload) {
return ctx.transcript.sys('usage: /steer <prompt>')
}
// If the agent isn't running, fall back to the queue so the user's
// message isn't lost — identical semantics to the gateway handler.
if (!ctx.ui.busy || !ctx.sid) {
ctx.composer.enqueue(payload)
ctx.transcript.sys(
`no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
)
return
}
ctx.gateway
.rpc<SessionSteerResponse>('session.steer', { session_id: ctx.sid, text: payload })
.then(
ctx.guarded<SessionSteerResponse>(r => {
if (r?.status === 'queued') {
ctx.transcript.sys(
`steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
)
} else {
ctx.transcript.sys('steer rejected')
}
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'undo last exchange',
name: 'undo',
run: (_arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('nothing to undo')
}
ctx.gateway.rpc<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
ctx.guarded<SessionUndoResponse>(r => {
if ((r.removed ?? 0) > 0) {
ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev))
ctx.transcript.sys(`undid ${r.removed} messages`)
} else {
ctx.transcript.sys('nothing to undo')
}
})
)
}
},
{
help: 'retry last user message',
name: 'retry',
run: (_arg, ctx) => {
const last = ctx.local.getLastUserMsg()
if (!last) {
return ctx.transcript.sys('nothing to retry')
}
if (!ctx.sid) {
return ctx.transcript.send(last)
}
ctx.gateway.rpc<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
ctx.guarded<SessionUndoResponse>(r => {
if ((r.removed ?? 0) <= 0) {
return ctx.transcript.sys('nothing to retry')
}
ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev))
ctx.transcript.send(last)
})
)
}
}
]
+48
View File
@@ -0,0 +1,48 @@
import { formatBytes, performHeapDump } from '../../../lib/memory.js'
import type { SlashCommand } from '../types.js'
export const debugCommands: SlashCommand[] = [
{
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
name: 'heapdump',
run: (_arg, ctx) => {
const { heapUsed, rss } = process.memoryUsage()
ctx.transcript.sys(`writing heap dump (heap ${formatBytes(heapUsed)} · rss ${formatBytes(rss)})…`)
void performHeapDump('manual').then(r => {
if (ctx.stale()) {
return
}
if (!r.success) {
return ctx.transcript.sys(`heapdump failed: ${r.error ?? 'unknown error'}`)
}
ctx.transcript.sys(`heapdump: ${r.heapPath}`)
ctx.transcript.sys(`diagnostics: ${r.diagPath}`)
})
}
},
{
help: 'print live V8 heap + rss numbers',
name: 'mem',
run: (_arg, ctx) => {
const { arrayBuffers, external, heapTotal, heapUsed, rss } = process.memoryUsage()
ctx.transcript.panel('Memory', [
{
rows: [
['heap used', formatBytes(heapUsed)],
['heap total', formatBytes(heapTotal)],
['external', formatBytes(external)],
['array buffers', formatBytes(arrayBuffers)],
['rss', formatBytes(rss)],
['uptime', `${process.uptime().toFixed(0)}s`]
]
}
])
}
}
]
+717
View File
@@ -0,0 +1,717 @@
import type {
BrowserManageResponse,
CommandsCatalogResponse,
DelegationPauseResponse,
ProcessStopResponse,
ReloadEnvResponse,
ReloadMcpResponse,
RollbackDiffResponse,
RollbackListResponse,
RollbackRestoreResponse,
SlashExecResponse,
SpawnTreeListResponse,
SpawnTreeLoadResponse,
ToolsConfigureResponse
} from '../../../gatewayTypes.js'
import type { PanelSection } from '../../../types.js'
import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js'
import { patchOverlayState } from '../../overlayStore.js'
import { getSpawnHistory, pushDiskSnapshot, setDiffPair, type SpawnSnapshot } from '../../spawnHistoryStore.js'
import type { SlashCommand } from '../types.js'
interface SkillInfo {
category?: string
description?: string
name?: string
path?: string
}
interface SkillsListResponse {
skills?: Record<string, string[]>
}
interface SkillsInspectResponse {
info?: SkillInfo
}
interface SkillsSearchResponse {
results?: { description?: string; name: string }[]
}
interface SkillsInstallResponse {
installed?: boolean
name?: string
}
interface SkillsBrowseItem {
description?: string
name: string
source?: string
trust?: string
}
interface SkillsBrowseResponse {
items?: SkillsBrowseItem[]
page?: number
total?: number
total_pages?: number
}
interface SkillsReloadResponse {
output?: string
}
export const opsCommands: SlashCommand[] = [
{
help: 'stop background processes',
name: 'stop',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ProcessStopResponse>('process.stop', {})
.then(
ctx.guarded<ProcessStopResponse>(r => {
const killed = Number(r.killed ?? 0)
const noun = killed === 1 ? 'process' : 'processes'
ctx.transcript.sys(`stopped ${killed} background ${noun}`)
})
)
.catch(ctx.guardedErr)
}
},
{
aliases: ['reload_mcp'],
help: 'reload MCP servers in the live session (warns about prompt cache invalidation)',
name: 'reload-mcp',
run: (arg, ctx) => {
// Parse arg: `now` / `always` skip the confirmation gate.
// `always` additionally persists approvals.mcp_reload_confirm=false.
const a = (arg || '').trim().toLowerCase()
const params: { session_id: string | null; confirm?: boolean; always?: boolean } = {
session_id: ctx.sid
}
if (a === 'now' || a === 'approve' || a === 'once' || a === 'yes') {
params.confirm = true
} else if (a === 'always') {
params.confirm = true
params.always = true
}
ctx.gateway
.rpc<ReloadMcpResponse>('reload.mcp', params)
.then(
ctx.guarded<ReloadMcpResponse>(r => {
if (r.status === 'confirm_required') {
ctx.transcript.sys(r.message || '/reload-mcp requires confirmation')
return
}
if (r.status === 'reloaded') {
ctx.transcript.sys(
params.always
? 'MCP servers reloaded · future /reload-mcp will run without confirmation'
: 'MCP servers reloaded'
)
return
}
ctx.transcript.sys('reload complete')
})
)
.catch(ctx.guardedErr)
}
},
{
help: 're-read ~/.hermes/.env into the running gateway (CLI parity)',
name: 'reload',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ReloadEnvResponse>('reload.env', {})
.then(
ctx.guarded<ReloadEnvResponse>(r => {
const n = Number(r.updated ?? 0)
const noun = n === 1 ? 'var' : 'vars'
ctx.transcript.sys(`reloaded .env (${n} ${noun} updated)`)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'manage browser CDP connection [connect|disconnect|status]',
name: 'browser',
run: (arg, ctx) => {
const [rawAction = 'status', ...rest] = arg.trim().split(/\s+/).filter(Boolean)
const action = rawAction.toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(action)) {
return ctx.transcript.sys(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
}
const sid = ctx.sid ?? null
const url = action === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
if (url) {
ctx.transcript.sys(`checking Chromium-family browser remote debugging at ${url}...`)
}
ctx.gateway
.rpc<BrowserManageResponse>('browser.manage', { action, session_id: sid, ...(url && { url }) })
.then(
ctx.guarded<BrowserManageResponse>(r => {
// Without a session we can't subscribe to streamed
// browser.progress events, so flush the bundled list.
if (!sid) {
r.messages?.forEach(message => ctx.transcript.sys(message))
}
if (action === 'status') {
return ctx.transcript.sys(
r.connected
? `browser connected: ${r.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
}
if (action === 'disconnect') {
return ctx.transcript.sys('browser disconnected')
}
if (r.connected) {
ctx.transcript.sys('Browser connected to live Chromium-family browser via CDP')
ctx.transcript.sys(`Endpoint: ${r.url || '(url unavailable)'}`)
ctx.transcript.sys('next browser tool call will use this CDP endpoint')
}
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'list, diff, or restore checkpoints',
name: 'rollback',
run: (arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('no active session — nothing to rollback')
}
const trimmed = arg.trim()
const [first = '', ...rest] = trimmed.split(/\s+/).filter(Boolean)
const lower = first.toLowerCase()
if (!trimmed || lower === 'list' || lower === 'ls') {
return ctx.gateway
.rpc<RollbackListResponse>('rollback.list', { session_id: ctx.sid })
.then(
ctx.guarded<RollbackListResponse>(r => {
if (!r.enabled) {
return ctx.transcript.sys('checkpoints are not enabled')
}
const checkpoints = r.checkpoints ?? []
if (!checkpoints.length) {
return ctx.transcript.sys('no checkpoints found')
}
ctx.transcript.panel('Rollback checkpoints', [
{
rows: checkpoints.map((c, idx) => [
`${idx + 1}. ${c.hash.slice(0, 10)}`,
[c.timestamp, c.message].filter(Boolean).join(' · ') || '(no metadata)'
])
}
])
})
)
.catch(ctx.guardedErr)
}
if (lower === 'diff') {
const hash = rest[0]
if (!hash) {
return ctx.transcript.sys('usage: /rollback diff <checkpoint>')
}
return ctx.gateway
.rpc<RollbackDiffResponse>('rollback.diff', { hash, session_id: ctx.sid })
.then(
ctx.guarded<RollbackDiffResponse>(r => {
const body = (r.rendered || r.diff || '').trim()
if (!body && !r.stat) {
return ctx.transcript.sys('no changes since this checkpoint')
}
const text = [r.stat || '', body].filter(Boolean).join('\n\n')
ctx.transcript.page(text, 'Rollback diff')
})
)
.catch(ctx.guardedErr)
}
const hash = first
const filePath = rest.join(' ').trim()
return ctx.gateway
.rpc<RollbackRestoreResponse>('rollback.restore', {
...(filePath ? { file_path: filePath } : {}),
hash,
session_id: ctx.sid
})
.then(
ctx.guarded<RollbackRestoreResponse>(r => {
if (!r.success) {
return ctx.transcript.sys(`rollback failed: ${r.error || r.message || 'unknown error'}`)
}
const target = filePath || 'workspace'
const detail = r.reason || r.message || r.restored_to || 'restored'
ctx.transcript.sys(`rollback restored ${target}: ${detail}`)
if ((r.history_removed ?? 0) > 0) {
ctx.transcript.setHistoryItems(prev => ctx.transcript.trimLastExchange(prev))
}
})
)
.catch(ctx.guardedErr)
}
},
{
aliases: ['tasks'],
help: 'open the spawn-tree dashboard (live audit + kill/pause controls)',
name: 'agents',
run: (arg, ctx) => {
const sub = arg.trim().toLowerCase()
// Stay compatible with the gateway `/agents [pause|resume|status]` CLI —
// explicit subcommands skip the overlay and act directly so scripts and
// multi-step flows can drive it without entering interactive mode.
if (sub === 'pause' || sub === 'resume' || sub === 'unpause') {
const paused = sub === 'pause'
ctx.gateway.gw
.request<DelegationPauseResponse>('delegation.pause', { paused })
.then(r => {
applyDelegationStatus({ paused: r?.paused })
ctx.transcript.sys(`delegation · ${r?.paused ? 'paused' : 'resumed'}`)
})
.catch(ctx.guardedErr)
return
}
if (sub === 'status') {
const d = getDelegationState()
ctx.transcript.sys(
`delegation · ${d.paused ? 'paused' : 'active'} · caps d${d.maxSpawnDepth ?? '?'}/${d.maxConcurrentChildren ?? '?'}`
)
return
}
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
}
},
{
help: 'replay a completed spawn tree · `/replay [N|last|list|load <path>]`',
name: 'replay',
run: (arg, ctx) => {
const history = getSpawnHistory()
const raw = arg.trim()
const lower = raw.toLowerCase()
// ── Disk-backed listing ─────────────────────────────────────
if (lower === 'list' || lower === 'ls') {
ctx.gateway
.rpc<SpawnTreeListResponse>('spawn_tree.list', {
limit: 30,
session_id: ctx.sid ?? 'default'
})
.then(
ctx.guarded<SpawnTreeListResponse>(r => {
const entries = r.entries ?? []
if (!entries.length) {
return ctx.transcript.sys('no archived spawn trees on disk for this session')
}
const rows: [string, string][] = entries.map(e => {
const ts = e.finished_at ? new Date(e.finished_at * 1000).toLocaleString() : '?'
const label = e.label || `${e.count} subagents`
return [`${ts} · ${e.count}×`, `${label}\n ${e.path}`]
})
ctx.transcript.panel('Archived spawn trees', [{ rows }])
})
)
.catch(ctx.guardedErr)
return
}
// ── Disk-backed load by path ─────────────────────────────────
if (lower.startsWith('load ')) {
const path = raw.slice(5).trim()
if (!path) {
return ctx.transcript.sys('usage: /replay load <path>')
}
ctx.gateway
.rpc<SpawnTreeLoadResponse>('spawn_tree.load', { path })
.then(
ctx.guarded<SpawnTreeLoadResponse>(r => {
if (!r.subagents?.length) {
return ctx.transcript.sys('snapshot empty or unreadable')
}
// Push onto the in-memory history so the overlay picks it up
// by index 1 just like any other snapshot.
pushDiskSnapshot(r, path)
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 1 })
})
)
.catch(ctx.guardedErr)
return
}
// ── In-memory nav (same-session) ─────────────────────────────
if (!history.length) {
return ctx.transcript.sys('no completed spawn trees this session · try /replay list')
}
let index = 1
if (raw && lower !== 'last') {
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed) || parsed < 1 || parsed > history.length) {
return ctx.transcript.sys(`replay: index out of range 1..${history.length} · use /replay list for disk`)
}
index = parsed
}
patchOverlayState({ agents: true, agentsInitialHistoryIndex: index })
}
},
{
help: 'diff two completed spawn trees · `/replay-diff <baseline> <candidate>` (indexes from /replay list or history N)',
name: 'replay-diff',
run: (arg, ctx) => {
const parts = arg.trim().split(/\s+/).filter(Boolean)
if (parts.length !== 2) {
return ctx.transcript.sys('usage: /replay-diff <a> <b> (e.g. /replay-diff 1 2 for last two)')
}
const [a, b] = parts
const history = getSpawnHistory()
const resolve = (token: string): null | SpawnSnapshot => {
const n = parseInt(token!, 10)
if (Number.isFinite(n) && n >= 1 && n <= history.length) {
return history[n - 1] ?? null
}
return null
}
const baseline = resolve(a!)
const candidate = resolve(b!)
if (!baseline || !candidate) {
return ctx.transcript.sys(`replay-diff: could not resolve indices · history has ${history.length} entries`)
}
setDiffPair({ baseline, candidate })
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
}
},
{
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, cmd) => {
const text = arg.trim()
if (!text) {
return patchOverlayState({ skillsHub: true })
}
const [sub, ...rest] = text.split(/\s+/)
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' })
.then(
ctx.guarded<SkillsListResponse>(r => {
const cats = Object.entries(r.skills ?? {}).sort()
if (!cats.length) {
return sys('no skills available')
}
panel(
'Skills',
cats.map<PanelSection>(([title, items]) => ({ items, title }))
)
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'inspect') {
if (!query) {
return sys('usage: /skills inspect <name>')
}
rpc<SkillsInspectResponse>('skills.manage', { action: 'inspect', query })
.then(
ctx.guarded<SkillsInspectResponse>(r => {
const info = r.info ?? {}
if (!info.name) {
return sys(`unknown skill: ${query}`)
}
const rows: [string, string][] = [
['Name', String(info.name)],
['Category', String(info.category ?? '')],
['Path', String(info.path ?? '')]
]
const sections: PanelSection[] = [{ rows }]
if (info.description) {
sections.push({ text: String(info.description) })
}
panel('Skill', sections)
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'search') {
if (!query) {
return sys('usage: /skills search <query>')
}
rpc<SkillsSearchResponse>('skills.manage', { action: 'search', query })
.then(
ctx.guarded<SkillsSearchResponse>(r => {
const results = r.results ?? []
if (!results.length) {
return sys(`no results for: ${query}`)
}
panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }])
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'install') {
if (!query) {
return sys('usage: /skills install <name or url>')
}
sys(`installing ${query}`)
rpc<SkillsInstallResponse>('skills.manage', { action: 'install', query })
.then(
ctx.guarded<SkillsInstallResponse>(r =>
sys(r.installed ? `installed ${r.name ?? query}` : 'install failed')
)
)
.catch(ctx.guardedErr)
return
}
if (sub === 'browse') {
const pageNum = query ? parseInt(query, 10) : 1
if (Number.isNaN(pageNum) || pageNum < 1) {
return sys('usage: /skills browse [page] (page must be a positive number)')
}
sys('fetching community skills (scans 6 sources, may take ~15s)…')
rpc<SkillsBrowseResponse>('skills.manage', { action: 'browse', page: pageNum })
.then(
ctx.guarded<SkillsBrowseResponse>(r => {
const items = r.items ?? []
if (!items.length) {
return sys(`no skills on page ${pageNum}${r.total ? ` (total ${r.total})` : ''}`)
}
const rows: [string, string][] = items.map(s => [
s.trust ? `${s.name} · ${s.trust}` : s.name,
String(s.description ?? '').slice(0, 160)
])
const footer: string[] = []
if (r.page && r.total_pages) {
footer.push(`page ${r.page} of ${r.total_pages}`)
}
if (r.total) {
footer.push(`${r.total} skills total`)
}
if (r.page && r.total_pages && r.page < r.total_pages) {
footer.push(`/skills browse ${r.page + 1} for more`)
}
panel(`Browse Skills${pageNum > 1 ? ` — p${pageNum}` : ''}`, [
{ rows },
...(footer.length ? [{ text: footer.join(' · ') }] : [])
])
})
)
.catch(ctx.guardedErr)
return
}
runViaSlashWorker()
}
},
{
help: 'enable or disable tools (client-side history reset on change)',
name: 'tools',
run: (arg, ctx, cmd) => {
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
if (subcommand !== 'disable' && subcommand !== 'enable') {
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 || '/tools: no output'
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(text, 'Tools') : ctx.transcript.sys(text)
})
.catch(ctx.guardedErr)
return
}
if (!names.length) {
ctx.transcript.sys(`usage: /tools ${subcommand} <name> [name ...]`)
ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`)
ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`)
return
}
ctx.gateway
.rpc<ToolsConfigureResponse>('tools.configure', { action: subcommand, names, session_id: ctx.sid })
.then(
ctx.guarded<ToolsConfigureResponse>(r => {
if (r.info) {
ctx.session.setSessionStartedAt(Date.now())
ctx.session.resetVisibleHistory(r.info)
}
if (r.changed?.length) {
ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`)
}
if (r.unknown?.length) {
ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`)
}
if (r.missing_servers?.length) {
ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`)
}
if (r.reset) {
ctx.transcript.sys('session reset. new tool configuration is active.')
}
})
)
.catch(ctx.guardedErr)
}
}
]
+554
View File
@@ -0,0 +1,554 @@
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
import type {
BackgroundStartResponse,
ConfigGetValueResponse,
ConfigSetResponse,
ImageAttachResponse,
SessionBranchResponse,
SessionCompressResponse,
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'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g')
const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim()
const modelValueForConfigSet = (arg: string) => {
const trimmed = arg.trim()
if (!trimmed) {
return trimmed
}
if (TUI_SESSION_MODEL_RE.test(trimmed)) {
return stripTuiSessionFlag(trimmed)
}
return trimmed
}
export const sessionCommands: SlashCommand[] = [
{
aliases: ['bg', 'btw'],
help: 'launch a background prompt',
name: 'background',
run: (arg, ctx) => {
if (!arg) {
return ctx.transcript.sys('/background <prompt>')
}
ctx.gateway.rpc<BackgroundStartResponse>('prompt.background', { session_id: ctx.sid, text: arg }).then(
ctx.guarded<BackgroundStartResponse>(r => {
if (!r.task_id) {
return
}
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id!) }))
ctx.transcript.sys(`bg ${r.task_id} started`)
})
)
}
},
{
help: 'change or show model',
name: 'model',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('change models')) {
return
}
if (!arg.trim()) {
return patchOverlayState({ modelPicker: true })
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) })
.then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return ctx.transcript.sys('error: invalid response: model switch')
}
ctx.transcript.sys(`model → ${r.value}`)
ctx.local.maybeWarn(r)
patchUiState(state => ({
...state,
info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
}))
})
)
}
},
{
aliases: ['switch'],
help: 'switch between live TUI sessions',
name: 'sessions',
run: (arg, ctx) => {
if (arg.trim().toLowerCase() === 'new') {
return ctx.session.newLiveSession()
}
patchOverlayState({ sessions: true })
}
},
{
help: 'attach an image',
name: 'image',
run: (arg, ctx) => {
ctx.gateway.rpc<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then(
ctx.guarded<ImageAttachResponse>(r => {
ctx.transcript.sys(attachedImageNotice(r))
if (r.remainder) {
ctx.composer.setInput(r.remainder)
}
})
)
}
},
{
help: 'switch personality for this session',
name: 'personality',
run: (arg, ctx) => {
if (!arg) {
return
}
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then(
ctx.guarded<ConfigSetResponse>(r => {
if (r.history_reset) {
ctx.session.resetVisibleHistory(r.info ?? null)
}
ctx.transcript.sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`)
ctx.local.maybeWarn(r)
})
)
}
},
{
help: 'compress transcript',
name: 'compress',
run: (arg, ctx) => {
ctx.gateway
.rpc<SessionCompressResponse>('session.compress', {
session_id: ctx.sid,
...(arg ? { focus_topic: arg } : {})
})
.then(
ctx.guarded<SessionCompressResponse>(r => {
if (Array.isArray(r.messages)) {
const rows = toTranscriptMessages(r.messages)
ctx.transcript.setHistoryItems(r.info ? [introMsg(r.info), ...rows] : rows)
}
if (r.info) {
patchUiState({ info: r.info })
}
if (r.usage) {
patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } }))
}
if (r.summary?.headline) {
const prefix = r.summary.noop ? '' : '✓ '
ctx.transcript.sys(`${prefix}${r.summary.headline}`)
if (r.summary.token_line) {
ctx.transcript.sys(` ${r.summary.token_line}`)
}
if (r.summary.note) {
ctx.transcript.sys(` ${r.summary.note}`)
}
return
}
if ((r.removed ?? 0) <= 0) {
return ctx.transcript.sys('nothing to compress')
}
ctx.transcript.sys(
`compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}`
)
})
)
.catch(ctx.guardedErr)
}
},
{
aliases: ['fork'],
help: 'branch the session',
name: 'branch',
run: (arg, ctx) => {
const prevSid = ctx.sid
ctx.gateway.rpc<SessionBranchResponse>('session.branch', { name: arg, session_id: ctx.sid }).then(
ctx.guarded<SessionBranchResponse>(r => {
if (!r.session_id) {
return
}
void ctx.session.closeSession(prevSid)
patchUiState({ sid: r.session_id })
ctx.session.setSessionStartedAt(Date.now())
ctx.transcript.sys(`branched → ${r.title ?? ''}`)
})
)
}
},
{
help: 'voice mode: [on|off|tts|status]',
name: 'voice',
run: (arg, ctx) => {
const normalized = (arg ?? '').trim().toLowerCase()
const action =
normalized === 'on' || normalized === 'off' || normalized === 'tts' || normalized === 'status'
? normalized
: 'status'
ctx.gateway.rpc<VoiceToggleResponse>('voice.toggle', { action }).then(
ctx.guarded<VoiceToggleResponse>(r => {
ctx.voice.setVoiceEnabled(!!r.enabled)
ctx.voice.setVoiceTts(!!r.tts)
// 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.
if (action === 'status') {
const mode = r.enabled ? 'ON' : 'OFF'
const tts = r.tts ? 'ON' : 'OFF'
ctx.transcript.sys('Voice Mode Status')
ctx.transcript.sys(` Mode: ${mode}`)
ctx.transcript.sys(` TTS: ${tts}`)
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 record-key press.
if (r.details) {
ctx.transcript.sys('')
ctx.transcript.sys(' Requirements:')
for (const line of r.details.split('\n')) {
if (line.trim()) {
ctx.transcript.sys(` ${line}`)
}
}
}
return
}
if (action === 'tts') {
ctx.transcript.sys(`Voice TTS ${r.tts ? 'enabled' : 'disabled'}.`)
return
}
// on/off — mirror cli.py:_enable_voice_mode's 3-line output
if (r.enabled) {
const tts = r.tts ? ' (TTS enabled)' : ''
ctx.transcript.sys(`Voice mode enabled${tts}`)
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 {
ctx.transcript.sys('Voice mode disabled.')
}
})
)
}
},
{
help: 'switch theme skin (fires skin.changed)',
name: 'skin',
run: (arg, ctx) => {
if (!arg) {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'skin' })
.then(ctx.guarded<ConfigGetValueResponse>(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`)))
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'skin', value: arg })
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`skin → ${r.value}`)))
}
},
{
help: 'pick the busy indicator: kaomoji (default), emoji, unicode (braille), or ascii',
name: 'indicator',
usage: `/indicator [${INDICATOR_STYLES.join('|')}]`,
run: (arg, ctx) => {
const value = arg.trim().toLowerCase()
if (!value) {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'indicator' })
.then(
ctx.guarded<ConfigGetValueResponse>(r =>
ctx.transcript.sys(`indicator: ${r.value || DEFAULT_INDICATOR_STYLE}`)
)
)
}
if (!(INDICATOR_STYLES as readonly string[]).includes(value)) {
return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`)
}
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value }).then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return
}
// Hot-swap the running TUI immediately so the next render
// uses the new style without waiting for the 5s mtime poll
// to re-apply config.full.
patchUiState({ indicatorStyle: value as IndicatorStyle })
ctx.transcript.sys(`indicator → ${r.value}`)
})
)
}
},
{
help: 'toggle yolo mode (per-session approvals)',
name: 'yolo',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: ctx.sid })
.then(ctx.guarded<ConfigSetResponse>(r => ctx.transcript.sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)))
}
},
{
help: 'inspect or set reasoning effort (updates live agent)',
name: 'reasoning',
run: (arg, ctx) => {
if (!arg) {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'reasoning' })
.then(
ctx.guarded<ConfigGetValueResponse>(
r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
)
)
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg })
.then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return
}
if (r.value === 'hide') {
patchUiState(state => ({
...state,
sections: { ...state.sections, thinking: 'hidden' },
showReasoning: false
}))
} else if (r.value === 'show') {
patchUiState(state => ({
...state,
sections: { ...state.sections, thinking: 'expanded' },
showReasoning: true
}))
}
ctx.transcript.sys(`reasoning: ${r.value}`)
})
)
}
},
{
help: 'toggle fast mode [normal|fast|status|on|off|toggle]',
name: 'fast',
run: (arg, ctx) => {
const mode = arg.trim().toLowerCase()
const valid = new Set(['', 'status', 'normal', 'fast', 'on', 'off', 'toggle'])
if (!valid.has(mode)) {
return ctx.transcript.sys('usage: /fast [normal|fast|status|on|off|toggle]')
}
if (!mode || mode === 'status') {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'fast', session_id: ctx.sid })
.then(
ctx.guarded<ConfigGetValueResponse>(r =>
ctx.transcript.sys(`fast mode: ${r.value === 'fast' ? 'fast' : 'normal'}`)
)
)
.catch(ctx.guardedErr)
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'fast', session_id: ctx.sid, value: mode })
.then(
ctx.guarded<ConfigSetResponse>(r => {
const next = r.value === 'fast' ? 'fast' : 'normal'
ctx.transcript.sys(`fast mode: ${next}`)
patchUiState(state => ({
...state,
info: state.info
? {
...state.info,
fast: next === 'fast',
service_tier: next === 'fast' ? 'priority' : ''
}
: state.info
}))
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'control busy enter mode [queue|steer|interrupt|status]',
name: 'busy',
run: (arg, ctx) => {
const mode = arg.trim().toLowerCase()
const valid = new Set(['', 'status', 'queue', 'steer', 'interrupt'])
if (!valid.has(mode)) {
return ctx.transcript.sys('usage: /busy [queue|steer|interrupt|status]')
}
if (!mode || mode === 'status') {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'busy' })
.then(
ctx.guarded<ConfigGetValueResponse>(r => {
const current = r.value || 'interrupt'
ctx.transcript.sys(`busy input mode: ${current}`)
})
)
.catch(ctx.guardedErr)
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'busy', value: mode })
.then(
ctx.guarded<ConfigSetResponse>(r => {
const next = r.value || mode
ctx.transcript.sys(`busy input mode: ${next}`)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'cycle verbose tool-output mode (updates live agent)',
name: 'verbose',
run: (arg, ctx) => {
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' })
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`verbose: ${r.value}`)))
}
},
{
help: 'session usage (live counts — worker sees zeros)',
name: 'usage',
run: (_arg, ctx) => {
ctx.gateway.rpc<SessionUsageResponse>('session.usage', { session_id: ctx.sid }).then(r => {
if (ctx.stale()) {
return
}
if (r) {
patchUiState({
usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 }
})
}
if (!r?.calls) {
return ctx.transcript.sys('no API calls yet')
}
const f = (v: number | undefined) => (v ?? 0).toLocaleString()
const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
const rows: [string, string][] = [
['Model', r.model ?? ''],
['Input tokens', f(r.input)],
['Cache read tokens', f(r.cache_read)],
['Cache write tokens', f(r.cache_write)],
['Output tokens', f(r.output)],
['Total tokens', f(r.total)],
['API calls', f(r.calls)]
]
if (cost) {
rows.push(['Cost', cost])
}
const sections: PanelSection[] = [{ rows }]
if (r.context_max) {
sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` })
}
if (r.compressions) {
sections.push({ text: `Compressions: ${r.compressions}` })
}
ctx.transcript.panel('Usage', sections)
})
}
}
]
+20
View File
@@ -0,0 +1,20 @@
import { withInkSuspended } from '@hermes/ink'
import { launchHermesCommand } from '../../../lib/externalCli.js'
import { runExternalSetup } from '../../setupHandoff.js'
import type { SlashCommand } from '../types.js'
export const setupCommands: SlashCommand[] = [
{
help: 'run full setup wizard (launches `hermes setup`)',
name: 'setup',
run: (arg, ctx) =>
void runExternalSetup({
args: ['setup', ...arg.split(/\s+/).filter(Boolean)],
ctx,
done: 'setup complete — starting session…',
launcher: launchHermesCommand,
suspend: withInkSuspended
})
}
]
+20
View File
@@ -0,0 +1,20 @@
import { coreCommands } from './commands/core.js'
import { debugCommands } from './commands/debug.js'
import { opsCommands } from './commands/ops.js'
import { sessionCommands } from './commands/session.js'
import { setupCommands } from './commands/setup.js'
import type { SlashCommand } from './types.js'
export const SLASH_COMMANDS: SlashCommand[] = [
...coreCommands,
...sessionCommands,
...opsCommands,
...setupCommands,
...debugCommands
]
const byName = new Map<string, SlashCommand>(
SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const))
)
export const findSlashCommand = (name: string) => byName.get(name.toLowerCase())
+21
View File
@@ -0,0 +1,21 @@
import type { MutableRefObject } from 'react'
import type { SlashHandlerContext, UiState } from '../interfaces.js'
export interface SlashRunCtx extends SlashHandlerContext {
flight: number
guarded: <T>(fn: (r: T) => void) => (r: null | T) => void
guardedErr: (e: unknown) => void
sid: null | string
slashFlightRef: MutableRefObject<number>
stale: () => boolean
ui: UiState
}
export interface SlashCommand {
aliases?: string[]
help?: string
name: string
run: (arg: string, ctx: SlashRunCtx, cmd: string) => void
usage?: string
}
+159
View File
@@ -0,0 +1,159 @@
import { atom } from 'nanostores'
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
import type { SubagentProgress, SubagentStatus } from '../types.js'
export interface SpawnSnapshot {
finishedAt: number
fromDisk?: boolean
id: string
label: string
path?: string
sessionId: null | string
startedAt: number
subagents: SubagentProgress[]
}
export interface SpawnDiffPair {
baseline: SpawnSnapshot
candidate: SpawnSnapshot
}
const HISTORY_LIMIT = 10
const KNOWN_SUBAGENT_STATUSES = new Set<SubagentStatus>([
'completed',
'error',
'failed',
'interrupted',
'queued',
'running',
'timeout'
])
const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => {
if (typeof status !== 'string') {
return fallback
}
const normalized = status.toLowerCase() as SubagentStatus
return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback
}
export const $spawnHistory = atom<SpawnSnapshot[]>([])
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
export const getSpawnHistory = () => $spawnHistory.get()
export const getSpawnDiff = () => $spawnDiff.get()
export const clearSpawnHistory = () => $spawnHistory.set([])
export const clearDiffPair = () => $spawnDiff.set(null)
export const setDiffPair = (pair: SpawnDiffPair) => $spawnDiff.set(pair)
/**
* Commit a finished turn's spawn tree to history. Keeps the last 10
* non-empty snapshots empty turns (no subagents) are dropped.
*
* Why in-memory? The primary investigation loop is "I just ran a fan-out,
* it misbehaved, let me look at what happened" same-session debugging.
* Disk persistence across process restarts is a natural extension but
* adds RPC surface for a less-common path.
*/
export const pushSnapshot = (
subagents: readonly SubagentProgress[],
meta: { sessionId?: null | string; startedAt?: null | number }
) => {
if (!subagents.length) {
return
}
const now = Date.now()
const started = meta.startedAt ?? Math.min(...subagents.map(s => s.startedAt ?? now))
const snap: SpawnSnapshot = {
finishedAt: now,
id: `snap-${now.toString(36)}`,
label: summarizeLabel(subagents),
sessionId: meta.sessionId ?? null,
startedAt: Number.isFinite(started) ? started : now,
subagents: subagents.map(item => ({ ...item }))
}
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
$spawnHistory.set(next)
}
function summarizeLabel(subagents: readonly SubagentProgress[]): string {
const top = subagents
.filter(s => s.parentId == null || subagents.every(o => o.id !== s.parentId))
.slice(0, 2)
.map(s => s.goal || 'subagent')
.join(' · ')
return top || `${subagents.length} agent${subagents.length === 1 ? '' : 's'}`
}
/**
* Push a disk-loaded snapshot onto the front of the history stack so the
* overlay can pick it up at index 1 via /replay load. Normalises the
* server payload (arbitrary list) into the same SubagentProgress shape
* used for live data defensive against cross-version reads.
*/
export const pushDiskSnapshot = (r: SpawnTreeLoadResponse, path: string) => {
const raw = Array.isArray(r.subagents) ? r.subagents : []
const normalised = raw.map(normaliseSubagent)
if (!normalised.length) {
return
}
const snap: SpawnSnapshot = {
finishedAt: (r.finished_at ?? Date.now() / 1000) * 1000,
fromDisk: true,
id: `disk-${path}`,
label: r.label || `${normalised.length} subagents`,
path,
sessionId: r.session_id ?? null,
startedAt: (r.started_at ?? r.finished_at ?? Date.now() / 1000) * 1000,
subagents: normalised
}
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
$spawnHistory.set(next)
}
function normaliseSubagent(raw: unknown): SubagentProgress {
const o = raw as Record<string, unknown>
const s = (v: unknown) => (typeof v === 'string' ? v : undefined)
const n = (v: unknown) => (typeof v === 'number' ? v : undefined)
const arr = <T>(v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined)
return {
apiCalls: n(o.apiCalls),
costUsd: n(o.costUsd),
depth: typeof o.depth === 'number' ? o.depth : 0,
durationSeconds: n(o.durationSeconds),
filesRead: arr<string>(o.filesRead),
filesWritten: arr<string>(o.filesWritten),
goal: s(o.goal) ?? 'subagent',
id: s(o.id) ?? `sa-${Math.random().toString(36).slice(2, 8)}`,
index: typeof o.index === 'number' ? o.index : 0,
inputTokens: n(o.inputTokens),
iteration: n(o.iteration),
model: s(o.model),
notes: (arr<string>(o.notes) ?? []).filter(x => typeof x === 'string'),
outputTail: arr(o.outputTail) as SubagentProgress['outputTail'],
outputTokens: n(o.outputTokens),
parentId: s(o.parentId) ?? null,
reasoningTokens: n(o.reasoningTokens),
startedAt: n(o.startedAt),
status: normalizeSubagentStatus(o.status, 'completed'),
summary: s(o.summary),
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),
toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0,
tools: (arr<string>(o.tools) ?? []).filter(x => typeof x === 'string'),
toolsets: arr<string>(o.toolsets)
}
}
+866
View File
@@ -0,0 +1,866 @@
import {
REASONING_PULSE_MS,
STREAM_BATCH_MS,
STREAM_IDLE_BATCH_MS,
STREAM_SCROLL_BATCH_MS,
STREAM_TYPING_BATCH_MS
} from '../config/timing.js'
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
import { appendToolShelfMessage, isToolShelfMessage } from '../lib/liveProgress.js'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import {
boundedLiveRenderText,
buildToolTrailLine,
buildVerboseToolTrailLine,
estimateTokensRough,
isTransientTrailLine,
sameToolTrailGroup,
toolTrailLabel
} from '../lib/text.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
import { resetFlowOverlays } from './overlayStore.js'
import { pushSnapshot } from './spawnHistoryStore.js'
import { archiveDoneTodos, getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const INTERRUPT_COOLDOWN_MS = 1500
const ACTIVITY_LIMIT = 8
const TRAIL_LIMIT = 8
// Extracts the raw patch from a diff-only segment produced by
// pushInlineDiffSegment. Used at message.complete to dedupe against final
// assistant text that narrates the same patch. Returns null for anything
// else so real assistant narration never gets touched.
const diffSegmentBody = (msg: Msg): null | string => {
if (msg.kind !== 'diff') {
return null
}
const m = msg.text.match(/^```diff\n([\s\S]*?)\n```$/)
return m ? m[1]! : null
}
const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens)
const isTodoStatus = (status: unknown): status is TodoItem['status'] =>
status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled'
const parseTodos = (value: unknown): null | TodoItem[] => {
if (!Array.isArray(value)) {
return null
}
return value
.map(item => {
if (!item || typeof item !== 'object') {
return null
}
const row = item as Record<string, unknown>
const status = row.status
if (!isTodoStatus(status)) {
return null
}
return {
content: String(row.content ?? '').trim(),
id: String(row.id ?? '').trim(),
status
}
})
.filter((item): item is TodoItem => Boolean(item?.id && item.content))
}
const textSegments = (segments: Msg[]) =>
segments.filter(msg => msg.role === 'assistant' && msg.kind !== 'diff').map(msg => msg.text)
const finalTail = (finalText: string, segments: Msg[]) => {
let tail = finalText
for (const text of textSegments(segments)) {
const trimmed = text.trim()
if (trimmed && tail.startsWith(trimmed)) {
tail = tail.slice(trimmed.length).trimStart()
}
}
return tail
}
export interface InterruptDeps {
appendMessage: (msg: Msg) => void
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
sid: string
sys: (text: string) => void
}
type Timer = null | ReturnType<typeof setTimeout>
const clear = (t: Timer): null => {
if (t) {
clearTimeout(t)
}
return null
}
class TurnController {
bufRef = ''
interrupted = false
lastStatusNote = ''
persistedToolLabels = new Set<string>()
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
protocolWarned = false
reasoningText = ''
segmentMessages: Msg[] = []
pendingSegmentTools: string[] = []
statusTimer: Timer = null
toolTokenAcc = 0
turnTools: string[] = []
private activeTools: ActiveTool[] = []
private activeReasoningText = ''
private reasoningSegmentIndex: null | number = null
private activityId = 0
private reasoningStreamingTimer: Timer = null
private reasoningTimer: Timer = null
private streamTimer: Timer = null
private streamDelay = STREAM_IDLE_BATCH_MS
private toolProgressTimer: Timer = null
boostStreamingForTyping() {
this.streamDelay = STREAM_TYPING_BATCH_MS
}
boostStreamingForScroll() {
this.streamDelay = Math.max(this.streamDelay, STREAM_SCROLL_BATCH_MS)
}
relaxStreaming() {
this.streamDelay = STREAM_IDLE_BATCH_MS
}
clearReasoning() {
this.reasoningTimer = clear(this.reasoningTimer)
this.activeReasoningText = ''
this.reasoningSegmentIndex = null
this.reasoningText = ''
this.toolTokenAcc = 0
patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 })
}
clearStatusTimer() {
this.statusTimer = clear(this.statusTimer)
}
endReasoningPhase() {
this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer)
patchTurnState({ reasoningActive: false, reasoningStreaming: false })
}
idle() {
this.endReasoningPhase()
this.activeTools = []
this.streamTimer = clear(this.streamTimer)
this.bufRef = ''
this.pendingSegmentTools = []
this.segmentMessages = []
patchTurnState({
streamPendingTools: [],
streamSegments: [],
streaming: '',
subagents: [],
tools: [],
turnTrail: []
})
patchUiState({ busy: false })
resetFlowOverlays()
}
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
this.interrupted = true
gw.request<SessionInterruptResponse>('session.interrupt', { session_id: sid }).catch(() => {})
this.closeReasoningSegment()
const segments = this.segmentMessages
const partial = this.bufRef.trimStart()
const tools = this.pendingSegmentTools
// Drain streaming/segment state off the nanostore before writing the
// preserved snapshot to the transcript — otherwise each flushed segment
// appears in both `turn.streamSegments` and the transcript for one frame.
this.idle()
this.clearReasoning()
this.turnTools = []
patchTurnState({ activity: [], outcome: '' })
for (const msg of segments) {
appendMessage(msg)
}
// Always surface an interruption indicator — if there's an in-flight
// `partial` or pending tools, fold them into a single assistant message;
// otherwise emit a sys note so the transcript always records that the
// turn was cancelled, even when only prior `segments` were preserved.
if (partial || tools.length) {
appendMessage({
role: 'assistant',
text: partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*',
...(tools.length && { tools })
})
} else {
sys('interrupted')
}
patchUiState({ status: 'interrupted' })
this.clearStatusTimer()
this.statusTimer = setTimeout(() => {
this.statusTimer = null
patchUiState({ status: 'ready' })
}, INTERRUPT_COOLDOWN_MS)
}
pruneTransient() {
this.turnTools = this.turnTools.filter(line => !isTransientTrailLine(line))
patchTurnState(state => {
const next = state.turnTrail.filter(line => !isTransientTrailLine(line))
return next.length === state.turnTrail.length ? state : { ...state, turnTrail: next }
})
}
private syncReasoningSegment() {
const thinking = this.activeReasoningText.trim()
if (!thinking) {
return
}
const msg: Msg = {
kind: 'trail',
role: 'system',
text: '',
thinking,
thinkingTokens: estimateTokensRough(thinking),
toolTokens: this.toolTokenAcc || undefined
}
if (this.reasoningSegmentIndex === null) {
this.reasoningSegmentIndex = this.segmentMessages.length
this.segmentMessages = [...this.segmentMessages, msg]
} else {
this.segmentMessages = this.segmentMessages.map((item, i) => (i === this.reasoningSegmentIndex ? msg : item))
}
patchTurnState({ streamSegments: this.segmentMessages })
}
private closeReasoningSegment() {
this.syncReasoningSegment()
this.activeReasoningText = ''
this.reasoningSegmentIndex = null
}
private pushSegment(msg: Msg) {
this.segmentMessages = appendToolShelfMessage(this.segmentMessages, msg)
}
flushStreamingSegment() {
const raw = this.bufRef.trimStart()
const split = raw
? hasReasoningTag(raw)
? splitReasoning(raw)
: { reasoning: '', text: raw }
: { reasoning: '', text: '' }
if (split.reasoning && !this.reasoningText.trim()) {
this.reasoningText = split.reasoning
this.activeReasoningText = split.reasoning
patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
this.syncReasoningSegment()
}
const msg: Msg = {
role: split.text ? 'assistant' : 'system',
text: split.text,
...(!split.text && { kind: 'trail' as const }),
...(this.pendingSegmentTools.length && { tools: this.pendingSegmentTools })
}
this.streamTimer = clear(this.streamTimer)
if (split.text || hasDetails(msg)) {
this.pushSegment(msg)
}
this.pendingSegmentTools = []
this.bufRef = ''
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
}
pulseReasoningStreaming() {
this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer)
patchTurnState({ reasoningActive: true, reasoningStreaming: true })
this.reasoningStreamingTimer = setTimeout(() => {
this.reasoningStreamingTimer = null
patchTurnState({ reasoningStreaming: false })
}, REASONING_PULSE_MS)
}
recordTodos(value: unknown) {
if (this.interrupted) {
return
}
const todos = parseTodos(value)
if (todos !== null) {
patchTurnState({ todos })
}
}
private flushPendingToolsIntoLastSegment() {
if (!this.pendingSegmentTools.length) {
return false
}
const next = appendToolShelfMessage(this.segmentMessages, {
kind: 'trail',
role: 'system',
text: '',
tools: this.pendingSegmentTools
})
if (next.length === this.segmentMessages.length + 1) {
return false
}
this.segmentMessages = next
this.pendingSegmentTools = []
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages })
return true
}
pushInlineDiffSegment(diffText: string, tools: string[] = []) {
// Strip CLI chrome the gateway emits before the unified diff (e.g. a
// leading "┊ review diff" header written by `_emit_inline_diff` for the
// terminal printer). That header only makes sense as stdout dressing,
// not inside a markdown ```diff block.
const stripped = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
if (!stripped) {
return
}
// Flush any in-progress streaming text as its own segment first, so the
// diff lands BETWEEN the assistant narration that preceded the edit and
// whatever the agent streams afterwards — not glued onto the final
// message. This is the whole point of segment-anchored diffs: the diff
// renders where the edit actually happened.
this.flushStreamingSegment()
const block = `\`\`\`diff\n${stripped}\n\`\`\``
// Skip consecutive duplicates (same tool firing tool.complete twice, or
// two edits producing the same patch). Keeping this cheap — deeper
// dedupe against the final assistant text happens at message.complete.
if (this.segmentMessages.at(-1)?.text === block) {
return
}
this.segmentMessages = [
...this.segmentMessages,
{ kind: 'diff', role: 'assistant', text: block, ...(tools.length && { tools }) }
]
patchTurnState({ streamSegments: this.segmentMessages })
}
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
patchTurnState(state => {
const base = replaceLabel
? state.activity.filter(item => !sameToolTrailGroup(replaceLabel, item.text))
: state.activity
const tail = base.at(-1)
if (tail?.text === text && tail.tone === tone) {
return state
}
return { ...state, activity: [...base, { id: ++this.activityId, text, tone }].slice(-ACTIVITY_LIMIT) }
})
}
pushTrail(line: string) {
if (this.interrupted) {
return
}
patchTurnState(state => {
if (state.turnTrail.at(-1) === line) {
return state
}
const next = [...state.turnTrail.filter(item => !isTransientTrailLine(item)), line].slice(-TRAIL_LIMIT)
this.turnTools = next
return { ...state, turnTrail: next }
})
}
recordError() {
this.idle()
this.clearReasoning()
this.clearStatusTimer()
this.pendingSegmentTools = []
this.segmentMessages = []
this.turnTools = []
this.persistedToolLabels.clear()
}
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
this.closeReasoningSegment()
// Ink renders markdown via <Md>; the gateway's Rich-rendered ANSI
// (`payload.rendered`) is for terminals that can't. Prioritising
// `rendered` here garbles output whenever a user opts into
// `display.final_response_markdown: render` because raw ANSI escapes
// pass through into the React tree. Prefer raw text and fall back
// only when the gateway elected not to send any (#16391).
const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart()
const split = splitReasoning(rawText)
const finalText = finalTail(split.text, this.segmentMessages)
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
const savedToolTokens = this.toolTokenAcc
let tools = this.pendingSegmentTools
const last = this.segmentMessages[this.segmentMessages.length - 1]
if (tools.length && isToolShelfMessage(last)) {
this.segmentMessages = [
...this.segmentMessages.slice(0, -1),
{ ...last, tools: [...(last.tools ?? []), ...tools] }
]
this.pendingSegmentTools = []
tools = []
}
// Drop diff-only segments the agent is about to narrate in the final
// reply. Without this, a closing "here's the diff …" message would
// render two stacked copies of the same patch. Only touches segments
// with `kind: 'diff'` emitted by pushInlineDiffSegment — real
// assistant narration stays put.
const finalHasOwnDiffFence = /```(?:diff|patch)\b/i.test(finalText)
const segments = this.segmentMessages.filter(msg => {
const body = diffSegmentBody(msg)
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
const hasReasoningSegment =
this.reasoningSegmentIndex !== null || segments.some(msg => Boolean(msg.thinking?.trim()))
const finalThinking = hasReasoningSegment ? '' : savedReasoning.trim()
const finalDetails: Msg = {
kind: 'trail',
role: 'system',
text: '',
thinking: finalThinking || undefined,
thinkingTokens: finalThinking ? estimateTokensRough(finalThinking) : undefined,
toolTokens: savedToolTokens || undefined,
...(tools.length && { tools })
}
// Archive prepended so the trail msg anchors under the user prompt,
// not between thinking/tools and final assistant text.
const finalMessages: Msg[] = [
...archiveDoneTodos(),
...segments,
...(hasDetails(finalDetails) ? [finalDetails] : [])
]
if (finalText) {
finalMessages.push({ role: 'assistant', text: finalText })
}
const wasInterrupted = this.interrupted
// Archive the turn's spawn tree to history BEFORE idle() drops subagents
// from turnState. Lets /replay and the overlay's history nav pull up
// finished fan-outs without a round-trip to disk.
const finishedSubagents = getTurnState().subagents
const sessionId = getUiState().sid
if (finishedSubagents.length > 0) {
pushSnapshot(finishedSubagents, { sessionId, startedAt: null })
// Fire-and-forget disk persistence so /replay survives process restarts.
// The same snapshot lives in memory via spawnHistoryStore for immediate
// recall — disk is the long-term archive.
void this.persistSpawnTree?.(finishedSubagents, sessionId)
}
this.idle()
this.clearReasoning()
this.turnTools = []
this.persistedToolLabels.clear()
this.bufRef = ''
this.interrupted = false
patchTurnState({ activity: [], outcome: '' })
return { finalMessages, finalText, wasInterrupted }
}
recordMessageDelta({ text }: { rendered?: string; text?: string }) {
if (this.interrupted || !text) {
return
}
this.pruneTransient()
this.endReasoningPhase()
// Always accumulate the raw text delta. The pre-#16391 path replaced
// the entire buffer with `rendered` (an *incremental* Rich ANSI
// fragment), which on every tick discarded everything streamed so far
// — visible as overlapping coloured text and lost prose under
// `display.final_response_markdown: render`.
this.bufRef += text
if (getUiState().streaming) {
this.scheduleStreaming()
}
}
recordReasoningAvailable(text: string, force = false) {
if (this.interrupted || (!force && !getUiState().showReasoning)) {
return
}
const incoming = text.trim()
if (!incoming || this.reasoningText.trim()) {
return
}
this.reasoningText = incoming
this.activeReasoningText = incoming
this.scheduleReasoning()
this.syncReasoningSegment()
this.pulseReasoningStreaming()
}
recordReasoningDelta(text: string, force = false) {
if (this.interrupted || (!force && !getUiState().showReasoning)) {
return
}
if (!this.activeReasoningText.trim() && this.pendingSegmentTools.length) {
this.flushStreamingSegment()
}
this.reasoningText += text
this.activeReasoningText += text
if (this.reasoningText.length > 80_000) {
this.reasoningText = this.reasoningText.slice(-60_000)
}
this.scheduleReasoning()
this.syncReasoningSegment()
this.pulseReasoningStreaming()
}
recordToolComplete(
toolId: string,
fallbackName?: string,
error?: string,
summary?: string,
duration?: number,
todos?: unknown,
resultText?: string
) {
if (this.interrupted) {
return
}
this.recordTodos(todos)
const line = this.completeTool(toolId, fallbackName, error, summary, duration, resultText)
this.pendingSegmentTools = [...this.pendingSegmentTools, line]
this.flushPendingToolsIntoLastSegment()
this.publishToolState()
}
recordInlineDiffToolComplete(
diffText: string,
toolId: string,
fallbackName?: string,
error?: string,
duration?: number,
resultText?: string
) {
if (this.interrupted) {
return
}
this.flushStreamingSegment()
this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '', duration, resultText)])
this.publishToolState()
}
private completeTool(
toolId: string,
fallbackName?: string,
error?: string,
summary?: string,
duration?: number,
resultText?: string
) {
const done = this.activeTools.find(tool => tool.id === toolId)
const name = done?.name ?? fallbackName ?? 'tool'
const label = toolTrailLabel(name)
const fallbackDuration = done?.startedAt ? (Date.now() - done.startedAt) / 1000 : undefined
const line =
done?.verboseArgs || resultText
? buildVerboseToolTrailLine(
name,
done?.context || '',
Boolean(error),
duration ?? fallbackDuration,
done?.verboseArgs,
error || resultText || summary || ''
)
: buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '', duration ?? fallbackDuration)
this.activeTools = this.activeTools.filter(tool => tool.id !== toolId)
const next = this.turnTools.filter(item => !sameToolTrailGroup(label, item))
if (!this.activeTools.length) {
next.push('analyzing tool output…')
}
this.turnTools = next.slice(-TRAIL_LIMIT)
return line
}
private publishToolState() {
patchTurnState({
streamPendingTools: this.pendingSegmentTools,
tools: this.activeTools,
turnTrail: this.turnTools
})
}
recordToolProgress(toolName: string, preview: string) {
if (this.interrupted) {
return
}
const index = this.activeTools.findIndex(tool => tool.name === toolName)
if (index < 0) {
return
}
this.activeTools = this.activeTools.map((tool, i) => (i === index ? { ...tool, context: preview } : tool))
if (this.toolProgressTimer) {
return
}
this.toolProgressTimer = setTimeout(() => {
this.toolProgressTimer = null
patchTurnState({ tools: [...this.activeTools] })
}, STREAM_BATCH_MS)
}
recordToolStart(toolId: string, name: string, context: string, verboseArgs?: string) {
if (this.interrupted) {
return
}
this.flushStreamingSegment()
this.closeReasoningSegment()
this.pruneTransient()
this.endReasoningPhase()
const sample = `${name} ${context}`.trim()
this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0
this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now(), verboseArgs }]
patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools })
}
reset() {
this.clearReasoning()
this.clearStatusTimer()
this.idle()
this.bufRef = ''
this.interrupted = false
this.lastStatusNote = ''
this.activeReasoningText = ''
this.pendingSegmentTools = []
this.protocolWarned = false
this.reasoningSegmentIndex = null
this.segmentMessages = []
this.turnTools = []
this.toolTokenAcc = 0
this.persistedToolLabels.clear()
patchTurnState({ activity: [], outcome: '' })
}
fullReset() {
this.reset()
resetTurnState()
}
scheduleReasoning() {
if (this.reasoningTimer) {
return
}
this.reasoningTimer = setTimeout(() => {
this.reasoningTimer = null
patchTurnState({
reasoning: this.reasoningText,
reasoningTokens: estimateTokensRough(this.reasoningText)
})
}, STREAM_BATCH_MS)
}
scheduleStreaming() {
if (this.streamTimer) {
return
}
this.streamTimer = setTimeout(() => {
this.streamTimer = null
const raw = this.bufRef.trimStart()
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
patchTurnState({ streaming: boundedLiveRenderText(visible) })
}, this.streamDelay)
}
hydrateStreamingText(text: string) {
this.streamTimer = clear(this.streamTimer)
this.bufRef = text
const raw = this.bufRef.trimStart()
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
patchTurnState({ streaming: boundedLiveRenderText(visible) })
}
startMessage() {
this.endReasoningPhase()
this.clearReasoning()
this.activeTools = []
this.activeReasoningText = ''
this.reasoningSegmentIndex = null
this.turnTools = []
this.toolTokenAcc = 0
this.interrupted = false
this.persistedToolLabels.clear()
patchUiState({ busy: true })
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
}
upsertSubagent(
p: SubagentEventPayload,
patch: (current: SubagentProgress) => Partial<SubagentProgress>,
opts: { createIfMissing?: boolean } = { createIfMissing: true }
) {
// Stable id: prefer the server-issued subagent_id (survives nested
// grandchildren + cross-tree joins). Fall back to the composite key
// for older gateways that omit the field — those produce a flat list.
const id = p.subagent_id || `sa:${p.task_index}:${p.goal || 'subagent'}`
patchTurnState(state => {
const existing = state.subagents.find(item => item.id === id)
// Late events (subagent.complete/tool/progress arriving after message.complete
// has already fired idle()) would otherwise resurrect a finished
// subagent into turn.subagents and block the "finished" title on the
// /agents overlay. When `createIfMissing` is false we drop silently.
if (!existing && !opts.createIfMissing) {
return state
}
const base: SubagentProgress = existing ?? {
depth: p.depth ?? 0,
goal: p.goal,
id,
index: p.task_index,
model: p.model,
notes: [],
parentId: p.parent_id ?? null,
startedAt: Date.now(),
status: 'running',
taskCount: p.task_count ?? 1,
thinking: [],
toolCount: p.tool_count ?? 0,
tools: [],
toolsets: p.toolsets
}
// Map snake_case payload keys onto camelCase state. Only overwrite
// when the event actually carries the field; `??` preserves prior
// values across streaming events that emit partial payloads.
const outputTail = p.output_tail
? p.output_tail.map(e => ({
isError: Boolean(e.is_error),
preview: String(e.preview ?? ''),
tool: String(e.tool ?? 'tool')
}))
: base.outputTail
const next: SubagentProgress = {
...base,
apiCalls: p.api_calls ?? base.apiCalls,
costUsd: p.cost_usd ?? base.costUsd,
depth: p.depth ?? base.depth,
filesRead: p.files_read ?? base.filesRead,
filesWritten: p.files_written ?? base.filesWritten,
goal: p.goal || base.goal,
inputTokens: p.input_tokens ?? base.inputTokens,
iteration: p.iteration ?? base.iteration,
model: p.model ?? base.model,
outputTail,
outputTokens: p.output_tokens ?? base.outputTokens,
parentId: p.parent_id ?? base.parentId,
reasoningTokens: p.reasoning_tokens ?? base.reasoningTokens,
taskCount: p.task_count ?? base.taskCount,
toolCount: p.tool_count ?? base.toolCount,
toolsets: p.toolsets ?? base.toolsets,
...patch(base)
}
// Stable order: by spawn (depth, parent, index) rather than insert time.
// Without it, grandchildren can shuffle relative to siblings when
// events arrive out of order under high concurrency.
const subagents = existing
? state.subagents.map(item => (item.id === id ? next : item))
: [...state.subagents, next].sort((a, b) => a.depth - b.depth || a.index - b.index)
return { ...state, subagents }
})
}
}
export const turnController = new TurnController()
export type { TurnController }
+85
View File
@@ -0,0 +1,85 @@
import { atom } from 'nanostores'
import { useSyncExternalStore } from 'react'
import { isTodoDone } from '../lib/liveProgress.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
const buildTurnState = (): TurnState => ({
activity: [],
outcome: '',
reasoning: '',
reasoningActive: false,
reasoningStreaming: false,
reasoningTokens: 0,
streamPendingTools: [],
streamSegments: [],
streaming: '',
subagents: [],
todoCollapsed: false,
todos: [],
toolTokens: 0,
tools: [],
turnTrail: []
})
export const $turnState = atom<TurnState>(buildTurnState())
export const getTurnState = () => $turnState.get()
const subscribeTurn = (cb: () => void) => $turnState.listen(() => cb())
export const useTurnSelector = <T>(selector: (state: TurnState) => T): T =>
useSyncExternalStore(
subscribeTurn,
() => selector($turnState.get()),
() => selector($turnState.get())
)
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
export const toggleTodoCollapsed = () => patchTurnState(state => ({ ...state, todoCollapsed: !state.todoCollapsed }))
export const archiveDoneTodos = () => archiveTodosAtTurnEnd()
export const archiveTodosAtTurnEnd = () => {
const state = $turnState.get()
if (!state.todos.length) {
return []
}
const done = isTodoDone(state.todos)
const msg: Msg = {
kind: 'trail',
role: 'system',
text: '',
todos: state.todos,
...(done ? { todoCollapsedByDefault: true } : { todoIncomplete: true })
}
patchTurnState({ todoCollapsed: false, todos: [] })
return [msg]
}
export const resetTurnState = () => $turnState.set(buildTurnState())
export interface TurnState {
activity: ActivityItem[]
outcome: string
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
reasoningTokens: number
streamPendingTools: string[]
streamSegments: Msg[]
streaming: string
subagents: SubagentProgress[]
todoCollapsed: boolean
todos: TodoItem[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
}
+44
View File
@@ -0,0 +1,44 @@
import { atom, computed } from 'nanostores'
import { MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js'
import { DEFAULT_THEME } from '../theme.js'
import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
const buildUiState = (): UiState => ({
bgTasks: new Set(),
busy: false,
busyInputMode: 'queue',
compact: false,
detailsMode: 'collapsed',
detailsModeCommandOverride: false,
indicatorStyle: DEFAULT_INDICATOR_STYLE,
info: null,
liveSessionCount: 0,
inlineDiffs: true,
mouseTracking: MOUSE_TRACKING,
pasteCollapseLines: 5,
pasteCollapseChars: 2000,
sections: {},
showCost: false,
showReasoning: false,
sid: null,
status: 'summoning hermes…',
statusBar: 'top',
streaming: true,
theme: DEFAULT_THEME,
usage: ZERO
})
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)) =>
$uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next })
export const resetUiState = () => $uiState.set(buildUiState())
+367
View File
@@ -0,0 +1,367 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { useStdin, withInkSuspended } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useMemo, useState } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js'
import { useCompletion } from '../hooks/useCompletion.js'
import { useInputHistory } from '../hooks/useInputHistory.js'
import { useQueue } from '../hooks/useQueue.js'
import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
import { resolveEditor } from '../lib/editor.js'
import { readOsc52Clipboard } from '../lib/osc52.js'
import { isRemoteShellSession } from '../lib/terminalSetup.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
import { $isBlocked } from './overlayStore.js'
import { getUiState } from './uiStore.js'
const PASTE_SNIP_MAX_COUNT = 32
const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024
const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => {
let total = 0
const out: PasteSnippet[] = []
for (let i = snips.length - 1; i >= 0; i--) {
const snip = snips[i]!
const size = snip.text.length
if (out.length >= PASTE_SNIP_MAX_COUNT || total + size > PASTE_SNIP_MAX_TOTAL_BYTES) {
break
}
total += size
out.unshift(snip)
}
return out.length === snips.length ? snips : out
}
/** Insert text at the cursor position, adding spacing to separate from adjacent non-whitespace. */
function insertAtCursor(value: string, cursor: number, text: string): { cursor: number; value: string } {
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
const insert = `${lead}${text}${tail}`
return {
cursor: cursor + insert.length,
value: value.slice(0, cursor) + insert + value.slice(cursor)
}
}
/**
* Quick client-side heuristic to detect text that looks like a dropped file path.
* When this returns true the composer sends RPC calls to the server for actual
* validation. Keep in sync with _detect_file_drop() in cli.py see that
* function for the canonical prefix list.
*/
export function looksLikeDroppedPath(text: string): boolean {
const trimmed = text.trim()
if (!trimmed || trimmed.includes('\n')) {
return false
}
// file:// URIs, relative, home-relative, quoted, and Windows drive paths
if (
trimmed.startsWith('file://') ||
trimmed.startsWith('~/') ||
trimmed.startsWith('./') ||
trimmed.startsWith('../') ||
trimmed.startsWith('"/') ||
trimmed.startsWith("'/") ||
trimmed.startsWith('"~') ||
trimmed.startsWith("'~") ||
/^[A-Za-z]:[/\\]/.test(trimmed) ||
/^["'][A-Za-z]:[/\\]/.test(trimmed)
) {
return true
}
// Bare absolute paths (start with /) — require a second '/' or a '.' to avoid
// false positives on short strings like "/api" or "/help" which would trigger
// unnecessary RPC round-trips.
if (trimmed.startsWith('/')) {
const rest = trimmed.slice(1)
return rest.includes('/') || rest.includes('.')
}
return false
}
export function useComposerState({
gw,
onClipboardPaste,
onImageAttached,
submitRef
}: UseComposerStateOptions): UseComposerStateResult {
const [input, setInput] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([])
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
const isBlocked = useStore($isBlocked)
const { querier } = useStdin() as { querier: Parameters<typeof readOsc52Clipboard>[0] }
const {
queueRef,
queueEditRef,
queuedDisplay,
queueEditIdx,
enqueue,
dequeue,
removeQ,
replaceQ,
setQueueEdit,
syncQueue
} = useQueue()
const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory()
const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw)
const clearIn = useCallback(() => {
setInput('')
setInputBuf([])
setPasteSnips([])
setQueueEdit(null)
setHistoryIdx(null)
historyDraftRef.current = ''
}, [historyDraftRef, setQueueEdit, setHistoryIdx])
const handleResolvedPaste = useCallback(
async ({
bracketed,
cursor,
text,
value
}: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => {
const cleanedText = stripTrailingPasteNewlines(text)
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
if (bracketed) {
void onClipboardPaste(true)
}
return null
}
const sid = getUiState().sid
if (sid && looksLikeDroppedPath(cleanedText)) {
try {
const attached = await gw.request<ImageAttachResponse>('image.attach', {
path: cleanedText,
session_id: sid
})
if (attached?.name) {
onImageAttached?.(attached)
const remainder = attached.remainder?.trim() ?? ''
if (!remainder) {
return { cursor, value }
}
return insertAtCursor(value, cursor, remainder)
}
} catch {
// Fall back to generic file-drop detection below.
}
try {
const dropped = await gw.request<InputDetectDropResponse>('input.detect_drop', {
session_id: sid,
text: cleanedText
})
if (dropped?.matched && dropped.text) {
return insertAtCursor(value, cursor, dropped.text)
}
} catch {
// Fall through to normal text paste behavior.
}
}
const lineCount = cleanedText.split('\n').length
const pasteCollapseLines = getUiState().pasteCollapseLines
const pasteCollapseChars = getUiState().pasteCollapseChars
const linesHit = pasteCollapseLines > 0 && lineCount >= pasteCollapseLines
const charsHit = pasteCollapseChars > 0 && cleanedText.length >= pasteCollapseChars
if (!linesHit && !charsHit) {
return {
cursor: cursor + cleanedText.length,
value: value.slice(0, cursor) + cleanedText + value.slice(cursor)
}
}
const label = pasteTokenLabel(cleanedText, lineCount)
const inserted = insertAtCursor(value, cursor, label)
setPasteSnips(prev => trimSnips([...prev, { label, text: cleanedText }]))
void gw
.request<{ path?: string }>('paste.collapse', { text: cleanedText })
.then(r => {
const path = r?.path
if (!path) {
return
}
setPasteSnips(prev => prev.map(s => (s.label === label ? { ...s, path } : s)))
})
.catch(() => {})
return inserted
},
[gw, onClipboardPaste, onImageAttached]
)
const handleTextPaste = useCallback(
({
bracketed,
cursor,
hotkey,
text,
value
}: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => {
if (hotkey) {
const preferOsc52 = isRemoteShellSession(process.env)
const readPreferredText = preferOsc52
? readOsc52Clipboard(querier).then(async osc52Text => {
if (isUsableClipboardText(osc52Text)) {
return osc52Text
}
return readClipboardText()
})
: readClipboardText().then(async clipText => {
if (isUsableClipboardText(clipText)) {
return clipText
}
return readOsc52Clipboard(querier)
})
return readPreferredText.then(async preferredText => {
if (isUsableClipboardText(preferredText)) {
return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value })
}
void onClipboardPaste(false)
return null
})
}
return handleResolvedPaste({ bracketed: !!bracketed, cursor, text, value })
},
[handleResolvedPaste, onClipboardPaste, querier]
)
const openEditor = useCallback(async () => {
const dir = mkdtempSync(join(tmpdir(), 'hermes-'))
const file = join(dir, 'prompt.md')
const [cmd, ...args] = resolveEditor()
writeFileSync(file, [...inputBuf, input].join('\n'))
let exitCode: null | number = null
await withInkSuspended(async () => {
exitCode = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status
})
try {
if (exitCode !== 0) {
return
}
const text = readFileSync(file, 'utf8').trimEnd()
if (!text) {
return
}
setInput('')
setInputBuf([])
submitRef.current(text)
} finally {
rmSync(dir, { force: true, recursive: true })
}
}, [input, inputBuf, submitRef])
const actions = useMemo(
() => ({
clearIn,
dequeue,
enqueue,
handleTextPaste,
openEditor,
pushHistory,
removeQueue: removeQ,
replaceQueue: replaceQ,
setCompIdx,
setHistoryIdx,
setInput,
setInputBuf,
setPasteSnips,
setQueueEdit,
syncQueue
}),
[
clearIn,
dequeue,
enqueue,
handleTextPaste,
openEditor,
pushHistory,
removeQ,
replaceQ,
setCompIdx,
setHistoryIdx,
setQueueEdit,
syncQueue
]
)
const refs = useMemo(
() => ({
historyDraftRef,
historyRef,
queueEditRef,
queueRef,
submitRef
}),
[historyDraftRef, historyRef, queueEditRef, queueRef, submitRef]
)
const state = useMemo(
() => ({
compIdx,
compReplace,
completions,
historyIdx,
input,
inputBuf,
pasteSnips,
queueEditIdx,
queuedDisplay
}),
[compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay]
)
return {
actions,
refs,
state
}
}
+288
View File
@@ -0,0 +1,288 @@
import type { MouseTrackingMode } from '@hermes/ink'
import { useEffect, useRef } from 'react'
import { resolveDetailsMode, resolveSections } from '../domain/details.js'
import type { GatewayClient } from '../gatewayClient.js'
import type {
ConfigFullResponse,
ConfigMtimeResponse,
ReloadMcpResponse
} from '../gatewayTypes.js'
import {
DEFAULT_VOICE_RECORD_KEY,
type ParsedVoiceRecordKey,
parseVoiceRecordKey
} from '../lib/platform.js'
import { asRpcResult } from '../lib/rpc.js'
import {
type BusyInputMode,
DEFAULT_INDICATOR_STYLE,
INDICATOR_STYLES,
type IndicatorStyle,
type StatusBarMode
} from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
bottom: 'bottom',
off: 'off',
on: 'top',
top: 'top'
}
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
const BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
// TUI defaults to `queue` even though the framework default
// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen
// TUI you're typically authoring the next prompt while the agent is
// still streaming, and an unintended interrupt loses work. Set
// `display.busy_input_mode: interrupt` (or `steer`) explicitly to
// opt out per-config; CLI / messaging adapters keep their `interrupt`
// default unchanged.
const TUI_BUSY_DEFAULT: BusyInputMode = 'queue'
export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
if (typeof raw !== 'string') {
return TUI_BUSY_DEFAULT
}
const v = raw.trim().toLowerCase() as BusyInputMode
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
}
const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
if (typeof raw !== 'string') {
return DEFAULT_INDICATOR_STYLE
}
const v = raw.trim().toLowerCase() as IndicatorStyle
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
}
const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off'])
const TRUTHY_MOUSE_ALL = new Set(['1', 'true', 'yes', 'on', 'all', 'full', 'any'])
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)
// `display.mouse_tracking` accepts boolean (`true` ⇒ all modes, `false` ⇒ off)
// for back-compat, plus the string presets `off|wheel|buttons|all` (aliases:
// `on`/`full`/`any`/`1`/`true`/... → `all`; `0`/`false`/`no`/`off` → `off`).
// `wheel` enables 1000+1006 — scroll wheel + click only, no drag or hover,
// which silences tmux's "No image in clipboard" spam over the prompt row.
// `buttons` adds 1002 so terminal-side text selection drags still register.
// Legacy `tui_mouse` is honored only if `mouse_tracking` is absent.
export const normalizeMouseTracking = (display: {
mouse_tracking?: unknown
tui_mouse?: unknown
}): MouseTrackingMode => {
const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse
if (raw === false || raw === 0) {
return 'off'
}
if (raw === true || raw === undefined || raw === null) {
return 'all'
}
if (typeof raw === 'number') {
return 'all'
}
if (typeof raw !== 'string') {
return 'all'
}
const v = raw.trim().toLowerCase()
if (FALSEY_MOUSE.has(v)) {
return 'off'
}
if (TRUTHY_MOUSE_ALL.has(v)) {
return 'all'
}
if (v === 'wheel' || v === 'scroll') {
return 'wheel'
}
if (v === 'buttons' || v === 'button' || v === 'click') {
return 'buttons'
}
return 'all'
}
const MTIME_POLL_MS = 5000
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
gw: GatewayClient,
method: string,
params: Record<string, unknown> = {}
): Promise<null | T> => {
try {
return asRpcResult<T>(await gw.request<T>(method, params))
} catch {
return null
}
}
const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceRecordKey => {
const raw = cfg?.config?.voice?.record_key
return raw ? parseVoiceRecordKey(raw) : DEFAULT_VOICE_RECORD_KEY
}
const _pasteCollapseLinesFromConfig = (cfg: ConfigFullResponse | null): number => {
if (!cfg?.config) return 5
const raw = cfg.config.paste_collapse_threshold
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw)
if (typeof raw === 'string') {
const n = parseInt(raw, 10)
if (Number.isFinite(n) && n >= 0) return n
}
return 5
}
const _pasteCollapseCharsFromConfig = (cfg: ConfigFullResponse | null): number => {
if (!cfg?.config) return 2000
const raw = cfg.config.paste_collapse_char_threshold
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw)
if (typeof raw === 'string') {
const n = parseInt(raw, 10)
if (Number.isFinite(n) && n >= 0) return n
}
return 2000
}
/** 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,
detailsMode: resolveDetailsMode(d),
detailsModeCommandOverride: false,
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
inlineDiffs: d.inline_diffs !== false,
mouseTracking: normalizeMouseTracking(d),
pasteCollapseLines: _pasteCollapseLinesFromConfig(cfg),
pasteCollapseChars: _pasteCollapseCharsFromConfig(cfg),
sections: resolveSections(d.sections),
showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning,
statusBar: normalizeStatusBar(d.tui_statusbar),
streaming: d.streaming !== false
})
}
export function useConfigSync({
gw,
setBellOnComplete,
setVoiceEnabled,
setVoiceRecordKey,
sid
}: UseConfigSyncOptions) {
const mtimeRef = useRef(0)
useEffect(() => {
if (!sid) {
return
}
// Keep startup cheap: voice.toggle status probes optional audio/STT deps and
// can run long enough to delay prompt.submit on the single stdio RPC pipe.
// Environment flags are enough to initialize the UI bit; the heavier status
// check still runs when the user opens /voice.
setVoiceEnabled(process.env.HERMES_VOICE === '1')
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
})
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
}, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid])
useEffect(() => {
if (!sid) {
return
}
const id = setInterval(() => {
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
const next = Number(r?.mtime ?? 0)
if (!mtimeRef.current) {
if (next) {
mtimeRef.current = next
}
return
}
if (!next || next === mtimeRef.current) {
return
}
mtimeRef.current = next
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
})
}, MTIME_POLL_MS)
return () => clearInterval(id)
}, [gw, setBellOnComplete, setVoiceRecordKey, sid])
}
export interface UseConfigSyncOptions {
gw: GatewayClient
setBellOnComplete: (v: boolean) => void
setVoiceEnabled: (v: boolean) => void
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
sid: null | string
}
+576
View File
@@ -0,0 +1,576 @@
import { forceRedraw, useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { TYPING_IDLE_MS } from '../config/timing.js'
import type {
ApprovalRespondResponse,
ConfigSetResponse,
SecretRespondResponse,
SudoRespondResponse,
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'
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
/**
* Approval / clarify / confirm overlays mount their own `useInput` handlers
* for the in-prompt keys (arrows, numbers, Enter, sometimes Esc). The global
* input handler used to early-return for any other key while one of those
* overlays was up, which silently disabled transcript scrolling the user
* couldn't read context above the prompt that the prompt itself was asking
* about. Returns true when the key is a transcript-scroll input that should
* fall through to the global scroll handlers even while a prompt is active.
*
* Modifier-held wheel (precision mode) is included a user who wants to
* scroll a single line at a time during a prompt expects it to work.
*/
export function shouldFallThroughForScroll(key: {
downArrow: boolean
pageDown: boolean
pageUp: boolean
shift: boolean
upArrow: boolean
wheelDown: boolean
wheelUp: boolean
}): boolean {
if (key.wheelUp || key.wheelDown) {
return true
}
if (key.pageUp || key.pageDown) {
return true
}
if (key.shift && (key.upArrow || key.downArrow)) {
return true
}
return false
}
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
const { actions: cActions, refs: cRefs, state: cState } = composer
const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked)
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
const scrollIdleTimer = useRef<null | ReturnType<typeof setTimeout>>(null)
// Wheel accel ported from claude-code: inter-event timing drives step size,
// direction flips reset. wheelStep (WHEEL_SCROLL_STEP) is the base; final
// rows = wheelStep × accelMult. State mutates in place across renders.
const wheelAccelRef = useRef(initWheelAccelForHost())
const precisionWheelRef = useRef(initPrecisionWheel())
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
const scrollTranscript = (delta: number) => {
if (getUiState().busy) {
turnController.boostStreamingForScroll()
clearTimeout(scrollIdleTimer.current ?? undefined)
scrollIdleTimer.current = setTimeout(() => {
scrollIdleTimer.current = null
turnController.relaxStreaming()
}, TYPING_IDLE_MS)
}
terminal.scrollWithSelection(delta)
}
const copySelection = () => {
// ink's copySelection() already calls setClipboard() which handles
// pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback.
terminal.selection.copySelection()
}
const clearSelection = () => {
terminal.selection.clearSelection()
}
const cancelOverlayFromCtrlC = () => {
if (overlay.clarify) {
return actions.answerClarify('')
}
if (overlay.approval) {
return gateway
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
.then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
}
if (overlay.sudo) {
return gateway
.rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: overlay.sudo.requestId })
.then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled')))
}
if (overlay.secret) {
return gateway
.rpc<SecretRespondResponse>('secret.respond', { request_id: overlay.secret.requestId, value: '' })
.then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled')))
}
if (overlay.modelPicker) {
return patchOverlayState({ modelPicker: false })
}
if (overlay.skillsHub) {
return patchOverlayState({ skillsHub: false })
}
if (overlay.picker) {
return patchOverlayState({ picker: false })
}
if (overlay.agents) {
return patchOverlayState({ agents: false })
}
}
const cycleQueue = (dir: 1 | -1) => {
const len = cRefs.queueRef.current.length
if (!len) {
return false
}
const index = cState.queueEditIdx === null ? (dir > 0 ? 0 : len - 1) : (cState.queueEditIdx + dir + len) % len
cActions.setQueueEdit(index)
cActions.setHistoryIdx(null)
cActions.setInput(cRefs.queueRef.current[index] ?? '')
return true
}
const cycleHistory = (dir: 1 | -1) => {
const h = cRefs.historyRef.current
const cur = cState.historyIdx
if (dir < 0) {
if (!h.length) {
return
}
if (cur === null) {
cRefs.historyDraftRef.current = cState.input
}
const index = cur === null ? h.length - 1 : Math.max(0, cur - 1)
cActions.setHistoryIdx(index)
cActions.setQueueEdit(null)
cActions.setInput(h[index] ?? '')
return
}
if (cur === null) {
return
}
const next = cur + 1
if (next >= h.length) {
cActions.setHistoryIdx(null)
cActions.setInput(cRefs.historyDraftRef.current)
} else {
cActions.setHistoryIdx(next)
cActions.setInput(h[next] ?? '')
}
}
// 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 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 = () => {
if (!voice.enabled) {
return actions.sys('voice: mode is off — enable with /voice on')
}
const starting = !voice.recording
const action = starting ? 'start' : 'stop'
// Optimistic UI — flip the REC badge immediately so the user gets
// feedback while the RPC round-trips; the voice.status event is the
// authoritative source and may correct us.
if (starting) {
voice.setRecording(true)
} else {
voice.setRecording(false)
voice.setProcessing(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}`)
})
}
useInput((ch, key) => {
const live = getUiState()
if (isBlocked) {
// When approval/clarify/confirm overlays are active, their own useInput
// handlers must receive keystrokes (arrow keys, numbers, Enter). Only
// intercept Ctrl+C here so the user can deny/dismiss — all other keys
// fall through to the component-level handlers.
//
// Scroll inputs (wheel / PageUp / PageDown / Shift+↑↓) are special:
// they must reach the transcript scroll handlers below even with a
// prompt up. Long-thread context the prompt is asking about often
// lives above the visible viewport, and being unable to read it while
// answering felt like the prompt had locked the entire UI. Explicitly
// skip the prompt-overlay early-return for scroll keys so they fall
// through to the wheel / PageUp / Shift+arrow handlers below.
const promptOverlay = overlay.approval || overlay.clarify || overlay.confirm
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
if (promptOverlay && !fallThroughForScroll) {
if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC()
}
return
}
if (overlay.pager) {
if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') {
return patchOverlayState({ pager: null })
}
const move = (delta: number | 'top' | 'bottom') =>
patchOverlayState(prev => {
if (!prev.pager) {
return prev
}
const { lines, offset } = prev.pager
const max = Math.max(0, lines.length - pagerPageSize)
const step = delta === 'top' ? -lines.length : delta === 'bottom' ? lines.length : delta
const next = Math.max(0, Math.min(offset + step, max))
return next === offset ? prev : { ...prev, pager: { ...prev.pager, offset: next } }
})
if (key.upArrow || ch === 'k') {
return move(-1)
}
if (key.downArrow || ch === 'j') {
return move(1)
}
if (key.pageUp || ch === 'b') {
return move(-pagerPageSize)
}
if (ch === 'g') {
return move('top')
}
if (ch === 'G') {
return move('bottom')
}
if (key.return || ch === ' ' || key.pageDown) {
patchOverlayState(prev => {
if (!prev.pager) {
return prev
}
const { lines, offset } = prev.pager
const max = Math.max(0, lines.length - pagerPageSize)
// Auto-close only when already at the last page — otherwise clamp
// to `max` so the offset matches what the line/page-back handlers
// can reach (prevents a snap-back jump on the next ↑/↓/PgUp).
return offset >= max
? { ...prev, pager: null }
: { ...prev, pager: { ...prev.pager, offset: Math.min(offset + pagerPageSize, max) } }
})
}
return
}
if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC()
} else if (key.escape && overlay.picker) {
patchOverlayState({ picker: false })
}
// When a prompt overlay is up and the user pressed a scroll key, fall
// through to the global scroll handlers below instead of returning.
// Otherwise nothing above this comment matched, and there's nothing
// useful to do for an arbitrary key while blocked.
if (!fallThroughForScroll) {
return
}
}
if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) {
const len = cState.completions.length
cActions.setCompIdx(i => (key.upArrow ? (i - 1 + len) % len : (i + 1) % len))
return
}
if (key.wheelUp || key.wheelDown) {
const dir: -1 | 1 = key.wheelUp ? -1 : 1
const now = Date.now()
// 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 = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now)
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()
}
return precision.rows ? scrollTranscript(dir * wheelStep) : undefined
}
// 0 = direction-flip bounce deferred; skip the no-op scroll.
const rows = computeWheelStep(wheelAccelRef.current, dir, now)
return rows ? scrollTranscript(dir * rows * wheelStep) : undefined
}
if (key.shift && key.upArrow) {
return scrollTranscript(-1)
}
if (key.shift && key.downArrow) {
return scrollTranscript(1)
}
if (key.pageUp || key.pageDown) {
// Half-viewport keeps 50% continuity and stays under Ink's
// `delta < innerHeight` DECSTBM fast-path threshold.
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
const step = Math.max(4, Math.floor(viewport / 2))
return scrollTranscript(key.pageUp ? -step : step)
}
// 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()
}
if (key.escape && terminal.hasSelection) {
return clearSelection()
}
if (key.upArrow && !cState.inputBuf.length) {
const inputSel = getInputSelection()
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
const noLineAbove =
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)
if (noLineAbove) {
cycleQueue(1) || cycleHistory(-1)
return
}
}
if (key.downArrow && !cState.inputBuf.length) {
const inputSel = getInputSelection()
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
const noLineBelow = !cState.input || (cursor !== null && cState.input.indexOf('\n', cursor) < 0)
if (noLineBelow || cState.historyIdx !== null) {
cycleQueue(-1) || cycleHistory(1)
return
}
}
if (isCopyShortcut(key, ch)) {
if (terminal.hasSelection) {
return copySelection()
}
const inputSel = getInputSelection()
if (inputSel && inputSel.end > inputSel.start) {
inputSel.clear()
return
}
// On macOS, Cmd+C with no selection is a no-op (Ctrl+C below handles interrupt).
// On non-macOS, isAction uses Ctrl, so fall through to interrupt/clear/exit.
if (isMac) {
return
}
}
if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) {
cActions.removeQueue(cState.queueEditIdx)
return cActions.clearIn()
}
if (isCtrl(key, ch, 'x')) {
return patchOverlayState({ sessions: true })
}
if (key.ctrl && ch.toLowerCase() === 'c') {
if (live.busy && live.sid) {
return turnController.interruptTurn({
appendMessage: actions.appendMessage,
gw: gateway.gw,
sid: live.sid,
sys: actions.sys
})
}
if (cState.input || cState.inputBuf.length) {
return cActions.clearIn()
}
return actions.die()
}
if (isAction(key, ch, 'd')) {
return actions.die()
}
if (isAction(key, ch, 'l')) {
clearSelection()
forceRedraw(terminal.stdout ?? process.stdout)
return
}
if (isVoiceToggleKey(key, ch, voice.recordKey)) {
return voiceRecordToggle()
}
// Cmd/Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind the
// primary keystroke to "Find Next" before the TUI sees it; Alt+G
// arrives as meta+g across platforms).
if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) {
return void cActions.openEditor().catch((err: unknown) => {
actions.sys(err instanceof Error ? `failed to open editor: ${err.message}` : 'failed to open editor')
})
}
// shift-tab flips yolo without spending a turn (claude-code parity)
if (key.shift && key.tab && !cState.completions.length) {
if (!live.sid) {
return void actions.sys('yolo needs an active session')
}
// gateway.rpc swallows errors with its own sys() message and resolves to null,
// so we only speak when it came back with a real shape. null = rpc already spoke.
return void gateway.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: live.sid }).then(r => {
if (r?.value === '1') {
return actions.sys('yolo on')
}
if (r?.value === '0') {
return actions.sys('yolo off')
}
if (r) {
actions.sys('failed to toggle yolo')
}
})
}
if (key.tab && cState.completions.length) {
const row = cState.completions[cState.compIdx]
if (row?.text) {
const text =
cState.input.startsWith('/') && row.text.startsWith('/') && cState.compReplace > 0
? row.text.slice(1)
: row.text
cActions.setInput(cState.input.slice(0, cState.compReplace) + text)
}
return
}
if (isAction(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) {
const next = cActions.dequeue()
if (next) {
cActions.setQueueEdit(null)
actions.dispatchSubmission(next)
}
}
})
return { pagerPageSize }
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react'
import { LONG_RUN_CHARMS } from '../content/charms.js'
import { pick, toolTrailLabel } from '../lib/text.js'
import { turnController } from './turnController.js'
import { useTurnSelector } from './turnStore.js'
import { getUiState } from './uiStore.js'
const DELAY_MS = 8_000
const INTERVAL_MS = 10_000
const MAX_CHARMS_PER_TOOL = 2
interface Slot {
count: number
lastAt: number
}
export function useLongRunToolCharms() {
const tools = useTurnSelector(state => state.tools)
const slots = useRef(new Map<string, Slot>())
useEffect(() => {
if (!getUiState().busy || !tools.length) {
slots.current.clear()
return
}
const tick = () => {
if (!getUiState().busy) {
slots.current.clear()
return
}
const now = Date.now()
const liveIds = new Set(tools.map(t => t.id))
for (const key of Array.from(slots.current.keys())) {
if (!liveIds.has(key)) {
slots.current.delete(key)
}
}
for (const tool of tools) {
if (!tool.startedAt || now - tool.startedAt < DELAY_MS) {
continue
}
const slot = slots.current.get(tool.id) ?? { count: 0, lastAt: 0 }
if (slot.count >= MAX_CHARMS_PER_TOOL || now - slot.lastAt < INTERVAL_MS) {
continue
}
slots.current.set(tool.id, { count: slot.count + 1, lastAt: now })
turnController.pushActivity(
`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)`
)
}
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [tools])
}
File diff suppressed because it is too large Load Diff
+366
View File
@@ -0,0 +1,366 @@
import { writeFileSync } from 'node:fs'
import type { ScrollBoxHandle } from '@hermes/ink'
import { evictInkCaches } from '@hermes/ink'
import { type RefObject, useCallback } from 'react'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
import { ZERO } from '../domain/usage.js'
import { type GatewayClient } from '../gatewayClient.js'
import type {
SessionActivateResponse,
SessionCloseResponse,
SessionCreateResponse,
SessionInflightTurn,
SessionResumeResponse,
SessionTitleResponse,
SetupStatusResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js'
import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO)
const statusFromLiveSession = (status?: string, running = false) => {
if (status === 'waiting') {
return 'waiting for input…'
}
if (status === 'starting') {
return 'starting agent…'
}
return running || status === 'working' ? 'running…' : 'ready'
}
export const writeActiveSessionFile = (sessionId: null | string, file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE) => {
if (!file || !sessionId) {
return
}
try {
writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 })
} catch {
// Best-effort shell epilogue hint only; never break live session changes.
}
}
export const liveSessionInflightMessages = (inflight?: null | SessionInflightTurn): Msg[] => {
const user = String(inflight?.user ?? '').trim()
return user ? [{ role: 'user', text: user }] : []
}
export const hydrateLiveSessionInflight = (inflight?: null | SessionInflightTurn) => {
const assistant = String(inflight?.assistant ?? '')
if (!assistant && !inflight?.streaming) {
return
}
turnController.hydrateStreamingText(assistant)
}
const trimTail = (items: Msg[]) => {
const q = [...items]
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
q.pop()
}
if (q.at(-1)?.role === 'user') {
q.pop()
}
return q
}
export interface UseSessionLifecycleOptions {
colsRef: { current: number }
composerActions: ComposerActions
gw: GatewayClient
panel: (title: string, sections: PanelSection[]) => void
rpc: GatewayRpc
scrollRef: RefObject<null | ScrollBoxHandle>
setHistoryItems: StateSetter<Msg[]>
setLastUserMsg: StateSetter<string>
setSessionStartedAt: StateSetter<number>
setStickyPrompt: StateSetter<string>
setVoiceProcessing: StateSetter<boolean>
setVoiceRecording: StateSetter<boolean>
sys: (text: string) => void
}
export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
const {
colsRef,
composerActions,
gw,
panel,
rpc,
scrollRef,
setHistoryItems,
setLastUserMsg,
setSessionStartedAt,
setStickyPrompt,
setVoiceProcessing,
setVoiceRecording,
sys
} = opts
const closeSession = useCallback(
(targetSid?: null | string) =>
targetSid ? rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null),
[rpc]
)
const resetSession = useCallback(() => {
turnController.fullReset()
setVoiceRecording(false)
setVoiceProcessing(false)
patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO })
setHistoryItems([])
setLastUserMsg('')
setStickyPrompt('')
composerActions.setPasteSnips([])
// Half-prune: new session has new keys, but keep a warm pool in case
// the user resumes back to the prior session.
evictInkCaches('half')
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
const resetVisibleHistory = useCallback(
(info: null | SessionInfo = null) => {
turnController.idle()
turnController.clearReasoning()
turnController.turnTools = []
turnController.persistedToolLabels.clear()
setHistoryItems(info ? [introMsg(info)] : [])
setStickyPrompt('')
setLastUserMsg('')
composerActions.setPasteSnips([])
patchTurnState({ activity: [] })
patchUiState({ info, usage: usageFrom(info) })
},
[composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt]
)
const startNewSession = useCallback(
async (msg?: string, title?: string, keepCurrent = false) => {
const setup = await rpc<SetupStatusResponse>('setup.status', {})
if (setup?.provider_configured === false) {
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
patchUiState({ status: 'setup required' })
return null
}
if (!keepCurrent) {
await closeSession(getUiState().sid)
}
const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current })
if (!r) {
patchUiState({ status: 'ready' })
return null
}
const info = r.info ?? null
const requestedTitle = title?.trim() ?? ''
resetSession()
setSessionStartedAt(Date.now())
writeActiveSessionFile(r.session_id)
patchUiState({
info,
sid: r.session_id,
status: info?.version ? 'ready' : 'starting agent…',
usage: usageFrom(info)
})
if (info) {
setHistoryItems([introMsg(info)])
}
if (info?.credential_warning) {
sys(`warning: ${info.credential_warning}`)
}
if (info?.config_warning) {
sys(`warning: ${info.config_warning}`)
}
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}`)
})
}
return r.session_id
},
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
)
const newSession = useCallback(
(msg?: string, title?: string) => startNewSession(msg, title, false),
[startNewSession]
)
const newLiveSession = useCallback(
(msg = 'new live session started', title?: string) => {
patchOverlayState({ sessions: false })
return startNewSession(msg, title, true)
},
[startNewSession]
)
const activateLiveSession = useCallback(
(id: string) => {
patchOverlayState({ sessions: false })
patchUiState({ status: 'switching session…' })
gw.request<SessionActivateResponse>('session.activate', { session_id: id })
.then(raw => {
const r = asRpcResult<SessionActivateResponse>(raw)
if (!r) {
sys('error: invalid response: session.activate')
return patchUiState({ status: 'ready' })
}
const info = r.info ?? null
const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting')
resetSession()
setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now())
const transcript = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)]
setHistoryItems(info ? [introMsg(info), ...transcript] : transcript)
writeActiveSessionFile(r.session_key ?? r.session_id)
patchUiState({
busy: running,
info,
sid: r.session_id,
status: statusFromLiveSession(r.status, running),
usage: usageFrom(info)
})
hydrateLiveSessionInflight(r.inflight)
setTimeout(() => scrollRef.current?.scrollToBottom(), 0)
})
.catch((e: Error) => {
sys(`error: ${e.message}`)
patchUiState({ status: 'ready' })
})
},
[gw, resetSession, scrollRef, setHistoryItems, setSessionStartedAt, sys]
)
const resumeById = useCallback(
(id: string) => {
patchOverlayState({ picker: false })
patchUiState({ status: 'resuming…' })
rpc<SetupStatusResponse>('setup.status', {}).then(setup => {
if (setup?.provider_configured === false) {
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
patchUiState({ status: 'setup required' })
return
}
closeSession(getUiState().sid === id ? null : getUiState().sid).then(() =>
gw
.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r) {
sys('error: invalid response: session.resume')
return patchUiState({ status: 'ready' })
}
resetSession()
setSessionStartedAt(Date.now())
const resumed = toTranscriptMessages(r.messages)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
writeActiveSessionFile(r.resumed ?? r.session_id)
patchUiState({
info: r.info ?? null,
sid: r.session_id,
status: 'ready',
usage: usageFrom(r.info ?? null)
})
setTimeout(() => scrollRef.current?.scrollToBottom(), 0)
})
.catch((e: Error) => {
sys(`error: ${e.message}`)
patchUiState({ status: 'ready' })
})
)
})
},
[closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys]
)
const guardBusySessionSwitch = useCallback(
(what = 'switch sessions') => {
if (!getUiState().busy) {
return false
}
sys(`interrupt the current turn before trying to ${what}`)
return true
},
[sys]
)
return {
activateLiveSession,
closeSession,
guardBusySessionSwitch,
newLiveSession,
newSession,
resetSession,
resetVisibleHistory,
resumeById,
trimLastExchange: trimTail
}
}
+429
View File
@@ -0,0 +1,429 @@
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { TYPING_IDLE_MS } from '../config/timing.js'
import { attachedImageNotice } from '../domain/messages.js'
import { looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type {
InputDetectDropResponse,
PromptSubmitResponse,
SessionSteerResponse,
ShellExecResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
import type { Msg } from '../types.js'
import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
const DOUBLE_ENTER_MS = 450
const SESSION_BUSY_RE = /session busy|waiting for model response/i
const isSessionBusyError = (e: unknown) => e instanceof Error && SESSION_BUSY_RE.test(e.message)
const expandSnips = (snips: PasteSnippet[]) => {
const byLabel = new Map<string, string[]>()
for (const { label, text } of snips) {
const hit = byLabel.get(label)
hit ? hit.push(text) : byLabel.set(label, [text])
}
return (value: string) => value.replace(PASTE_SNIPPET_RE, tok => byLabel.get(tok)?.shift() ?? tok)
}
const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) =>
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
export function useSubmission(opts: UseSubmissionOptions) {
const {
appendMessage,
composerActions,
composerRefs,
composerState,
gw,
maybeGoodVibes,
setLastUserMsg,
slashRef,
submitRef,
sys
} = opts
const lastEmptyAt = useRef(0)
const typingIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (typingIdleTimer.current) {
clearTimeout(typingIdleTimer.current)
typingIdleTimer.current = null
}
if (!composerState.input && !composerState.inputBuf.length) {
turnController.relaxStreaming()
return
}
if (getUiState().busy) {
turnController.boostStreamingForTyping()
}
typingIdleTimer.current = setTimeout(() => {
typingIdleTimer.current = null
turnController.relaxStreaming()
}, TYPING_IDLE_MS)
return () => {
if (typingIdleTimer.current) {
clearTimeout(typingIdleTimer.current)
typingIdleTimer.current = null
}
}
}, [composerState.input, composerState.inputBuf])
const send = useCallback(
(text: string, showUserMessage = true) => {
const expand = expandSnips(composerState.pasteSnips)
const startSubmit = (displayText: string, submitText: string, showUserMessage = true) => {
const sid = getUiState().sid
if (!sid) {
return sys('session not ready yet')
}
turnController.clearStatusTimer()
maybeGoodVibes(submitText)
setLastUserMsg(text)
if (showUserMessage) {
appendMessage({ role: 'user', text: displayText })
}
patchUiState({ busy: true, status: 'running…' })
turnController.bufRef = ''
turnController.interrupted = false
gw.request<PromptSubmitResponse>('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => {
if (isSessionBusyError(e)) {
composerActions.enqueue(submitText)
patchUiState({ busy: true, status: 'queued for next turn' })
return sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`)
}
sys(`error: ${e.message}`)
patchUiState({ busy: false, status: 'ready' })
})
}
const sid = getUiState().sid
if (!sid) {
return sys('session not ready yet')
}
// 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) {
return startSubmit(text, expand(text), showUserMessage)
}
if (r.is_image) {
turnController.pushActivity(attachedImageNotice(r))
} else {
turnController.pushActivity(`detected file: ${r.name}`)
}
startSubmit(r.text || text, expand(r.text || text), showUserMessage)
})
.catch(() => startSubmit(text, expand(text), showUserMessage))
},
[appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]
)
const shellExec = useCallback(
(cmd: string) => {
appendMessage({ role: 'user', text: `!${cmd}` })
patchUiState({ busy: true, status: 'running…' })
gw.request<ShellExecResponse>('shell.exec', { command: cmd })
.then(raw => {
const r = asRpcResult<ShellExecResponse>(raw)
if (!r) {
return sys('error: invalid response: shell.exec')
}
const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim()
if (out) {
sys(out)
}
if (r.code !== 0 || !out) {
sys(`exit ${r.code}`)
}
})
.catch((e: Error) => sys(`error: ${e.message}`))
.finally(() => patchUiState({ busy: false, status: 'ready' }))
},
[appendMessage, gw, sys]
)
const interpolate = useCallback(
(text: string, then: (result: string) => void) => {
patchUiState({ status: 'interpolating…' })
const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))]
Promise.all(
matches.map(m =>
gw
.request<ShellExecResponse>('shell.exec', { command: m[1]! })
.then(raw => {
const r = asRpcResult<ShellExecResponse>(raw)
return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim()
})
.catch(() => '(error)')
)
).then(results => then(spliceMatches(text, matches, results)))
},
[gw]
)
const sendQueued = useCallback(
(text: string) => {
if (text.startsWith('!')) {
return shellExec(text.slice(1).trim())
}
if (hasInterpolation(text)) {
patchUiState({ busy: true })
return interpolate(text, send)
}
send(text)
},
[interpolate, send, shellExec]
)
// Honors `display.busy_input_mode` from config.yaml (CLI parity):
// - 'queue' (legacy): append to queueRef; drains on busy → false
// - 'steer' : inject into the current turn via session.steer; falls
// back to queue when steer is rejected (no agent / no
// tool window).
// - 'interrupt' (default): cancel the in-flight turn, then send the
// new text as a fresh prompt so it actually moves.
//
// `opts.fallbackToFront` controls whether a steer fallback re-inserts
// at the front of the queue (used by the queue-edit path to preserve
// a picked item's position); the mainline submit path always appends.
const handleBusyInput = useCallback(
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
const live = getUiState()
const mode = live.busyInputMode
const fallback = (note: string) => {
if (opts.fallbackToFront) {
composerRefs.queueRef.current.unshift(full)
composerActions.syncQueue()
} else {
composerActions.enqueue(full)
}
sys(note)
}
if (mode === 'queue') {
return composerActions.enqueue(full)
}
if (mode === 'steer' && live.sid) {
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: full })
.then(raw => {
const r = asRpcResult<SessionSteerResponse>(raw)
if (r?.status !== 'queued') {
fallback('steer rejected — message queued for next turn')
}
})
.catch(() => fallback('steer failed — message queued for next turn'))
return
}
// 'interrupt' (default): tear down the current turn, then send.
// `interruptTurn` fires `session.interrupt` without awaiting; if
// the gateway is still mid-response when `prompt.submit` lands,
// `send()`'s catch path re-queues with a "queued: ..." sys note
// (`isSessionBusyError`) — so a lost race degrades to queue
// semantics, not a dropped message.
if (live.sid) {
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
}
if (hasInterpolation(full)) {
patchUiState({ busy: true })
return interpolate(full, send)
}
send(full)
},
[appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
)
const dispatchSubmission = useCallback(
(full: string) => {
if (!full.trim()) {
return
}
if (looksLikeSlashCommand(full)) {
appendMessage({ kind: 'slash', role: 'system', text: full })
composerActions.pushHistory(full)
slashRef.current(full)
composerActions.clearIn()
return
}
if (full.startsWith('!')) {
composerActions.clearIn()
return shellExec(full.slice(1).trim())
}
const live = getUiState()
if (!live.sid) {
composerActions.pushHistory(full)
composerActions.enqueue(full)
composerActions.clearIn()
return
}
const editIdx = composerRefs.queueEditRef.current
composerActions.clearIn()
if (editIdx !== null) {
composerActions.replaceQueue(editIdx, full)
const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0]
composerActions.syncQueue()
composerActions.setQueueEdit(null)
if (!picked || !live.sid) {
return
}
if (getUiState().busy) {
// 'interrupt' / 'steer' should reach the live turn instead of
// silently going back to the queue. handleBusyInput resolves
// mode-specific behavior (interrupt-and-send, steer, or queue).
if (getUiState().busyInputMode === 'queue') {
composerRefs.queueRef.current.unshift(picked)
return composerActions.syncQueue()
}
return handleBusyInput(picked, { fallbackToFront: true })
}
return sendQueued(picked)
}
composerActions.pushHistory(full)
if (getUiState().busy) {
return handleBusyInput(full)
}
if (hasInterpolation(full)) {
patchUiState({ busy: true })
return interpolate(full, send)
}
send(full)
},
[appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
)
const submit = useCallback(
(value: string) => {
if (composerState.completions.length) {
const row = composerState.completions[composerState.compIdx]
if (row?.text) {
const text = value.startsWith('/') && row.text.startsWith('/') ? row.text.slice(1) : row.text
const next = value.slice(0, composerState.compReplace) + text
if (next !== value) {
return composerActions.setInput(next)
}
}
}
if (!value.trim() && !composerState.inputBuf.length) {
const live = getUiState()
const now = Date.now()
const doubleTap = now - lastEmptyAt.current < DOUBLE_ENTER_MS
lastEmptyAt.current = now
if (doubleTap && live.busy && live.sid) {
return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
}
if (doubleTap && live.sid && composerRefs.queueRef.current.length) {
const next = composerActions.dequeue()
composerActions.syncQueue()
if (next) {
composerActions.setQueueEdit(null)
dispatchSubmission(next)
}
}
return
}
lastEmptyAt.current = 0
if (value.endsWith('\\')) {
composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)])
return composerActions.setInput('')
}
dispatchSubmission([...composerState.inputBuf, value].join('\n'))
},
[appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys]
)
submitRef.current = submit
return { dispatchSubmission, send, sendQueued, submit }
}
export interface UseSubmissionOptions {
appendMessage: (msg: Msg) => void
composerActions: ComposerActions
composerRefs: ComposerRefs
composerState: ComposerState
gw: GatewayClient
maybeGoodVibes: (text: string) => void
setLastUserMsg: (value: string) => void
slashRef: MutableRefObject<(cmd: string) => boolean>
submitRef: MutableRefObject<(value: string) => void>
sys: (text: string) => void
}
+93
View File
@@ -0,0 +1,93 @@
import type { ThemeColors } from './theme.js'
const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g
export function parseRichMarkup(markup: string): Line[] {
const lines: Line[] = []
for (const raw of markup.split('\n')) {
const trimmed = raw.trimEnd()
if (!trimmed) {
lines.push(['', ' '])
continue
}
const matches = [...trimmed.matchAll(RICH_RE)]
if (!matches.length) {
lines.push(['', trimmed])
continue
}
let cursor = 0
for (const m of matches) {
const before = trimmed.slice(cursor, m.index)
if (before) {
lines.push(['', before])
}
lines.push([m[1]!, m[2]!])
cursor = m.index! + m[0].length
}
if (cursor < trimmed.length) {
lines.push(['', trimmed.slice(cursor)])
}
}
return lines
}
const LOGO_ART = [
'██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
'██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
'███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ',
'██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ',
'██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ',
'╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ '
]
const CADUCEUS_ART = [
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀',
'⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀',
'⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠉⠛⠷⠄⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠈⠀'
]
const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => {
const p = [c.primary, c.accent, c.border, c.muted]
return art.map((text, i) => [p[gradient[i]!] ?? c.muted, text])
}
export const LOGO_WIDTH = Math.max(...LOGO_ART.map(line => line.length))
export const CADUCEUS_WIDTH = Math.max(...CADUCEUS_ART.map(line => line.length))
export const logo = (c: ThemeColors, customLogo?: string): Line[] =>
customLogo ? parseRichMarkup(customLogo) : colorize(LOGO_ART, LOGO_GRADIENT, c)
export const caduceus = (c: ThemeColors, customHero?: string): Line[] =>
customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, t.length), 0)
type Line = [string, string]
@@ -0,0 +1,635 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useCallback, useEffect, useRef, useState } from 'react'
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { SessionActiveItem, SessionActiveListResponse, SessionCloseResponse } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { ModelPicker } from './modelPicker.js'
import { windowOffset } from './overlayControls.js'
import { TextInput } from './textInput.js'
const VISIBLE = 12
const MIN_WIDTH = 64
const MAX_WIDTH = 128
const TITLE_MAX = 64
const STATUS_GLYPH: Record<string, string> = {
idle: '✓',
starting: '…',
waiting: '?',
working: '▶'
}
const STATUS_LABEL: Record<string, string> = {
idle: 'idle',
starting: 'starting',
waiting: 'waiting',
working: 'working'
}
const CTRL_OFFSET = 96
const shortModel = (model = '') => model.replace(/^.*\//, '') || 'model?'
const ctrlChar = (letter: string) => String.fromCharCode(letter.charCodeAt(0) - CTRL_OFFSET)
export const fixedSessionColumnStyle = () => ({ flexShrink: 0 })
export const activeSessionCountLabel = (count: number) =>
`${count} live ${count === 1 ? 'session' : 'sessions'}`
export type OrchestratorHintRole = 'hotkey' | 'label' | 'text'
export interface OrchestratorHintSegment {
role: OrchestratorHintRole
text: string
}
export const orchestratorContextHintSegments = (newSelected: boolean): OrchestratorHintSegment[] =>
newSelected
? [
{ role: 'label', text: 'New row:' },
{ role: 'text', text: ' type prompt · ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' start · ' },
{ role: 'hotkey', text: 'Tab' },
{ role: 'text', text: ' model' }
]
: [
{ role: 'label', text: 'Session row:' },
{ role: 'text', text: ' ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' switch · ' },
{ role: 'hotkey', text: 'Ctrl+D' },
{ role: 'text', text: ' close' }
]
export const orchestratorGlobalHotkeyHintSegments: OrchestratorHintSegment[] = [
{ role: 'hotkey', text: '↑↓' },
{ role: 'text', text: ' move · ' },
{ role: 'hotkey', text: 'Ctrl+N' },
{ role: 'text', text: ' new · ' },
{ role: 'hotkey', text: 'Ctrl+R' },
{ role: 'text', text: ' refresh · ' },
{ role: 'hotkey', text: 'Esc' },
{ role: 'text', text: ' close' }
]
const hintText = (segments: readonly OrchestratorHintSegment[]) => segments.map(segment => segment.text).join('')
export const orchestratorContextHint = (newSelected: boolean) => hintText(orchestratorContextHintSegments(newSelected))
export const orchestratorGlobalHotkeyHint = hintText(orchestratorGlobalHotkeyHintSegments)
export const orchestratorHintSegmentColor = (t: Theme, role: OrchestratorHintRole) => {
if (role === 'hotkey') {
return t.color.accent
}
if (role === 'label') {
return t.color.label
}
return t.color.muted
}
export const selectedSessionRowStyle = (t: Theme) => ({
backgroundColor: t.color.selectionBg,
color: t.color.text
})
export const newSessionMarkerColor = (t: Theme, selected: boolean) =>
selected ? selectedSessionRowStyle(t).color : t.color.label
export const newSessionRowIndex = (sessionCount: number) => Math.max(0, sessionCount)
export const isNewSessionRow = (index: number, sessionCount: number) => index >= newSessionRowIndex(sessionCount)
export const canTypeOrchestratorPrompt = (index: number, sessionCount: number) => isNewSessionRow(index, sessionCount)
export const clampOrchestratorSelection = (index: number, sessionCount: number) =>
Math.max(0, Math.min(index, newSessionRowIndex(sessionCount)))
export const currentSessionSelectionIndex = (
sessions: readonly SessionActiveItem[],
currentSessionId: null | string
) => {
const index = sessions.findIndex(s => Boolean(s.current) || (!!currentSessionId && s.id === currentSessionId))
return index >= 0 ? index : 0
}
export const orchestratorVisibleRowIndexes = (sessionCount: number, selected: number, visible = VISIBLE) => {
const total = Math.max(0, sessionCount) + 1
const clamped = clampOrchestratorSelection(selected, sessionCount)
const offset = windowOffset(total, clamped, visible)
const count = Math.min(visible, total - offset)
return Array.from({ length: count }, (_, i) => offset + i)
}
export type CloseFallback = { action: 'activate'; sessionId: string } | { action: 'new' } | { action: 'stay' }
export const closeFallbackAfterClose = (
closedId: string,
currentSessionId: null | string,
remaining: readonly SessionActiveItem[]
): CloseFallback => {
if (!currentSessionId || closedId !== currentSessionId) {
return { action: 'stay' }
}
const next = remaining.find(s => s.id !== closedId)
return next ? { action: 'activate', sessionId: next.id } : { action: 'new' }
}
export const draftModelArgFromPickerValue = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean)
const kept: string[] = []
for (const part of parts) {
if (part === TUI_SESSION_MODEL_FLAG || part === '--global') {
continue
}
kept.push(part)
}
return kept.join(' ')
}
export const draftModelNameFromArg = (value: string) => {
const parts = draftModelArgFromPickerValue(value).split(/\s+/).filter(Boolean)
const modelParts: string[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!
if (part === '--provider') {
i++
continue
}
if (part.startsWith('--')) {
continue
}
modelParts.push(part)
}
return modelParts.join(' ').trim()
}
export const draftModelDisplayLabel = (value: string) => {
const modelName = draftModelNameFromArg(value)
return modelName ? shortModel(modelName) : 'current/default'
}
export type OrchestratorRowClickAction = { action: 'activate'; sessionId: string } | { action: 'select-new' }
export const orchestratorRowClickAction = (
index: number,
sessions: readonly SessionActiveItem[]
): OrchestratorRowClickAction => {
const target = sessions[index]
return target && !isNewSessionRow(index, sessions.length)
? { action: 'activate', sessionId: target.id }
: { action: 'select-new' }
}
export const draftTitleFromPrompt = (prompt: string, max = TITLE_MAX) => {
const compact = prompt.replace(/\s+/g, ' ').trim()
if (compact.length <= max) {
return compact
}
return `${compact.slice(0, Math.max(0, max - 1)).trimEnd()}`
}
function OrchestratorHintSegments({ segments, t }: OrchestratorHintTextProps) {
return (
<>
{segments.map((segment, index) => (
<Text color={orchestratorHintSegmentColor(t, segment.role)} key={`${segment.role}-${index}`}>
{segment.text}
</Text>
))}
</>
)
}
function OrchestratorHintText({ segments, t }: OrchestratorHintTextProps) {
return (
<Text color={orchestratorHintSegmentColor(t, 'text')} wrap="truncate-end">
<OrchestratorHintSegments segments={segments} t={t} />
</Text>
)
}
export function ActiveSessionSwitcher({
currentSessionId,
gw,
onCancel,
onClose,
onNew,
onNewPrompt,
onSelect,
t
}: ActiveSessionSwitcherProps) {
const [items, setItems] = useState<SessionActiveItem[]>([])
const [err, setErr] = useState('')
const [sel, setSel] = useState(0)
const [loading, setLoading] = useState(true)
const [draft, setDraft] = useState('')
const [draftModel, setDraftModel] = useState('')
const [pickingModel, setPickingModel] = useState(false)
const [closingId, setClosingId] = useState('')
const initialSelectionAppliedRef = useRef(false)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const promptColumns = Math.max(20, width - 11)
const load = useCallback(
async (quiet = false) => {
if (!quiet) {
setLoading(true)
}
try {
const raw = await gw.request<SessionActiveListResponse>('session.active_list', {
current_session_id: currentSessionId
})
const r = asRpcResult<SessionActiveListResponse>(raw)
if (!r) {
setErr('invalid response: session.active_list')
setLoading(false)
return []
}
const next = r.sessions ?? []
const initializeSelection = !initialSelectionAppliedRef.current
initialSelectionAppliedRef.current = true
setItems(next)
setSel(s =>
initializeSelection
? clampOrchestratorSelection(currentSessionSelectionIndex(next, currentSessionId), next.length)
: clampOrchestratorSelection(s, next.length)
)
setErr('')
setLoading(false)
return next
} catch (e: unknown) {
setErr(rpcErrorMessage(e))
setLoading(false)
return []
}
},
[currentSessionId, gw]
)
useEffect(() => {
void load()
const timer = setInterval(() => void load(true), 1500)
return () => clearInterval(timer)
}, [load])
const submitDraft = useCallback(
(value: string) => {
const prompt = value.trim()
if (!prompt) {
return
}
setDraft('')
onNewPrompt(prompt, draftModel || undefined)
},
[draftModel, onNewPrompt]
)
const closeSelected = useCallback(async () => {
const target = items[sel]
if (!target || isNewSessionRow(sel, items.length) || closingId) {
return
}
setErr('')
setClosingId(target.id)
try {
const result = await onClose(target.id)
const closed = Boolean(result?.closed ?? result?.ok)
if (!closed) {
setErr('session was already closed')
return
}
const remaining = await load(true)
const fallback = closeFallbackAfterClose(target.id, currentSessionId, remaining)
if (fallback.action === 'activate') {
onSelect(fallback.sessionId)
} else if (fallback.action === 'new') {
onNew()
} else {
setSel(s => clampOrchestratorSelection(s, remaining.length))
}
} catch (e: unknown) {
setErr(rpcErrorMessage(e))
} finally {
setClosingId('')
}
}, [closingId, currentSessionId, items, load, onClose, onNew, onSelect, sel])
const handleRowClick = useCallback(
(index: number) => (event: { stopImmediatePropagation?: () => void }) => {
event.stopImmediatePropagation?.()
const action = orchestratorRowClickAction(index, items)
if (action.action === 'activate') {
setSel(clampOrchestratorSelection(index, items.length))
onSelect(action.sessionId)
return
}
setSel(newSessionRowIndex(items.length))
},
[items, onSelect]
)
const newSelected = isNewSessionRow(sel, items.length)
const draftHasText = Boolean(draft.trim())
useInput((ch, key) => {
if (pickingModel) {
return
}
const lower = ch?.toLowerCase() ?? ''
const isCtrl = (letter: string) => key.ctrl && (lower === letter || ch === ctrlChar(letter))
if (key.escape) {
return onCancel()
}
if (isCtrl('n')) {
return onNew()
}
if (isCtrl('r')) {
void load()
return
}
if (key.tab) {
if (newSelected) {
setPickingModel(true)
}
return
}
if (isCtrl('d')) {
if (!newSelected) {
void closeSelected()
}
return
}
if (newSelected && draftHasText) {
return
}
if (key.upArrow && sel > 0) {
return setSel(s => clampOrchestratorSelection(s - 1, items.length))
}
if (key.downArrow && sel < newSessionRowIndex(items.length)) {
return setSel(s => clampOrchestratorSelection(s + 1, items.length))
}
if (key.return) {
if (newSelected) {
if (!draftHasText) {
return onNew()
}
return
}
if (items[sel]) {
return onSelect(items[sel]!.id)
}
}
})
if (pickingModel) {
return (
<ModelPicker
allowPersistGlobal={false}
gw={gw}
onCancel={() => setPickingModel(false)}
onSelect={value => {
setDraftModel(draftModelArgFromPickerValue(value))
setPickingModel(false)
}}
sessionId={currentSessionId}
t={t}
/>
)
}
if (loading) {
return <Text color={t.color.muted}>loading session orchestrator</Text>
}
const totalRows = items.length + 1
const offset = windowOffset(totalRows, sel, VISIBLE)
const visibleRows = orchestratorVisibleRowIndexes(items.length, sel, VISIBLE)
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Session Orchestrator
</Text>
<Text color={t.color.muted}>{activeSessionCountLabel(items.length)}</Text>
{err && <Text color={t.color.label}>error: {err}</Text>}
{!items.length && (
<Text color={t.color.muted}>no live sessions closed TUIs only leave resumable transcripts</Text>
)}
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{visibleRows.map(i => {
const selected = sel === i
const selectedStyle = selected ? selectedSessionRowStyle(t) : null
const rowTextColor = selectedStyle?.color
if (isNewSessionRow(i, items.length)) {
const promptTitle = draftTitleFromPrompt(draft) || 'Start a new live session'
const markerColor = newSessionMarkerColor(t, selected)
return (
<Box
backgroundColor={selectedStyle?.backgroundColor}
flexDirection="row"
key="new-session"
onClick={handleRowClick(i)}
width="100%"
>
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{selected ? '▸ ' : ' '}
</Text>
<Box {...fixedSessionColumnStyle()} width={5}>
<Text bold={selected} color={markerColor}>
+
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text bold={selected} color={markerColor} wrap="truncate-end">
new
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text color={rowTextColor ?? t.color.muted} wrap="truncate-end">
draft
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={18}>
<Text color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{draftModelDisplayLabel(draftModel)}
</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0}>
<Text bold={selected} color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{promptTitle}
</Text>
</Box>
</Box>
)
}
const s = items[i]!
const status = s.status ?? 'idle'
const current = s.current || s.id === currentSessionId
const title = closingId === s.id ? 'closing…' : s.title || s.preview || '(untitled)'
return (
<Box
backgroundColor={selectedStyle?.backgroundColor}
flexDirection="row"
key={s.id}
onClick={handleRowClick(i)}
width="100%"
>
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{selected ? '▸ ' : ' '}
</Text>
<Box {...fixedSessionColumnStyle()} width={5}>
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{String(i + 1).padStart(2)}.
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text
bold={selected}
color={rowTextColor ?? (current ? t.color.label : t.color.muted)}
wrap="truncate-end"
>
{current ? 'current' : s.id}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text
color={
rowTextColor ??
(status === 'working' ? t.color.ok : status === 'waiting' ? t.color.label : t.color.muted)
}
wrap="truncate-end"
>
{STATUS_GLYPH[status] ?? '·'} {STATUS_LABEL[status] ?? status}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={18}>
<Text color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{shortModel(s.model)}
</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0}>
<Text bold={selected} color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{title}
</Text>
</Box>
</Box>
)
})}
{offset + VISIBLE < totalRows && <Text color={t.color.muted}> {totalRows - offset - VISIBLE} more</Text>}
{newSelected ? (
<>
<Box marginTop={1}>
<Text color={t.color.label}>prompt </Text>
<TextInput columns={promptColumns} onChange={setDraft} onSubmit={submitDraft} value={draft} />
</Box>
<OrchestratorHintText segments={orchestratorContextHintSegments(true)} t={t} />
<Text color={t.color.muted} wrap="truncate-end">
model: {draftModelDisplayLabel(draftModel)}
</Text>
</>
) : (
<Box marginTop={1} flexDirection="column">
<OrchestratorHintText segments={orchestratorContextHintSegments(false)} t={t} />
<Text color={t.color.muted} wrap="truncate-end">
Select <Text color={newSessionMarkerColor(t, false)}>+new</Text> to type a prompt
</Text>
</Box>
)}
<OrchestratorHintText segments={orchestratorGlobalHotkeyHintSegments} t={t} />
</Box>
)
}
interface OrchestratorHintTextProps {
segments: readonly OrchestratorHintSegment[]
t: Theme
}
interface ActiveSessionSwitcherProps {
currentSessionId: null | string
gw: GatewayClient
onCancel: () => void
onClose: (id: string) => Promise<null | SessionCloseResponse>
onNew: () => void
onNewPrompt: (prompt: string, modelArg?: string) => void
onSelect: (id: string) => void
t: Theme
}
File diff suppressed because it is too large Load Diff
+554
View File
@@ -0,0 +1,554 @@
import { Box, type ScrollBoxHandle, stringWidth, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
import unicodeSpinners from 'unicode-animations'
import { $delegationState } from '../app/delegationStore.js'
import type { IndicatorStyle } from '../app/interfaces.js'
import { useTurnSelector } from '../app/turnStore.js'
import { $uiState } from '../app/uiStore.js'
import { FACES } from '../content/faces.js'
import { VERBS } from '../content/verbs.js'
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 { 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 = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮']
const ASCII_FRAMES = ['|', '/', '-', '\\']
// Faster tick for spinner-style indicators — they read as motion only
// at frame rates closer to their authored interval.
const SPINNER_TICK_MS = 100
interface IndicatorRender {
frame: string
intervalMs: number
// When false, FaceTicker hides the rotating verb and just shows the
// glyph + duration. Lets `unicode` stay minimal while the other
// styles keep the verb-rotation flavour users associate with the
// running… status.
showVerb: boolean
}
const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => {
if (style === 'kaomoji') {
return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true }
}
if (style === 'emoji') {
return {
frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? '⚕ ',
intervalMs: SPINNER_TICK_MS * 6,
showVerb: true
}
}
if (style === 'ascii') {
return {
frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|',
intervalMs: SPINNER_TICK_MS,
showVerb: true
}
}
// 'unicode' — braille spinner (fixed 1-col). Authored interval is
// ~80ms; honour it but bound below at a safe minimum so React
// re-renders stay reasonable. This style is for users who want
// the cleanest possible status, so no verb rotation either.
const spinner = unicodeSpinners.braille
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false }
}
function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) {
const ui = useStore($uiState)
const style = ui.indicatorStyle
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length))
const [now, setNow] = useState(() => Date.now())
// Pre-compute cadence + verb-visibility for the active style so an
// `/indicator` switch re-arms the interval (and skips the verb timer
// for verb-less styles like `unicode`) without leaving the previous
// timer dangling.
const { intervalMs, showVerb } = renderIndicator(style, 0)
useEffect(() => {
const glyph = setInterval(() => setTick(n => n + 1), intervalMs)
const clock = setInterval(() => setNow(Date.now()), 1000)
// Verb timer is gated on `showVerb` — `unicode` style hides the verb
// entirely, so cycling `verbTick` would be an avoidable re-render.
const verb = showVerb ? setInterval(() => setVerbTick(n => n + 1), FACE_TICK_MS) : null
return () => {
clearInterval(glyph)
clearInterval(clock)
if (verb !== null) {
clearInterval(verb)
}
}
}, [intervalMs, showVerb])
const { frame } = renderIndicator(style, tick)
const verb = VERBS[verbTick % VERBS.length] ?? ''
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 (
<Text color={color}>
{frame}
{verbSegment}
{durationSegment}
</Text>
)
}
function ctxBarColor(pct: number | undefined, t: Theme) {
if (pct == null) {
return t.color.muted
}
if (pct >= 95) {
return t.color.statusCritical
}
if (pct > 80) {
return t.color.statusBad
}
if (pct >= 50) {
return t.color.statusWarn
}
return t.color.statusGood
}
function statusSessionCountLabel(count: number) {
return `${count} ${count === 1 ? 'session' : 'sessions'}`
}
function ctxBar(pct: number | undefined, w = 10) {
const p = Math.max(0, Math.min(100, pct ?? 0))
const filled = Math.round((p / 100) * w)
return '█'.repeat(filled) + '░'.repeat(w - filled)
}
export function statusRuleWidths(cols: number, cwdLabel: string) {
const width = Math.max(1, Math.floor(cols || 1))
const desiredSeparatorWidth = width >= 24 ? 3 : 1
const minLeftWidth = width >= 24 ? 8 : 1
const maxRightWidth = Math.max(0, width - desiredSeparatorWidth - minLeftWidth)
if (!cwdLabel || maxRightWidth <= 0) {
return { leftWidth: width, rightWidth: 0, separatorWidth: 0 }
}
const rightWidth = Math.max(0, Math.min(stringWidth(cwdLabel), maxRightWidth))
const separatorWidth = rightWidth > 0 ? desiredSeparatorWidth : 0
const leftWidth = Math.max(1, width - separatorWidth - rightWidth)
return { leftWidth, rightWidth, separatorWidth }
}
function SpawnHud({ t }: { t: Theme }) {
// Tight HUD that only appears when the session is actually fanning out.
// Colour escalates to warn/error as depth or concurrency approaches the cap.
const delegation = useStore($delegationState)
const subagents = useTurnSelector(state => state.subagents)
const tree = useMemo(() => buildSubagentTree(subagents), [subagents])
const totals = useMemo(() => treeTotals(tree), [tree])
if (!totals.descendantCount && !delegation.paused) {
return null
}
const maxDepth = delegation.maxSpawnDepth
const maxConc = delegation.maxConcurrentChildren
const depth = Math.max(0, totals.maxDepthFromHere)
const active = totals.activeCount
// `max_concurrent_children` is a per-parent cap, not a global one.
// `activeCount` sums every running agent across the tree and would
// over-warn for multi-orchestrator runs. The widest level of the tree
// is a closer proxy to "most concurrent spawns that could be hitting a
// single parent's slot budget".
const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0)
const depthRatio = maxDepth ? depth / maxDepth : 0
const concRatio = maxConc ? widestLevel / maxConc : 0
const ratio = Math.max(depthRatio, concRatio)
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.muted
const pieces: string[] = []
if (delegation.paused) {
pieces.push('⏸ paused')
}
if (totals.descendantCount > 0) {
const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}`
pieces.push(`d${depthLabel}`)
if (active > 0) {
// Label pairs the widest-level count (drives concRatio above) with
// the total active count for context. `W/cap` triggers the warn,
// `+N` is everything else currently running across the tree.
const extra = Math.max(0, active - widestLevel)
const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}`
const suffix = extra > 0 ? `+${extra}` : ''
pieces.push(`${widthLabel}${suffix}`)
}
}
const atCap = depthRatio >= 1 || concRatio >= 1
return (
<Text color={color}>
{atCap ? ' │ ⚠ ' : ' │ '}
{pieces.join(' ')}
</Text>
)
}
function SessionDuration({ startedAt }: { startedAt: number }) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [startedAt])
return fmtDuration(now - startedAt)
}
const effortLabel = (effort?: string) => {
const value = String(effort ?? '')
.trim()
.toLowerCase()
return value && value !== 'medium' && value !== 'normal' && value !== 'default' ? value : ''
}
const shortModelLabel = (model: string) =>
model
.split('/')
.pop()!
.replace(/^claude[-_]/, '')
.replace(/^anthropic[-_]/, '')
.replace(/[-_]/g, ' ')
.replace(/\b(\d+)\s+(\d+)\b/g, '$1.$2')
.trim()
const modelLabel = (model: string, effort?: string, fast?: boolean) =>
[shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ')
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
const [active, setActive] = useState(false)
const [color, setColor] = useState(t.color.accent)
useEffect(() => {
if (tick <= 0) {
return
}
const palette = [t.color.error, t.color.warn, t.color.accent]
setColor(palette[Math.floor(Math.random() * palette.length)]!)
setActive(true)
const id = setTimeout(() => setActive(false), 650)
return () => clearTimeout(id)
}, [t.color.accent, tick])
if (!active) {
return null
}
return <Text color={color}></Text>
}
export function StatusRule({
cwdLabel,
cols,
busy,
status,
statusColor,
model,
modelFast,
modelReasoningEffort,
usage,
bgCount,
liveSessionCount,
sessionStartedAt,
showCost,
turnStartedAt,
voiceLabel,
onSessionCountClick,
t
}: StatusRuleProps) {
const pct = usage.context_percent
const barColor = ctxBarColor(pct, t)
const ctxLabel = usage.context_max
? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
: usage.total > 0
? `${fmtK(usage.total)} tok`
: ''
const bar = usage.context_max ? ctxBar(pct) : ''
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel)
const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : ''
const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => {
event.stopImmediatePropagation?.()
onSessionCountClick?.()
}
const sessionCountNode = sessionCountText ? (
onSessionCountClick ? (
<Box flexShrink={0} onClick={handleSessionCountClick}>
<Text color={t.color.accent}> {sessionCountText}</Text>
</Box>
) : (
<Text color={t.color.muted}> {sessionCountText}</Text>
)
) : null
return (
<Box height={1}>
<Box flexDirection="row" flexShrink={1} overflow="hidden" width={leftWidth}>
<Text color={t.color.border} wrap="truncate-end">
{'─ '}
</Text>
{busy ? (
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
) : (
<Text color={statusColor} wrap="truncate-end">
{status}
</Text>
)}
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{modelLabel(model, modelReasoningEffort, modelFast)}
</Text>
{ctxLabel ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{ctxLabel}
</Text>
) : null}
{bar ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
</Text>
) : null}
{sessionStartedAt ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<SessionDuration startedAt={sessionStartedAt} />
</Text>
) : null}
{typeof usage.compressions === 'number' && usage.compressions > 0 ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<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
color={
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.muted
}
wrap="truncate-end"
>
{' │ '}
{voiceLabel}
</Text>
) : null}
{sessionCountNode}
{bgCount > 0 ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{bgCount} bg
</Text>
) : null}
{showCost && typeof usage.cost_usd === 'number' ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ $'}
{usage.cost_usd.toFixed(4)}
</Text>
) : null}
</Box>
{rightWidth > 0 ? (
<>
<Text color={t.color.border}>{separatorWidth >= 3 ? ' ─ ' : ' '}</Text>
<Box flexShrink={0} width={rightWidth}>
<Text color={t.color.label} wrap="truncate-end">
{cwdLabel}
</Text>
</Box>
</>
) : null}
</Box>
)
}
export function FloatBox({ children, color }: { children: ReactNode; color: string }) {
return (
<Box
alignSelf="flex-start"
borderColor={color}
borderStyle="double"
flexDirection="column"
marginTop={1}
opaque
paddingX={1}
>
{children}
</Box>
)
}
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
const { atBottom, bottom, top } = useViewportSnapshot(scrollRef)
const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom)
useEffect(() => onChange(text), [onChange, text])
return null
}
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<number | null>(null)
const grabRef = useRef<number | null>(null)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)
if (!vp) {
return <Box width={1} />
}
const s = scrollRef.current
const scrollable = total > vp
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
const trackColor = hover ? t.color.border : t.color.muted
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
return
}
s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
}
return (
<Box
flexDirection="column"
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)), grabRef.current ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => {
grabRef.current = null
setGrab(null)
}}
width={1}
>
{!scrollable ? (
<Text color={trackColor} dim>
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
</Text>
) : null}
{thumb > 0 ? (
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
) : null}
{vp - thumbTop - thumb > 0 ? (
<Text color={trackColor} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
</Text>
) : null}
</>
)}
</Box>
)
}
interface StatusRuleProps {
bgCount: number
liveSessionCount: number
busy: boolean
cols: number
cwdLabel: string
model: string
modelFast?: boolean
modelReasoningEffort?: string
sessionStartedAt?: null | number
showCost: boolean
status: string
statusColor: string
t: Theme
turnStartedAt?: null | number
usage: Usage
voiceLabel?: string
onSessionCountClick?: () => void
}
interface StickyPromptTrackerProps {
messages: readonly Msg[]
offsets: ArrayLike<number>
onChange: (text: string) => void
scrollRef: RefObject<ScrollBoxHandle | null>
}
interface TranscriptScrollbarProps {
scrollRef: RefObject<ScrollBoxHandle | null>
t: Theme
}
+444
View File
@@ -0,0 +1,444 @@
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { Fragment, memo, useMemo, useRef } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js'
import { PLACEHOLDER } from '../content/placeholders.js'
import {
COMPOSER_PROMPT_GAP_WIDTH,
composerPromptWidth,
inputVisualHeight,
stableComposerColumns
} from '../lib/inputMetrics.js'
import { PerfPane } from '../lib/perfPane.js'
import { composerPromptText } from '../lib/prompt.js'
import { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { FloatingOverlays, PromptZone } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { FpsOverlay } from './fpsOverlay.js'
import { HelpHint } from './helpHint.js'
import { MessageLine } from './messageLine.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
import { TextInput, type TextInputMouseApi } from './textInput.js'
const PromptPrefix = memo(function PromptPrefix({
bold = false,
color,
promptText,
width
}: {
bold?: boolean
color: string
promptText: string
width: number
}) {
const glyphWidth = Math.max(1, width - COMPOSER_PROMPT_GAP_WIDTH)
return (
<Box width={width}>
<Box width={glyphWidth}>
<Text bold={bold} color={color}>
{promptText}
</Text>
</Box>
<Box width={COMPOSER_PROMPT_GAP_WIDTH} />
</Box>
)
})
const TranscriptPane = memo(function TranscriptPane({
actions,
composer,
progress,
transcript
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState)
// LiveTodoPanel rides as a child of the latest user-message row so it
// visually belongs to the prompt and follows it during scroll. -1 when
// empty → row.index === -1 is always false → no render.
const lastUserIdx = useMemo(() => {
const items = transcript.historyItems
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].role === 'user') {
return i
}
}
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
flexDirection="column"
flexGrow={1}
flexShrink={1}
onClick={(e: { cellIsBlank?: boolean }) => {
if (e.cellIsBlank) {
actions.clearSelection()
}
}}
ref={transcript.scrollRef}
stickyScroll
>
<Box flexDirection="column" paddingX={1}>
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
{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 maxWidth={Math.max(1, composer.cols - 2)} t={ui.theme} />
{row.msg.info && <SessionPanel info={row.msg.info} maxWidth={Math.max(1, composer.cols - 2)} sid={ui.sid} t={ui.theme} />}
</Box>
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
) : (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
msg={row.msg}
sections={ui.sections}
t={ui.theme}
/>
)}
{row.index === lastUserIdx && <LiveTodoPanel />}
</Box>
))}
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
<StreamingAssistant
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
progress={progress}
sections={ui.sections}
/>
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
</NoSelect>
<StickyPromptTracker
messages={transcript.historyItems}
offsets={transcript.virtualHistory.offsets}
onChange={actions.setStickyPrompt}
scrollRef={transcript.scrollRef}
/>
</>
)
})
const ComposerPane = memo(function ComposerPane({
actions,
composer,
status
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const promptText = composerPromptText(ui.theme.brand.prompt, ui.info?.profile_name, sh, TERMUX_TUI_MODE, composer.cols)
const promptWidth = composerPromptWidth(promptText)
const promptBlank = ' '.repeat(promptWidth)
const inputColumns = stableComposerColumns(composer.cols, promptWidth, TERMUX_TUI_MODE)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
const captureInputDrag = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.startAtBeginning()
}
// Drag origin matches the input box's top-left, so localRow / localCol
// map directly into TextInput coords (after backing out the prompt cell).
const dragFromPromptRow = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth)
}
// Spacer rows live on a different vertical origin; only the column is
// parent-aligned with the input. Force row=0 so vertical drags can't
// jump the cursor to the wrong wrapped line.
const dragFromSpacer = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
}
const endInputDrag = () => inputMouseRef.current?.end()
return (
<NoSelect
flexDirection="column"
flexShrink={0}
fromLeftEdge
onClick={(e: { cellIsBlank?: boolean }) => {
if (e.cellIsBlank) {
actions.clearSelection()
}
}}
paddingX={1}
>
<QueuedMessages
cols={composer.cols}
queued={composer.queuedDisplay}
queueEditIdx={composer.queueEditIdx}
t={ui.theme}
/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.muted}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.muted} wrap="truncate-end">
<Text color={ui.theme.color.label}> </Text>
{status.stickyPrompt}
</Text>
) : (
<Box height={1} onMouseDown={captureInputDrag} onMouseDrag={dragFromSpacer} onMouseUp={endInputDrag} />
)}
<StatusRulePane at="top" composer={composer} status={status} />
<Box flexDirection="column" marginTop={ui.statusBar === 'top' ? 0 : 1} position="relative">
<FloatingOverlays
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onActiveSessionSelect={actions.activateLiveSession}
onActiveSessionClose={actions.closeLiveSession}
onModelSelect={actions.onModelSelect}
onNewLiveSession={actions.newLiveSession}
onNewPromptSession={actions.newPromptSession}
onPickerSelect={actions.resumeById}
pagerPageSize={composer.pagerPageSize}
/>
{composer.input === '?' && !composer.inputBuf.length && <HelpHint t={ui.theme} />}
{!isBlocked && (
<>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={promptWidth}>
{i === 0 ? (
<PromptPrefix color={ui.theme.color.muted} promptText={promptText} width={promptWidth} />
) : (
<Text color={ui.theme.color.muted}>{promptBlank}</Text>
)}
</Box>
<Text color={ui.theme.color.text}>{line || ' '}</Text>
</Box>
))}
<Box
onMouseDown={captureInputDrag}
onMouseDrag={dragFromPromptRow}
onMouseUp={endInputDrag}
position="relative"
width={Math.max(1, composer.cols - 2)}
>
<Box width={promptWidth}>
{sh ? (
<PromptPrefix color={ui.theme.color.shellDollar} promptText={promptText} width={promptWidth} />
) : composer.inputBuf.length ? (
<Text color={ui.theme.color.prompt}>{promptBlank}</Text>
) : (
<PromptPrefix bold color={ui.theme.color.prompt} promptText={promptText} width={promptWidth} />
)}
</Box>
<Box flexGrow={0} flexShrink={0} height={inputHeight} width={inputColumns}>
{/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */}
<TextInput
columns={inputColumns}
mouseApiRef={inputMouseRef}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
voiceRecordKey={composer.voiceRecordKey}
/>
</Box>
<Box position="absolute" right={0}>
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
</Box>
</Box>
</>
)}
</Box>
{!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}> {ui.status}</Text>}
<StatusRulePane at="bottom" composer={composer} status={status} />
</NoSelect>
)
})
const AgentsOverlayPane = memo(function AgentsOverlayPane() {
const { gw } = useGateway()
const ui = useStore($uiState)
const overlay = useStore($overlayState)
return (
<AgentsOverlay
gw={gw}
initialHistoryIndex={overlay.agentsInitialHistoryIndex}
onClose={() => patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })}
t={ui.theme}
/>
)
})
const StatusRulePane = memo(function StatusRulePane({
at,
composer,
status
}: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'top' }) {
const ui = useStore($uiState)
if (ui.statusBar !== at) {
return null
}
return (
<Box marginTop={at === 'top' ? 1 : 0}>
<StatusRule
bgCount={ui.bgTasks.size}
busy={ui.busy}
cols={composer.cols}
cwdLabel={status.cwdLabel}
liveSessionCount={ui.liveSessionCount}
model={ui.info?.model ?? ''}
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
modelReasoningEffort={ui.info?.reasoning_effort}
onSessionCountClick={() => patchOverlayState({ sessions: true })}
sessionStartedAt={status.sessionStartedAt}
showCost={ui.showCost}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
turnStartedAt={status.turnStartedAt}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
</Box>
)
})
export const AppLayout = memo(function AppLayout({
actions,
composer,
mouseTracking,
progress,
status,
transcript
}: AppLayoutProps) {
const overlay = useStore($overlayState)
const ui = useStore($uiState)
// Inline mode skips AlternateScreen so the host terminal's native
// scrollback captures rows scrolled off the top; composer + progress
// stay anchored via normal flex-column flow.
const Shell = INLINE_MODE ? Fragment : AlternateScreen
const shellProps = INLINE_MODE ? {} : { mouseTracking }
return (
<Shell {...shellProps}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}>
{overlay.agents ? (
<PerfPane id="agents">
<AgentsOverlayPane />
</PerfPane>
) : (
<PerfPane id="transcript">
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
</PerfPane>
)}
</Box>
{!overlay.agents && (
<>
<PerfPane id="prompt">
<PromptZone
cols={composer.cols}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
/>
</PerfPane>
<PerfPane id="composer">
<ComposerPane actions={actions} composer={composer} status={status} />
</PerfPane>
{SHOW_FPS && (
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
<FpsOverlay t={ui.theme} />
</Box>
)}
</>
)}
</Box>
</Shell>
)
})
type GutterMouseEvent = {
button: number
localCol?: number
localRow?: number
stopImmediatePropagation?: () => void
}
+254
View File
@@ -0,0 +1,254 @@
import { Box, Text } from '@hermes/ink'
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 { $uiSessionId, $uiTheme } from '../app/uiStore.js'
import { ActiveSessionSwitcher } from './activeSessionSwitcher.js'
import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js'
const COMPLETION_WINDOW = 16
export function PromptZone({
cols,
onApprovalChoice,
onClarifyAnswer,
onSecretSubmit,
onSudoSubmit
}: Pick<AppOverlaysProps, 'cols' | 'onApprovalChoice' | 'onClarifyAnswer' | 'onSecretSubmit' | 'onSudoSubmit'>) {
const overlay = useStore($overlayState)
const theme = useStore($uiTheme)
if (overlay.approval) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={theme} />
</Box>
)
}
if (overlay.confirm) {
const req = overlay.confirm
const onConfirm = () => {
patchOverlayState({ confirm: null })
req.onConfirm()
}
const onCancel = () => patchOverlayState({ confirm: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ConfirmPrompt onCancel={onCancel} onConfirm={onConfirm} req={req} t={theme} />
</Box>
)
}
if (overlay.clarify) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ClarifyPrompt
cols={cols}
onAnswer={onClarifyAnswer}
onCancel={() => onClarifyAnswer('')}
req={overlay.clarify}
t={theme}
/>
</Box>
)
}
if (overlay.sudo) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={theme} />
</Box>
)
}
if (overlay.secret) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<MaskedPrompt
cols={cols}
icon="🔑"
label={overlay.secret.prompt}
onSubmit={onSecretSubmit}
sub={`for ${overlay.secret.envVar}`}
t={theme}
/>
</Box>
)
}
return null
}
export function FloatingOverlays({
cols,
compIdx,
completions,
onActiveSessionSelect,
onActiveSessionClose,
onModelSelect,
onNewLiveSession,
onNewPromptSession,
onPickerSelect,
pagerPageSize
}: Pick<
AppOverlaysProps,
| 'cols'
| 'compIdx'
| 'completions'
| 'onActiveSessionSelect'
| 'onActiveSessionClose'
| 'onModelSelect'
| 'onNewLiveSession'
| 'onNewPromptSession'
| 'onPickerSelect'
| 'pagerPageSize'
>) {
const { gw } = useGateway()
const overlay = useStore($overlayState)
const sid = useStore($uiSessionId)
const theme = useStore($uiTheme)
const hasAny =
overlay.modelPicker ||
overlay.pager ||
overlay.picker ||
overlay.sessions ||
overlay.skillsHub ||
completions.length
if (!hasAny) {
return null
}
// Fixed viewport centered on compIdx — previously the slice end was
// compIdx + 8 so the dropdown grew from 8 rows to 16 as the user scrolled
// down, bouncing the height on every keystroke.
const viewportSize = Math.min(COMPLETION_WINDOW, completions.length)
const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize))
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && (
<FloatBox color={theme.color.border}>
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={theme}
/>
</FloatBox>
)}
{overlay.sessions && (
<FloatBox color={theme.color.border}>
<ActiveSessionSwitcher
currentSessionId={sid}
gw={gw}
onCancel={() => patchOverlayState({ sessions: false })}
onClose={onActiveSessionClose}
onNew={onNewLiveSession}
onNewPrompt={onNewPromptSession}
onSelect={onActiveSessionSelect}
t={theme}
/>
</FloatBox>
)}
{overlay.modelPicker && (
<FloatBox color={theme.color.border}>
<ModelPicker
gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={sid}
t={theme}
/>
</FloatBox>
)}
{overlay.skillsHub && (
<FloatBox color={theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
</FloatBox>
)}
{overlay.pager && (
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={theme.color.primary}>
{overlay.pager.title}
</Text>
</Box>
)}
{overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<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)`}
</OverlayHint>
</Box>
</Box>
</FloatBox>
)}
{!!completions.length && (
<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 ? theme.color.completionCurrentBg : theme.color.completionBg}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
{/* flexShrink=0 when meta overflows the row, Ink/Yoga
otherwise shaves the last char off the display column
(e.g. /goal renders as /goa). */}
<Box flexShrink={0}>
<Text bold color={theme.color.label}>
{' '}
{item.display}
</Text>
</Box>
{item.meta ? (
<Text
backgroundColor={active ? theme.color.completionMetaCurrentBg : theme.color.completionMetaBg}
color={theme.color.muted}
>
{' '}
{item.meta}
</Text>
) : null}
</Box>
)
})}
</Box>
</FloatBox>
)}
</Box>
)
}
+466
View File
@@ -0,0 +1,466 @@
import { Box, Text, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react'
import unicodeSpinners from 'unicode-animations'
import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js'
import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { PanelSection, SessionInfo } from '../types.js'
const LOADER_TICK_MS = 120
function InlineLoader({ label, t }: { label: string; t: Theme }) {
const [tick, setTick] = useState(0)
const spinner = unicodeSpinners.braille
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
useEffect(() => {
const id = setInterval(() => setTick(n => n + 1), Math.max(LOADER_TICK_MS, spinner.interval))
return () => clearInterval(id)
}, [spinner.interval])
return (
<Text color={t.color.muted} wrap="truncate">
<Text color={t.color.accent}>{frame}</Text> {label}
</Text>
)
}
export function ArtLines({ lines }: { lines: [string, string][] }) {
return (
<Box flexDirection="column" height={lines.length} opaque width={artWidth(lines)}>
{lines.map(([c, text], i) => (
<Text color={c} key={i} wrap="truncate-end">
{text}
</Text>
))}
</Box>
)
}
// Responsive Banner: full art → compact rule → text → hidden.
//
// Terminals can't scale glyphs, so "responsive" means picking a layout that
// fits the available columns. Thresholds are picked so each tier reads
// comfortably without forcing wrap or truncation drift on box-drawing edges.
const TAG_FULL = 'Nous Research · Messenger of the Digital Gods'
const TAG_MID = 'Messenger of the Digital Gods'
const TAG_TINY = 'Nous Research'
const HIDE_BELOW = 34
const COMPACT_FROM = 58
const clip = (s: string, w: number) =>
w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}` : s
const centerIn = (s: string, w: number) => {
const f = clip(s, w)
const slack = Math.max(0, w - f.length)
const left = slack >> 1
return `${' '.repeat(left)}${f}${' '.repeat(slack - left)}`
}
const ruleIn = (label: string, w: number) => {
const f = clip(label, Math.max(1, w - 4))
const slack = Math.max(0, w - f.length - 2)
const left = slack >> 1
return `${'─'.repeat(left)} ${f} ${'─'.repeat(slack - left)}`
}
function CompactBanner({ cols, t }: { cols: number; t: Theme }) {
// -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap.
const w = Math.max(28, cols - 4)
return (
<Box flexDirection="column" height={3} marginBottom={1} opaque width={w}>
<Text bold color={t.color.primary}>{ruleIn(t.brand.name, w)}</Text>
<Text color={t.color.muted}>{centerIn(TAG_FULL, w)}</Text>
<Text color={t.color.primary}>{'─'.repeat(w)}</Text>
</Box>
)
}
export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
const term = useStdout().stdout?.columns ?? 80
const cols = Math.max(1, Math.min(term, maxWidth ?? term))
if (cols < HIDE_BELOW) {
return null
}
const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
if (cols >= logoW + 2) {
return (
<Box flexDirection="column" marginBottom={1}>
<ArtLines lines={logoLines} />
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {TAG_FULL}
</Text>
</Box>
)
}
if (cols >= COMPACT_FROM) {
return <CompactBanner cols={cols} t={t} />
}
const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name)
const tag = cols >= 64 ? TAG_FULL : cols >= 46 ? TAG_MID : TAG_TINY
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={t.color.primary} wrap="truncate-end">{t.brand.icon} {name}</Text>
<Text color={t.color.muted} wrap="truncate-end">{t.brand.icon} {tag}</Text>
</Box>
)
}
// ── 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, maxWidth, sid, t }: SessionPanelProps) {
const term = useStdout().stdout?.columns ?? 100
const cols = Math.max(20, Math.min(term, maxWidth ?? term))
const heroLines = caduceus(t.color, t.bannerHero || undefined)
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
const wide = cols >= 90 && leftW + 40 < cols
const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
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
for (const item of [...items].sort()) {
const next = line ? `${line}, ${item}` : item
if (pfx.length + next.length > lineBudget) {
return line ? `${line}, …+${items.length - shown}` : `${item}, …`
}
line = next
shown++
}
return line
}
// ── 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 (
<>
{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>
)}
</>
)
}
// ── 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>
)
}
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
{wide && (
<Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={heroLines} />
<Text />
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
)}
<Box flexDirection="column" width={w}>
{wide ? (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
) : (
// Narrow layout hides the hero column; surface model/cwd/session
// here so they aren't lost.
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.accent} wrap="truncate-end">
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text wrap="truncate-end">
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
)}
{/* ── 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}>
<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}>
{toolsTotal} tools{' · '}
{skillsTotal} skills
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
{' · '}
<Text color={t.color.muted}>/help for commands</Text>
</Text>
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color={t.color.warn}>
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
<Text bold={false} color={t.color.warn} dimColor>
{' '}
- run{' '}
</Text>
<Text bold color={t.color.warn}>
{info.update_command || 'hermes update'}
</Text>
<Text bold={false} color={t.color.warn} dimColor>
{' '}
to update
</Text>
</Text>
)}
</Box>
</Box>
)
}
export function Panel({ sections, t, title }: PanelProps) {
return (
<Box borderColor={t.color.border} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{title}
</Text>
</Box>
{sections.map((sec, si) => (
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
{sec.title && (
<Text bold color={t.color.accent}>
{sec.title}
</Text>
)}
{sec.rows?.map(([k, v], ri) => (
<Text key={ri} wrap="truncate">
<Text color={t.color.muted}>{k.padEnd(20)}</Text>
<Text color={t.color.text}>{v}</Text>
</Text>
))}
{sec.items?.map((item, ii) => (
<Text color={t.color.text} key={ii} wrap="truncate">
{item}
</Text>
))}
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
</Box>
))}
</Box>
)
}
interface PanelProps {
sections: PanelSection[]
t: Theme
title: string
}
interface SessionPanelProps {
info: SessionInfo
maxWidth?: number
sid?: string | null
t: Theme
}
+30
View File
@@ -0,0 +1,30 @@
// FPS counter overlay (HERMES_TUI_FPS=1). Zero-cost when disabled.
import { Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { SHOW_FPS } from '../config/env.js'
import { $fpsState } from '../lib/fpsStore.js'
import type { Theme } from '../theme.js'
const fpsColor = (fps: number, t: Theme) =>
fps >= 50 ? t.color.statusGood : fps >= 30 ? t.color.statusWarn : t.color.error
export function FpsOverlay({ t }: { t: Theme }) {
if (!SHOW_FPS) {
return null
}
return <FpsOverlayInner t={t} />
}
function FpsOverlayInner({ t }: { t: Theme }) {
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
// Zero-pad widths so digit churn doesn't jitter the corner.
return (
<Text color={fpsColor(fps, t)}>
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
</Text>
)
}

Some files were not shown because too many files have changed in this diff Show More