forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
activeSessionCountLabel,
|
||||
canTypeOrchestratorPrompt,
|
||||
clampOrchestratorSelection,
|
||||
closeFallbackAfterClose,
|
||||
currentSessionSelectionIndex,
|
||||
draftModelArgFromPickerValue,
|
||||
draftModelDisplayLabel,
|
||||
draftTitleFromPrompt,
|
||||
fixedSessionColumnStyle,
|
||||
isNewSessionRow,
|
||||
newSessionMarkerColor,
|
||||
newSessionRowIndex,
|
||||
orchestratorContextHint,
|
||||
orchestratorContextHintSegments,
|
||||
orchestratorGlobalHotkeyHint,
|
||||
orchestratorGlobalHotkeyHintSegments,
|
||||
orchestratorHintSegmentColor,
|
||||
orchestratorRowClickAction,
|
||||
orchestratorVisibleRowIndexes,
|
||||
relativeSessionAge,
|
||||
resumableHistory,
|
||||
selectedSessionRowStyle,
|
||||
sessionRowKindAt,
|
||||
sessionsCountLabel
|
||||
} from '../components/activeSessionSwitcher.js'
|
||||
import type { SessionActiveItem } from '../gatewayTypes.js'
|
||||
import type { SessionListItem } from '../gatewayTypes.js'
|
||||
import { DEFAULT_THEME } from '../theme.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…'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unified Sessions overlay helpers', () => {
|
||||
it('orders rows as [new][live…][history…]', () => {
|
||||
// 2 live sessions, any number of history rows after them.
|
||||
expect(sessionRowKindAt(0, 2)).toBe('new')
|
||||
expect(sessionRowKindAt(1, 2)).toBe('live')
|
||||
expect(sessionRowKindAt(2, 2)).toBe('live')
|
||||
expect(sessionRowKindAt(3, 2)).toBe('history')
|
||||
expect(sessionRowKindAt(9, 2)).toBe('history')
|
||||
// No live sessions: row 0 is new, everything after is history.
|
||||
expect(sessionRowKindAt(0, 0)).toBe('new')
|
||||
expect(sessionRowKindAt(1, 0)).toBe('history')
|
||||
})
|
||||
|
||||
it('drops already-live sessions from the resumable history (dedupe by id)', () => {
|
||||
const history = [
|
||||
{ id: 'a', message_count: 1, preview: '', started_at: 0, title: 'A' },
|
||||
{ id: 'b', message_count: 2, preview: '', started_at: 0, title: 'B' },
|
||||
{ id: 'c', message_count: 3, preview: '', started_at: 0, title: 'C' }
|
||||
] satisfies SessionListItem[]
|
||||
|
||||
const live = [{ id: 'b', status: 'idle' }] satisfies SessionActiveItem[]
|
||||
|
||||
expect(resumableHistory(history, live).map(h => h.id)).toEqual(['a', 'c'])
|
||||
expect(resumableHistory(history, []).map(h => h.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('labels live + resumable counts compactly', () => {
|
||||
expect(sessionsCountLabel(0, 0)).toBe('0 live · 0 resumable')
|
||||
expect(sessionsCountLabel(2, 7)).toBe('2 live · 7 resumable')
|
||||
})
|
||||
|
||||
it('renders relative session age, blank when unknown', () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
|
||||
expect(relativeSessionAge(nowSec)).toBe('today')
|
||||
expect(relativeSessionAge(nowSec - 36 * 3600)).toBe('yesterday')
|
||||
expect(relativeSessionAge(nowSec - 3 * 86400)).toBe('3d ago')
|
||||
expect(relativeSessionAge(undefined)).toBe('')
|
||||
expect(relativeSessionAge(0)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,330 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Find the innermost element whose own (direct) text content includes the
|
||||
// needle. Used to assert the colour the notice text is rendered with.
|
||||
const findElementWithText = (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 = findElementWithText(child, needle)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!React.isValidElement(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Prefer the deepest matching element so we get the leaf <Text> that
|
||||
// actually carries the colour, not an ancestor Box.
|
||||
const deeper = findElementWithText(node.props.children, needle)
|
||||
|
||||
if (deeper) {
|
||||
return deeper
|
||||
}
|
||||
|
||||
return textContent(node).includes(needle) ? node : null
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 100,
|
||||
cwdLabel: '~/repo',
|
||||
liveSessionCount: 0,
|
||||
model: 'opus-4.8',
|
||||
sessionStartedAt: null,
|
||||
showCost: false,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 },
|
||||
voiceLabel: ''
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('keeps status + model and drops the low-value tail on a narrow terminal', () => {
|
||||
const element = StatusRule({
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 44,
|
||||
cwdLabel: '~/src/hermes-agent/apps/desktop (bb/tui-statusbar-responsive)',
|
||||
liveSessionCount: 3,
|
||||
model: 'opus-4.8',
|
||||
onSessionCountClick: vi.fn(),
|
||||
sessionStartedAt: Date.now() - 60_000,
|
||||
showCost: true,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, cost_usd: 0.5, total: 50_000 },
|
||||
voiceLabel: 'voice off'
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// Must-keep essentials survive intact …
|
||||
expect(rendered).toContain('ready')
|
||||
expect(rendered).toContain('opus 4.8')
|
||||
// … while the low-value tail (session count, cost) is dropped, not truncated.
|
||||
expect(rendered).not.toContain('3 sessions')
|
||||
expect(rendered).not.toContain('$0.5000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule credits notice render priority', () => {
|
||||
it('replaces the idle status with the notice text and keeps model + context', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ credits exhausted' }
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// Notice replaces the status verb slot …
|
||||
expect(rendered).toContain('✕ credits exhausted')
|
||||
expect(rendered).not.toContain('ready')
|
||||
// … but model + context stay visible.
|
||||
expect(rendered).toContain('opus 4.8')
|
||||
expect(rendered).toContain('50k')
|
||||
})
|
||||
|
||||
it('busy wins: the FaceTicker shows, the notice is hidden mid-turn', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
busy: true,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
turnStartedAt: Date.now()
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// Notice must NOT render while busy.
|
||||
expect(rendered).not.toContain('⚠ 90% used')
|
||||
// Model still visible.
|
||||
expect(rendered).toContain('opus 4.8')
|
||||
})
|
||||
|
||||
it('colours the notice by level (error → theme error, success → statusGood)', () => {
|
||||
const errEl = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' }
|
||||
})
|
||||
const errText = findElementWithText(errEl, '✕ exhausted')
|
||||
expect(errText?.props.color).toBe(DEFAULT_THEME.color.error)
|
||||
|
||||
const okEl = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 }
|
||||
})
|
||||
const okText = findElementWithText(okEl, '✓ restored')
|
||||
expect(okText?.props.color).toBe(DEFAULT_THEME.color.statusGood)
|
||||
})
|
||||
|
||||
it('does NOT add a glyph — the notice text is rendered verbatim', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }
|
||||
})
|
||||
const noticeText = findElementWithText(element, '90% used')
|
||||
|
||||
// The leaf carries exactly the policy text — no extra prepended glyph.
|
||||
expect(noticeText?.props.children).toBe('⚠ 90% used')
|
||||
})
|
||||
|
||||
it('the notice text is the shrinkable element (flexShrink=1 + truncate-end) so a long notice ellipsizes', () => {
|
||||
const longText = '⚠ ' + 'x'.repeat(200)
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
cols: 50,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: longText }
|
||||
})
|
||||
|
||||
// The leaf <Text> truncates rather than wrapping/clipping the pinned tail.
|
||||
const noticeText = findElementWithText(element, 'xxxxx')
|
||||
expect(noticeText?.props.wrap).toBe('truncate-end')
|
||||
|
||||
// Its container box yields first (flexShrink=1) so model stays visible.
|
||||
const findShrinkBoxContaining = (node: ReactNodeLike): React.ReactElement | null => {
|
||||
if (!React.isValidElement(node)) {
|
||||
if (Array.isArray(node)) {
|
||||
for (const c of node) {
|
||||
const f = findShrinkBoxContaining(c)
|
||||
if (f) return f
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (node.props.flexShrink === 1 && textContent(node).includes('xxxxx') && node.type !== StatusRule) {
|
||||
// Prefer the closest shrink box that wraps the notice text.
|
||||
const deeper = findShrinkBoxContaining(node.props.children)
|
||||
return deeper ?? node
|
||||
}
|
||||
return findShrinkBoxContaining(node.props.children)
|
||||
}
|
||||
const shrinkBox = findShrinkBoxContaining(element)
|
||||
expect(shrinkBox).not.toBeNull()
|
||||
|
||||
// Model survives on a narrow terminal because the notice yields.
|
||||
expect(textContent(element)).toContain('opus 4.8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule idle-since read-out', () => {
|
||||
// The IdleSince component uses hooks, so it can't be invoked outside a
|
||||
// renderer — assert on the element tree instead (same reason the duration
|
||||
// tests don't check SessionDuration's text).
|
||||
const findComponentByName = (node: ReactNodeLike, name: 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 = findComponentByName(child, name)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!React.isValidElement(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof node.type === 'function' && node.type.name === name) {
|
||||
return node
|
||||
}
|
||||
|
||||
return findComponentByName(node.props.children, name)
|
||||
}
|
||||
|
||||
it('shows time since the last final agent response when idle', () => {
|
||||
const endedAt = Date.now() - 42_000
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
lastTurnEndedAt: endedAt,
|
||||
sessionStartedAt: Date.now() - 60_000
|
||||
})
|
||||
|
||||
const idle = findComponentByName(element, 'IdleSince')
|
||||
|
||||
expect(idle).not.toBeNull()
|
||||
expect(idle!.props.endedAt).toBe(endedAt)
|
||||
})
|
||||
|
||||
it('is hidden while a turn is busy', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
busy: true,
|
||||
lastTurnEndedAt: Date.now() - 42_000,
|
||||
turnStartedAt: Date.now()
|
||||
})
|
||||
|
||||
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
||||
})
|
||||
|
||||
it('is hidden before the first turn completes', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
lastTurnEndedAt: null,
|
||||
sessionStartedAt: Date.now() - 60_000
|
||||
})
|
||||
|
||||
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StatusRule } from '../components/appChrome.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
// DEV_CREDITS_MODE is a module-load-time constant (config/env.ts reads
|
||||
// process.env.HERMES_DEV_CREDITS exactly once, at import). Mutating process.env
|
||||
// inside a test can't flip it after the module is loaded — so mock the module to
|
||||
// the dev-on value for this file. vitest hoists vi.mock above the imports, so
|
||||
// appChrome picks up the mocked flag. Lives in its own file so the override
|
||||
// stays scoped (the other StatusRule tests run with the real, dev-off value).
|
||||
vi.mock('../config/env.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/env.js')>()
|
||||
return { ...actual, DEV_CREDITS_MODE: true }
|
||||
})
|
||||
|
||||
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 baseProps = {
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 100,
|
||||
cwdLabel: '~/repo',
|
||||
liveSessionCount: 0,
|
||||
model: 'opus-4.8',
|
||||
sessionStartedAt: null,
|
||||
showCost: false,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 },
|
||||
voiceLabel: ''
|
||||
}
|
||||
|
||||
describe('StatusRule dev-credits banner (HERMES_DEV_CREDITS on)', () => {
|
||||
it('keeps the dev-credits banner visible alongside a notice', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
usage: { ...baseProps.usage, dev_credits_spent_micros: 12_345 }
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// The notice and the dev banner coexist …
|
||||
expect(rendered).toContain('⚠ 90% used')
|
||||
expect(rendered).toContain('(dev credits)')
|
||||
// … and the Δ spend segment renders (12345 micros → 1.2¢).
|
||||
expect(rendered).toContain('Δ')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
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' })
|
||||
})
|
||||
|
||||
it('respects a reduced option set when permanent allow is disabled', () => {
|
||||
// tirith content-security warning present → no "always"; the 3-item set is
|
||||
// once/session/deny, so 3 maps to deny and 4 is out of range.
|
||||
const opts = ['once', 'session', 'deny'] as const
|
||||
|
||||
expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
expect(approvalAction('4', {}, 0, opts)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('', { downArrow: true }, 2, opts)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('', { return: true }, 2, opts)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
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'
|
||||
})
|
||||
expect(asCommandDispatch({ type: 'prefill', message: 'edit me' })).toEqual({
|
||||
type: 'prefill',
|
||||
message: 'edit me'
|
||||
})
|
||||
expect(asCommandDispatch({ type: 'prefill', message: 'edit me', notice: '↶ rewound' })).toEqual({
|
||||
type: 'prefill',
|
||||
message: 'edit me',
|
||||
notice: '↶ rewound'
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
expect(asCommandDispatch({ type: 'prefill' })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'prefill', message: 42 })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { blockRenders, hasLeadGap, messageGroup, prevRenderedMsg } from '../domain/blockLayout.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
const m = (over: Partial<Msg>): Msg => ({ role: 'assistant', text: '', ...over })
|
||||
|
||||
describe('messageGroup', () => {
|
||||
it('classifies each block kind into its visual band', () => {
|
||||
expect(messageGroup(m({ role: 'assistant' }))).toBe('model')
|
||||
expect(messageGroup(m({ role: 'assistant', kind: 'diff' }))).toBe('diff')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'trail' }))).toBe('trail')
|
||||
expect(messageGroup(m({ role: 'system' }))).toBe('note')
|
||||
expect(messageGroup(m({ role: 'user' }))).toBe('user')
|
||||
expect(messageGroup(m({ role: 'user', kind: 'slash' }))).toBe('slash')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'intro' }))).toBe('intro')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'panel' }))).toBe('intro')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasLeadGap', () => {
|
||||
const trail = m({ role: 'system', kind: 'trail' })
|
||||
const model = m({ role: 'assistant' })
|
||||
const note = m({ role: 'system' })
|
||||
const user = m({ role: 'user' })
|
||||
const diff = m({ role: 'assistant', kind: 'diff' })
|
||||
const slash = m({ role: 'user', kind: 'slash' })
|
||||
|
||||
it('opens a gap only at a boundary between working-area groups', () => {
|
||||
expect(hasLeadGap(trail, model)).toBe(true)
|
||||
expect(hasLeadGap(model, trail)).toBe(true)
|
||||
expect(hasLeadGap(model, note)).toBe(true)
|
||||
expect(hasLeadGap(note, model)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps same-group neighbours flush (the grouping)', () => {
|
||||
expect(hasLeadGap(trail, trail)).toBe(false)
|
||||
expect(hasLeadGap(model, model)).toBe(false)
|
||||
expect(hasLeadGap(note, note)).toBe(false)
|
||||
})
|
||||
|
||||
it('never gaps the first block (no predecessor)', () => {
|
||||
expect(hasLeadGap(undefined, model)).toBe(false)
|
||||
expect(hasLeadGap(undefined, trail)).toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses the gap after blocks that already paint a trailing line', () => {
|
||||
// user and diff carry their own marginBottom — the following block must
|
||||
// not add a second blank line on top of it.
|
||||
expect(hasLeadGap(user, trail)).toBe(false)
|
||||
expect(hasLeadGap(user, model)).toBe(false)
|
||||
expect(hasLeadGap(diff, model)).toBe(false)
|
||||
})
|
||||
|
||||
it('still gaps after a slash echo (it has no trailing margin)', () => {
|
||||
expect(hasLeadGap(slash, model)).toBe(true)
|
||||
expect(hasLeadGap(slash, trail)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets user / slash / diff own their spacing (never managed here)', () => {
|
||||
expect(hasLeadGap(model, user)).toBe(false)
|
||||
expect(hasLeadGap(model, slash)).toBe(false)
|
||||
expect(hasLeadGap(model, diff)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blockRenders', () => {
|
||||
const trail: Msg = { role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }
|
||||
const model: Msg = { role: 'assistant', text: 'hi' }
|
||||
const todos: Msg = { role: 'system', kind: 'trail', text: '', todos: [{ content: 'a', id: '1', status: 'pending' }] }
|
||||
|
||||
it('always renders non-trail blocks', () => {
|
||||
expect(blockRenders(model, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a content-bearing trail unless every section is hidden', () => {
|
||||
expect(blockRenders(trail, { detailsMode: 'collapsed' })).toBe(true)
|
||||
expect(blockRenders(trail, { detailsMode: 'expanded' })).toBe(true)
|
||||
// /details hidden routes through commandOverride, which hides every section.
|
||||
expect(blockRenders(trail, { detailsMode: 'hidden', commandOverride: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render a content-less trail (e.g. finalDetails with only a token tally)', () => {
|
||||
const tally: Msg = { role: 'system', kind: 'trail', text: '', toolTokens: 40 }
|
||||
|
||||
expect(blockRenders(tally, { detailsMode: 'expanded' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps todo trails visible even when details are hidden', () => {
|
||||
expect(blockRenders(todos, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prevRenderedMsg', () => {
|
||||
const hiddenCtx = { commandOverride: true, detailsMode: 'hidden' as const }
|
||||
const shownCtx = { detailsMode: 'collapsed' as const }
|
||||
|
||||
const rows: Msg[] = [
|
||||
{ role: 'user', text: 'q' }, // 0
|
||||
{ role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }, // 1
|
||||
{ role: 'assistant', text: 'first' }, // 2
|
||||
{ role: 'system', kind: 'trail', text: '', tools: ['Edit bar.ts'] }, // 3
|
||||
{ role: 'assistant', text: 'second' } // 4
|
||||
]
|
||||
const at = (i: number) => rows[i]
|
||||
|
||||
it('returns the literal predecessor when everything renders', () => {
|
||||
expect(prevRenderedMsg(at, 2, shownCtx)).toBe(rows[1])
|
||||
expect(prevRenderedMsg(at, 4, shownCtx)).toBe(rows[3])
|
||||
})
|
||||
|
||||
it('skips hidden trails so grouping sees the nearest visible block', () => {
|
||||
// With trails hidden, the prose at index 2 groups against the user (not the
|
||||
// invisible trail) and the prose at index 4 groups against the prose at 2.
|
||||
expect(prevRenderedMsg(at, 2, hiddenCtx)).toBe(rows[0])
|
||||
expect(prevRenderedMsg(at, 4, hiddenCtx)).toBe(rows[2])
|
||||
})
|
||||
|
||||
it('returns undefined at the top of the transcript', () => {
|
||||
expect(prevRenderedMsg(at, 0, shownCtx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,849 @@
|
||||
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 unified sessions overlay for /resume', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/resume')).toBe(true)
|
||||
expect(getOverlayState().sessions).toBe(true)
|
||||
})
|
||||
|
||||
it('resumes a prior session by id when /resume has an argument', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true)
|
||||
expect(ctx.session.resumeById).toHaveBeenCalledWith('sid-old')
|
||||
expect(getOverlayState().sessions).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the unified sessions overlay 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('blocks immediate resume-by-id while a turn is busy', () => {
|
||||
patchUiState({ busy: true, sid: 'sid-abc' })
|
||||
const ctx = buildCtx({ session: { ...buildSession(), guardBusySessionSwitch: vi.fn(() => true) } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true)
|
||||
expect(ctx.session.guardBusySessionSwitch).toHaveBeenCalled()
|
||||
expect(ctx.session.resumeById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats /session (singular) as an alias of the sessions overlay', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/session')).toBe(true)
|
||||
expect(getOverlayState().sessions).toBe(true)
|
||||
})
|
||||
|
||||
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', {
|
||||
confirm_expensive_model: false,
|
||||
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', {
|
||||
confirm_expensive_model: false,
|
||||
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', {
|
||||
confirm_expensive_model: false,
|
||||
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,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { creditsCommands } from '../app/slash/commands/credits.js'
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import type { CreditsViewResponse } from '../gatewayTypes.js'
|
||||
|
||||
// The command opens the top-up URL through this helper on confirm. Mock it so
|
||||
// the test never shells out to a real browser/`xdg-open` and we can assert the
|
||||
// success/failure messaging deterministically.
|
||||
vi.mock('../lib/openExternalUrl.js', () => ({
|
||||
openExternalUrl: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
import { openExternalUrl } from '../lib/openExternalUrl.js'
|
||||
|
||||
const openExternalUrlMock = vi.mocked(openExternalUrl)
|
||||
|
||||
const creditsCommand = creditsCommands.find(cmd => cmd.name === 'credits')!
|
||||
|
||||
const buildView = (overrides: Partial<CreditsViewResponse> = {}): CreditsViewResponse => ({
|
||||
balance_lines: ['Grant: $9.50 left', 'Top-up: $25.00'],
|
||||
depleted: false,
|
||||
identity_line: 'Signed in as ada@example.com',
|
||||
logged_in: true,
|
||||
topup_url: 'https://portal.nousresearch.com/billing/topup',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Mirror createSlashHandler's real `guarded` wrapper: skip the handler when the
|
||||
// command is stale OR the response is falsy. Tests stay non-stale, so this is a
|
||||
// straightforward "run the handler when we got a response" shim.
|
||||
const guarded =
|
||||
<T,>(fn: (r: T) => void) =>
|
||||
(r: null | T) => {
|
||||
if (r) {
|
||||
fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
const buildCtx = (rpcResult: CreditsViewResponse) => {
|
||||
const sys = vi.fn()
|
||||
const rpc = vi.fn(() => Promise.resolve(rpcResult))
|
||||
const guardedErr = vi.fn()
|
||||
|
||||
const ctx = {
|
||||
gateway: { rpc },
|
||||
guarded,
|
||||
guardedErr,
|
||||
sid: 'sid-abc',
|
||||
stale: () => false,
|
||||
transcript: { page: vi.fn(), panel: vi.fn(), sys }
|
||||
}
|
||||
|
||||
// Run the command, then await the rpc promise so the .then() handler has
|
||||
// flushed before assertions — deterministic, no polling/timeouts.
|
||||
const run = async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
creditsCommand.run('', ctx as any, 'credits')
|
||||
await rpc.mock.results[0]?.value
|
||||
// Allow the chained .then() microtask to settle.
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
return { ctx, rpc, run, sys }
|
||||
}
|
||||
|
||||
describe('/credits slash command', () => {
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
openExternalUrlMock.mockClear()
|
||||
openExternalUrlMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('renders the balance (including top-up URL) and arms the confirm overlay', async () => {
|
||||
const view = buildView()
|
||||
const { rpc, run, sys } = buildCtx(view)
|
||||
|
||||
await run()
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('credits.view', { session_id: 'sid-abc' })
|
||||
|
||||
// (a) sys received the balance text including the topup_url
|
||||
const printed = sys.mock.calls.map(call => call[0]).join('\n')
|
||||
expect(printed).toContain('💳 Nous credits')
|
||||
expect(printed).toContain('Grant: $9.50 left')
|
||||
expect(printed).toContain('Signed in as ada@example.com')
|
||||
expect(printed).toContain(view.topup_url)
|
||||
|
||||
// (b) confirm overlay set with the expected label + detail
|
||||
const confirm = getOverlayState().confirm
|
||||
expect(confirm).toBeTruthy()
|
||||
expect(confirm?.confirmLabel).toBe('Open top-up in browser')
|
||||
expect(confirm?.cancelLabel).toBe('Cancel')
|
||||
expect(confirm?.title).toBe('Add credits?')
|
||||
expect(confirm?.detail).toBe(view.topup_url)
|
||||
|
||||
// onConfirm opens the URL and reports success back to the transcript
|
||||
confirm?.onConfirm()
|
||||
expect(openExternalUrlMock).toHaveBeenCalledWith(view.topup_url)
|
||||
expect(sys).toHaveBeenCalledWith(
|
||||
'Complete your top-up in the browser — credits will appear in /credits shortly.'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to printing the URL when the browser open is rejected', async () => {
|
||||
openExternalUrlMock.mockReturnValue(false)
|
||||
const view = buildView()
|
||||
const { run, sys } = buildCtx(view)
|
||||
|
||||
await run()
|
||||
|
||||
const confirm = getOverlayState().confirm
|
||||
expect(confirm).toBeTruthy()
|
||||
confirm?.onConfirm()
|
||||
expect(sys).toHaveBeenCalledWith(`Open this URL to top up: ${view.topup_url}`)
|
||||
})
|
||||
|
||||
it('does not arm the confirm overlay when there is no top-up URL', async () => {
|
||||
const view = buildView({ topup_url: null })
|
||||
const { run, sys } = buildCtx(view)
|
||||
|
||||
await run()
|
||||
|
||||
const printed = sys.mock.calls.map(call => call[0]).join('\n')
|
||||
expect(printed).toContain('💳 Nous credits')
|
||||
expect(getOverlayState().confirm).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the not-logged-in message and does NOT arm the confirm overlay', async () => {
|
||||
const view = buildView({
|
||||
balance_lines: [],
|
||||
identity_line: null,
|
||||
logged_in: false,
|
||||
topup_url: null
|
||||
})
|
||||
const { run, sys } = buildCtx(view)
|
||||
|
||||
await run()
|
||||
|
||||
expect(sys).toHaveBeenCalledWith('💳 Not logged into Nous Portal — run /portal to log in.')
|
||||
expect(getOverlayState().confirm).toBeNull()
|
||||
expect(openExternalUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
@@ -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&T 'Deals'</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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,375 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface ListenerEntry {
|
||||
callback: (event: any) => void
|
||||
once: boolean
|
||||
}
|
||||
|
||||
const { FakeWebSocket } = vi.hoisted(() => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { FakeWebSocket }
|
||||
})
|
||||
|
||||
vi.mock('undici', () => ({ WebSocket: FakeWebSocket }))
|
||||
|
||||
import { GatewayClient } from '../gatewayClient.js'
|
||||
|
||||
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('uses the undici WebSocket fallback when global WebSocket is unavailable', () => {
|
||||
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()
|
||||
|
||||
gw.start()
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
expect(FakeWebSocket.instances[0]?.url).toBe('ws://gateway.test/api/ws?token=hunter2&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 0–65535
|
||||
// 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
|
||||
;(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('alice')
|
||||
expect(tail).not.toContain('hunter2')
|
||||
expect(tail).not.toContain('token=secret')
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { GATEWAY_RECOVERY_LIMIT, GATEWAY_RECOVERY_WINDOW_MS, planGatewayRecovery } from '../app/gatewayRecovery.js'
|
||||
|
||||
describe('planGatewayRecovery', () => {
|
||||
it('recovers the live session and records the attempt', () => {
|
||||
const plan = planGatewayRecovery('sess-1', null, [], 1000)
|
||||
|
||||
expect(plan).toEqual({ attempts: [1000], recover: true, sid: 'sess-1' })
|
||||
})
|
||||
|
||||
it('does not recover when there is no session to resume', () => {
|
||||
expect(planGatewayRecovery(null, null, [], 1000)).toEqual({ attempts: [], recover: false, sid: null })
|
||||
})
|
||||
|
||||
it('keeps retrying the recovery target through a startup crash-loop, bounded by the budget', () => {
|
||||
// First exit: live sid present.
|
||||
let attempts: number[] = []
|
||||
let plan = planGatewayRecovery('sess-1', null, attempts, 0)
|
||||
|
||||
expect(plan.recover).toBe(true)
|
||||
expect(plan.sid).toBe('sess-1')
|
||||
attempts = plan.attempts
|
||||
|
||||
// Respawn crash-loops before gateway.ready: live sid is now null, but the
|
||||
// recovery target carries it forward so we keep trying up to the budget.
|
||||
for (let i = 1; i < GATEWAY_RECOVERY_LIMIT; i++) {
|
||||
plan = planGatewayRecovery(null, 'sess-1', attempts, i)
|
||||
expect(plan.recover).toBe(true)
|
||||
expect(plan.sid).toBe('sess-1')
|
||||
attempts = plan.attempts
|
||||
}
|
||||
|
||||
// Budget exhausted: fall back to the inert state instead of spawn-storming.
|
||||
plan = planGatewayRecovery(null, 'sess-1', attempts, GATEWAY_RECOVERY_LIMIT)
|
||||
expect(plan.recover).toBe(false)
|
||||
expect(plan.sid).toBe('sess-1')
|
||||
})
|
||||
|
||||
it('prunes attempts older than the window so recovery re-arms', () => {
|
||||
const old = Array.from({ length: GATEWAY_RECOVERY_LIMIT }, (_, i) => i)
|
||||
const plan = planGatewayRecovery('sess-1', null, old, GATEWAY_RECOVERY_WINDOW_MS + 100)
|
||||
|
||||
expect(plan.attempts).toEqual([GATEWAY_RECOVERY_WINDOW_MS + 100])
|
||||
expect(plan.recover).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// memory.js performs real heap dumps / fs work — stub it so the monitor's
|
||||
// dump path is a no-op in tests.
|
||||
vi.mock('../lib/memory.js', () => ({
|
||||
performHeapDump: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
// @hermes/ink is dynamically imported only on the dump path; stub the eviction.
|
||||
vi.mock('@hermes/ink', () => ({ evictInkCaches: vi.fn() }))
|
||||
|
||||
import { startMemoryMonitor } from '../lib/memoryMonitor.js'
|
||||
|
||||
const GB = 1024 ** 3
|
||||
const MB = 1024 ** 2
|
||||
|
||||
describe('startMemoryMonitor thresholds (#34095)', () => {
|
||||
let stop: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stop?.()
|
||||
stop = undefined
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const withHeap = (heapUsed: number, rss = heapUsed) =>
|
||||
vi.spyOn(process, 'memoryUsage').mockReturnValue({
|
||||
arrayBuffers: 0,
|
||||
external: 0,
|
||||
heapTotal: heapUsed,
|
||||
heapUsed,
|
||||
rss
|
||||
} as NodeJS.MemoryUsage)
|
||||
|
||||
it('does NOT fire onCritical at 2.5GB when the heap ceiling is 8GB', async () => {
|
||||
// The old hardcoded 2.5GB constant killed the process at ~31% of the real
|
||||
// ceiling. With relative thresholds (~88%), 2.5GB is well within normal.
|
||||
const onCritical = vi.fn()
|
||||
withHeap(2.5 * GB)
|
||||
stop = startMemoryMonitor({ criticalBytes: 7 * GB, highBytes: 5 * GB, intervalMs: 1, onCritical })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
|
||||
expect(onCritical).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fires onCritical only near the configured ceiling', async () => {
|
||||
const onCritical = vi.fn()
|
||||
// Explicit small ceiling-derived thresholds via override to keep the test
|
||||
// independent of the host V8 heap_size_limit.
|
||||
withHeap(7.5 * GB)
|
||||
stop = startMemoryMonitor({ criticalBytes: 7 * GB, highBytes: 5 * GB, intervalMs: 1, onCritical })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
|
||||
expect(onCritical).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fires onWarn once on fast sub-threshold heap growth, then re-arms', async () => {
|
||||
const onWarn = vi.fn()
|
||||
// Start low, then jump >150MB across a tick while above the 600MB floor and
|
||||
// below `high` — the silent-death regime.
|
||||
const spy = withHeap(100 * MB)
|
||||
stop = startMemoryMonitor({ highBytes: 2 * GB, intervalMs: 1, onWarn, warnBytes: 600 * MB })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2) // seed lastHeap at 100MB, below floor
|
||||
expect(onWarn).not.toHaveBeenCalled()
|
||||
|
||||
spy.mockReturnValue({ arrayBuffers: 0, external: 0, heapTotal: 800 * MB, heapUsed: 800 * MB, rss: 800 * MB } as NodeJS.MemoryUsage)
|
||||
await vi.advanceTimersByTimeAsync(2) // jumped 700MB → above floor + steep
|
||||
expect(onWarn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Stays elevated but not re-firing.
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
expect(onWarn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Falls back below the floor → re-armed, then climbs again → fires again.
|
||||
spy.mockReturnValue({ arrayBuffers: 0, external: 0, heapTotal: 100 * MB, heapUsed: 100 * MB, rss: 100 * MB } as NodeJS.MemoryUsage)
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
spy.mockReturnValue({ arrayBuffers: 0, external: 0, heapTotal: 800 * MB, heapUsed: 800 * MB, rss: 800 * MB } as NodeJS.MemoryUsage)
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
expect(onWarn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not warn on slow growth below the steep-growth step', async () => {
|
||||
const onWarn = vi.fn()
|
||||
const spy = withHeap(650 * MB)
|
||||
stop = startMemoryMonitor({ highBytes: 2 * GB, intervalMs: 1, onWarn, warnBytes: 600 * MB })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
// +50MB per tick — above the floor but gentle, not a render-tree blowup.
|
||||
spy.mockReturnValue({ arrayBuffers: 0, external: 0, heapTotal: 700 * MB, heapUsed: 700 * MB, rss: 700 * MB } as NodeJS.MemoryUsage)
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
|
||||
expect(onWarn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// parentLog gates itself off under VITEST so unit tests can't pollute a real
|
||||
// ~/.hermes. To exercise the real persistence path we clear that gate, point
|
||||
// HERMES_HOME at a temp dir, and re-import the module fresh (path + enabled
|
||||
// flag are captured at module load).
|
||||
const loadFresh = async (home: string) => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITEST', '')
|
||||
vi.stubEnv('HERMES_HOME', home)
|
||||
|
||||
return import('../lib/parentLog.js')
|
||||
}
|
||||
|
||||
describe('recordParentLifecycle', () => {
|
||||
let home: string
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'hermes-parentlog-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(home, { force: true, recursive: true })
|
||||
})
|
||||
|
||||
it('appends a timestamped breadcrumb to logs/tui_gateway_crash.log', async () => {
|
||||
const { recordParentLifecycle } = await loadFresh(home)
|
||||
|
||||
recordParentLifecycle('graceful-exit received signal=SIGHUP → killing gateway')
|
||||
|
||||
const contents = readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8')
|
||||
|
||||
expect(contents).toContain('[tui-parent]')
|
||||
expect(contents).toContain('graceful-exit received signal=SIGHUP → killing gateway')
|
||||
expect(contents).toMatch(/\d{4}-\d{2}-\d{2}T/)
|
||||
})
|
||||
|
||||
it('collapses embedded newlines so a value stays one breadcrumb', async () => {
|
||||
const { recordParentLifecycle } = await loadFresh(home)
|
||||
|
||||
recordParentLifecycle('uncaughtException: boom\n at foo()\r\n at bar()')
|
||||
|
||||
const lines = readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8').trimEnd().split('\n')
|
||||
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toContain('boom ↵ at foo() ↵ at bar()')
|
||||
})
|
||||
|
||||
it('caps an oversized breadcrumb so it cannot bloat the shared crash log', async () => {
|
||||
const { recordParentLifecycle } = await loadFresh(home)
|
||||
|
||||
recordParentLifecycle('x'.repeat(10_000))
|
||||
|
||||
const line = readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8')
|
||||
|
||||
expect(line).toContain('[truncated 10000 chars]')
|
||||
expect(line.length).toBeLessThan(4_500)
|
||||
})
|
||||
|
||||
it('is a no-op under VITEST so tests stay hermetic', async () => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITEST', 'true')
|
||||
vi.stubEnv('HERMES_HOME', home)
|
||||
|
||||
const { recordParentLifecycle } = await import('../lib/parentLog.js')
|
||||
|
||||
expect(() => recordParentLifecycle('should not be written')).not.toThrow()
|
||||
expect(() => readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { composeTabTitle, 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(')')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeTabTitle', () => {
|
||||
it('joins marker, name, model, and cwd in order', () => {
|
||||
expect(composeTabTitle('✓', 'auth refactor', 'opus-4', '~/proj')).toBe('✓ auth refactor · opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('glues the marker to the first segment with a space, not a separator', () => {
|
||||
expect(composeTabTitle('⏳', 'my session', 'opus-4', '~/proj').startsWith('⏳ my session')).toBe(true)
|
||||
})
|
||||
|
||||
it('omits the session name when empty (matches the pre-name format)', () => {
|
||||
expect(composeTabTitle('✓', '', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('treats a whitespace-only name as absent', () => {
|
||||
expect(composeTabTitle('✓', ' ', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('omits the cwd when empty', () => {
|
||||
expect(composeTabTitle('✓', 'my session', 'opus-4', '')).toBe('✓ my session · opus-4')
|
||||
})
|
||||
|
||||
it('falls back to just the marker when only the marker is present', () => {
|
||||
expect(composeTabTitle('✓', '', '', '')).toBe('✓')
|
||||
})
|
||||
|
||||
it('truncates an over-long session name with an ellipsis', () => {
|
||||
const long = 'a'.repeat(40)
|
||||
const out = composeTabTitle('✓', long, 'opus-4', '', 28)
|
||||
const namePart = out.slice('✓ '.length).split(' · ')[0]
|
||||
expect(namePart.endsWith('…')).toBe(true)
|
||||
expect(namePart.length).toBe(28)
|
||||
})
|
||||
|
||||
it('keeps a name at the boundary length intact', () => {
|
||||
const name = 'b'.repeat(28)
|
||||
const out = composeTabTitle('✓', name, 'opus-4', '', 28)
|
||||
expect(out).toBe(`✓ ${name} · opus-4`)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 >')
|
||||
})
|
||||
})
|
||||
@@ -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)'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { busyIndicatorWidth, statusBarSegments, 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)
|
||||
})
|
||||
|
||||
it('reserves the high-priority left content so the cwd/branch yields first', () => {
|
||||
const cwd = '~/src/hermes-agent/apps/desktop (bb/tui-statusbar-responsive)'
|
||||
|
||||
const greedy = statusRuleWidths(70, cwd) // legacy behaviour: cwd hogs the row
|
||||
const reserved = statusRuleWidths(70, cwd, 40) // reserve indicator+model+ctx
|
||||
|
||||
expect(reserved.leftWidth).toBeGreaterThanOrEqual(40)
|
||||
expect(reserved.leftWidth).toBeGreaterThan(greedy.leftWidth)
|
||||
expect(reserved.rightWidth).toBeLessThan(greedy.rightWidth)
|
||||
expect(reserved.leftWidth + reserved.separatorWidth + reserved.rightWidth).toBeLessThanOrEqual(70)
|
||||
})
|
||||
|
||||
it('drops the cwd entirely when the essential left content needs the whole row', () => {
|
||||
expect(statusRuleWidths(40, '~/some/cwd (branch)', 60)).toEqual({
|
||||
leftWidth: 40,
|
||||
rightWidth: 0,
|
||||
separatorWidth: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the default (no reservation) behaviour identical for legacy callers', () => {
|
||||
const cwd = '~/src/hermes-agent/main (some-long-branch-name)'
|
||||
|
||||
expect(statusRuleWidths(80, cwd, 0)).toEqual(statusRuleWidths(80, cwd))
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusBarSegments', () => {
|
||||
it('shows every segment on a wide terminal', () => {
|
||||
const s = statusBarSegments(120)
|
||||
|
||||
expect(s).toEqual({
|
||||
compactCtx: false,
|
||||
bar: true,
|
||||
duration: true,
|
||||
compressions: true,
|
||||
voice: true,
|
||||
bg: true,
|
||||
cost: true
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses the context bar to a token count on narrow terminals', () => {
|
||||
const s = statusBarSegments(60)
|
||||
|
||||
expect(s.compactCtx).toBe(true)
|
||||
expect(s.bar).toBe(false)
|
||||
expect(s.duration).toBe(false)
|
||||
expect(s.cost).toBe(false)
|
||||
})
|
||||
|
||||
it('sheds tail segments in priority order as the terminal narrows', () => {
|
||||
// cost is the first to go, the context bar the last of the tail.
|
||||
const order: (keyof ReturnType<typeof statusBarSegments>)[] = [
|
||||
'bar',
|
||||
'duration',
|
||||
'compressions',
|
||||
'voice',
|
||||
'bg',
|
||||
'cost'
|
||||
]
|
||||
|
||||
let prevCount = Infinity
|
||||
|
||||
for (const cols of [120, 95, 87, 83, 79, 75, 71]) {
|
||||
const s = statusBarSegments(cols)
|
||||
const visible = order.filter(k => s[k]).length
|
||||
|
||||
expect(visible).toBeLessThanOrEqual(prevCount)
|
||||
prevCount = visible
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('busyIndicatorWidth', () => {
|
||||
it('reserves a bare spinner for the verb-less unicode style', () => {
|
||||
// unicode is a 1-col braille spinner with no verb; far slimmer than the
|
||||
// kaomoji face which carries a wide glyph + rotating verb.
|
||||
expect(busyIndicatorWidth('unicode', false)).toBeLessThan(busyIndicatorWidth('kaomoji', false))
|
||||
expect(busyIndicatorWidth('unicode', false)).toBe(1)
|
||||
})
|
||||
|
||||
it('reserves room for the elapsed-time tail only when a turn is timed', () => {
|
||||
for (const style of ['kaomoji', 'emoji', 'ascii', 'unicode'] as const) {
|
||||
expect(busyIndicatorWidth(style, true)).toBeGreaterThan(busyIndicatorWidth(style, false))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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,61 @@
|
||||
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()
|
||||
})
|
||||
|
||||
// entry.tsx installs `process.on('exit', () => resetTerminalModes())` as the
|
||||
// final backstop (#28419): /quit, Ctrl+C, Ctrl+D and any process.exit() path
|
||||
// must disarm DEC mouse tracking so the parent shell / next TUI doesn't read
|
||||
// leaked mouse reports as keystrokes. 'exit' handlers run synchronously only,
|
||||
// so the reset must complete via a single synchronous write — verify that an
|
||||
// exit-style invocation disables every SGR mouse mode that produced the
|
||||
// reported `…;…M` garbage.
|
||||
it('disarms mouse tracking from a synchronous exit-style handler', () => {
|
||||
const write = vi.fn()
|
||||
const stream = { isTTY: true, write } as unknown as NodeJS.WriteStream
|
||||
|
||||
// Mirror entry.tsx's process.on('exit') callback.
|
||||
const onExit = () => resetTerminalModes(stream)
|
||||
onExit()
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
const written = write.mock.calls[0]?.[0] as string
|
||||
for (const mode of ['\x1b[?1006l', '\x1b[?1003l', '\x1b[?1002l', '\x1b[?1000l']) {
|
||||
expect(written).toContain(mode)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
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: '✗'
|
||||
})
|
||||
})
|
||||
|
||||
it('caps a large result to a small persisted preview (#34095)', () => {
|
||||
// A 40KB browser-snapshot-sized result must NOT be embedded whole — the
|
||||
// persisted, expanded-by-default trail block is what blew up the Ink
|
||||
// render tree and silently OOM-killed the TUI. The block stays small.
|
||||
const huge = 'A'.repeat(40_000)
|
||||
const line = buildVerboseToolTrailLine('browser_snapshot', 'https://x.example', false, 2, undefined, huge)
|
||||
|
||||
expect(line).toContain('Result:\n')
|
||||
// Far below the old 16KB live-render budget; the whole line (call + label +
|
||||
// omitted marker + preview) must stay on the order of ~1KB, not ~40KB.
|
||||
expect(line.length).toBeLessThan(2_000)
|
||||
expect(line).toContain('omitted')
|
||||
expect(line.endsWith(' ✓')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not truncate a result that already fits the preview budget', () => {
|
||||
const small = 'ok: 3 files changed'
|
||||
const line = buildVerboseToolTrailLine('patch', 'index.html', false, 0.1, undefined, small)
|
||||
|
||||
expect(line).toContain(`Result:\n${small}`)
|
||||
expect(line).not.toContain('omitted')
|
||||
})
|
||||
})
|
||||
|
||||
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 '
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { turnController } from '../app/turnController.js'
|
||||
import { resetTurnState } from '../app/turnStore.js'
|
||||
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
|
||||
|
||||
// turnController.startMessage() treats "flash and yield" notices (the usage-band
|
||||
// credits.usage and the one-time credits.grant_spent transition) as "show until next
|
||||
// prompt": they flash once, then yield when the next turn starts. Depletion (and
|
||||
// other notices) are sticky until the policy clears them.
|
||||
describe('turnController.startMessage — flash-and-yield notices clear on next prompt', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
resetTurnState()
|
||||
turnController.fullReset()
|
||||
})
|
||||
|
||||
it('clears a standing credits.usage notice when a new turn starts', () => {
|
||||
patchUiState({
|
||||
notice: { key: 'credits.usage', kind: 'sticky', level: 'warn', text: '⚠ Credits 90% used · $20.00 cap' }
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice).toBeNull()
|
||||
})
|
||||
|
||||
it('clears a standing credits.grant_spent notice when a new turn starts', () => {
|
||||
// One-time "you've crossed onto top-up" heads-up — shouldn't camp the bar
|
||||
// (e.g. "Grant spent · $990 top-up left" with plenty of top-up remaining).
|
||||
patchUiState({
|
||||
notice: { key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $990.00 top-up left' }
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a sticky credits.depleted notice across a new turn', () => {
|
||||
patchUiState({
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /credits to top up' }
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice?.key).toBe('credits.depleted')
|
||||
})
|
||||
})
|
||||
@@ -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 /
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,104 @@
|
||||
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('adds one row for a group-boundary lead gap', () => {
|
||||
const msg: Msg = { role: 'assistant', text: 'reply' }
|
||||
|
||||
expect(estimatedMsgHeight(msg, 80, { compact: false, details: false, leadGap: true })).toBe(
|
||||
estimatedMsgHeight(msg, 80, { compact: false, details: false, leadGap: false }) + 1
|
||||
)
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
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 { formatAbandonedClarify, 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, recoverSidRef, 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
|
||||
|
||||
// Request IDs of clarify prompts we've already flushed to the transcript as
|
||||
// an abandoned-prompt record, so the tool.complete and message.complete
|
||||
// paths can't both persist the same prompt twice.
|
||||
const persistedAbandonedClarify = new Set<string>()
|
||||
|
||||
// When a clarify prompt is dismissed without an answer (the backend _block
|
||||
// timed out and returned an empty string), the live ClarifyPrompt overlay is
|
||||
// left set until the next turn's idle() silently nulls it — so the question
|
||||
// and options vanish from the screen while the agent's follow-up still refers
|
||||
// to them. The reliable signal is the clarify tool's own tool.complete (and,
|
||||
// as a backstop, message.complete): at those points the overlay is provably
|
||||
// still set on a timeout, but already cleared by answerClarify() on a real
|
||||
// answer (so this no-ops there). Flush the question + options into the
|
||||
// transcript as a persistent system line, then clear the overlay.
|
||||
const flushAbandonedClarify = () => {
|
||||
const { clarify } = getOverlayState()
|
||||
|
||||
if (!clarify || persistedAbandonedClarify.has(clarify.requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
persistedAbandonedClarify.add(clarify.requestId)
|
||||
appendMessage({
|
||||
role: 'system',
|
||||
text: formatAbandonedClarify(clarify.question, clarify.choices, 'timed out')
|
||||
})
|
||||
patchOverlayState({ clarify: null })
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Kick off the config fetch once the gateway is actually ready. If handler
|
||||
// construction does this during React render, a startup transport error can
|
||||
// report through sys(), mutate transcript state, and trip React's
|
||||
// "too many re-renders" guard in embedded dashboard PTYs.
|
||||
ensureAgentsNudgeConfig()
|
||||
|
||||
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'))
|
||||
|
||||
// Crash recovery: a respawn triggered by an unexpected gateway death
|
||||
// resumes the session that was live, not a brand-new one. One-shot — the
|
||||
// ref is cleared so an ordinary later restart still forges/resumes per
|
||||
// config. No startup prompt here (this is mid-session, not a cold boot).
|
||||
const recoverSid = recoverSidRef?.current
|
||||
|
||||
if (recoverSidRef && recoverSid) {
|
||||
recoverSidRef.current = null
|
||||
resumeById(recoverSid)
|
||||
// After resumeById: it synchronously sets status to 'resuming…' on entry,
|
||||
// so override it here to keep the distinct "recovering" label visible for
|
||||
// the duration of the resume RPC (which later flips status to 'ready').
|
||||
patchUiState({ status: 'recovering session…' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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 'notification.show': {
|
||||
// Credits/usage notice from the gateway. Payload is snake_case on the
|
||||
// wire and stays snake_case in UiState.notice (no mapping layer). The
|
||||
// text already carries its own glyph; turnController decides whether to
|
||||
// show now or hold until turn end (FaceTicker wins while busy).
|
||||
const p = ev.payload
|
||||
|
||||
if (!p?.text) {
|
||||
return
|
||||
}
|
||||
|
||||
turnController.showNotice({
|
||||
id: p.id,
|
||||
key: p.key,
|
||||
kind: p.kind ?? 'sticky',
|
||||
level: p.level ?? 'info',
|
||||
text: p.text,
|
||||
ttl_ms: p.ttl_ms ?? null
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'notification.clear':
|
||||
// Key-matched clear only — a stale/late clear must not wipe a newer
|
||||
// notice (turnController guards the key match).
|
||||
turnController.clearNotice(ev.payload?.key)
|
||||
|
||||
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': {
|
||||
// The clarify tool finishing with its overlay still live means it was
|
||||
// abandoned (backend _block timed out, empty answer). A real answer
|
||||
// clears the overlay in answerClarify() before this fires, so this
|
||||
// no-ops there. Persist the question + options so they don't vanish.
|
||||
if (ev.payload.name === 'clarify') {
|
||||
flushAbandonedClarify()
|
||||
}
|
||||
|
||||
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')
|
||||
// Only an explicit false (tirith warning) drops the permanent-allow option.
|
||||
const allowPermanent = ev.payload.allow_permanent !== false
|
||||
|
||||
patchOverlayState({
|
||||
approval: { allowPermanent, 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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`)
|
||||
}
|
||||
|
||||
if (d.type === 'prefill') {
|
||||
// /undo returns prefill: drop the backed-up message text into
|
||||
// the composer so the user can edit and resubmit, instead of
|
||||
// submitting it immediately like 'send'.
|
||||
if (d.notice?.trim()) {
|
||||
sys(d.notice)
|
||||
}
|
||||
if (d.message) {
|
||||
ctx.composer.setInput(d.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
.catch(guardedErr)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Crash-recovery budget for the gateway exit handler. A gateway that
|
||||
// crash-loops on startup must not let the TUI spawn-storm, so respawn+resume
|
||||
// attempts are capped to GATEWAY_RECOVERY_LIMIT within a sliding
|
||||
// GATEWAY_RECOVERY_WINDOW_MS; past the budget the app falls back to the inert
|
||||
// "gateway exited" state. Kept pure (no refs/UI) so the bound — including the
|
||||
// crash-loop case — is unit-testable.
|
||||
export const GATEWAY_RECOVERY_LIMIT = 3
|
||||
export const GATEWAY_RECOVERY_WINDOW_MS = 60_000
|
||||
|
||||
export interface RecoveryPlan {
|
||||
// Attempt timestamps to persist (the pruned window, plus `now` iff recovering).
|
||||
attempts: number[]
|
||||
recover: boolean
|
||||
// Session to resume — the live sid, or the not-yet-consumed recovery target
|
||||
// when the live sid was already cleared by a prior exit.
|
||||
sid: null | string
|
||||
}
|
||||
|
||||
// Decide whether to respawn+resume after a gateway death. `liveSid` is the
|
||||
// current session (nulled on the first exit); `recoverSid` is a pending
|
||||
// recovery target carried across a respawn that died before gateway.ready —
|
||||
// so a startup crash-loop keeps retrying the same session up to the budget
|
||||
// instead of stranding it after one attempt.
|
||||
export function planGatewayRecovery(
|
||||
liveSid: null | string,
|
||||
recoverSid: null | string,
|
||||
attempts: number[],
|
||||
now: number
|
||||
): RecoveryPlan {
|
||||
const sid = liveSid ?? recoverSid
|
||||
const recent = attempts.filter(t => now - t < GATEWAY_RECOVERY_WINDOW_MS)
|
||||
const recover = Boolean(sid) && recent.length < GATEWAY_RECOVERY_LIMIT
|
||||
|
||||
return { attempts: recover ? [...recent, now] : recent, recover, sid }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,417 @@
|
||||
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'
|
||||
|
||||
export type NoticeLevel = 'error' | 'info' | 'success' | 'warn'
|
||||
|
||||
// Credits/usage notice surfaced in the status bar. Shape is snake_case to
|
||||
// match the gateway WS wire (`notification.show` payload) and the existing
|
||||
// `Usage` type — no camelCase mapping layer. The `text` already carries its
|
||||
// own leading glyph (⚠ • ✕ ✓) from the Python policy, so the renderer only
|
||||
// colours it by `level` and never adds another glyph.
|
||||
export interface Notice {
|
||||
id?: string
|
||||
key?: string
|
||||
kind?: 'sticky' | 'ttl'
|
||||
level?: NoticeLevel
|
||||
text: string
|
||||
ttl_ms?: null | number
|
||||
}
|
||||
|
||||
// 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
|
||||
pluginsHub: 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
|
||||
notice: Notice | null
|
||||
pasteCollapseLines: number
|
||||
pasteCollapseChars: number
|
||||
|
||||
sections: SectionVisibility
|
||||
sessionTitle: string
|
||||
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
|
||||
// Set by useMainApp's exit handler to the session that was live when the
|
||||
// gateway died unexpectedly; consumed once by the next `gateway.ready` so a
|
||||
// respawn resumes that session instead of forging a fresh one.
|
||||
recoverSidRef?: MutableRefObject<null | string>
|
||||
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
|
||||
lastTurnEndedAt: null | 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
|
||||
onResumeSelect: (sessionId: string) => void
|
||||
onSecretSubmit: (value: string) => void
|
||||
onSudoSubmit: (pw: string) => void
|
||||
pagerPageSize: number
|
||||
}
|
||||
|
||||
export interface PasteSnippet {
|
||||
label: string
|
||||
path?: string
|
||||
text: string
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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,
|
||||
pluginsHub: 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, pluginsHub, secret, sessions, skillsHub, sudo }) =>
|
||||
Boolean(
|
||||
agents || approval || clarify || confirm || modelPicker || pager || pluginsHub || 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, sessions overlay. 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,
|
||||
pluginsHub: $overlayState.get().pluginsHub,
|
||||
sessions: $overlayState.get().sessions,
|
||||
skillsHub: $overlayState.get().skillsHub
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
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: '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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CreditsViewResponse } from '../../../gatewayTypes.js'
|
||||
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
export const creditsCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'Show Nous credit balance and top up',
|
||||
name: 'credits',
|
||||
run: (_arg, ctx) => {
|
||||
ctx.gateway
|
||||
.rpc<CreditsViewResponse>('credits.view', { session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<CreditsViewResponse>(view => {
|
||||
if (!view.logged_in) {
|
||||
ctx.transcript.sys('💳 Not logged into Nous Portal — run /portal to log in.')
|
||||
return
|
||||
}
|
||||
|
||||
const lines = ['💳 Nous credits', ...view.balance_lines]
|
||||
|
||||
if (view.identity_line) {
|
||||
lines.push('', view.identity_line)
|
||||
}
|
||||
|
||||
if (view.topup_url) {
|
||||
lines.push('', `Top up: ${view.topup_url}`)
|
||||
}
|
||||
|
||||
ctx.transcript.sys(lines.join('\n'))
|
||||
|
||||
const url = view.topup_url
|
||||
|
||||
if (url) {
|
||||
patchOverlayState({
|
||||
confirm: {
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Open top-up in browser',
|
||||
detail: url,
|
||||
onConfirm: () => {
|
||||
const ok = openExternalUrl(url)
|
||||
ctx.transcript.sys(
|
||||
ok
|
||||
? 'Complete your top-up in the browser — credits will appear in /credits shortly.'
|
||||
: `Open this URL to top up: ${url}`
|
||||
)
|
||||
},
|
||||
title: 'Add credits?'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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`]
|
||||
]
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,745 @@
|
||||
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: 'view & toggle plugins (no arg opens the hub; enable/disable <name> for direct toggle)',
|
||||
name: 'plugins',
|
||||
run: (arg, ctx, cmd) => {
|
||||
// No argument → open the interactive Plugins Hub overlay. Any
|
||||
// subcommand (enable/disable/list/install/…) falls through to the
|
||||
// text slash worker so it stays at parity with `hermes plugins`.
|
||||
if (!arg.trim()) {
|
||||
return patchOverlayState({ pluginsHub: true })
|
||||
}
|
||||
|
||||
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 || '/plugins: 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, 'Plugins') : ctx.transcript.sys(text)
|
||||
})
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,598 @@
|
||||
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 })
|
||||
}
|
||||
|
||||
const switchModel = (confirmExpensiveModel = false) => ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { confirm_expensive_model: confirmExpensiveModel, key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) })
|
||||
.then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (r.confirm_required) {
|
||||
patchOverlayState({
|
||||
confirm: {
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Switch anyway',
|
||||
danger: true,
|
||||
detail: r.confirm_message || r.warning || 'This model has unusually high known pricing.',
|
||||
onConfirm: () => switchModel(true),
|
||||
title: 'Expensive model selection'
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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: {} }
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
switchModel()
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
aliases: ['switch', 'session', 'resume'],
|
||||
help: 'browse, switch, or resume sessions',
|
||||
name: 'sessions',
|
||||
run: (arg, ctx) => {
|
||||
const trimmed = arg.trim()
|
||||
|
||||
// A new *live* session keeps the current one running in the background
|
||||
// (it doesn't close it), so fanning out while busy is allowed — that's
|
||||
// the whole point of multiple live sessions.
|
||||
if (trimmed.toLowerCase() === 'new') {
|
||||
return ctx.session.newLiveSession()
|
||||
}
|
||||
|
||||
// `/resume <id|title>` (and `/sessions <id>`) load a cold session and
|
||||
// CLOSE the current one, so guard it while a turn is in-flight to avoid
|
||||
// corrupting streaming/busy state. Bare opens the overlay to browse.
|
||||
if (trimmed) {
|
||||
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
|
||||
return
|
||||
}
|
||||
|
||||
return ctx.session.resumeById(trimmed)
|
||||
}
|
||||
|
||||
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 + Nous credits',
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
// Nous credits block is agent-independent (a portal fetch), so it shows
|
||||
// even with zero API calls or on a resumed session. Render it whenever
|
||||
// present, before the token panel.
|
||||
const creditsLines = r?.credits_lines ?? []
|
||||
if (creditsLines.length) {
|
||||
ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }])
|
||||
}
|
||||
|
||||
if (!r?.calls) {
|
||||
if (!creditsLines.length) {
|
||||
ctx.transcript.sys('no API calls yet')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
import { coreCommands } from './commands/core.js'
|
||||
import { creditsCommands } from './commands/credits.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,
|
||||
...creditsCommands,
|
||||
...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())
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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[]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
notice: null,
|
||||
pasteCollapseLines: 5,
|
||||
pasteCollapseChars: 2000,
|
||||
sections: {},
|
||||
sessionTitle: '',
|
||||
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())
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
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.pluginsHub) {
|
||||
return patchOverlayState({ pluginsHub: false })
|
||||
}
|
||||
|
||||
if (overlay.sessions) {
|
||||
return patchOverlayState({ sessions: 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.sessions) {
|
||||
patchOverlayState({ sessions: 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 }
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,375 @@
|
||||
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({ sessions: 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
|
||||
}
|
||||
|
||||
const previousSid = getUiState().sid
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
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 resumed = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)]
|
||||
|
||||
setHistoryItems(info ? [introMsg(info), ...resumed] : resumed)
|
||||
writeActiveSessionFile(r.resumed ?? r.session_id)
|
||||
patchUiState({
|
||||
busy: running,
|
||||
info,
|
||||
sid: r.session_id,
|
||||
status: statusFromLiveSession(r.status, running),
|
||||
usage: usageFrom(info)
|
||||
})
|
||||
hydrateLiveSessionInflight(r.inflight)
|
||||
|
||||
if (previousSid && previousSid !== r.session_id) {
|
||||
void closeSession(previousSid)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user