Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -0,0 +1,217 @@
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 { listRowStyle } from '../components/overlayPrimitives.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 the shared list-row primitive for the selected row (same as completions)', () => {
const style = selectedSessionRowStyle(DEFAULT_THEME)
const shared = listRowStyle(DEFAULT_THEME, true)
// One source of truth: the session switcher and the completions popover
// cannot disagree about what "selected" looks like.
expect(style.backgroundColor).toBe(shared.backgroundColor)
expect(style.color).toBe(shared.color)
// Readability contract survives: never accent-on-accent inverse.
expect(style.backgroundColor).not.toBe(DEFAULT_THEME.color.accent)
expect(style.color).not.toBe(DEFAULT_THEME.color.accent)
// Inactive rows paint nothing — the terminal's canvas is the row bg.
expect(listRowStyle(DEFAULT_THEME, false)).toEqual({})
})
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 --session'
)
expect(draftModelArgFromPickerValue('openai/gpt-5.5 --provider openai-codex --global')).toBe(
'openai/gpt-5.5 --provider openai-codex --session'
)
})
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,459 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GatewayProvider } from '../app/gatewayContext.js'
import type { AppLayoutProps, OverlayState, UiState } from '../app/interfaces.js'
import { patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
import { StatusRule } from '../components/appChrome.js'
import { AppLayout } from '../components/appLayout.js'
import type { GatewayClient } from '../gatewayClient.js'
import { DEFAULT_VOICE_RECORD_KEY } from '../lib/platform.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
type StatusRuleProps = React.ComponentProps<typeof StatusRule>
type IntervalSpy = ReturnType<typeof vi.spyOn<typeof globalThis, 'setInterval'>>
// Fixed wall clock so the rendered elapsed read-outs are exact strings rather
// than whatever the machine's clock happens to produce mid-test.
const T0 = 1_800_000_000_000
const mounted: Array<() => void> = []
/**
* Mount a real StatusRule through Ink so the leaf components' effects — and
* therefore their `setInterval` calls — actually run. The existing
* appChromeStatusRule tests invoke `StatusRule(...)` as a plain function,
* which only builds the element tree and never mounts FaceTicker /
* SessionDuration / IdleSince, so it cannot observe timer behaviour.
*
* Teardown is registered up front so a failing assertion still unmounts the
* tree — a leaked instance would keep re-arming timers into the next test.
*/
const mountTree = (tree: React.ReactElement, { interactive = false } = {}) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 120, isTTY: false, rows: 20 })
// PromptZone's prompts call `useInput`, which needs raw mode; without it Ink
// swaps the whole tree for an error panel and stops updating.
Object.assign(
stdin,
interactive ? { isTTY: true, ref: () => {}, setRawMode: () => {}, unref: () => {} } : { isTTY: false }
)
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(tree, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
mounted.push(() => {
instance.unmount()
instance.cleanup()
})
return {
/** Drop frames rendered so far so `output()` reads only what comes next. */
clear: () => {
output = ''
},
output: () => stripAnsi(output)
}
}
const mount = (props: StatusRuleProps) => mountTree(<StatusRule {...props} />)
const idleProps: StatusRuleProps = {
bgCount: 0,
busy: false,
cols: 120,
cwdLabel: '~/repo',
lastTurnEndedAt: T0 - 5_000,
liveSessionCount: 0,
model: 'opus-4.8',
sessionStartedAt: T0 - 60_000,
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: ''
}
// Busy swaps the idle read-out for the FaceTicker, which owns the glyph +
// verb + elapsed-clock trio.
const busyProps: StatusRuleProps = {
...idleProps,
busy: true,
indicatorStyle: 'kaomoji',
lastTurnEndedAt: null,
turnStartedAt: T0 - 30_000
}
/** Delays of every interval armed while the spy was installed. */
const armedDelays = (spy: IntervalSpy) => spy.mock.calls.map(call => call[1])
const oneSecondTimers = (spy: IntervalSpy) => armedDelays(spy).filter(delay => delay === 1000).length
/** The handlers of every 1s clock armed so far — `() => setNow(Date.now())`. */
const oneSecondTicks = (spy: IntervalSpy) =>
spy.mock.calls.filter(call => call[1] === 1000).map(call => call[0] as () => void)
// ── AppLayout harness ────────────────────────────────────────────────
//
// teknium1's review of this file was right that mounting StatusRule alone
// proves the store pauses timers but NOT that the overlay in question covers
// the status rule. These props render the real AppLayout so the rule sits in
// its true position relative to PromptZone / FloatingOverlays / the widget
// slot, and the assertions can read what is actually on screen.
const gatewayStub = {
gw: {
request: () => new Promise<never>(() => {}),
send: () => {}
} as unknown as GatewayClient,
rpc: (() => new Promise<never>(() => {})) as never
}
const layoutProps: AppLayoutProps = {
actions: {
activateLiveSession: () => {},
answerApproval: () => {},
answerClarify: () => {},
answerSecret: () => {},
answerSudo: () => {},
clearSelection: () => {},
closeLiveSession: () => Promise.resolve(null),
newLiveSession: () => {},
newPromptSession: () => {},
onModelSelect: () => {},
resumeById: () => {},
setStickyPrompt: () => {}
},
composer: {
cols: 120,
compIdx: 0,
completions: [],
empty: true,
handleTextPaste: () => null,
input: '',
inputBuf: [],
pagerPageSize: 10,
queueEditIdx: null,
queuedDisplay: [],
submit: () => {},
updateInput: () => {},
voiceRecordKey: DEFAULT_VOICE_RECORD_KEY
},
mouseTracking: 'off',
progress: { showProgressArea: false },
status: {
cwdLabel: '~/repo',
goodVibesTick: 0,
lastTurnEndedAt: T0 - 5_000,
sessionStartedAt: T0 - 60_000,
sessionTitle: '',
showStickyPrompt: false,
statusColor: DEFAULT_THEME.color.ok,
stickyPrompt: '',
turnStartedAt: null,
voiceLabel: ''
},
transcript: {
historyItems: [],
scrollRef: { current: null },
virtualHistory: {
bottomSpacer: 0,
end: 0,
measureRef: () => () => {},
offsets: [],
start: 0,
topSpacer: 0
},
virtualRows: []
}
}
/** Mount the real AppLayout with the given overlay + ui state applied first. */
const mountLayout = (overlay: Partial<OverlayState> = {}, ui: Partial<UiState> = {}) => {
patchUiState({ sessionTitle: 'test', sid: 'sid-1', status: 'ready', ...ui })
patchOverlayState(overlay)
return mountTree(
<GatewayProvider value={gatewayStub}>
<AppLayout {...layoutProps} />
</GatewayProvider>,
{ interactive: true }
)
}
// Give React's scheduler a turn so a store-driven re-render (and the effect
// re-arm that follows it) lands before we assert.
const flush = () => new Promise(resolve => setTimeout(resolve, 20))
let intervalSpy: IntervalSpy
let nowSpy: ReturnType<typeof vi.spyOn<typeof Date, 'now'>>
beforeEach(() => {
resetOverlayState()
resetUiState()
nowSpy = vi.spyOn(Date, 'now').mockReturnValue(T0)
intervalSpy = vi.spyOn(globalThis, 'setInterval')
})
afterEach(() => {
while (mounted.length > 0) {
mounted.pop()!()
}
intervalSpy.mockRestore()
nowSpy.mockRestore()
resetOverlayState()
resetUiState()
})
describe('status-chrome timers under an occluding overlay', () => {
it('arms the one-second SessionDuration + IdleSince clocks when nothing covers the rule', () => {
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
it('arms no timer at all when an occluding overlay is already open', () => {
patchOverlayState({ modelPicker: true })
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(0)
})
it('arms the FaceTicker glyph/verb/clock trio mid-turn when nothing covers the rule', () => {
mount(busyProps)
// kaomoji cadence for the glyph + verb rotation, plus the elapsed clock.
expect(armedDelays(intervalSpy)).toContain(2500)
expect(oneSecondTimers(intervalSpy)).toBeGreaterThan(0)
})
it('freezes the FaceTicker verb on compacting and skips verb rotation (#97239)', () => {
const { output } = mount({ ...busyProps, compacting: true })
expect(output()).toContain('compacting')
// Glyph still ticks at the kaomoji cadence; the rotating-verb timer does not.
expect(armedDelays(intervalSpy).filter(delay => delay === 2500)).toHaveLength(1)
expect(oneSecondTimers(intervalSpy)).toBeGreaterThan(0)
})
it('arms no FaceTicker timer mid-turn while the modal widget slot is open', () => {
patchOverlayState({ widget: { appId: 'demo', state: null } })
mount(busyProps)
expect(armedDelays(intervalSpy)).not.toContain(2500)
expect(oneSecondTimers(intervalSpy)).toBe(0)
})
it('keeps the FaceTicker running mid-turn under a flow-layout sudo prompt', () => {
// `sudo` is in `$isBlocked` but renders in PromptZone's normal flow, so it
// pushes the rule down rather than covering it — the trio must keep going.
patchOverlayState({ sudo: { requestId: 'sudo-1' } })
mount(busyProps)
expect(armedDelays(intervalSpy)).toContain(2500)
expect(oneSecondTimers(intervalSpy)).toBeGreaterThan(0)
})
it('keeps the clocks running when a floating overlay cannot reach a bottom status rule', () => {
// FloatingOverlays is `position="absolute" bottom="100%"` inside
// ComposerPane's relative Box, so it grows UPWARD: it covers the `at="top"`
// rule and never the `at="bottom"` one.
patchUiState({ statusBar: 'bottom' })
patchOverlayState({ modelPicker: true })
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
it('re-syncs the elapsed read-outs from the wall clock on reveal instead of resuming stale', async () => {
// Regression guard for the naive fix: an early `return` that pauses the
// interval but never re-seeds `now` leaves SessionDuration and IdleSince
// frozen at the instant the overlay opened.
patchOverlayState({ sessions: true })
const rule = mount(idleProps)
expect(rule.output()).toContain('1m 0s')
expect(rule.output()).toContain('✓ 5s')
// Five minutes of wall clock elapse while the overlay covers the rule.
nowSpy.mockReturnValue(T0 + 300_000)
rule.clear()
resetOverlayState()
await flush()
const resumed = rule.output()
// Caught up to real elapsed time, not stuck on the pre-overlay values.
expect(resumed).toContain('6m 0s')
expect(resumed).toContain('✓ 5m 5s')
expect(resumed).not.toContain('1m 0s')
// …and the clocks are running again.
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
it('tears the clocks down when an overlay opens over an already-running status rule', async () => {
mount(idleProps)
// Handles of the two live 1-second clocks (SessionDuration + IdleSince).
const clocks = intervalSpy.mock.results
.filter((_result, i) => intervalSpy.mock.calls[i]?.[1] === 1000)
.map(result => result.value as ReturnType<typeof setInterval>)
expect(clocks).toHaveLength(2)
const clearSpy = vi.spyOn(globalThis, 'clearInterval')
patchOverlayState({ pluginsHub: true })
await flush()
// Each running clock is cleared as the overlay goes up …
for (const handle of clocks) {
expect(clearSpy).toHaveBeenCalledWith(handle)
}
// … and the occluded re-run arms no replacement (still just the original two).
expect(oneSecondTimers(intervalSpy)).toBe(2)
clearSpy.mockRestore()
})
})
// teknium1's review of #12463 called out that its test asserted on a `picker`
// overlay state that no longer exists. Pin the gate to fields the current
// OverlayState actually carries so a rename breaks this file loudly.
describe('status-chrome timers track the current overlay model', () => {
// Everything that genuinely paints over the rule: the modal widget slot,
// plus the FloatingOverlays set (with the rule at its default `top`).
const occluding: Array<[string, Partial<OverlayState>]> = [
['modelPicker', { modelPicker: true }],
['pager', { pager: { lines: ['a'], offset: 0 } }],
['petPicker', { petPicker: true }],
['pluginsHub', { pluginsHub: true }],
['sessions', { sessions: true }],
['skillsHub', { skillsHub: true }],
['widget', { widget: { appId: 'demo', state: null } }]
]
// In `$isBlocked` but NOT occluding. `agents` / `journey` unmount the whole
// ComposerPane subtree, so React's effect cleanup already stops the clocks
// and gating on them would be dead code; the rest are PromptZone states that
// render in normal flow and push the rule down without covering it.
const nonOccluding: Array<[string, Partial<OverlayState>]> = [
['agents', { agents: true }],
['approval', { approval: { command: 'ls', requestId: 'a-1' } as OverlayState['approval'] }],
['billing', { billing: { kind: 'credits' } as OverlayState['billing'] }],
['clarify', { clarify: { question: 'which?', requestId: 'c-1' } as OverlayState['clarify'] }],
['confirm', { confirm: { onConfirm: () => {}, prompt: 'sure?' } as OverlayState['confirm'] }],
['journey', { journey: true }],
['secret', { secret: { envVar: 'TOKEN', prompt: 'token?' } as OverlayState['secret'] }],
['subscription', { subscription: { kind: 'expired' } as OverlayState['subscription'] }],
['sudo', { sudo: { requestId: 'sudo-1' } as OverlayState['sudo'] }]
]
it.each(occluding)('pauses the status clocks while %s covers the rule', (_name, patch) => {
patchOverlayState(patch)
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(0)
})
it.each(nonOccluding)('keeps the status clocks running while %s is open', (_name, patch) => {
patchOverlayState(patch)
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
it('keeps the clocks running for the non-occluding ambient dock', () => {
// `ambient` is a glanceable in-flow dock that reserves its own rows and
// doesn't cover the status rule, so pausing there would be a regression.
patchOverlayState({ ambient: [{ appId: 'clock', state: null }] })
mount(idleProps)
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
})
// The visibility gate teknium1 asked for: mount the REAL AppLayout so the
// status rule sits in its true position relative to PromptZone (normal flow,
// above ComposerPane) and FloatingOverlays (absolute, growing upward), then
// assert on what is actually on screen rather than on the store alone.
describe('AppLayout status-rule visibility', () => {
it('keeps the status rule on screen AND its clock advancing under a flow-layout approval prompt', async () => {
const layout = mountLayout({ approval: { command: 'rm -rf /', requestId: 'a-1' } as OverlayState['approval'] })
await flush()
// The rule is genuinely rendered — the approval prompt pushed it, it did
// not cover it — so freezing its clock would freeze something visible.
expect(layout.output()).toContain('~/repo')
expect(layout.output()).toContain('1m 0s')
expect(oneSecondTimers(intervalSpy)).toBe(2)
// …and it really advances: drive the armed 1s handlers forward.
nowSpy.mockReturnValue(T0 + 30_000)
for (const tick of oneSecondTicks(intervalSpy)) {
tick()
}
await flush()
await flush()
expect(layout.output()).toContain('1m 30s')
})
it('keeps the status rule on screen AND its clock advancing under a flow-layout sudo prompt', async () => {
const layout = mountLayout({ sudo: { requestId: 'sudo-1' } as OverlayState['sudo'] })
await flush()
expect(layout.output()).toContain('1m 0s')
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
it('arms no clock under a floating model picker while the rule is at the top', async () => {
mountLayout({ modelPicker: true }, { statusBar: 'top' })
await flush()
expect(oneSecondTimers(intervalSpy)).toBe(0)
})
it('keeps the clocks armed under a floating model picker while the rule is at the bottom', async () => {
mountLayout({ modelPicker: true }, { statusBar: 'bottom' })
await flush()
expect(oneSecondTimers(intervalSpy)).toBe(2)
})
})
@@ -0,0 +1,549 @@
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,
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 title', () => {
it('pins the named session at the far-right edge instead of the cwd label', () => {
const element = StatusRule({
...baseProps,
sessionTitle: 'weekly-digest'
})
const rendered = textContent(element)
const title = findElementWithText(element, 'weekly-digest')
expect(rendered).toContain('weekly-digest')
expect(rendered).not.toContain('~/repo')
// Regression for issue #82465: a raw, full-saturation accent-hue
// background (e.g. #FFBF00 on DARK_SEEDS) paired with statusFg (a
// near-white tone never designed to sit on it) rendered at roughly a
// 1.5-2:1 contrast ratio -- unreadable. No background fill at all;
// the accent color goes on the text instead, matching the theme's
// own convention that a raw accent hue is never used as a solid
// fill elsewhere (fills are always softened, e.g. activeRow).
expect(title?.props.backgroundColor).toBeUndefined()
expect(title?.props.color).toBe(DEFAULT_THEME.color.accent)
})
})
describe('StatusRule background-subagent indicator', () => {
it('renders ⛓ N on a wide terminal when subagents are running', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 3 }
})
expect(textContent(element)).toContain('⛓ 3')
})
it('omits the segment when no subagents are running', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 0 }
})
expect(textContent(element)).not.toContain('⛓')
})
it('omits the segment when the field is absent', () => {
const element = StatusRule({ ...baseProps })
expect(textContent(element)).not.toContain('⛓')
})
it('spells out the auto-resume hint when idle with subagents in flight', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 1 }
})
expect(textContent(element)).toContain('resumes when subagent finishes')
})
it('pluralizes the resume hint for multiple in-flight subagents', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 3 }
})
expect(textContent(element)).toContain('resumes when 3 subagents finish')
})
it('hides the resume hint mid-turn (a busy turn owns the indicator)', () => {
const element = StatusRule({
...baseProps,
busy: true,
turnStartedAt: Date.now(),
usage: { ...baseProps.usage, active_subagents: 2 }
})
expect(textContent(element)).not.toContain('resumes when')
})
it('omits the resume hint when no subagents are running', () => {
const element = StatusRule({ ...baseProps })
expect(textContent(element)).not.toContain('resumes when')
})
it('drops the subagent segment before the bg segment on a narrow terminal', () => {
// cols=44 is below the subagents breakpoint (92) but the bg breakpoint
// (88) too — both gone. Assert the lower-priority subagent indicator is
// not shown when space is tight even with a live count.
const element = StatusRule({
...baseProps,
cols: 44,
bgCount: 1,
usage: { ...baseProps.usage, active_subagents: 2 }
})
expect(textContent(element)).not.toContain('⛓')
})
})
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,
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,
status: 'ready',
statusColor: DEFAULT_THEME.color.ok,
t: DEFAULT_THEME,
turnStartedAt: null,
usage: {
calls: 0,
context_max: 200_000,
context_percent: 25,
context_used: 50_000,
input: 0,
output: 0,
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) is dropped, not truncated.
expect(rendered).not.toContain('3 sessions')
})
})
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 battery indicator', () => {
it('renders the battery label with a battery glyph on AC-off', () => {
const element = StatusRule({
...baseProps,
battery: { available: true, category: 'good', percent: 82, plugged: false }
})
expect(textContent(element)).toContain('🔋 82%')
})
it('uses a bolt glyph while charging', () => {
const element = StatusRule({
...baseProps,
battery: { available: true, category: 'good', percent: 82, plugged: true }
})
expect(textContent(element)).toContain('⚡ 82%')
})
it('colours the read-out by category (critical → theme statusCritical)', () => {
const element = StatusRule({
...baseProps,
battery: { available: true, category: 'critical', percent: 7, plugged: false }
})
const leaf = findElementWithText(element, '7%')
expect(leaf?.props.color).toBe(DEFAULT_THEME.color.statusCritical)
})
it('omits the segment when battery is null', () => {
const element = StatusRule({ ...baseProps, battery: null })
expect(textContent(element)).not.toContain('%🔋')
expect(textContent(element)).not.toContain('🔋')
})
it('omits the segment when no battery is available (desktop/server)', () => {
const element = StatusRule({
...baseProps,
battery: { available: false, category: 'dim', percent: null, plugged: null }
})
expect(textContent(element)).not.toContain('🔋')
})
})
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()
})
})
describe('StatusRule perf read-outs (cache hit / latency / tps)', () => {
const perfUsage = {
...baseProps.usage,
avg_latency_s: 3.2,
avg_tps: 50.4,
cache_hit_pct: 87,
calls: 4,
input: 1000,
output: 500
}
it('renders all three segments on a wide terminal', () => {
const element = StatusRule({ ...baseProps, cols: 160, usage: perfUsage })
const rendered = textContent(element)
expect(rendered).toContain('◎ 87%')
expect(rendered).toContain('◷ 3.2s')
expect(rendered).toContain('↑ 50 t/s')
})
it('self-hides when the server omits the keys', () => {
const element = StatusRule({ ...baseProps, cols: 160 })
const rendered = textContent(element)
expect(rendered).not.toContain('◎')
expect(rendered).not.toContain('◷')
expect(rendered).not.toContain('t/s')
})
it('honors the display.status_bar.fields visibility filter', () => {
const element = StatusRule({
...baseProps,
cols: 160,
statusBarFields: new Set(['model', 'context_pct', 'cache_hit']),
usage: perfUsage
})
const rendered = textContent(element)
expect(rendered).toContain('◎ 87%')
expect(rendered).not.toContain('◷')
expect(rendered).not.toContain('t/s')
})
it('hides the session title badge when the fields filter omits title', () => {
const element = StatusRule({
...baseProps,
cols: 160,
sessionTitle: 'weekly-digest',
statusBarFields: new Set(['model', 'context_pct'])
})
expect(textContent(element)).not.toContain('weekly-digest')
})
})
@@ -0,0 +1,74 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { StatusRule } from '../components/appChrome.js'
import type * as EnvModule from '../config/env.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 EnvModule>()
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,
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,85 @@
import { describe, expect, it } from 'vitest'
import { approvalAction, approvalOptions } 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' })
})
it('offers only once and deny for Smart DENY owner override', () => {
const opts = approvalOptions({
allowPermanent: true,
command: 'rm -rf /',
description: 'blocked',
smartDenied: true
})
expect(opts).toEqual(['once', 'deny'])
expect(approvalAction('2', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'noop' })
})
it('uses explicit gateway choices as the prompt contract', () => {
expect(
approvalOptions({
allowPermanent: true,
choices: ['once', 'deny'],
command: 'rm -rf /',
description: 'blocked'
})
).toEqual(['once', '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()
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import type { ComposerToken } from '../app/interfaces.js'
import { droppedTokens, expandTokens, imageToken, nextImageIndex } from '../domain/attachments.js'
const paste = (label: string, text: string): ComposerToken => ({ kind: 'paste', label, text })
const image = (index: number, path = `/tmp/img${index}.png`): ComposerToken => ({
index,
kind: 'image',
label: imageToken(index),
path
})
describe('expandTokens (what the agent actually receives)', () => {
it('replaces a collapsed paste label with its full content', () => {
const label = '[[ hello.. [3 lines] .. world ]]'
const expand = expandTokens([paste(label, 'hello\nfoo\nworld')])
expect(expand(`here: ${label} done`)).toBe('here: hello\nfoo\nworld done')
})
it('is a no-op for already-expanded / token-free text (recall round-trip)', () => {
const expanded = 'hello\nfoo\nworld'
expect(expandTokens([])(expanded)).toBe(expanded)
})
it('expands repeated identical labels in submission order', () => {
const label = '[[ x [1 lines] ]]'
const expand = expandTokens([paste(label, 'first'), paste(label, 'second')])
expect(expand(`${label} then ${label}`)).toBe('first then second')
})
it('leaves an unmatched label intact', () => {
const label = '[[ orphan [2 lines] ]]'
expect(expandTokens([])(label)).toBe(label)
})
it('drops an image token from the text — the gateway already holds the file', () => {
const expand = expandTokens([image(1)])
expect(expand(`what is in ${imageToken(1)}`)).toBe('what is in')
})
it('leaves no double space where an image token sat mid-sentence', () => {
const expand = expandTokens([image(1)])
expect(expand(`before ${imageToken(1)} after`)).toBe('before after')
})
it('resolves an image-only message to empty text', () => {
expect(expandTokens([image(1)])(imageToken(1))).toBe('')
})
it('resolves pastes and images in one pass', () => {
const label = '[[ log.. [9 lines] ]]'
const expand = expandTokens([paste(label, 'stack\ntrace'), image(2)])
expect(expand(`${label} and ${imageToken(2)}`)).toBe('stack\ntrace and')
})
})
describe('nextImageIndex (user-facing numbering)', () => {
it('starts at 1', () => {
expect(nextImageIndex([])).toBe(1)
})
it('counts past existing images', () => {
expect(nextImageIndex([image(1), image(2)])).toBe(3)
})
it('ignores paste tokens', () => {
expect(nextImageIndex([paste('[[ x ]]', 'y')])).toBe(1)
})
it('does not reuse an index after an earlier image is deleted', () => {
// [[ Image 1 ]] was erased; the next attach must not become Image 1 again
// or expandTokens would resolve two different files to one label.
expect(nextImageIndex([image(2)])).toBe(3)
})
})
describe('droppedTokens (deleting the token unattaches the thing)', () => {
it('reports an image whose token was erased from the text', () => {
expect(droppedTokens([image(1)], 'just text now')).toEqual([image(1)])
})
it('reports nothing while the token is still present', () => {
expect(droppedTokens([image(1)], `look at ${imageToken(1)}`)).toEqual([])
})
it('keeps a surviving token when a sibling is erased', () => {
expect(droppedTokens([image(1), image(2)], imageToken(2))).toEqual([image(1)])
})
})
+199
View File
@@ -0,0 +1,199 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
// Stub useInput so the overlay doesn't enter raw mode under renderSync.
vi.mock('@hermes/ink', async importOriginal => {
const mod = await importOriginal()
return { ...mod, useInput: () => {} }
})
import type { BillingOverlayState } from '../app/interfaces.js'
import { BillingOverlay } from '../components/billingOverlay.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
function render(overlay: BillingOverlayState): string {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
React.createElement(BillingOverlay, {
onClose: () => {},
onPatch: () => {},
overlay,
t
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
return stripAnsi(output)
}
const billState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse =>
({
auto_reload: null,
balance_display: '$12.00',
balance_usd: '12',
can_charge: true,
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
charge_presets: ['25', '50'],
charge_presets_display: ['$25', '$50'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '1000',
min_usd: '10',
monthly_cap: null,
ok: true,
org_name: 'Acme',
portal_url: 'https://portal/billing',
role: 'OWNER',
...overrides
}) as BillingStateResponse
const ctx = {
applyAutoReload: vi.fn(() => Promise.resolve(true)),
charge: vi.fn(() => Promise.resolve('submitted' as const)),
openPortal: vi.fn(),
refreshState: vi.fn(() => Promise.resolve(null)),
requestRemoteSpending: vi.fn(() => Promise.resolve(true)),
sys: vi.fn(),
validate: vi.fn((raw: string) => ({ amount: raw }))
}
const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => ({
ctx,
pendingCharge: { amount: '100' },
screen,
state: billState()
})
describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => {
it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => {
const out = render(overlay('stepup'))
expect(out).toContain('One-time setup')
expect(out).toContain('Allow Remote Spending')
expect(out).toContain('$100') // resumes the held purchase
expect(out).toContain('Not now')
expect(out).not.toContain('billing:manage')
})
})
describe('BillingOverlay — overview (reordered, dollars)', () => {
it('leads with balance in the title, Add funds first, no "credits"', () => {
const out = render(overlay('overview'))
expect(out).toContain('Top up · balance $12.00') // balance in the title
expect(out).toContain('Add funds') // buy action, renamed
expect(out).toContain('Auto-reload')
expect(out).toContain('Manage on portal')
expect(out.toLowerCase()).not.toContain('credits') // dollars only
// No standalone "Allow Remote Spending" item — discovered at pay time.
expect(out).not.toContain('Allow Remote Spending')
})
it('renders the two-bar dollar usage when a usage model is present', () => {
const withUsage: BillingOverlayState = {
...overlay('overview'),
state: {
...billState(),
usage: {
available: true,
status: 'healthy',
plan_name: 'Plus',
has_topup: true,
plan_bar: {
kind: 'plan',
remaining_display: '$14.00',
total_display: '$20.00',
spent_display: '$6.00',
pct_used: 30,
fill_fraction: 0.7
},
topup_bar: {
kind: 'topup',
remaining_display: '$12.00',
total_display: '$12.00',
spent_display: '$0.00',
pct_used: null,
fill_fraction: 1
}
}
}
}
const out = render(withUsage)
expect(out).toContain('$14.00 left of $20.00')
expect(out).toContain('30% used')
expect(out).toContain('never expires')
})
})
describe('BillingOverlay — auto-reload card divergence', () => {
const autoReload = (card: NonNullable<BillingStateResponse['auto_reload']>['card']) => ({
card,
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$20',
threshold_usd: '20'
})
it('warns when auto-reload charges a distinct card and offers the portal hand-off', () => {
const out = render({
...overlay('autoreload'),
state: billState({
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: 'Visa', last4: '9999' })
})
})
expect(out).toContain('Auto-refill is charging Visa ••9999 — not your card on file')
expect(out).toContain('authorize Nous Research to charge Visa ••9999')
expect(out).toContain('Use your card on file — manage on portal')
})
it('uses generic distinct-card copy when metadata is unresolved', () => {
const out = render({
...overlay('autoreload'),
state: billState({
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: null, last4: null })
})
})
expect(out).toContain('Auto-refill is charging a different card — not your card on file')
})
it.each(['canonical', 'none'] as const)('does not warn for a %s auto-reload card', kind => {
const out = render({
...overlay('autoreload'),
state: billState({ auto_reload: autoReload({ kind }) })
})
expect(out).not.toContain('not your card on file')
expect(out).not.toContain('Use your card on file — manage on portal')
})
})
+123
View File
@@ -0,0 +1,123 @@
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,111 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { SessionPanel } from '../components/branding.js'
import { DEFAULT_THEME } from '../theme.js'
import type { McpServerStatus, SessionInfo } from '../types.js'
// Invariant under test: the TUI banner's MCP headline counts *connected*
// servers, never configured-but-disabled ones. This mirrors the classic CLI
// banner (`mcp_connected = sum(1 for s in mcp_status if s["connected"])` in
// hermes_cli/banner.py) and the "connected" label on the MCP collapse toggle.
//
// Regression: branding.tsx used the raw `info.mcp_servers.length`, so a
// disabled `linear` server alongside a connected `nous-support` server made
// the TUI report "2 MCP" while the classic CLI correctly reported "1 MCP".
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const makeStreams = (columns = 100) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
Object.assign(stdout, { columns, isTTY: false, rows: 40 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
let captured = ''
stdout.on('data', chunk => {
captured += chunk.toString()
})
return { capture: () => captured, stderr, stdin, stdout }
}
const mcp = (over: Partial<McpServerStatus> & Pick<McpServerStatus, 'name'>): McpServerStatus => ({
connected: false,
tools: 0,
transport: 'http',
...over
})
const baseInfo = (mcp_servers: McpServerStatus[]): SessionInfo => ({
mcp_servers,
model: 'test-model',
skills: { core: ['a', 'b'] },
tools: { file: ['read_file', 'write_file'] }
})
async function renderFooter(info: SessionInfo): Promise<string> {
const streams = makeStreams()
const instance = renderSync(React.createElement(SessionPanel, { info, sid: 'test', t: DEFAULT_THEME }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
// Strip ANSI so we can assert on the rendered text content.
// eslint-disable-next-line no-control-regex
return streams.capture().replace(/\u001b\[[0-9;]*m/g, '')
} finally {
instance.unmount()
instance.cleanup()
}
}
describe('branding MCP headline count', () => {
it('counts only connected servers, not configured-but-disabled ones', async () => {
const frame = await renderFooter(
baseInfo([
mcp({ connected: true, name: 'nous-support', status: 'connected', tools: 6 }),
mcp({ connected: false, disabled: true, name: 'linear', status: 'disabled' })
])
)
// One connected server → "1 MCP", never "2 MCP".
expect(frame).toContain('1 MCP')
expect(frame).not.toContain('2 MCP')
})
it('drops the MCP segment entirely when no server is connected', async () => {
const frame = await renderFooter(
baseInfo([mcp({ connected: false, disabled: true, name: 'linear', status: 'disabled' })])
)
// Matches the classic CLI, which only appends "· N MCP" when N > 0.
expect(frame).not.toContain('MCP servers')
expect(frame).not.toMatch(/\d MCP\b/)
})
it('counts every connected server when several are connected', async () => {
const frame = await renderFooter(
baseInfo([
mcp({ connected: true, name: 'alpha', status: 'connected' }),
mcp({ connected: true, name: 'beta', status: 'connected' }),
mcp({ connected: false, disabled: true, name: 'gamma', status: 'disabled' })
])
)
expect(frame).toContain('2 MCP')
expect(frame).not.toContain('3 MCP')
})
})
@@ -0,0 +1,102 @@
/**
* Bundle-shape regression for issue #31227.
*
* The dashboard TUI ships as a single esbuild-bundled `dist/entry.js`.
* When the bundle contains an `async`-init `__esm` wrapper that participates
* in a circular module graph, esbuild's lightweight init helper deadlocks
* the top-level `await Promise.all([...])` in src/entry.tsx — the user
* sees only 141 bytes of ANSI reset sequences and a blank screen forever.
*
* Root cause: re-exporting `ink-text-input` from `@hermes/ink`'s
* entry-exports drags the upstream `ink` package into the bundle. That
* `ink` graph and our in-tree `@hermes/ink` graph reference each other
* via React/`ink-text-input`, producing the circular async cycle that
* `__esm` cannot resolve.
*
* These tests guard the two structural properties that, together,
* keep the bundle deadlock-free:
*
* 1. No `async` `__esm` modules in the bundle. As long as every init
* runs synchronously, `__esm`'s closure-capture quirk is irrelevant.
* 2. No `ink-text-input` / `node_modules/ink/build` modules in the
* bundle. Their absence is what makes #1 hold; if a future commit
* re-introduces the re-export, it would reintroduce the cycle.
*
* The bundle is a build artifact, so the test builds it on demand and
* skips itself when esbuild can't be resolved (e.g. during a partial
* install). It does not need a TTY.
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { beforeAll, describe, expect, it } from 'vitest'
const here = dirname(fileURLToPath(import.meta.url))
const uiTuiRoot = resolve(here, '..', '..')
const bundlePath = resolve(uiTuiRoot, 'dist', 'entry.js')
function bundleIsFresh(): boolean {
if (!existsSync(bundlePath)) {
return false
}
try {
const bundleMtime = statSync(bundlePath).mtimeMs
const sourceMtime = statSync(resolve(uiTuiRoot, 'packages/hermes-ink/src/entry-exports.ts')).mtimeMs
return bundleMtime >= sourceMtime
} catch {
return false
}
}
let bundleSrc = ''
beforeAll(() => {
if (!bundleIsFresh()) {
// Refresh the bundle so the regression test runs against current
// sources, not whatever was last committed by hand.
execFileSync(process.execPath, [resolve(uiTuiRoot, 'scripts/build.mjs')], {
cwd: uiTuiRoot,
stdio: ['ignore', 'ignore', 'inherit'],
timeout: 120_000
})
}
bundleSrc = readFileSync(bundlePath, 'utf8')
}, 180_000)
describe('TUI bundle (issue #31227)', () => {
it('has no async __esm wrappers (would risk circular-await deadlock)', () => {
// esbuild emits `async "<path>"() { ... }` as the first key of a
// module's `__esm` definition when the module body contains
// top-level await. The lightweight `__esm` helper at the top of
// the bundle does NOT await nested inits, so any async __esm
// module in a circular graph hangs forever the first time it's
// entered.
const matches = bundleSrc.match(/async "(packages|src|node_modules)\/[^"]+"\s*\(\)/g) ?? []
expect(
matches,
`Found ${matches.length} async __esm wrappers — these can deadlock #31227. First few:\n${matches.slice(0, 3).join('\n')}`
).toEqual([])
})
it('does not bundle the upstream ink package or ink-text-input', () => {
// Pulling either of these in re-creates the circular async chain
// that #31227 was about. The in-tree fork at @hermes/ink replaces
// all of `ink`; nothing in ui-tui imports `TextInput` from
// `@hermes/ink` so the re-export is unused dead weight.
expect(bundleSrc.includes('node_modules/ink/build/index.js')).toBe(false)
expect(bundleSrc.includes('node_modules/ink-text-input/build/index.js')).toBe(false)
})
it('has the @hermes/ink entry-exports module compiled to sync init', () => {
// Sanity check that the alias swap to packages/hermes-ink/src/entry-exports.ts
// is still active and producing the expected synchronous init shape.
expect(bundleSrc).toMatch(/var init_entry_exports = __esm\(\{\s*"packages\/hermes-ink\/src\/entry-exports\.ts"\(\)/)
})
})
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js'
describe('chart primitives', () => {
it('sparkline spans the block ramp and respects width', () => {
const line = sparkline([0, 1, 2, 3, 4, 5, 6, 7], 8)
expect(line).toBe('▁▂▃▄▅▆▇█')
expect(sparkline([1, 2, 3, 4], 2)).toHaveLength(2) // window = last N
})
it('is dimension-stable: short/empty series pad to exactly width', () => {
// Warm-up must never resize the card — latest sample pins right.
expect(sparkline([5], 6)).toHaveLength(6)
expect(sparkline([5], 6).endsWith('▁')).toBe(true) // flat series → bottom block, right-pinned
expect(sparkline([], 6)).toBe(' ')
expect(sparkRows([7], 5, 2).every(row => row.length === 5)).toBe(true)
expect(sparkRows([], 5, 2)).toEqual([' ', ' '])
expect(hbars([1, 4], 8).every(bar => bar.length === 8)).toBe(true)
})
it('sparkRows partitions each column across rows (top line first)', () => {
const rows = sparkRows([0, 8, 4], 3, 2)
expect(rows).toHaveLength(2)
expect(rows.every(r => r.length === 3)).toBe(true)
// Max value fills the top row cell; min value leaves it blank.
expect(rows[0]![1]).toBe('█')
expect(rows[0]![0]).toBe(' ')
})
it('gauge clamps and fills proportionally', () => {
expect(gauge(0.5, 8)).toBe('████░░░░')
expect(gauge(-1, 4)).toBe('░░░░')
expect(gauge(9, 4)).toBe('████')
})
it('hbars scales to the max with eighth-block tips', () => {
const [half, full] = hbars([4, 8], 8)
expect(full).toBe('████████')
expect(half).toBe('████ ')
expect(hbars([3, 8], 8)[0]).toBe('███ ') // 3/8 of 8 cells, padded
expect(hbars([1, 2], 3)[0]).toMatch(/^█?[▏▎▍▌▋▊▉█] *$/) // fractional tip, padded
})
})
+393
View File
@@ -0,0 +1,393 @@
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 b64 = Buffer.from('from windows\r\n', 'utf8').toString('base64')
const run = vi.fn().mockResolvedValue({ stdout: b64 })
await expect(readClipboardText('win32', run)).resolves.toBe('from windows\r\n')
expect(run).toHaveBeenCalledWith(
'powershell',
[
'-NoProfile',
'-NonInteractive',
'-Command',
'[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'
],
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
)
})
it('tries powershell.exe first on WSL', async () => {
const b64 = Buffer.from('from wsl\n', 'utf8').toString('base64')
const run = vi.fn().mockResolvedValue({ stdout: b64 })
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',
'[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((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()
})
it('preserves CJK text via base64 decoding from PowerShell on WSL', async () => {
const cjkText = '你好世界,测试中文 🎉'
const b64 = Buffer.from(cjkText, 'utf8').toString('base64')
const run = vi.fn().mockResolvedValue({ stdout: b64 })
await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe(
cjkText
)
})
})
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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
}),
unref: vi.fn(),
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,63 @@
import { describe, expect, it } from 'vitest'
import { applyCompletion, completionToApplyOnSubmit } from '../domain/slash.js'
describe('applyCompletion', () => {
it('replaces from compReplace and drops the leading slash from the row', () => {
// The gateway's slash completer returns bare command names with
// replace_from = 1 (after the leading "/").
expect(applyCompletion('/ex', 'exit', 1)).toBe('/exit')
})
it('keeps the leading slash when the row carries one and input does not', () => {
expect(applyCompletion('ex', '/exit', 0)).toBe('/exit')
})
it('replaces an argument token after a space (subcommand completion)', () => {
expect(applyCompletion('/cron ad', 'add', 6)).toBe('/cron add')
})
it('applies an inline skill pick without disturbing the prose in front of it', () => {
// The gateway returns bare names for `complete.slash`; a mid-message
// reference replaces from just after its own `/`, so "please run " stays.
expect(applyCompletion('please run /cle', 'clean', 12)).toBe('please run /clean')
})
it('drops the row slash based on the character before the replace point, not the input start', () => {
// Widget-app rows carry a leading slash. Mid-message the input does NOT
// start with `/`, so a start-anchored check would double it up.
expect(applyCompletion('please run /cle', '/clean', 12)).toBe('please run /clean')
})
})
describe('completionToApplyOnSubmit', () => {
it('accepts a completion that finishes a partial command name', () => {
// "/ex" -> "/exit": a real token change, so Enter accepts it.
expect(completionToApplyOnSubmit('/ex', 'exit', 1)).toBe('/exit')
})
it('does NOT swallow Enter when the completion only adds a trailing space', () => {
// This is the bug: once "/exit" is fully typed, the gateway returns the
// command with a trailing space ("exit ") so the classic-CLI dropdown
// stays open. In the TUI that must NOT eat the Enter — the command is
// already complete, so Enter should submit.
expect(completionToApplyOnSubmit('/exit', 'exit ', 1)).toBeNull()
})
it('does not swallow Enter when applying the row is a no-op', () => {
expect(completionToApplyOnSubmit('/exit', 'exit', 1)).toBeNull()
})
it('still accepts a real argument completion (no trailing-space false positive)', () => {
expect(completionToApplyOnSubmit('/cron ad', 'add', 6)).toBe('/cron add')
})
it('submits (no accept) once an argument is fully typed and only a space is added', () => {
expect(completionToApplyOnSubmit('/cron add', 'add ', 6)).toBeNull()
})
it('returns null when there is no row text', () => {
expect(completionToApplyOnSubmit('/exit', undefined, 1)).toBeNull()
expect(completionToApplyOnSubmit('/exit', '', 1)).toBeNull()
})
})
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { highlightsStable, splitComposerHighlights } from '../domain/composerHighlights.js'
const painted = (text: string) =>
splitComposerHighlights(text)
.filter(segment => segment.ref)
.map(segment => segment.text)
describe('splitComposerHighlights', () => {
it('marks a command invocation and a skill named mid-prose', () => {
expect(painted('/work fix the leak')).toEqual(['/work'])
expect(painted('clean this up with /clean')).toEqual(['/clean'])
expect(painted('run /clean then /work')).toEqual(['/clean', '/work'])
})
it('marks @ references, including quoted values with spaces', () => {
expect(painted('see @file:src/a.ts please')).toEqual(['@file:src/a.ts'])
expect(painted('see @file:`my notes.md` please')).toEqual(['@file:`my notes.md`'])
expect(painted('diff @diff and @staged')).toEqual(['@diff', '@staged'])
})
it('marks attachment and paste tokens', () => {
expect(painted('what is in [[ Image 1 ]] here')).toEqual(['[[ Image 1 ]]'])
expect(painted('paste [[ log.. [3 lines] ]] ok')).toEqual(['[[ log.. [3 lines] ]]'])
})
it('marks every kind in one message', () => {
expect(painted('/work with @file:a.ts and [[ Image 2 ]]')).toEqual(['/work', '@file:a.ts', '[[ Image 2 ]]'])
})
it('leaves paths, bare slashes, and email addresses alone', () => {
for (const text of [
'look at /usr/local/bin',
'check src/foo/bar',
'a 3 /4 b',
'either / or',
'email me@example.com'
]) {
expect(splitComposerHighlights(text)).toEqual([{ ref: false, text }])
}
})
it('marks a half-typed token so the accent tracks the caret', () => {
// The composer paints while you type — waiting for the token to close
// would flash the accent on only after the last character. A bare `/`
// counts at the caret: that's the command menu opening.
expect(painted('/wor')).toEqual(['/wor'])
expect(painted('ref @fi')).toEqual(['@fi'])
expect(painted('/')).toEqual(['/'])
})
it('round-trips the input exactly', () => {
for (const text of ['/work a', 'x @file:b [[ Image 1 ]]', 'plain text', '', 'look at /usr/local/bin']) {
expect(
splitComposerHighlights(text)
.map(segment => segment.text)
.join('')
).toBe(text)
}
})
it('always returns at least one segment', () => {
expect(splitComposerHighlights('')).toEqual([{ ref: false, text: '' }])
})
})
describe('highlightsStable', () => {
// Fast-echo writes ONLY the new cells, so it may run only when every
// character already on screen keeps the colour it had.
it('allows the bypass while a token just grows', () => {
expect(highlightsStable('/wor', '/work')).toBe(true)
expect(highlightsStable('hello', 'hello ')).toBe(true)
})
it('blocks the bypass when a keystroke re-colours existing cells', () => {
expect(highlightsStable('[[ a ]', '[[ a ]]')).toBe(false)
expect(highlightsStable('/usr', '/usr/')).toBe(false)
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { FACES } from '../content/faces.js'
import { HOTKEYS } from '../content/hotkeys.js'
import { PLACEHOLDERS } from '../content/placeholders.js'
import { TOOL_VERBS, VERBS } from '../content/verbs.js'
import { ROLE } from '../domain/roles.js'
import { ZERO } from '../domain/usage.js'
import { INTERPOLATION_RE } from '../protocol/interpolation.js'
import { DEFAULT_THEME } from '../theme.js'
describe('constants', () => {
it('ZERO', () => expect(ZERO).toEqual({ calls: 0, input: 0, output: 0, total: 0 }))
it('string arrays are populated', () => {
for (const arr of [FACES, PLACEHOLDERS, VERBS]) {
expect(arr.length).toBeGreaterThan(0)
arr.forEach(s => expect(typeof s).toBe('string'))
}
})
it('HOTKEYS are [key, desc] pairs', () => {
HOTKEYS.forEach(([k, d]) => {
expect(typeof k).toBe('string')
expect(typeof d).toBe('string')
})
})
it('documents Ctrl/Cmd+L as non-destructive redraw', () => {
const hotkey = HOTKEYS.find(([k]) => k.endsWith('+L'))
expect(hotkey).toBeDefined()
expect(hotkey?.[1]).toBe('redraw / repaint')
})
it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => {
expect(TOOL_VERBS.terminal).toBe('terminal')
expect(TOOL_VERBS.read_file).toBe('reading')
})
it('INTERPOLATION_RE matches {!cmd}', () => {
INTERPOLATION_RE.lastIndex = 0
expect(INTERPOLATION_RE.test('{!date}')).toBe(true)
INTERPOLATION_RE.lastIndex = 0
expect(INTERPOLATION_RE.test('plain')).toBe(false)
})
it('ROLE produces glyph/body/prefix per role', () => {
for (const role of ['assistant', 'system', 'tool', 'user'] as const) {
expect(ROLE[role](DEFAULT_THEME)).toHaveProperty('glyph')
}
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
}, 30_000)
it('keeps cursor on the same row when text exactly fills the terminal width', () => {
// wrap-ansi does NOT push exact-fill text onto a phantom next line.
// The previous algorithm did — that's what produced the visible
// "cursor parked one row below the last char" symptom on narrow
// terminals at certain message lengths.
for (const cols of [8, 12, 18, 24]) {
const text = 'a'.repeat(cols)
const layout = cursorLayout(text, text.length, cols)
const inkLines = wrapAnsi(text, cols, { hard: true, trim: false }).split('\n')
expect(layout.line).toBe(0)
expect(layout.column).toBe(cols)
expect(inkLines).toHaveLength(1)
expect(inputVisualHeight(text, cols)).toBe(1)
}
})
it('does not stuff a trailing whitespace word onto a phantom line', () => {
// "branch investigate" at cols=20 fits on one row in wrap-ansi. The
// bug claimed otherwise, parking the cursor at (line=1, col=?) and
// leaving the user's "branch investigate" rendered alone on row 0
// with the cursor block several cells past it.
const text = 'branch investigate'
const cols = 20
expect(cursorLayout(text, text.length, cols)).toEqual({ column: text.length, line: 0 })
expect(cursorLayout(text, text.length, cols)).toEqual(wrapAnsiEnd(text, cols))
})
it('agrees with wrap-ansi for word-wrap that pushes a word onto the next line', () => {
// "hello world" at cols=8 wraps to ["hello ", "world"] in wrap-ansi.
// The cursor at end-of-text must land at line=1, col=5 — where Ink
// actually renders the last 'd'. The previous algorithm reported
// (line=2, col=0) here (phantom extra wrap), which parked the
// cursor on a row Ink never painted.
const text = 'hello world'
const cols = 8
expect(cursorLayout(text, text.length, cols)).toEqual({ column: 5, line: 1 })
expect(cursorLayout(text, text.length, cols)).toEqual(wrapAnsiEnd(text, cols))
})
})
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { isSectionName, parseDetailsMode, resolveSections, SECTION_NAMES, sectionMode } from '../domain/details.js'
describe('parseDetailsMode', () => {
it('accepts the canonical modes case-insensitively', () => {
expect(parseDetailsMode('hidden')).toBe('hidden')
expect(parseDetailsMode(' COLLAPSED ')).toBe('collapsed')
expect(parseDetailsMode('Expanded')).toBe('expanded')
})
it('rejects junk', () => {
expect(parseDetailsMode('truncated')).toBeNull()
expect(parseDetailsMode('')).toBeNull()
expect(parseDetailsMode(undefined)).toBeNull()
expect(parseDetailsMode(42)).toBeNull()
})
})
describe('isSectionName', () => {
it('only lets the four canonical sections through', () => {
expect(isSectionName('thinking')).toBe(true)
expect(isSectionName('tools')).toBe(true)
expect(isSectionName('subagents')).toBe(true)
expect(isSectionName('activity')).toBe(true)
expect(isSectionName('Thinking')).toBe(false) // case-sensitive on purpose
expect(isSectionName('bogus')).toBe(false)
expect(isSectionName('')).toBe(false)
expect(isSectionName(7)).toBe(false)
})
it('SECTION_NAMES exposes them all', () => {
expect([...SECTION_NAMES].sort()).toEqual(['activity', 'subagents', 'thinking', 'tools'])
})
})
describe('resolveSections', () => {
it('parses a well-formed sections object', () => {
expect(
resolveSections({
thinking: 'expanded',
tools: 'expanded',
subagents: 'collapsed',
activity: 'hidden'
})
).toEqual({
thinking: 'expanded',
tools: 'expanded',
subagents: 'collapsed',
activity: 'hidden'
})
})
it('drops unknown section names and unknown modes', () => {
expect(
resolveSections({
thinking: 'expanded',
tools: 'maximised',
bogus: 'hidden',
activity: 'hidden'
})
).toEqual({ thinking: 'expanded', activity: 'hidden' })
})
it('treats nullish/non-objects as empty overrides', () => {
expect(resolveSections(undefined)).toEqual({})
expect(resolveSections(null)).toEqual({})
expect(resolveSections('hidden')).toEqual({})
expect(resolveSections([])).toEqual({})
})
})
describe('sectionMode', () => {
it('falls back to the global mode for sections without a built-in default', () => {
expect(sectionMode('subagents', 'collapsed', {})).toBe('collapsed')
expect(sectionMode('subagents', 'expanded', undefined)).toBe('expanded')
expect(sectionMode('subagents', 'hidden', {})).toBe('hidden')
})
it('streams thinking + tools expanded by default for persisted config values', () => {
expect(sectionMode('thinking', 'collapsed', {})).toBe('expanded')
expect(sectionMode('thinking', 'hidden', undefined)).toBe('expanded')
expect(sectionMode('tools', 'collapsed', {})).toBe('expanded')
expect(sectionMode('tools', 'hidden', undefined)).toBe('expanded')
})
it('hides the activity panel by default for persisted config values', () => {
expect(sectionMode('activity', 'collapsed', {})).toBe('hidden')
expect(sectionMode('activity', 'expanded', undefined)).toBe('hidden')
expect(sectionMode('activity', 'hidden', {})).toBe('hidden')
})
it('applies in-session /details mode globally over built-in defaults', () => {
expect(sectionMode('thinking', 'collapsed', {}, true)).toBe('collapsed')
expect(sectionMode('tools', 'hidden', {}, true)).toBe('hidden')
expect(sectionMode('activity', 'expanded', undefined, true)).toBe('expanded')
})
it('honours per-section overrides over both the section default and global mode', () => {
expect(sectionMode('thinking', 'collapsed', { thinking: 'collapsed' })).toBe('collapsed')
expect(sectionMode('tools', 'collapsed', { tools: 'hidden' })).toBe('hidden')
expect(sectionMode('activity', 'collapsed', { activity: 'expanded' })).toBe('expanded')
expect(sectionMode('activity', 'expanded', { activity: 'collapsed' })).toBe('collapsed')
})
it('lets per-section overrides escape the global hidden mode', () => {
// Regression for the case where global details_mode: hidden used to
// short-circuit the entire accordion and prevent overrides from
// surfacing — `sections.tools: expanded` must still resolve to expanded.
expect(sectionMode('subagents', 'hidden', { subagents: 'expanded' })).toBe('expanded')
expect(sectionMode('thinking', 'hidden', { thinking: 'collapsed' })).toBe('collapsed')
expect(sectionMode('activity', 'hidden', { activity: 'expanded' })).toBe('expanded')
})
})
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { ensureEmojiPresentation } from '../lib/emoji.js'
const VS16 = '\uFE0F'
describe('ensureEmojiPresentation', () => {
it('passes through ASCII unchanged', () => {
expect(ensureEmojiPresentation('hello world')).toBe('hello world')
expect(ensureEmojiPresentation('')).toBe('')
})
it('passes through emoji that already defaults to emoji presentation', () => {
expect(ensureEmojiPresentation('🚀 rocket')).toBe('🚀 rocket')
expect(ensureEmojiPresentation('😀')).toBe('😀')
})
it('injects VS16 after text-default emoji codepoints', () => {
expect(ensureEmojiPresentation('⚠ careful')).toBe(`${VS16} careful`)
expect(ensureEmojiPresentation(' info')).toBe(`${VS16} info`)
expect(ensureEmojiPresentation('love ❤ you')).toBe(`love ❤${VS16} you`)
expect(ensureEmojiPresentation('✔ done')).toBe(`${VS16} done`)
})
it('is idempotent when VS16 is already present', () => {
const already = `${VS16} ${VS16}${VS16}`
expect(ensureEmojiPresentation(already)).toBe(already)
expect(ensureEmojiPresentation(ensureEmojiPresentation('⚠'))).toBe(`${VS16}`)
})
it('leaves keycap sequences alone when the base is not a text-default emoji', () => {
expect(ensureEmojiPresentation('1\u20e3')).toBe('1\u20e3')
})
it('injects VS16 before ZWJ so text-default bases participate in emoji sequences', () => {
// ❤ + ZWJ + 🔥 → ❤️‍🔥 (heart on fire). Without VS16 between the heart
// and the ZWJ, terminals render the heart in text/monochrome form and
// the ZWJ ligature can fail to form.
const heartFire = '\u2764\u200d\ud83d\udd25'
expect(ensureEmojiPresentation(heartFire)).toBe(`\u2764\uFE0F\u200d\ud83d\udd25`)
})
it('leaves explicit text-presentation selector (VS15) alone', () => {
// `❤︎` (U+2764 + U+FE0E) asks for text presentation — injecting VS16
// would create an invalid double-variation sequence.
const explicitText = '\u2764\ufe0e'
expect(ensureEmojiPresentation(explicitText)).toBe(explicitText)
})
it('returns the original reference when no change is needed', () => {
const already = `${VS16} ${VS16}${VS16}`
// Reference equality — the lazy allocator should short-circuit to the
// input when nothing needed injection.
expect(ensureEmojiPresentation(already)).toBe(already)
})
it('handles mixed content', () => {
expect(ensureEmojiPresentation('⚠ path: /tmp/x ❤ done')).toBe(`${VS16} path: /tmp/x ❤${VS16} done`)
})
})
+149
View File
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
__resetLinkTitleCache,
fetchLinkTitle,
hostPathLabel,
isTitleFetchable,
normalizeExternalUrl,
urlSlugTitleLabel
} from '../lib/externalLink.js'
afterEach(() => {
__resetLinkTitleCache()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('external link helpers', () => {
it('formats URL fallbacks as host + path', () => {
expect(
hostPathLabel(
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
)
).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894')
})
it('derives readable title fallbacks from URL slugs', () => {
expect(
urlSlugTitleLabel(
'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/'
)
).toBe('From Fajardo Icacos Island Full Day Catamaran Trip')
})
it('keeps x.com status fallbacks link-like instead of generic Status labels', () => {
expect(urlSlugTitleLabel('https://x.com/grok/status/2056065022749479209')).toBe(
'x.com/grok/status/2056065022749479209'
)
})
it('normalizes scheme-less links', () => {
expect(normalizeExternalUrl(' expedia.com/things-to-do/puerto-rico-el-yunque ')).toBe(
'https://expedia.com/things-to-do/puerto-rico-el-yunque'
)
})
it('filters out local/non-http targets for title fetches', () => {
expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true)
expect(isTitleFetchable('http://localhost:5174')).toBe(false)
expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false)
expect(isTitleFetchable('mailto:hello@example.com')).toBe(false)
})
it('blocks private, link-local, and intranet hosts', () => {
expect(isTitleFetchable('http://10.0.0.12/path')).toBe(false)
expect(isTitleFetchable('http://172.22.5.4/path')).toBe(false)
expect(isTitleFetchable('http://192.168.1.22/path')).toBe(false)
expect(isTitleFetchable('http://169.254.169.254/latest/meta-data')).toBe(false)
expect(isTitleFetchable('http://[fd00::1]/')).toBe(false)
expect(isTitleFetchable('http://[fe80::1]/')).toBe(false)
expect(isTitleFetchable('http://printer.local/status')).toBe(false)
expect(isTitleFetchable('http://intranet/status')).toBe(false)
expect(isTitleFetchable('https://8.8.8.8/status')).toBe(true)
})
it('deduplicates in-flight title fetches and caches results', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('<html><head><title>El Yunque Tour Water Slide, Rope Swing & Pickup</title></head></html>', {
headers: { 'content-type': 'text/html; charset=utf-8' },
status: 200
})
)
vi.stubGlobal('fetch', fetchMock)
const url =
'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure.a46272756.activity-details'
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(fetchMock).toHaveBeenCalledTimes(1)
const third = await fetchLinkTitle(url)
expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('shares cache across protocol/www URL variants', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('<html><head><title>Shared Canonical Title</title></head></html>', {
headers: { 'content-type': 'text/html' },
status: 200
})
)
vi.stubGlobal('fetch', fetchMock)
const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)])
expect(a).toBe('Shared Canonical Title')
expect(b).toBe('Shared Canonical Title')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('ignores error-like fetched titles', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('<html><head><title>Just a moment...</title></head></html>', {
headers: { 'content-type': 'text/html' },
status: 200
})
)
vi.stubGlobal('fetch', fetchMock)
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
await expect(fetchLinkTitle(url)).resolves.toBe('')
})
it('decodes HTML entities in fetched titles', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('<html><head><title>AT&amp;T &#39;Deals&#39;</title></head></html>', {
headers: { 'content-type': 'text/html' },
status: 200
})
)
vi.stubGlobal('fetch', fetchMock)
await expect(fetchLinkTitle('https://example.com/offers')).resolves.toBe("AT&T 'Deals'")
})
it('skips network fetch for non-fetchable targets', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(fetchLinkTitle('http://localhost:3000/path')).resolves.toBe('')
await expect(fetchLinkTitle('mailto:hello@example.com')).resolves.toBe('')
await expect(fetchLinkTitle('file:///tmp/demo.html')).resolves.toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
})
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest'
const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR', 'TERM', 'TERM_PROGRAM'] as const
let importId = 0
async function withCleanEnv(setup: () => void, body: () => Promise<void>) {
const saved: Record<string, string | undefined> = {}
for (const k of ENV_KEYS) {
saved[k] = process.env[k]
delete process.env[k]
}
try {
setup()
await body()
} finally {
for (const k of ENV_KEYS) {
if (saved[k] === undefined) {
delete process.env[k]
} else {
process.env[k] = saved[k]
}
}
}
}
describe('forceTruecolor', () => {
it('does not force truecolor by default', async () => {
await withCleanEnv(
() => {},
async () => {
await import('../lib/forceTruecolor.js?t=default-' + importId++)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('does not infer truecolor from Apple Terminal on pre-Tahoe macOS', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.TERM = 'xterm-256color'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=apple-' + importId++)
expect(mod.shouldForceTruecolor({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('downgrades Apple Terminal when truecolor is only advertised by env', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=downgrade-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'Apple_Terminal',
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
} as NodeJS.ProcessEnv)
).toBe(true)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('keeps non-Apple terminals untouched when they advertise truecolor', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'vscode'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=keep-non-apple-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'vscode',
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
} as NodeJS.ProcessEnv)
).toBe(false)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => {
await withCleanEnv(
() => {
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
await import('../lib/forceTruecolor.js?t=enabled-' + importId++)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('respects HERMES_TUI_TRUECOLOR=0 opt-out', async () => {
await withCleanEnv(
() => {
process.env.HERMES_TUI_TRUECOLOR = '0'
process.env.TERM_PROGRAM = 'Apple_Terminal'
},
async () => {
await import('../lib/forceTruecolor.js?t=optout-' + importId++)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('lets explicit opt-in keep Apple truecolor advertisement', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=apple-explicit-on-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'Apple_Terminal',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',
HERMES_TUI_TRUECOLOR: '1'
} as NodeJS.ProcessEnv)
).toBe(false)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('respects NO_COLOR', async () => {
await withCleanEnv(
() => {
process.env.NO_COLOR = '1'
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
await import('../lib/forceTruecolor.js?t=no-color-' + importId++)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('respects existing FORCE_COLOR unless Hermes truecolor is explicit', async () => {
await withCleanEnv(
() => {
process.env.FORCE_COLOR = ''
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=force-color-' + importId++)
expect(mod.shouldForceTruecolor(process.env)).toBe(false)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBe('')
}
)
})
it('lets explicit Hermes truecolor override existing FORCE_COLOR', async () => {
await withCleanEnv(
() => {
process.env.FORCE_COLOR = '0'
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
await import('../lib/forceTruecolor.js?t=explicit-force-' + importId++)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
})
+626
View File
@@ -0,0 +1,626 @@
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,
RECONNECT_BASE_MS,
RECONNECT_MAX_MS,
WS_HEARTBEAT_DEAD_MS,
WS_HEARTBEAT_INTERVAL_MS
} 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('drains buffered events on a later microtask, not synchronously inside drain()', async () => {
// Regression for #36658: in attach mode the already-running gateway
// replays `gateway.ready` the instant the socket connects, so it lands in
// bufferedEvents BEFORE the consumer's mount-time subscribe effect runs.
// If drain() emitted those synchronously, the gateway.ready handler's
// setState cascade would run inside React's first commit -> "Too many
// re-renders" (#301). drain() must defer the buffered flush so the first
// commit settles first.
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()
// Server replays ready BEFORE the consumer subscribes (attach-mode timing):
gatewaySocket.message(
JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })
)
const order: string[] = []
gw.on('event', ev => order.push(`event:${ev.type}`))
gw.drain()
order.push('after-drain')
// Buffered event must NOT have fired synchronously inside drain():
expect(order).toEqual(['after-drain'])
// ...and must arrive on the next microtask.
await vi.waitFor(() => expect(order).toContain('event:gateway.ready'))
expect(order).toEqual(['after-drain', 'event:gateway.ready'])
gw.kill()
})
it('preserves FIFO order when a live event arrives before the deferred flush', async () => {
// #36658 hardening: `subscribed` must NOT flip synchronously in drain().
// A live event delivered in the window between drain() returning and the
// deferred microtask running must still queue BEHIND the chronologically
// earlier buffered events, not jump ahead of them.
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()
// Buffered first (replayed on connect, before subscribe):
gatewaySocket.message(
JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })
)
const order: string[] = []
gw.on('event', ev => order.push(ev.type))
gw.drain()
// A LIVE event arrives synchronously in the post-drain / pre-microtask gap:
gatewaySocket.message(
JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'session.info', payload: {} } })
)
// Nothing emitted yet (subscribed stays false until the microtask):
expect(order).toEqual([])
await vi.waitFor(() => expect(order.length).toBe(2))
// FIFO preserved: the earlier-buffered gateway.ready precedes the live one.
expect(order).toEqual(['gateway.ready', 'session.info'])
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()
// drain() flips `subscribed` on a microtask now (#36658); let it settle so
// the subsequent live event takes the synchronous publish path.
await Promise.resolve()
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('publishes local dashboard-control events to the sidecar websocket', 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()
// drain() flips `subscribed` on a microtask now (#36658); let it settle.
await Promise.resolve()
gw.publishLocalEvent({
payload: { reason: 'idle_exit_hotkey' },
session_id: 'sid-old',
type: 'dashboard.new_session_requested'
})
expect(seen).toContain('dashboard.new_session_requested')
expect(JSON.parse(sidecarSocket.sent.at(-1) ?? '{}')).toEqual({
jsonrpc: '2.0',
method: 'event',
params: {
payload: { reason: 'idle_exit_hotkey' },
session_id: 'sid-old',
type: 'dashboard.new_session_requested'
}
})
gw.kill()
})
it('emits exit when attached websocket closes', async () => {
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()
// drain() flips `subscribed` on a microtask now (#36658); let it settle so
// the close below takes the synchronous exit path.
await Promise.resolve()
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 065535
// range and survives `.trim()`, so the fixture deterministically
// exercises `redactUrl()`'s fallback branch across Node versions.
// (An earlier `%zz` user-info fixture did NOT actually throw in
// recent Node — WHATWG accepts malformed percent escapes there —
// which silently routed the test through the structured-URL path.)
const fixture = 'ws://alice:hunter2@gateway.test:99999/api/ws?token=secret'
expect(() => new URL(fixture)).toThrow()
process.env.HERMES_TUI_GATEWAY_URL = fixture
;(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()
})
it('keeps a healthy idle websocket open when heartbeat acknowledgements arrive (issue #32997)', async () => {
vi.useFakeTimers()
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
try {
gw.start()
const socket = FakeWebSocket.instances[0]!
socket.open()
socket.message(
JSON.stringify({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: { heartbeat: true } }
})
)
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_INTERVAL_MS)
const heartbeat = JSON.parse(socket.sent.at(-1) ?? '{}') as { id: string; method: string }
expect(heartbeat.method).toBe('gateway.ping')
socket.message(JSON.stringify({ id: heartbeat.id, jsonrpc: '2.0', result: { ok: true } }))
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_DEAD_MS + WS_HEARTBEAT_INTERVAL_MS)
expect(socket.readyState).toBe(FakeWebSocket.OPEN)
expect(FakeWebSocket.instances).toHaveLength(1)
} finally {
gw.kill()
vi.useRealTimers()
}
})
it('auto-reconnects after a missing heartbeat acknowledgement (issue #32997)', async () => {
vi.useFakeTimers()
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
try {
gw.start()
const first = FakeWebSocket.instances[0]!
first.open()
first.message(
JSON.stringify({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: { heartbeat: true } }
})
)
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_INTERVAL_MS)
expect(JSON.parse(first.sent.at(-1) ?? '{}')).toMatchObject({ method: 'gateway.ping' })
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_DEAD_MS + WS_HEARTBEAT_INTERVAL_MS)
await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS)
expect(FakeWebSocket.instances.length).toBeGreaterThanOrEqual(2)
} finally {
gw.kill()
vi.useRealTimers()
}
})
it('does not heartbeat an older backend that omits the capability', async () => {
vi.useFakeTimers()
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
try {
gw.start()
const socket = FakeWebSocket.instances[0]!
socket.open()
socket.message(
JSON.stringify({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: {} }
})
)
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_DEAD_MS + WS_HEARTBEAT_INTERVAL_MS)
expect(socket.readyState).toBe(FakeWebSocket.OPEN)
expect(socket.sent).toEqual([])
expect(FakeWebSocket.instances).toHaveLength(1)
} finally {
gw.kill()
vi.useRealTimers()
}
})
it('does not double-reconnect when the exit subscriber restarts immediately', async () => {
vi.useFakeTimers()
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
try {
gw.on('exit', () => gw.start())
gw.start()
const first = FakeWebSocket.instances[0]!
first.open()
gw.drain()
await Promise.resolve()
first.close(1011)
expect(FakeWebSocket.instances).toHaveLength(2)
await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS)
expect(FakeWebSocket.instances).toHaveLength(2)
} finally {
gw.kill()
vi.useRealTimers()
}
})
it('does not auto-reconnect after an intentional kill() (issue #32997)', async () => {
vi.useFakeTimers()
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
gw.start()
FakeWebSocket.instances[0]!.open()
gw.kill() // sets disposed
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_DEAD_MS + RECONNECT_MAX_MS + 1000)
expect(FakeWebSocket.instances.length).toBe(1) // no reconnect attempted
vi.useRealTimers()
})
})
@@ -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)
})
})
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { shouldExitForSignal } from '../lib/gracefulExit.js'
describe('shouldExitForSignal', () => {
it('ignores only the signals explicitly disabled for embedded dashboard chat', () => {
expect(shouldExitForSignal('SIGINT', ['SIGINT'])).toBe(false)
expect(shouldExitForSignal('SIGTERM', ['SIGINT'])).toBe(true)
expect(shouldExitForSignal('SIGHUP', ['SIGINT'])).toBe(true)
})
})
@@ -0,0 +1,261 @@
import { EventEmitter } from 'events'
import { renderSync } from '@hermes/ink'
import React, { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TextInput } from '../components/textInput.js'
// End-to-end regression coverage for Vietnamese Telex IME recomposition
// (OpenKey / Unikey / EVKey). These IMEs commit a finished syllable by
// emitting a burst of backspaces (and, for OpenKey, a U+202F NARROW NO-BREAK
// SPACE marker) followed by the recomposed characters. The byte streams below
// are real captures taken from OpenKey and EVKey on macOS while typing the
// phrase "vương sỹ hạnh" (Telex: "vuonwg syx hanhj").
//
// The bug these guard against: characters were dropped and a stray space was
// left mid-syllable (e.g. "hạnh" rendered as "hạ "). Root causes fixed:
// 1. parse-keypress split fused control-byte+text chunks so the recomposed
// text survives instead of being discarded with the control byte.
// 2. textInput commits multi-character (IME/paste) inserts synchronously
// instead of through the 16ms key-burst path that raced re-renders.
class FakeTty extends EventEmitter {
chunks: string[] = []
columns = 80
rows = 24
isTTY = true
isRaw = false
private pendingReads: string[] = []
ref(): void {}
unref(): void {}
read(): string | null {
return this.pendingReads.shift() ?? null
}
send(chunk: string): void {
this.pendingReads.push(chunk)
this.emit('readable')
}
setEncoding(): this {
return this
}
setRawMode(mode: boolean): this {
this.isRaw = mode
return this
}
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
cb?.()
return true
}
}
const tick = () => new Promise<void>(resolve => setImmediate(resolve))
function Harness({ initial = '', onValue }: { initial?: string; onValue: (value: string) => void }) {
const [value, setValue] = useState(initial)
return React.createElement(TextInput, {
onChange: (next: string) => {
setValue(next)
onValue(next)
},
value
})
}
// Core driver: feeds reads, optionally advancing fake timers between reads to
// simulate the small macrotask gaps real IME reads arrive with. Returns the
// final value seen by the parent immediately after the last read (no trailing
// wait) so a passing assertion proves the commit was synchronous, not deferred.
async function drive(
reads: string[],
{ initial = '', gapMs = 0 }: { initial?: string; gapMs?: number } = {}
): Promise<string> {
const stdout = new FakeTty()
const stdin = new FakeTty()
const stderr = new FakeTty()
const values: string[] = []
const instance = renderSync(React.createElement(Harness, { initial, onValue: v => values.push(v) }), {
patchConsole: false,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
})
try {
await tick()
for (const r of reads) {
stdin.send(r)
await tick()
if (gapMs) {
// Advance the fake clock to flush any pending FRAME_BATCH_MS timers
// between reads (mirrors the real macrotask gap), then let microtasks run.
vi.advanceTimersByTime(gapMs)
await tick()
}
}
// Assert IMMEDIATELY after the final read — no trailing 60ms wait and
// WITHOUT advancing the fake clock past the deferred key-burst window.
// If the value is already correct here, the multi-char insert committed
// synchronously; the old deferred path (scheduleKeyBurstCommit, 16ms)
// has NOT flushed yet, so a stale/dropped tail would still be visible.
return values.at(-1) ?? ''
} finally {
instance.unmount()
instance.cleanup()
}
}
const NNBSP = '\u202f'
describe('Vietnamese Telex IME recomposition', () => {
beforeEach(() => {
// Only fake setTimeout/setInterval/Date — NOT setImmediate (used by tick()).
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'Date'] })
})
afterEach(() => {
vi.useRealTimers()
})
it('applies a parser-split backspace plus composed character through useInput', async () => {
// OpenKey fuses the erase + recomposed glyph into a single stdin read.
expect(await drive(['\x7fô'], { initial: 'o' })).toBe('ô')
})
it('commits a multi-character recompose synchronously (no dropped tail)', async () => {
// "hanhj" -> a U+202F marker, four backspaces, then the recomposed "ạnh".
// Only a single microtask after the last read — the sync commit must have
// already delivered the final value (the deferred path dropped "nh" here).
const reads = ['h', 'a', 'n', 'h', NNBSP, '\x7f\x7f', '\x7f\x7f', '\u1EA1nh']
// No gapMs, no advanceTimersMs — we assert BEFORE the 16ms FRAME_BATCH_MS could fire.
expect(await drive(reads)).toBe('h\u1EA1nh')
})
it('reproduces the full phrase "vương sỹ hạnh" from a real OpenKey capture', async () => {
// Captured byte stream for Telex "vuonwg syx hanhj": each syllable injects a
// U+202F marker, erases, and re-emits. Verified across read timings.
const reads = [
'v',
'u',
'o',
NNBSP,
'\x7f\x7f',
'\x7f\u01B0\u01A1',
'n',
'g',
' ',
's',
'y',
NNBSP,
'\x7f',
'\x7f\u1EF9',
' ',
'h',
'a',
'n',
'h',
NNBSP,
'\x7f\x7f\x7f\x7f\u1EA1nh'
]
for (const gapMs of [0, 17, 25]) {
expect(await drive(reads, { gapMs })).toBe('vương sỹ hạnh')
}
})
it('handles the EVKey capture (clean backspaces, no marker) for "hạnh"', async () => {
// EVKey emits three clean backspaces and no U+202F; must also yield "hạnh".
const reads = ['h', 'a', 'n', 'h', '\x7f', '\x7f', '\x7f', '\u1EA1nh']
expect(await drive(reads)).toBe('h\u1EA1nh')
})
})
describe('Fast-echo suppression reset (60ms window)', () => {
beforeEach(() => {
// Only fake setTimeout/setInterval/Date — NOT setImmediate (used by tick()).
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'Date'] })
})
afterEach(() => {
vi.useRealTimers()
})
it('suppresses fast-echo backspace for one keystroke after an Ink repaint (IME recompose)', async () => {
// Simulate: user types "ha" -> Ink commits normally -> then IME recompose arrives
// as NNBSP + backspaces + recomposed text. The first backspace after the Ink
// repaint must NOT fast-echo (would strand the NNBSP marker as a stray space).
// Type "ha" normally (each char goes through fast-echo append path)
let reads = ['h', 'a']
const stdout1 = new FakeTty()
const stdin1 = new FakeTty()
const stderr1 = new FakeTty()
const values1: string[] = []
const instance1 = renderSync(React.createElement(Harness, { initial: '', onValue: v => values1.push(v) }), {
patchConsole: false,
stderr: stderr1 as unknown as NodeJS.WriteStream,
stdin: stdin1 as unknown as NodeJS.ReadStream,
stdout: stdout1 as unknown as NodeJS.WriteStream
})
try {
await tick()
for (const r of reads) {
stdin1.send(r)
await tick()
}
// After "ha", fast-echo is enabled (inkRepaintedRef.current = false)
expect(values1.at(-1)).toBe('ha')
// Now simulate an IME recompose burst that forces an Ink repaint:
// NNBSP marker forces a full Ink render (syncParent=true in commit).
// The next backspace should be SUPPRESSED (fast-echo backspace disabled).
stdin1.send(NNBSP + '\x7f\x7f\u1EA1nh') // fused chunk: marker + 2x backspace + "ạnh"
await tick()
// The recomposed value must be committed synchronously (no dropped tail).
// The first backspace after the Ink repaint must NOT have written "\b \b" to stdout.
// We can't directly inspect stdout here, but we verify the FINAL value is correct.
expect(values1.at(-1)).toBe('h\u1EA1nh')
// Advance fake timers past the 60ms suppression window so the
// inkRepaintResetTimer fires and re-enables fast-echo backspace.
vi.advanceTimersByTime(60)
await tick()
// Now fast-echo backspace is RE-ENABLED. One backspace deletes exactly
// one grapheme ("h") off the end of "hạnh" -> "hạn".
stdin1.send('\x7f')
await tick()
expect(values1.at(-1)).toBe('h\u1EA1n')
} finally {
instance1.unmount()
instance1.cleanup()
}
})
it('does NOT suppress fast-echo backspace when no Ink repaint occurred (normal typing)', async () => {
// Normal ASCII typing never triggers the Ink-repaint suppression.
const reads = ['h', 'e', 'l', 'l', 'o']
expect(await drive(reads)).toBe('hello')
// Two backspaces off "hello" -> "hel" via the fast-echo path.
const reads2 = [...reads, '\x7f', '\x7f']
expect(await drive(reads2)).toBe('hel')
})
})
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import { inlineSlashTrigger } from '../domain/slash.js'
import { completionRequestForInput } from '../hooks/useCompletion.js'
describe('inlineSlashTrigger', () => {
it('detects a slash typed mid-message', () => {
// The reported bug: only a position-0 slash offered anything, so
// "please run /cle" completed nothing.
expect(inlineSlashTrigger('please run /cle')).toEqual({ query: 'cle', start: 11 })
})
it('detects a bare slash after whitespace, before any name is typed', () => {
expect(inlineSlashTrigger('please run /')).toEqual({ query: '', start: 11 })
})
it('fires after a newline, not just a space', () => {
expect(inlineSlashTrigger('text\n/skill')).toEqual({ query: 'skill', start: 5 })
})
it('does not fire at position 0 — that is a command invocation', () => {
expect(inlineSlashTrigger('/clean')).toBeNull()
expect(inlineSlashTrigger('/')).toBeNull()
})
it('leaves file paths alone', () => {
expect(inlineSlashTrigger('look at /usr/local/bin')).toBeNull()
expect(inlineSlashTrigger('check src/foo/bar')).toBeNull()
expect(inlineSlashTrigger('and/or')).toBeNull()
})
it('stops at the command token — an inline reference takes no args', () => {
// Only a position-0 slash is a real invocation, so `/personality alic`
// mid-message is prose with a reference in it, already ended.
expect(inlineSlashTrigger('hello there /personality alic')).toBeNull()
})
it('reports a start index that replaces only the typed token', () => {
const text = 'please run /cle'
const trigger = inlineSlashTrigger(text)!
expect(text.slice(0, trigger.start)).toBe('please run ')
expect(text.slice(trigger.start)).toBe('/cle')
})
})
describe('completionRequestForInput — inline skill references', () => {
it('asks for skills only when the slash is mid-message', () => {
const request = completionRequestForInput('please run /cle')
expect(request).toMatchObject({
method: 'complete.slash',
params: { text: '/cle' },
replaceFrom: 12,
skillsOnly: true
})
})
it('keeps the full command set at position 0', () => {
expect(completionRequestForInput('/cle')).toEqual({
method: 'complete.slash',
params: { text: '/cle' },
replaceFrom: 1
})
})
it('completes a second slash in a line that starts with a command', () => {
// Only the first slash is an invocation. Routing the whole line to the
// completer offered nothing, so `/work /cle` went dead while
// `do /work then /cle` completed fine.
expect(completionRequestForInput('/work /cle')).toMatchObject({
method: 'complete.slash',
params: { text: '/cle' },
replaceFrom: 7,
skillsOnly: true
})
})
it('leaves a command own arguments to the command', () => {
for (const input of ['/personality alic', '/cron ad', '/details ']) {
expect(completionRequestForInput(input)).toEqual({
method: 'complete.slash',
params: { text: input },
replaceFrom: 1
})
}
})
it('routes a real mid-message path to path completion, not skills', () => {
expect(completionRequestForInput('open src/foo/ba')).toMatchObject({ method: 'complete.path' })
expect(completionRequestForInput('open /usr/lo')).toMatchObject({ method: 'complete.path' })
})
})
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { handleInputSelectionClipboard } from '../app/useInputHandlers.js'
const selection = (start = 1, end = 4) => ({
clear: vi.fn(),
collapseToEnd: vi.fn(),
copy: vi.fn(),
cut: vi.fn(),
end,
start,
value: 'hello'
})
describe('handleInputSelectionClipboard', () => {
it('copies an active composer selection', () => {
const active = selection()
expect(handleInputSelectionClipboard(active, 'copy')).toBe(true)
expect(active.copy).toHaveBeenCalledOnce()
expect(active.cut).not.toHaveBeenCalled()
})
it('cuts an active composer selection', () => {
const active = selection()
expect(handleInputSelectionClipboard(active, 'cut')).toBe(true)
expect(active.cut).toHaveBeenCalledOnce()
expect(active.copy).not.toHaveBeenCalled()
})
it('leaves shortcuts available when there is no active selection', () => {
expect(handleInputSelectionClipboard(null, 'copy')).toBe(false)
expect(handleInputSelectionClipboard(selection(2, 2), 'cut')).toBe(false)
})
})
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { findSlashCommand } from '../app/slash/registry.js'
describe('/journey slash command', () => {
beforeEach(() => {
resetOverlayState()
})
it('resolves by name and aliases', () => {
expect(findSlashCommand('journey')?.name).toBe('journey')
for (const alias of ['learning', 'memory-graph']) {
expect(findSlashCommand(alias)?.name).toBe('journey')
}
})
it('opens the journey overlay when run', () => {
expect(getOverlayState().journey).toBe(false)
findSlashCommand('journey')!.run('', {} as never, 'journey')
expect(getOverlayState().journey).toBe(true)
})
it('is preserved by the flow-overlay soft reset (deliberate, user-opened)', async () => {
findSlashCommand('journey')!.run('', {} as never, 'journey')
// Mirror turnController.idle(): flow overlays clear, user-opened panels stay.
const { resetFlowOverlays } = await import('../app/overlayStore.js')
resetFlowOverlays()
expect(getOverlayState().journey).toBe(true)
})
})
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToScreen } from '../../packages/hermes-ink/src/ink/render-to-screen.js'
import { cellAtIndex } from '../../packages/hermes-ink/src/ink/screen.js'
import { ShimmerRows, shimmerSegments, subscribeShimmerClock } from '../components/loaders.js'
describe('ShimmerRows leniency (agent-authored calls)', () => {
it('accepts a bare row COUNT and derives widths — the generated-code shape', async () => {
const { createElement } = await import('react')
const { screen, height } = renderToScreen(
createElement(ShimmerRows, {
rows: 3,
width: 20,
t: { color: { completionBg: '#1a1a2e', label: '#DAA520', muted: '#B8860B' } }
}),
30
)
expect(height).toBe(3)
// Row 0 renders block cells, not a crash.
expect(cellAtIndex(screen, 0).char).toBe('▁')
})
})
describe('shimmerSegments', () => {
it('always partitions the full width', () => {
for (let phase = -40; phase < 80; phase++) {
const [pre, band, post] = shimmerSegments(20, phase)
expect(pre + band + post).toBe(20)
expect(Math.min(pre, band, post)).toBeGreaterThanOrEqual(0)
}
})
it('sweeps: enters from the left edge, exits off the right, then wraps', () => {
const bandAt = (phase: number) => shimmerSegments(10, phase, 4)
expect(bandAt(0)).toEqual([10, 0, 0]) // band fully off-left
expect(bandAt(1)).toEqual([0, 1, 9]) // entering
expect(bandAt(7)).toEqual([3, 4, 3]) // mid-sweep
expect(bandAt(13)).toEqual([9, 1, 0]) // exiting
expect(bandAt(14)).toEqual([10, 0, 0]) // gone → next cycle re-enters
expect(bandAt(15)).toEqual([0, 1, 9])
})
it('negative phases (row stagger) wrap instead of vanishing', () => {
const [pre, band, post] = shimmerSegments(10, -3, 4)
expect(pre + band + post).toBe(10)
})
})
// Review on #20379 (finding 5): independent 90 ms intervals per shimmer
// composition meant an idle TUI with two lazy sections repainted ~22x/sec
// forever. All compositions now share ONE clock, and the interval exists
// only while subscribers do.
describe('subscribeShimmerClock', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('drives any number of subscribers from a single interval', () => {
const a: number[] = []
const b: number[] = []
const timersBefore = vi.getTimerCount()
const unsubA = subscribeShimmerClock(p => a.push(p))
const unsubB = subscribeShimmerClock(p => b.push(p))
// Two subscribers, ONE new timer.
expect(vi.getTimerCount()).toBe(timersBefore + 1)
vi.advanceTimersByTime(300)
// Same shared phases, in lockstep.
expect(a.length).toBeGreaterThan(0)
expect(a).toEqual(b)
unsubA()
unsubB()
})
it('stops the interval with the last unsubscribe', () => {
const unsubA = subscribeShimmerClock(() => {})
const unsubB = subscribeShimmerClock(() => {})
const timersRunning = vi.getTimerCount()
unsubA()
// Still one subscriber — clock keeps running.
expect(vi.getTimerCount()).toBe(timersRunning)
unsubB()
expect(vi.getTimerCount()).toBe(timersRunning - 1)
})
it('a late subscriber restarts the clock cleanly', () => {
const unsubA = subscribeShimmerClock(() => {})
unsubA()
const seen: number[] = []
const unsubB = subscribeShimmerClock(p => seen.push(p))
vi.advanceTimersByTime(200)
expect(seen.length).toBeGreaterThan(0)
unsubB()
})
})
+458
View File
@@ -0,0 +1,458 @@
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import chalk from 'chalk'
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
import { __resetLinkTitleCache, fetchLinkTitle } from '../lib/externalLink.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME, LIGHT_THEME } from '../theme.js'
afterEach(() => {
__resetLinkTitleCache()
vi.unstubAllGlobals()
})
// Stub the network and warm the shared title cache, so a subsequent render
// has the resolved title available synchronously.
const stubFetchedTitle = (url: string, title: string) => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(`<html><head><title>${title}</title></head></html>`, {
headers: { 'content-type': 'text/html' },
status: 200
})
)
)
return fetchLinkTitle(url)
}
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 the authored markdown label even when a page title resolves', async () => {
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
// Warm the shared cache so `useLinkTitle` would have a title to render
// synchronously — the label must still win.
await stubFetchedTitle(url, 'El Yunque Rainforest Adventure | Expedia')
const lines = renderPlain(
React.createElement(
Box,
{ width: 80 },
React.createElement(Md, { t: DEFAULT_THEME, text: `[Trip details](${url})` })
)
)
const rendered = lines.join('\n')
expect(rendered).toContain('Trip details')
expect(rendered).not.toContain('El Yunque Rainforest Adventure | Expedia')
})
it('still resolves titles for links whose label is just the URL', async () => {
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
await stubFetchedTitle(url, 'Rainforest Adventure Tour')
const lines = renderPlain(
React.createElement(Box, { width: 120 }, React.createElement(Md, { t: DEFAULT_THEME, text: `[${url}](${url})` }))
)
expect(lines.join('\n')).toContain('Rainforest Adventure Tour')
})
})
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)
})
})
describe('body prose stays in the theme palette', () => {
// Prose used to render in the terminal's DEFAULT foreground while inline
// tokens beside it carried a theme color, so one line mixed two inks.
// Because an inline token can match mid-word, so could a single word.
// LIGHT_THEME is the vehicle here because every tone in it is hex, so
// emitted SGR maps back to palette entries without format juggling.
const foregroundRuns = (text: string): string[] => {
// chalk is a singleton and defaults to level 0 under vitest (no TTY),
// which would emit no SGR at all and make every assertion here vacuous.
const savedLevel = chalk.level
chalk.level = 3
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 80, isTTY: true, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
React.createElement(Box, { width: 70 }, React.createElement(Md, { cols: 68, t: LIGHT_THEME, text })),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
chalk.level = savedLevel
return [...output.matchAll(new RegExp(`${ESC}\\[38;2;(\\d+);(\\d+);(\\d+)m`, 'g'))].map(
m =>
'#' +
m
.slice(1, 4)
.map(v => Number(v).toString(16).padStart(2, '0'))
.join('')
)
}
const PALETTE = new Set(
Object.values(LIGHT_THEME.color)
.filter((v): v is string => typeof v === 'string' && v.startsWith('#'))
.map(v => v.toLowerCase())
)
const INK = LIGHT_THEME.color.text.toLowerCase()
it('opens a paragraph with the theme ink, not the terminal default', () => {
expect(foregroundRuns('plain prose line')[0]).toBe(INK)
})
it('keeps every foreground on a mixed-token line inside the palette', () => {
// `render_terminal_output` trips the underscore-italic token mid-word —
// the exact shape that split one word across two inks.
const fg = foregroundRuns('set the `flag` and re-render_terminal_output for the run')
expect(fg.length).toBeGreaterThan(0)
for (const c of fg) {
expect(PALETTE.has(c)).toBe(true)
}
})
it('returns to the theme ink after an inline token, not to the terminal default', () => {
const fg = foregroundRuns('before `code` after')
expect(fg[0]).toBe(INK)
expect(fg.at(-1)).toBe(INK)
})
it('themes list-item prose too', () => {
for (const text of ['- a bullet item', '1. a numbered item']) {
expect(foregroundRuns(text)).toContain(INK)
}
})
})
+295
View File
@@ -0,0 +1,295 @@
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('')
})
})
+126
View File
@@ -0,0 +1,126 @@
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,57 @@
import { describe, expect, it } from 'vitest'
import { mergeUsageStable, usageChanged } from '../app/createGatewayEventHandler.js'
import type { Usage } from '../types.js'
const baseUsage: Usage = {
calls: 3,
input: 1200,
output: 400,
total: 1600,
context_max: 200000,
context_percent: 12,
context_used: 24000
}
describe('mergeUsageStable (#41480 status-bar flicker)', () => {
it('returns the PRIOR reference when a patch changes nothing', () => {
// The load-bearing behavior: an unchanged-value patch must NOT mint a new
// object, or every $uiState subscriber re-renders per streaming delta.
const patch = { calls: 3, total: 1600 }
expect(mergeUsageStable(baseUsage, patch)).toBe(baseUsage)
})
it('returns the prior reference for an undefined patch', () => {
expect(mergeUsageStable(baseUsage, undefined)).toBe(baseUsage)
})
it('returns a new merged object when a value actually changes', () => {
const merged = mergeUsageStable(baseUsage, { total: 1700 })
expect(merged).not.toBe(baseUsage)
expect(merged.total).toBe(1700)
expect(merged.calls).toBe(3)
})
it('detects an active_subagents-only update (field the original PR missed)', () => {
// usageChanged iterates the key union generically, so optional fields the
// status rule consumes (active_subagents drives the ⛓ segment and the
// resume hint) can never be silently dropped from the comparison.
const withSubagents = mergeUsageStable(baseUsage, { active_subagents: 2 })
expect(withSubagents).not.toBe(baseUsage)
expect(withSubagents.active_subagents).toBe(2)
// And clearing it back down is also a change.
const cleared = mergeUsageStable(withSubagents, { active_subagents: 0 })
expect(cleared).not.toBe(withSubagents)
expect(cleared.active_subagents).toBe(0)
})
it('treats a key present on only one side as a change', () => {
expect(usageChanged(baseUsage, { ...baseUsage, cost_usd: 0.01 })).toBe(true)
expect(usageChanged({ ...baseUsage, cost_usd: 0.01 }, baseUsage)).toBe(true)
})
it('reports no change for deep-equal usages', () => {
expect(usageChanged(baseUsage, { ...baseUsage })).toBe(false)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { shouldShowResponseSeparator, shouldShowThinkingTrail } 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)
})
})
describe('shouldShowThinkingTrail', () => {
it('hides an ordinary reasoning trail when every section is hidden', () => {
const msg = { role: 'system', text: '', thinking: 'plan' } as const
expect(shouldShowThinkingTrail(msg, 'hidden', 'hidden', 'hidden')).toBe(false)
})
it('shows an ordinary reasoning trail when any section is visible', () => {
const msg = { role: 'system', text: '', thinking: 'plan' } as const
expect(shouldShowThinkingTrail(msg, 'collapsed', 'hidden', 'hidden')).toBe(true)
expect(shouldShowThinkingTrail(msg, 'hidden', 'collapsed', 'hidden')).toBe(true)
expect(shouldShowThinkingTrail(msg, 'hidden', 'hidden', 'expanded')).toBe(true)
})
it('keeps a MoA reference block visible even when every section is hidden (#64657)', () => {
const msg = {
role: 'system',
text: '',
thinking: '◇ Reference 1/2 — model-a\nadvice-a',
isMoaReference: true
} as const
expect(shouldShowThinkingTrail(msg, 'hidden', 'hidden', 'hidden')).toBe(true)
})
})
+273
View File
@@ -0,0 +1,273 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { fmtMsgTimestamp, MessageLine } from '../components/messageLine.js'
import { MAX_HISTORY } from '../config/limits.js'
import { toTranscriptMessages } from '../domain/messages.js'
import { appendTranscriptMessage, capTranscriptHistory, 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')
})
it('skips hidden display_kind rows entirely', () => {
const rows = [
{ role: 'user', text: 'visible prompt' },
{ role: 'user', text: '[CONTEXT COMPACTION — REFERENCE ONLY]', display_kind: 'hidden' },
{ role: 'assistant', text: 'visible reply' }
]
const result = toTranscriptMessages(rows)
expect(result.map(msg => msg.text)).toEqual(['visible prompt', 'visible reply'])
expect(result.every(m => !m.text?.includes('COMPACTION'))).toBe(true)
})
it('projects model_switch as an event with replaced text', () => {
const rows = [
{ role: 'user', text: 'hello' },
{ role: 'user', text: '[System: model changed to gpt-5]', display_kind: 'model_switch' },
{ role: 'assistant', text: 'hi' }
]
const result = toTranscriptMessages(rows)
expect(result.map(msg => [msg.kind, msg.role, msg.text])).toEqual([
[undefined, 'user', 'hello'],
['event', 'system', 'model changed'],
[undefined, 'assistant', 'hi']
])
})
it('projects async_delegation_complete with task_count metadata', () => {
const rows = [
{ role: 'user', text: 'do work' },
{ role: 'assistant', text: 'done' },
{
role: 'user',
text: '[IMPORTANT: delegation done]',
display_kind: 'async_delegation_complete',
display_metadata: { task_count: 3 }
},
{ role: 'assistant', text: 'merged' }
]
const result = toTranscriptMessages(rows)
expect(result.map(msg => [msg.kind, msg.text])).toEqual([
[undefined, 'do work'],
[undefined, 'done'],
['event', '3 background agents finished'],
[undefined, 'merged']
])
})
it('projects async_delegation_complete without metadata as generic text', () => {
const rows = [{ role: 'user', text: 'event', display_kind: 'async_delegation_complete' }]
const result = toTranscriptMessages(rows)
expect(result[0]?.kind).toBe('event')
expect(result[0]?.text).toBe('background agent work finished')
})
})
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')
})
it('keeps historical thinking blocks collapsed by default', () => {
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(
React.createElement(MessageLine, {
cols: 80,
msg: { kind: 'trail', role: 'system', text: '', thinking: 'step one\nstep two' },
t: DEFAULT_THEME
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
const rendered = stripAnsi(output)
expect(rendered).toContain('Thinking')
expect(rendered).not.toContain('step one')
expect(rendered).not.toContain('step two')
})
it('keeps live thinking blocks expanded while streaming', () => {
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(
React.createElement(MessageLine, {
cols: 80,
liveDetails: true,
msg: { kind: 'trail', role: 'system', text: '', thinking: 'step one\nstep two' },
t: DEFAULT_THEME
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
const rendered = stripAnsi(output)
expect(rendered).toContain('Thinking')
expect(rendered).toContain('step one')
expect(rendered).toContain('step two')
})
})
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)
})
})
describe('capTranscriptHistory', () => {
it('keeps the intro and the newest bounded display rows', () => {
const intro = { kind: 'intro' as const, role: 'system' as const, text: '' }
const rows = Array.from({ length: 1_005 }, (_, index) => ({ role: 'user' as const, text: `m${index}` }))
const capped = capTranscriptHistory([intro, ...rows])
expect(capped).toHaveLength(MAX_HISTORY)
expect(capped[0]).toBe(intro)
expect(capped[1]?.text).toBe(`m${rows.length - (MAX_HISTORY - 1)}`)
expect(capped.at(-1)?.text).toBe('m1004')
})
})
describe('display.timestamps (#41531)', () => {
it('formats a Unix-seconds timestamp as [HH:MM] and rejects garbage', () => {
const noon = new Date()
noon.setHours(13, 5, 0, 0)
expect(fmtMsgTimestamp(noon.getTime() / 1000)).toBe('[13:05]')
expect(fmtMsgTimestamp(undefined)).toBeNull()
expect(fmtMsgTimestamp(0)).toBeNull()
expect(fmtMsgTimestamp(Number.NaN)).toBeNull()
})
it('threads persisted transcript timestamps onto rehydrated rows', () => {
const rows = [
{ role: 'user', text: 'when was this', timestamp: 1_750_000_000 },
{ role: 'assistant', text: 'right then', timestamp: 1_750_000_060 }
]
const result = toTranscriptMessages(rows)
expect(result[0]?.createdAt).toBe(1_750_000_000)
expect(result[1]?.createdAt).toBe(1_750_000_060)
})
it('stamps live rows at append and preserves supplied times', () => {
const before = Date.now() / 1000
const [live] = appendTranscriptMessage([], { role: 'user', text: 'now' })
expect(live?.createdAt).toBeGreaterThanOrEqual(before - 1)
expect(live?.createdAt).toBeLessThanOrEqual(Date.now() / 1000 + 1)
const [kept] = appendTranscriptMessage([], { createdAt: 123, role: 'user', text: 'then' })
expect(kept?.createdAt).toBe(123)
})
})
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { resetOverlayState } from '../app/overlayStore.js'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
import type { Msg } from '../types.js'
const ref = <T>(current: T) => ({ current })
const buildCtx = (appended: Msg[]) =>
({
composer: {
dequeue: () => undefined,
queueEditRef: ref<null | number>(null),
sendQueued: () => undefined,
setInput: () => undefined
},
gateway: {
gw: { request: () => undefined },
rpc: async () => null
},
session: {
STARTUP_RESUME_ID: '',
colsRef: ref(80),
newSession: () => undefined,
resetSession: () => undefined,
resumeById: () => undefined,
setCatalog: () => undefined
},
submission: {
submitRef: { current: () => undefined }
},
system: {
bellOnComplete: false,
sys: () => undefined
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
panel: () => undefined,
setHistoryItems: () => undefined
},
voice: {
setProcessing: () => undefined,
setRecording: () => undefined,
setVoiceEnabled: () => undefined
}
}) as any
const activityTexts = () => getTurnState().activity.map(item => item.text)
describe('moa.progress / moa.phase activity surface', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
resetTurnState()
turnController.fullReset()
patchUiState({ showReasoning: true })
})
it('shows "MoA: refs k/n" as each reference completes, replacing in place', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { label: 'model-a', refs_done: 1, refs_total: 3 }, type: 'moa.progress' } as any)
expect(activityTexts()).toContain('MoA: refs 1/3')
onEvent({ payload: { label: 'model-b', refs_done: 2, refs_total: 3 }, type: 'moa.progress' } as any)
const texts = activityTexts()
expect(texts).toContain('MoA: refs 2/3')
// Replaced in place — the stale 1/3 line must not linger alongside 2/3.
expect(texts).not.toContain('MoA: refs 1/3')
})
it('swaps the progress line for aggregator copy on moa.phase', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { label: 'model-a', refs_done: 3, refs_total: 3 }, type: 'moa.progress' } as any)
onEvent({ payload: { phase: 'aggregator', refs_done: 3, refs_total: 3 }, type: 'moa.phase' } as any)
const texts = activityTexts()
expect(texts).toContain('MoA: aggregating…')
expect(texts).not.toContain('MoA: refs 3/3')
})
it('ignores malformed payloads (missing counters / unknown phase)', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: {}, type: 'message.start' } as any)
const before = activityTexts().length
onEvent({ payload: { label: 'model-a' }, type: 'moa.progress' } as any)
onEvent({ payload: { phase: 'reference' }, type: 'moa.phase' } as any)
expect(activityTexts().length).toBe(before)
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { providerIndexAfterClearingFilter } from '../components/modelPicker.js'
import type { ModelOptionProvider } from '../gatewayTypes.js'
const provider = (slug: string, name = slug): ModelOptionProvider => ({ name, slug })
describe('ModelPicker provider filtering', () => {
it('keeps the selected provider when clearing the provider filter', () => {
const nous = provider('nous', 'Nous Portal')
const ollama = provider('ollama-cloud', 'Ollama Cloud')
const rows = [
{ name: nous.name, provider: nous },
{ name: ollama.name, provider: ollama }
]
// With a provider-stage filter like "ollama", the selected row is index 0
// in the filtered list, but index 1 in the full list after setFilter('').
expect(providerIndexAfterClearingFilter(rows, ollama)).toBe(1)
})
it('returns -1 when provider is undefined', () => {
const rows = [{ name: 'A', provider: provider('a') }]
expect(providerIndexAfterClearingFilter(rows, undefined)).toBe(-1)
})
it('returns -1 when provider slug is not in rows', () => {
const rows = [
{ name: 'A', provider: provider('a') },
{ name: 'B', provider: provider('b') }
]
expect(providerIndexAfterClearingFilter(rows, provider('missing'))).toBe(-1)
})
it('returns -1 for empty rows', () => {
expect(providerIndexAfterClearingFilter([], provider('a'))).toBe(-1)
})
it('finds the first match when multiple rows share a slug', () => {
const p = provider('dup')
const rows = [
{ name: 'First', provider: p },
{ name: 'Second', provider: p }
]
expect(providerIndexAfterClearingFilter(rows, p)).toBe(0)
})
})
@@ -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 --session' }
}
],
['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([])
})
})
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildOsc52ClipboardQuery,
OSC52_CLIPBOARD_QUERY,
parseOsc52ClipboardData,
readOsc52Clipboard,
writeOsc52Clipboard
} 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()
})
})
describe('writeOsc52Clipboard', () => {
it('wraps writes for tmux passthrough', () => {
process.env.TMUX = '/tmp/tmux-123/default,1,0'
delete process.env.STY
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true as any)
writeOsc52Clipboard('hello')
expect(write).toHaveBeenCalledTimes(1)
const seq = String(write.mock.calls[0]?.[0] ?? '')
expect(seq).toContain('\x1bPtmux;')
expect(seq).toContain(']52;c;')
write.mockRestore()
})
it('keeps raw OSC52 outside multiplexers', () => {
delete process.env.TMUX
delete process.env.STY
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true as any)
writeOsc52Clipboard('hello')
expect(write).toHaveBeenCalledTimes(1)
const seq = String(write.mock.calls[0]?.[0] ?? '')
expect(seq.startsWith('\x1b]52;c;')).toBe(true)
write.mockRestore()
})
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { clampOverlayWidth } from '../components/overlayPrimitives.js'
describe('clampOverlayWidth', () => {
it('prefers preferred, capped by maxWidth', () => {
expect(clampOverlayWidth(60)).toBe(60)
expect(clampOverlayWidth(60, 40)).toBe(40)
expect(clampOverlayWidth(30, 80)).toBe(30)
})
it('honors caps BELOW the usability floor instead of overflowing the cell', () => {
// Copilot review on #20379: a 20-col grid cell must get 20, not 24.
expect(clampOverlayWidth(60, 20)).toBe(20)
expect(clampOverlayWidth(60, 1)).toBe(1)
})
it('keeps the floor when the cap allows it', () => {
expect(clampOverlayWidth(10, 80)).toBe(24)
expect(clampOverlayWidth(10)).toBe(24)
})
})
+77
View File
@@ -0,0 +1,77 @@
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()
})
})
+144
View File
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { composeTabTitle, fmtCwdBranch, fmtProjectCwdBranch, shortCwd, shortProject } 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('shortProject', () => {
it('trims whitespace', () => {
expect(shortProject(' website ')).toBe('website')
})
it('truncates long project names from the right', () => {
expect(shortProject('a-very-long-project-name', 10)).toBe('a-very-lo…')
})
})
describe('fmtProjectCwdBranch', () => {
const origHome = process.env.HOME
beforeEach(() => {
process.env.HOME = '/Users/bb'
})
afterEach(() => {
process.env.HOME = origHome
})
it('prefixes the cwd/branch label with the project name', () => {
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', 'website', 28)).toBe('website · ~/proj (main)')
})
it('falls back to the cwd/branch label when no project is known', () => {
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', null, 28)).toBe('~/proj (main)')
})
it('keeps the project visible when space is tight', () => {
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', 'hermes-agent', 12)).toBe('hermes-agent')
})
})
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`)
})
})
+90
View File
@@ -0,0 +1,90 @@
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { usePet } from '../app/usePet.js'
import { PetPane } from '../components/appLayout.js'
import { stripAnsi } from '../lib/text.js'
vi.mock('../app/usePet.js', () => ({
usePet: vi.fn()
}))
const opaqueCell = [255, 0, 0, 255, 0, 0, 255, 255]
const PET_GLYPHS = new Set(['▀', '▄', '█'])
const firstGlyphCol = (line: string) => [...line].findIndex(ch => PET_GLYPHS.has(ch))
const renderFrame = (element: React.ReactElement, columns = 40) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns, isTTY: false, rows: 12 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(element, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
instance.unmount()
instance.cleanup()
return stripAnsi(output)
.split('\n')
.map(line => line.replace(/\s+$/, ''))
}
describe('PetPane', () => {
afterEach(() => {
vi.mocked(usePet).mockReset()
})
it('overlays the bottom-right corner with a flat, right-aligned sprite', () => {
const columns = 40
vi.mocked(usePet).mockReturnValue({
enabled: true,
grid: [
[opaqueCell, opaqueCell],
[opaqueCell, opaqueCell]
],
kitty: null
})
const lines = renderFrame(
<Box flexDirection="column" height={8} position="relative" width={columns}>
<PetPane />
</Box>,
columns
)
const cols = lines.map(firstGlyphCol).filter(col => col >= 0)
expect(cols.length).toBeGreaterThanOrEqual(2)
// Flat (no per-row drift) and right-aligned (a corner block, not full width).
expect(new Set(cols).size).toBe(1)
expect(cols[0]).toBeGreaterThan(columns / 2)
})
it('renders nothing when disabled', () => {
vi.mocked(usePet).mockReturnValue({ enabled: false, grid: null, kitty: null })
const lines = renderFrame(
<Box flexDirection="column" height={8} position="relative" width={40}>
<PetPane />
</Box>
)
expect(lines.every(line => firstGlyphCol(line) < 0)).toBe(true)
})
})
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import { createPetSingleFlight, requestPetUpdate } from '../lib/petPolling.js'
const gateway = (request: ReturnType<typeof vi.fn>) => ({ request }) as never
describe('requestPetUpdate', () => {
it('does not enqueue pet.cells while pets are disabled', async () => {
const request = vi.fn().mockResolvedValue({ enabled: false })
const needsCells = vi.fn(() => true)
const update = await requestPetUpdate(gateway(request), 'idle', false, needsCells)
expect(update).toEqual({ cells: null, meta: { enabled: false } })
expect(request).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenCalledWith('pet.info.meta')
expect(needsCells).not.toHaveBeenCalled()
})
it('uses metadata only when the enabled state is already cached', async () => {
const request = vi.fn().mockResolvedValue({
enabled: true,
scale: 0.33,
slug: 'boba',
spritesheetRevision: '1:2'
})
const update = await requestPetUpdate(gateway(request), 'idle', false, () => false)
expect(update?.cells).toBeNull()
expect(request).toHaveBeenCalledTimes(1)
})
it('fetches cells only for an enabled uncached state', async () => {
const cells = { enabled: true, frames: [], slug: 'boba' }
const request = vi.fn().mockResolvedValueOnce({ enabled: true, slug: 'boba' }).mockResolvedValueOnce(cells)
const update = await requestPetUpdate(gateway(request), 'review', false, () => true)
expect(update?.cells).toEqual(cells)
expect(request).toHaveBeenNthCalledWith(1, 'pet.info.meta')
expect(request).toHaveBeenNthCalledWith(2, 'pet.cells', {
graphics: false,
state: 'review'
})
})
it('silently drops cosmetic gateway failures', async () => {
const request = vi.fn().mockRejectedValue(new Error('timeout: pet.info.meta'))
await expect(requestPetUpdate(gateway(request), 'idle', false, () => true)).resolves.toBeNull()
})
})
describe('createPetSingleFlight', () => {
it('suppresses overlapping polls and permits the next completed poll', async () => {
let release = () => undefined
const blocked = new Promise<void>(resolve => {
release = resolve
})
const operation = vi.fn(() => blocked)
const run = createPetSingleFlight()
const first = run(operation)
await expect(run(operation)).resolves.toBe(false)
expect(operation).toHaveBeenCalledTimes(1)
release()
await expect(first).resolves.toBe(true)
await expect(run(async () => undefined)).resolves.toBe(true)
})
})
+560
View File
@@ -0,0 +1,560 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalPlatform = process.platform
async function importPlatform(platform: NodeJS.Platform) {
vi.resetModules()
Object.defineProperty(process, 'platform', { value: platform })
return import('../lib/platform.js')
}
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.resetModules()
})
describe('platform action modifier', () => {
it('treats kitty Cmd sequences as the macOS action modifier', async () => {
const { isActionMod } = await importPlatform('darwin')
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(true)
expect(isActionMod({ ctrl: false, meta: true, super: false })).toBe(true)
expect(isActionMod({ ctrl: true, meta: false, super: false })).toBe(false)
})
it('still uses Ctrl as the action modifier on non-macOS', async () => {
const { isActionMod } = await importPlatform('linux')
expect(isActionMod({ ctrl: true, meta: false, super: false })).toBe(true)
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(false)
})
})
describe('isCopyShortcut', () => {
it('keeps Ctrl+C as the local non-macOS copy chord', async () => {
const { isCopyShortcut } = await importPlatform('linux')
expect(isCopyShortcut({ ctrl: true, meta: false, super: false }, 'c', {})).toBe(true)
})
it('accepts client Cmd+C over SSH even when running on Linux', async () => {
const { isCopyShortcut } = await importPlatform('linux')
const env = { SSH_CONNECTION: '1 2 3 4' } as NodeJS.ProcessEnv
expect(isCopyShortcut({ ctrl: false, meta: false, super: true }, 'c', env)).toBe(true)
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', env)).toBe(true)
})
it('does not treat local Linux Alt+C as copy', async () => {
const { isCopyShortcut } = await importPlatform('linux')
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false)
})
it('accepts the VS Code/Cursor forwarded Cmd+C copy sequence on macOS', async () => {
const { isCopyShortcut } = await importPlatform('darwin')
expect(isCopyShortcut({ ctrl: true, meta: false, super: true }, 'c', {})).toBe(true)
})
})
describe('isVoiceToggleKey', () => {
it('matches raw Ctrl+B on macOS (doc-default across platforms)', async () => {
const { isVoiceToggleKey } = await importPlatform('darwin')
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'B')).toBe(true)
})
it('matches kitty-style Cmd+B on macOS via key.super', async () => {
const { isVoiceToggleKey } = await importPlatform('darwin')
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b')).toBe(true)
// ``key.meta`` is NOT accepted as Cmd — hermes-ink uses meta for
// Alt too, so accepting it leaked Alt+B into the default binding
// (Copilot round-6 review on #19835). Legacy-terminal mac users
// get strict Ctrl+B.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b')).toBe(false)
})
it('matches Ctrl+B on non-macOS platforms', async () => {
const { isVoiceToggleKey } = await importPlatform('linux')
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
})
it('does not match unmodified b or other Ctrl combos', async () => {
const { isVoiceToggleKey } = await importPlatform('darwin')
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'b')).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'a')).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'c')).toBe(false)
})
})
describe('parseVoiceRecordKey (#18994)', () => {
it('falls back to Ctrl+B for empty input', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
expect(parseVoiceRecordKey('')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
it('parses ctrl+<letter> bindings', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
expect(parseVoiceRecordKey('ctrl+o')).toEqual({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' })
expect(parseVoiceRecordKey('Ctrl+R')).toEqual({ ch: 'r', mod: 'ctrl', raw: 'ctrl+r' })
})
it('parses alt/super aliases', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
expect(parseVoiceRecordKey('alt+b').mod).toBe('alt')
expect(parseVoiceRecordKey('option+b').mod).toBe('alt')
expect(parseVoiceRecordKey('super+b').mod).toBe('super')
expect(parseVoiceRecordKey('win+b').mod).toBe('super')
})
it('treats ambiguous mac modifiers (meta / cmd / command) as unrecognised', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// ``meta`` / ``cmd`` / ``command`` are ambiguous on the wire:
// hermes-ink sets ``key.meta`` for plain Alt on every platform AND
// for Cmd on legacy macOS terminals. Accepting any of them would
// produce a display/binding mismatch (Copilot round-6 review on
// #19835). Users on modern kitty-style terminals spell the
// platform action modifier ``super`` / ``win``.
expect(parseVoiceRecordKey('meta+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('cmd+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('command+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
it('parses named keys (space, enter, tab, escape, backspace, delete)', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
// Every named token from the CLI's prompt_toolkit ``c-<name>`` set is
// accepted with both the canonical name and its common alias.
expect(parseVoiceRecordKey('ctrl+space')).toEqual({
ch: 'space',
mod: 'ctrl',
named: 'space',
raw: 'ctrl+space'
})
expect(parseVoiceRecordKey('alt+enter').named).toBe('enter')
expect(parseVoiceRecordKey('alt+return').named).toBe('enter') // ``return`` ↔ ``enter``
expect(parseVoiceRecordKey('ctrl+tab').named).toBe('tab')
expect(parseVoiceRecordKey('ctrl+escape').named).toBe('escape')
expect(parseVoiceRecordKey('ctrl+esc').named).toBe('escape') // ``esc`` alias
expect(parseVoiceRecordKey('ctrl+backspace').named).toBe('backspace')
expect(parseVoiceRecordKey('ctrl+delete').named).toBe('delete')
expect(parseVoiceRecordKey('ctrl+del').named).toBe('delete') // ``del`` alias
})
it('falls back to Ctrl+B for unrecognised multi-character tokens', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// Typos / unsupported names (``ctrl+spcae``, ``ctrl+f5``, …) fall back
// to the documented Ctrl+B default rather than silently disabling the
// binding.
expect(parseVoiceRecordKey('ctrl+spcae')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('ctrl+f5')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
// Round-3 Copilot review regressions on #19835.
it('does not throw on non-string YAML scalars — falls back instead', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// ``config.get full`` surfaces raw YAML values; ``voice.record_key: 1``
// or ``voice.record_key: true`` would otherwise crash ``.trim()``.
expect(parseVoiceRecordKey(1 as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey(true as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey(null as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey(undefined as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey({} as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
it('rejects multi-modifier chords rather than silently dropping extras', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// Previously ``ctrl+alt+r`` parsed as ``ctrl+r`` and ``cmd+ctrl+b`` as
// ``super+b`` — a typo silently bound a different shortcut. Now a
// multi-modifier spelling falls back to the documented default.
expect(parseVoiceRecordKey('ctrl+alt+r')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('cmd+ctrl+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('alt+ctrl+space')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
// Round-4 Copilot review regressions on #19835.
it('rejects bare-char configs without an explicit modifier', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// The classic CLI's prompt_toolkit binds raw-char configs to the key
// itself (``c-o`` requires an explicit modifier); rewriting ``o``
// → ``ctrl+o`` would silently diverge the two runtimes. Refuse.
expect(parseVoiceRecordKey('o')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('space')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('escape')).toEqual(DEFAULT_VOICE_RECORD_KEY)
})
it('rejects ctrl+c / ctrl+d / ctrl+l — reserved by the TUI input handler', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
// ``useInputHandlers()`` intercepts these before the voice check,
// so a binding like ``ctrl+c`` would be advertised but never fire.
// Fall back to the documented default instead of lying to the user.
expect(parseVoiceRecordKey('ctrl+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('ctrl+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('ctrl+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
// Alt-modifier versions of those letters are NOT intercepted, so
// they remain usable.
expect(parseVoiceRecordKey('alt+c').mod).toBe('alt')
// ``ctrl+x`` is intentionally allowed — only intercepted during
// queue-edit (``queueEditIdx !== null``), so the voice binding
// works for most of the session (Copilot round-8 review).
expect(parseVoiceRecordKey('ctrl+x').mod).toBe('ctrl')
expect(parseVoiceRecordKey('ctrl+x').ch).toBe('x')
})
it('rejects super+{c,d,l,v} on macOS — action-mod chords are claimed before voice', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('darwin')
// On macOS super+c/d/l/v are copy / exit / clear / paste. Reject at
// parse time so /voice status doesn't advertise dead bindings.
expect(parseVoiceRecordKey('super+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('super+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('super+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('super+v')).toEqual(DEFAULT_VOICE_RECORD_KEY)
// Other super letters still work (no global chord claims them).
expect(parseVoiceRecordKey('super+b').mod).toBe('super')
expect(parseVoiceRecordKey('super+o').mod).toBe('super')
})
it('allows super+{c,d,l,v} on Linux/Windows — those globals key off Ctrl, not Super', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
// Kitty/CSI-u users on non-mac report Cmd/Super as ``key.super``,
// but the TUI's global shortcuts (copy/exit/clear/paste) key off
// Ctrl there, so ``super+<letter>`` doesn't collide. Reject would
// silently coerce valid configs to Ctrl+B (Copilot round-8 review).
expect(parseVoiceRecordKey('super+c').mod).toBe('super')
expect(parseVoiceRecordKey('super+d').mod).toBe('super')
expect(parseVoiceRecordKey('super+l').mod).toBe('super')
expect(parseVoiceRecordKey('super+v').mod).toBe('super')
})
it('rejects alt+{c,d,l} on macOS — meta-as-alt collides with isAction', async () => {
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('darwin')
// hermes-ink reports Alt as ``key.meta`` on many terminals, and
// ``isActionMod`` on darwin accepts ``key.meta`` as the action
// modifier. So ``alt+c`` / ``alt+d`` / ``alt+l`` get claimed by
// isCopyShortcut / isAction('d') / isAction('l') before voice
// runs (Copilot round-12 on #19835).
expect(parseVoiceRecordKey('alt+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('alt+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
expect(parseVoiceRecordKey('alt+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
// Other alt letters stay usable on darwin.
expect(parseVoiceRecordKey('alt+r').mod).toBe('alt')
expect(parseVoiceRecordKey('alt+space').mod).toBe('alt')
})
it('allows alt+{c,d,l} on Linux/Windows — non-mac isAction keys off Ctrl', async () => {
const { parseVoiceRecordKey } = await importPlatform('linux')
// On Linux/Windows ``isActionMod`` ignores key.meta, so alt+<letter>
// doesn't collide with copy/exit/clear. Those configs stay usable.
expect(parseVoiceRecordKey('alt+c').mod).toBe('alt')
expect(parseVoiceRecordKey('alt+d').mod).toBe('alt')
expect(parseVoiceRecordKey('alt+l').mod).toBe('alt')
})
// Round-5 Copilot review regressions on #19835.
it('super+<key> does NOT fire on key.meta-only events (Alt+X false-fire guard)', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
// hermes-ink sets ``key.meta`` for Alt/Option AND for bare Esc on
// some macOS terminals. The super branch used to accept
// ``isMac && key.meta`` as a Cmd fallback, which made super+<key>
// bindings silently fire on Alt+<key> / bare Esc.
const superB = parseVoiceRecordKey('super+b')
const superSpace = parseVoiceRecordKey('super+space')
const superEscape = parseVoiceRecordKey('super+escape')
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', superB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, ' ', superSpace)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', superEscape)).toBe(false)
})
// Round-6 Copilot review regressions on #19835.
it('default ctrl+b does NOT fire on Alt+B via isActionMod meta leak', async () => {
const { DEFAULT_VOICE_RECORD_KEY, isVoiceToggleKey } = await importPlatform('darwin')
// ``isActionMod(key)`` on darwin was accepting ``key.meta`` as the
// action modifier, so Alt+B (key.meta=true) fired the default
// ctrl+b binding. Now the Cmd-fallback path requires literal
// ``key.super`` on macOS and rejects ``key.meta``.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(false)
// Literal Ctrl+B and Cmd+B (kitty-style) still work on darwin.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
})
it('ctrl+<key> rejects chords with extra alt / meta / super bits', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const ctrlO = parseVoiceRecordKey('ctrl+o')
// ``ctrl+o`` must fire ONLY on literal Ctrl+O, not on
// Ctrl+Alt+O / Ctrl+Cmd+O / Ctrl+Meta+O — otherwise the runtime
// matches a different chord than the parser would let you
// configure.
expect(isVoiceToggleKey({ alt: true, ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: true, super: false }, 'o', ctrlO)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'o', ctrlO)).toBe(false)
// Sanity: plain Ctrl+O still fires.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
})
it('super+<key> rejects chords with extra ctrl / alt / meta bits', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const superB = parseVoiceRecordKey('super+b')
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: true }, 'b', superB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: true }, 'b', superB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'b', superB)).toBe(false)
// Sanity: plain Super+B still fires.
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', superB)).toBe(true)
})
it('alt+escape does not fire on bare Esc meta-shape', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const altEscape = parseVoiceRecordKey('alt+escape')
// Some terminals surface bare Esc as meta=true + escape=true.
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', altEscape)).toBe(false)
// Explicit alt bit (kitty-style) still fires the configured chord.
expect(isVoiceToggleKey({ alt: true, ctrl: false, escape: true, meta: false, super: false }, '', altEscape)).toBe(
true
)
})
it('rejects matches when Shift is held (different chord than configured)', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
// Parser rejects multi-modifier configs like ``ctrl+shift+tab``,
// so the runtime matcher must also reject Shift-held events —
// otherwise ``ctrl+tab`` would fire on Ctrl+Shift+Tab.
const ctrlTab = parseVoiceRecordKey('ctrl+tab')
const altEnter = parseVoiceRecordKey('alt+enter')
const ctrlO = parseVoiceRecordKey('ctrl+o')
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false, tab: true }, '', ctrlTab)).toBe(false)
expect(
isVoiceToggleKey({ alt: true, ctrl: false, meta: false, return: true, shift: true, super: false }, '', altEnter)
).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false }, 'o', ctrlO)).toBe(false)
// Sanity: same events without Shift still fire.
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: false, super: false, tab: true }, '', ctrlTab)).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: false, super: false }, 'o', ctrlO)).toBe(true)
})
})
describe('formatVoiceRecordKey (#18994)', () => {
it('renders as the user expects in /voice status', async () => {
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+b'))).toBe('Ctrl+B')
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+o'))).toBe('Ctrl+O')
expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+r'))).toBe('Alt+R')
// ``super``/``win`` render as ``Super`` on non-mac so the hint
// doesn't tell Linux/Windows users to press a Cmd key they don't
// have.
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Super+B')
})
it('renders named keys in title case (Ctrl+Space, Ctrl+Enter)', async () => {
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+space'))).toBe('Ctrl+Space')
expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+enter'))).toBe('Alt+Enter')
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+esc'))).toBe('Ctrl+Escape')
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+space'))).toBe('Super+Space')
})
})
describe('isVoiceToggleKey honours configured record key (#18994)', () => {
it('binds the configured letter, not hardcoded b', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const ctrlO = parseVoiceRecordKey('ctrl+o')
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
// The old hardcoded 'b' must NOT match when the user configured 'o'.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlO)).toBe(false)
})
it('alt+<letter> binding matches alt OR meta (terminal-protocol parity)', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const altR = parseVoiceRecordKey('alt+r')
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false }, 'r', altR)).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'r', altR)).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'r', altR)).toBe(false)
})
it('binds named keys via ink event flags (space → ch === " ", enter → key.return, …)', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
const ctrlSpace = parseVoiceRecordKey('ctrl+space')
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true)
// Single-char ``b`` must NOT match a ``space``-configured binding.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlSpace)).toBe(false)
// Space without the configured modifier must not fire either.
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, ' ', ctrlSpace)).toBe(false)
const ctrlEnter = parseVoiceRecordKey('ctrl+enter')
expect(isVoiceToggleKey({ ctrl: true, meta: false, return: true, super: false }, '', ctrlEnter)).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, meta: false, return: false, super: false }, '', ctrlEnter)).toBe(false)
const altTab = parseVoiceRecordKey('alt+tab')
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(true)
expect(isVoiceToggleKey({ alt: false, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(false)
const ctrlEscape = parseVoiceRecordKey('ctrl+escape')
expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, escape: false, meta: false, super: false }, '', ctrlEscape)).toBe(false)
const ctrlBackspace = parseVoiceRecordKey('ctrl+backspace')
expect(isVoiceToggleKey({ backspace: true, ctrl: true, meta: false, super: false }, '', ctrlBackspace)).toBe(true)
const ctrlDelete = parseVoiceRecordKey('ctrl+delete')
expect(isVoiceToggleKey({ ctrl: true, delete: true, meta: false, super: false }, '', ctrlDelete)).toBe(true)
})
it('omitted configured key falls back to ctrl+b (back-compat)', async () => {
const { isVoiceToggleKey } = await importPlatform('linux')
// No third arg → DEFAULT_VOICE_RECORD_KEY → Ctrl+B behaviour.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o')).toBe(false)
})
// Regressions from Copilot review on #19835: the previous implementation
// accepted ``isActionMod(key)`` in the ``ctrl`` branch for every
// configured key, so bare Esc (which hermes-ink reports with
// ``key.meta`` on some macOS terminals) fired ``ctrl+escape``, and
// Alt+Space / Alt+Tab fired ``ctrl+space`` / ``ctrl+tab``. The fallback
// is now gated to the documented default (``ctrl+b``) only.
it('ctrl+escape does NOT fire on bare Esc via key.meta on macOS', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const ctrlEscape = parseVoiceRecordKey('ctrl+escape')
// Bare Esc on a legacy macOS terminal: ``key.meta: true``, ``key.escape: true``, no ctrl.
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', ctrlEscape)).toBe(false)
// Real Ctrl+Esc still fires.
expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true)
})
it('ctrl+space does NOT fire on Alt+Space on macOS', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const ctrlSpace = parseVoiceRecordKey('ctrl+space')
// Alt+Space surfaces as ``key.meta: true`` with space char.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, ' ', ctrlSpace)).toBe(false)
// Real Ctrl+Space still fires.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true)
})
it('default ctrl+b accepts raw Ctrl+B and kitty-style Cmd+B on macOS', async () => {
const { DEFAULT_VOICE_RECORD_KEY, isVoiceToggleKey } = await importPlatform('darwin')
// Raw Ctrl+B: always works.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
// Cmd+B via kitty-style ``key.super``: still works.
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
// Cmd+B via legacy ``key.meta`` NO LONGER works — ``key.meta`` is
// hermes-ink's Alt signal, so accepting it leaked Alt+B into the
// default binding (Copilot round-6 review on #19835).
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(false)
})
it('custom ctrl+<letter> does NOT accept Cmd fallback on macOS', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const ctrlO = parseVoiceRecordKey('ctrl+o')
// Only ``ctrl+b`` gets the action-modifier fallback; ``ctrl+o`` must
// be a literal Ctrl bit — otherwise Cmd+O would steal the shortcut.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'o', ctrlO)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'o', ctrlO)).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
})
it('super+b renders "Cmd+B" on darwin and requires the literal key.super bit', async () => {
const { formatVoiceRecordKey, isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const superB = parseVoiceRecordKey('super+b')
expect(formatVoiceRecordKey(superB)).toBe('Cmd+B')
// Kitty-style: key.super fires the binding.
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', superB)).toBe(true)
// ``key.meta`` is NOT accepted — hermes-ink uses meta for Alt too,
// so accepting it here would make super+b silently fire on Alt+B
// (Copilot round-5 review on #19835).
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', superB)).toBe(false)
// Ctrl held at the same time → reject (different chord).
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'b', superB)).toBe(false)
})
// Round-2 Copilot review regressions on #19835.
it('super+b renders "Super+B" on Linux (not "Cmd+B")', async () => {
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Super+B')
expect(formatVoiceRecordKey(parseVoiceRecordKey('win+b'))).toBe('Super+B')
})
it('super+b still renders "Cmd+B" on macOS', async () => {
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('darwin')
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Cmd+B')
expect(formatVoiceRecordKey(parseVoiceRecordKey('win+b'))).toBe('Cmd+B')
})
it('ctrl+b aliases (control+b, "ctrl + b") still accept Cmd+B fallback on macOS', async () => {
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
const controlB = parseVoiceRecordKey('control+b')
const spacedB = parseVoiceRecordKey('ctrl + b')
// Both parse to the documented default semantically; both must keep
// the macOS Cmd+B muscle-memory fallback via kitty-style key.super.
// ``key.meta`` is NOT accepted — that's hermes-ink's Alt signal
// (round-6 review), so legacy-terminal users get strict Ctrl+B.
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', controlB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', spacedB)).toBe(false)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', controlB)).toBe(true)
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', spacedB)).toBe(true)
// Literal Ctrl+B still fires.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', controlB)).toBe(true)
// And still reject a ctrl bit on a different letter.
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', controlB)).toBe(false)
})
})
describe('isMacActionFallback', () => {
it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => {
const { isMacActionFallback } = await importPlatform('darwin')
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'k', 'k')).toBe(true)
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'w', 'w')).toBe(true)
// Must not fire when Cmd (meta/super) is held — those are distinct chords.
expect(isMacActionFallback({ ctrl: true, meta: true, super: false }, 'k', 'k')).toBe(false)
expect(isMacActionFallback({ ctrl: true, meta: false, super: true }, 'w', 'w')).toBe(false)
})
it('is a no-op on non-macOS (Linux routes Ctrl+K/W through isActionMod directly)', async () => {
const { isMacActionFallback } = await importPlatform('linux')
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'k', 'k')).toBe(false)
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'w', 'w')).toBe(false)
})
})
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
describe('precisionWheel', () => {
it('passes the first modifier-held wheel event', () => {
const s = initPrecisionWheel()
expect(computePrecisionWheelStep(s, 1, true, 1000)).toEqual({ active: true, entered: true, rows: 1 })
})
it('coalesces same-frame events without throttling line-by-line scroll', () => {
const s = initPrecisionWheel()
computePrecisionWheelStep(s, 1, true, 1000)
expect(computePrecisionWheelStep(s, 1, true, 1008).rows).toBe(0)
expect(computePrecisionWheelStep(s, 1, true, 1016).rows).toBe(1)
})
it('keeps queued momentum in precision mode briefly after modifier release', () => {
const s = initPrecisionWheel()
computePrecisionWheelStep(s, 1, true, 1000)
expect(computePrecisionWheelStep(s, 1, false, 1050)).toMatchObject({ active: true, rows: 1 })
})
it('leaves precision mode once modifier-free momentum goes idle', () => {
const s = initPrecisionWheel()
computePrecisionWheelStep(s, 1, true, 1000)
expect(computePrecisionWheelStep(s, 1, false, 1100)).toEqual({ active: false, entered: false, rows: 0 })
})
it('does not coalesce immediate reversals', () => {
const s = initPrecisionWheel()
computePrecisionWheelStep(s, 1, true, 1000)
expect(computePrecisionWheelStep(s, -1, true, 1008).rows).toBe(1)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { composerPromptText } from '../lib/prompt.js'
describe('composerPromptText', () => {
it('returns shell prompt for ! commands', () => {
expect(composerPromptText('', 'coder', true)).toBe('$')
})
it('prefixes named profiles onto the normal prompt', () => {
expect(composerPromptText('', 'coder')).toBe('coder ')
})
it('does not prefix default or custom profiles', () => {
expect(composerPromptText('', 'default')).toBe('')
expect(composerPromptText('', 'custom')).toBe('')
expect(composerPromptText('')).toBe('')
})
it('uses a Termux-safe ASCII prompt marker in normal mode', () => {
expect(composerPromptText('', 'coder', false, true, 50)).toBe('>')
})
it('keeps profile prefix suppressed on narrow Termux widths', () => {
expect(composerPromptText('', 'upstr', false, true, 72)).toBe('>')
})
it('allows profile prefix on very wide Termux panes', () => {
expect(composerPromptText('', 'upstr', false, true, 120)).toBe('upstr >')
})
})
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { providerDisplayNames } from '../domain/providers.js'
describe('providerDisplayNames', () => {
it('returns bare names when all are unique', () => {
expect(
providerDisplayNames([
{ name: 'Anthropic', slug: 'anthropic' },
{ name: 'OpenAI', slug: 'openai' }
])
).toEqual(['Anthropic', 'OpenAI'])
})
it('appends slug to every collision so the disambiguation is symmetric', () => {
expect(
providerDisplayNames([
{ name: 'Kimi For Coding', slug: 'kimi-coding' },
{ name: 'Kimi For Coding', slug: 'kimi-coding-cn' }
])
).toEqual(['Kimi For Coding (kimi-coding)', 'Kimi For Coding (kimi-coding-cn)'])
})
it('only disambiguates the colliding group', () => {
expect(
providerDisplayNames([
{ name: 'Anthropic', slug: 'anthropic' },
{ name: 'Foo', slug: 'foo-a' },
{ name: 'Foo', slug: 'foo-b' }
])
).toEqual(['Anthropic', 'Foo (foo-a)', 'Foo (foo-b)'])
})
it('falls back to plain name if slug is empty', () => {
expect(
providerDisplayNames([
{ name: 'Foo', slug: '' },
{ name: 'Foo', slug: '' }
])
).toEqual(['Foo', 'Foo'])
})
it('skips disambiguation when slug equals name', () => {
expect(
providerDisplayNames([
{ name: 'foo', slug: 'foo' },
{ name: 'foo', slug: 'foo' }
])
).toEqual(['foo', 'foo'])
})
it('handles empty input', () => {
expect(providerDisplayNames([])).toEqual([])
})
it('preserves order', () => {
const input = [
{ name: 'Z', slug: 'z' },
{ name: 'A', slug: 'a1' },
{ name: 'A', slug: 'a2' }
]
expect(providerDisplayNames(input)).toEqual(['Z', 'A (a1)', 'A (a2)'])
})
})
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import type { ComposerToken } from '../app/interfaces.js'
import { expandPasteTokens, prepareSlashSubmission, queueItemFromSlash } from '../app/useSubmission.js'
import { imageToken } from '../domain/attachments.js'
describe('/queue collapsed paste submission', () => {
it('keeps the collapsed argument for display and the full multiline payload for execution', () => {
const display = '[[ first.. [3 lines] .. last ]]'
expect(queueItemFromSlash(`/queue ${display}`, '/queue first\nmiddle\nlast')).toEqual({
display,
text: 'first\nmiddle\nlast'
})
})
it('supports the /q alias and rejects an empty queue command', () => {
expect(queueItemFromSlash('/q [[ payload ]]', '/q complete payload')).toEqual({
display: '[[ payload ]]',
text: 'complete payload'
})
expect(queueItemFromSlash('/queue', '/queue')).toBeUndefined()
})
it('expands paste tokens without consuming image tokens', () => {
const paste: ComposerToken = { kind: 'paste', label: '[[ paste [2 lines] ]]', text: 'one\ntwo' }
const image: ComposerToken = { kind: 'image', index: 1, label: imageToken(1), path: '/tmp/image.png' }
expect(expandPasteTokens([paste, image])(`${paste.label} and ${image.label}`)).toBe(`one\ntwo and ${image.label}`)
})
})
describe('prepareSlashSubmission', () => {
const label = '[[ Done — verified.. [412 lines] .. already on it. ]]'
const text = 'Done — verified through the real resolver\nline two\nline three'
const tokens: ComposerToken[] = [{ kind: 'paste', label, text }]
// The reported bug: `/pr-triage <paste>` dispatched the LABEL, so the skill
// received "[412 lines]" as its argument and the agent reported the paste as
// truncated. The command has to carry the full text; only the transcript
// stays collapsed.
it('dispatches the full paste while the transcript keeps the collapsed label', () => {
expect(prepareSlashSubmission(`/pr-triage ${label}`, tokens)).toEqual({
command: `/pr-triage ${text}`,
display: `/pr-triage ${label}`
})
})
it('leaves image tokens as labels — the gateway already holds the file', () => {
const image: ComposerToken = { kind: 'image', index: 1, label: imageToken(1), path: '/tmp/shot.png' }
expect(prepareSlashSubmission(`/pr-triage ${image.label}`, [image]).command).toBe(`/pr-triage ${image.label}`)
})
it('is a no-op on a token-free command', () => {
expect(prepareSlashSubmission('/model opus', [])).toEqual({ command: '/model opus', display: '/model opus' })
})
})
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import { cleanThinkingText } from '../lib/text.js'
describe('splitReasoning', () => {
it('extracts <think>…</think> and strips it from text', () => {
const { reasoning, text } = splitReasoning('<think>plotting</think>\n\nhere is the answer')
expect(reasoning).toBe('plotting')
expect(text).toBe('here is the answer')
})
it('handles multiple tag shapes', () => {
const input = '<reasoning>a</reasoning> <THINKING>b</THINKING> <thought>c</thought> body'
const { reasoning, text } = splitReasoning(input)
expect(reasoning).toContain('a')
expect(reasoning).toContain('b')
expect(reasoning).toContain('c')
expect(text).toBe('body')
})
it('treats unclosed leading <think>… as reasoning (real reasoning-model stream)', () => {
const { reasoning, text } = splitReasoning('<think>still deciding')
expect(reasoning).toBe('still deciding')
expect(text).toBe('')
})
it('does not strip trailing prose after a stray mid-text <think> mention', () => {
// Regression for "TUI eats last paragraph of output": when the model
// emits a literal `<think>` somewhere in prose (quoted explanation, code
// example, partial stream-mid-tag), the trailing greedy unclosed-tag
// regex used to consume every paragraph after it. Real unclosed
// reasoning blocks always lead the message — anchor to ^ so prose
// mentions are preserved.
const { reasoning, text } = splitReasoning(
'final answer paragraph one.\n\n<think>internal note never closed\n\nfinal answer paragraph two.'
)
expect(reasoning).toBe('')
expect(text).toBe('final answer paragraph one.\n\n<think>internal note never closed\n\nfinal answer paragraph two.')
})
it('returns empty reasoning and untouched text when no tags present', () => {
const { reasoning, text } = splitReasoning('plain body with no tags')
expect(reasoning).toBe('')
expect(text).toBe('plain body with no tags')
})
it('preserves text when reasoning block is empty', () => {
const { reasoning, text } = splitReasoning('<think></think>only body')
expect(reasoning).toBe('')
expect(text).toBe('only body')
})
it('detects presence of any supported tag', () => {
expect(hasReasoningTag('pre <think>x</think> post')).toBe(true)
expect(hasReasoningTag('pre <reasoning>x</reasoning>')).toBe(true)
expect(hasReasoningTag('<REASONING_SCRATCHPAD>x</REASONING_SCRATCHPAD>')).toBe(true)
expect(hasReasoningTag('no tags at all')).toBe(false)
})
})
describe('cleanThinkingText', () => {
it('removes face/status ticker fragments while preserving real reasoning', () => {
expect(
cleanThinkingText(
'(¬_¬) synthesizing...**Resolving comments on GitHub**\n( ͡° ͜ʖ ͡°) musing...\nActual step\n٩(๑❛ᴗ❛๑)۶ contemplating...next step'
)
).toBe('**Resolving comments on GitHub**\nActual step\nnext step')
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
describe('asRpcResult', () => {
it('keeps plain object payloads', () => {
expect(asRpcResult({ ok: true, value: 'x' })).toEqual({ ok: true, value: 'x' })
})
it('rejects missing or non-object payloads', () => {
expect(asRpcResult(undefined)).toBeNull()
expect(asRpcResult(null)).toBeNull()
expect(asRpcResult('oops')).toBeNull()
expect(asRpcResult(['bad'])).toBeNull()
})
})
describe('rpcErrorMessage', () => {
it('prefers Error messages', () => {
expect(rpcErrorMessage(new Error('boom'))).toBe('boom')
})
it('falls back for unknown errors', () => {
expect(rpcErrorMessage('broken')).toBe('broken')
expect(rpcErrorMessage({ code: 500 })).toBe('request failed')
})
})
+126
View File
@@ -0,0 +1,126 @@
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(),
scrollTo: vi.fn(),
...overrides
}
}
describe('scrollWithSelectionBy', () => {
it('commits the clamped target directly instead of queueing a scroll delta', () => {
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.scrollTo).toHaveBeenCalledWith(10)
expect(s.scrollBy).not.toHaveBeenCalled()
})
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.scrollTo).toHaveBeenCalledWith(14)
expect(s.scrollBy).not.toHaveBeenCalled()
})
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.scrollTo).toHaveBeenCalledWith(18)
expect(s.scrollBy).not.toHaveBeenCalled()
})
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.scrollTo).not.toHaveBeenCalled()
expect(s.scrollBy).not.toHaveBeenCalled()
})
it('preserves selection capture and shifting on the direct path', () => {
const s = makeScroll({
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20),
getViewportTop: vi.fn(() => 5)
})
const selection = {
captureScrolledRows: vi.fn(),
getState: vi.fn(() => ({ anchor: { row: 10 }, focus: { row: 12 } })),
shiftAnchor: vi.fn(),
shiftSelection: vi.fn()
}
scrollWithSelectionBy(3, { scrollRef: { current: s as never }, selection })
expect(selection.captureScrolledRows).toHaveBeenCalledWith(5, 7, 'above')
expect(selection.shiftSelection).toHaveBeenCalledWith(-3, 5, 24)
expect(s.scrollTo).toHaveBeenCalledWith(13)
expect(s.scrollBy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,626 @@
import { PassThrough } from 'stream'
import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import React, { useLayoutEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import SourceBox from '../../packages/hermes-ink/src/ink/components/Box.js'
import SourceScrollBox from '../../packages/hermes-ink/src/ink/components/ScrollBox.js'
import SourceText from '../../packages/hermes-ink/src/ink/components/Text.js'
import type { DOMElement } from '../../packages/hermes-ink/src/ink/dom.js'
import Output from '../../packages/hermes-ink/src/ink/output.js'
import { scrollFastPathStats as sourceScrollFastPathStats } from '../../packages/hermes-ink/src/ink/render-node-to-output.js'
import { renderSync as renderSourceSync } from '../../packages/hermes-ink/src/ink/root.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
interface Item {
height: number
heightAfterResize?: number
key: string
text?: 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 itemHeightForColumns = (item: Item | undefined, columns: number) =>
columns >= 80 ? (item?.heightAfterResize ?? item?.height ?? 1) : (item?.height ?? 1)
function Harness({
columns = 80,
expose,
height = 10,
generation = 0,
initialHeights,
items,
maxMounted = 16
}: {
columns?: number
expose: React.MutableRefObject<Exposed | null>
height?: number
generation?: number
initialHeights?: ReadonlyMap<string, 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),
generation,
initialHeights,
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.text ?? item.key)
)
),
virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null
)
)
}
function CorruptGeometryHarness({ expose, tick }: { expose: React.MutableRefObject<DOMElement[]>; tick: number }) {
const nodes = useRef<DOMElement[]>([])
useLayoutEffect(() => {
expose.current = nodes.current
})
return React.createElement(
ScrollBox,
{ flexDirection: 'column', height: 8 },
...['nan-top', 'positive-infinity', 'negative-infinity', 'billion-rows', 'clipped-huge-fill'].map((label, index) =>
React.createElement(
Box,
{
backgroundColor: 'blue',
borderStyle: label === 'clipped-huge-fill' ? 'single' : undefined,
height: 1,
key: label,
opaque: true,
ref: node => {
if (node) {
nodes.current[index] = node
}
},
width: '100%'
},
React.createElement(Text, null, `${label}-${tick}`)
)
),
React.createElement(
Box,
{
height: 1,
ref: node => {
if (node) {
nodes.current[5] = node
}
}
},
React.createElement(Text, null, React.createElement(Text, null, `nested-corrupt-${tick}`))
),
React.createElement(Text, null, `adjacent-valid-${tick}`),
React.createElement(Box, { height: 20 }, React.createElement(Text, null, `tail-${tick}`))
)
}
interface FastPathRepairExpose {
adjacent: DOMElement | null
dirtyChild: DOMElement | null
overlay: DOMElement | null
scroll: ScrollBoxHandle | null
scrollBox: DOMElement | null
}
function FastPathRepairHarness({
expose,
tick,
dirtyTick = tick,
includeOverlay = true
}: {
dirtyTick?: number
expose: React.MutableRefObject<FastPathRepairExpose | null>
includeOverlay?: boolean
tick: number
}) {
return React.createElement(
SourceBox,
{ flexDirection: 'column', height: 12, width: 40 },
React.createElement(
SourceScrollBox,
{
flexDirection: 'column',
height: 8,
ref: scroll => {
if (expose.current) {
expose.current.scroll = scroll
}
},
width: 40
},
React.createElement(SourceBox, { height: 2 }, React.createElement(SourceText, null, 'head-row')),
React.createElement(
SourceBox,
{
height: 2,
ref: dirtyChild => {
if (expose.current) {
expose.current.dirtyChild = dirtyChild
expose.current.scrollBox = dirtyChild?.parentNode?.parentNode ?? null
}
}
},
React.createElement(SourceText, null, `dirty-row-${dirtyTick}`)
),
React.createElement(SourceBox, { height: 20 }, React.createElement(SourceText, null, 'tail-row'))
),
includeOverlay
? React.createElement(
SourceBox,
{
height: 2,
left: 0,
position: 'absolute',
ref: overlay => {
if (expose.current) {
expose.current.overlay = overlay
}
},
top: 2,
width: 40
},
React.createElement(SourceText, null, 'overlay-row')
)
: null,
React.createElement(
SourceBox,
{
height: 1,
ref: adjacent => {
if (expose.current) {
expose.current.adjacent = adjacent
}
}
},
React.createElement(SourceText, null, `adjacent-fast-path-${tick}`)
)
)
}
function guardFastPathRepairAllocations(maxWidth: number, maxHeight: number) {
const originalArrayFill = Array.prototype.fill
const originalBlit = Output.prototype.blit
const originalClear = Output.prototype.clear
const originalRepeat = String.prototype.repeat
const originalWrite = Output.prototype.write
const observed = {
largestArrayRows: 0,
largestBlitHeight: 0,
largestBlitWidth: 0,
largestClearHeight: 0,
largestClearWidth: 0,
largestRepeat: 0,
largestWrite: 0,
repairWhitespaceWrites: 0
}
vi.spyOn(String.prototype, 'repeat').mockImplementation(function (count: number) {
observed.largestRepeat = Math.max(observed.largestRepeat, count)
if (!Number.isSafeInteger(count) || count < 0 || count > maxWidth) {
throw new Error(`unbounded fast-path repeat: ${count}`)
}
return originalRepeat.call(this, count)
})
vi.spyOn(Array.prototype, 'fill').mockImplementation(function (
this: unknown[],
value: unknown,
start?: number,
end?: number
) {
observed.largestArrayRows = Math.max(observed.largestArrayRows, this.length)
if (this.length > maxHeight) {
throw new Error(`unbounded fast-path row array: ${this.length}`)
}
return Reflect.apply(originalArrayFill, this, [value, start, end])
} as typeof Array.prototype.fill)
vi.spyOn(Output.prototype, 'blit').mockImplementation(function (...args: Parameters<Output['blit']>) {
observed.largestBlitWidth = Math.max(observed.largestBlitWidth, args[3])
observed.largestBlitHeight = Math.max(observed.largestBlitHeight, args[4])
return originalBlit.apply(this, args)
})
vi.spyOn(Output.prototype, 'clear').mockImplementation(function (...args: Parameters<Output['clear']>) {
observed.largestClearWidth = Math.max(observed.largestClearWidth, args[0].width)
observed.largestClearHeight = Math.max(observed.largestClearHeight, args[0].height)
return originalClear.apply(this, args)
})
vi.spyOn(Output.prototype, 'write').mockImplementation(function (...args: Parameters<Output['write']>) {
observed.largestWrite = Math.max(observed.largestWrite, args[2].length)
if (args[2].length > 0 && /^[ \n]+$/.test(args[2])) {
observed.repairWhitespaceWrites++
}
if (args[2].length > maxWidth * maxHeight + maxHeight) {
throw new Error(`unbounded fast-path Output.write input: ${args[2].length}`)
}
return originalWrite.apply(this, args)
})
return observed
}
describe('ScrollBox renderer bounds', () => {
it('rejects invalid imperative geometry without poisoning scroll state', async () => {
const items = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(React.createElement(Harness, { expose, items }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
scroll.scrollTo(4)
scroll.scrollTo(Number.NaN)
scroll.scrollBy(Number.POSITIVE_INFINITY)
scroll.adjustScrollTop(Number.NEGATIVE_INFINITY)
scroll.setClampBounds(Number.NaN, Number.POSITIVE_INFINITY)
await delay(20)
expect(scroll.getScrollTop()).toBe(4)
expect(scroll.getPendingDelta()).toBe(0)
expect(Number.isFinite(scroll.getScrollHeight())).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('fails closed on corrupt ScrollBox child geometry and keeps adjacent rows renderable', async () => {
const expose = { current: [] as DOMElement[] }
const streams = makeStreams()
const originalRepeat = String.prototype.repeat
const originalWrite = Output.prototype.write
let largestWriteInput = 0
let largestWrite = 0
let output = ''
vi.spyOn(String.prototype, 'repeat').mockImplementation(function (count: number) {
if (!Number.isSafeInteger(count) || count < 0 || count > 10_000) {
throw new Error(`unbounded string repeat: ${count}`)
}
return originalRepeat.call(this, count)
})
vi.spyOn(Output.prototype, 'write').mockImplementation(function (...args: Parameters<Output['write']>) {
largestWriteInput = Math.max(largestWriteInput, args[2].length)
if (args[2].length > 10_000) {
throw new Error(`unbounded Output.write input: ${args[2].length}`)
}
return originalWrite.apply(this, args)
})
streams.stdout.removeAllListeners('data')
streams.stdout.on('data', chunk => {
largestWrite = Math.max(largestWrite, chunk.length)
output += chunk.toString()
})
const instance = renderSync(React.createElement(CorruptGeometryHarness, { expose, tick: 0 }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const [nanTop, positiveInfinity, negativeInfinity, billionRows, clippedHugeFill, nestedTextWrapper] =
expose.current
expect(nanTop?.yogaNode).toBeDefined()
expect(positiveInfinity?.yogaNode).toBeDefined()
expect(negativeInfinity?.yogaNode).toBeDefined()
expect(billionRows?.yogaNode).toBeDefined()
expect(clippedHugeFill?.yogaNode).toBeDefined()
expect(nestedTextWrapper?.yogaNode).toBeDefined()
const nestedText = nestedTextWrapper!.childNodes.find(child => child.nodeName === 'ink-text') as
DOMElement | undefined
const nestedTextChild = nestedText?.childNodes[0]
expect(nestedTextChild).toBeDefined()
vi.spyOn(nanTop!.yogaNode!, 'getComputedTop').mockReturnValue(Number.NaN)
vi.spyOn(positiveInfinity!.yogaNode!, 'getComputedHeight').mockReturnValue(Number.POSITIVE_INFINITY)
vi.spyOn(negativeInfinity!.yogaNode!, 'getComputedHeight').mockReturnValue(Number.NEGATIVE_INFINITY)
vi.spyOn(billionRows!.yogaNode!, 'getComputedHeight').mockReturnValue(1_000_000_000)
vi.spyOn(clippedHugeFill!.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
vi.spyOn(clippedHugeFill!.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
let nestedOffsetX = 0
let nestedOffsetY = 0
nestedTextChild!.yogaNode = {
getComputedLeft: () => nestedOffsetX,
getComputedTop: () => nestedOffsetY
} as DOMElement['yogaNode']
output = ''
largestWrite = 0
largestWriteInput = 0
const corruptNestedOffsets = [
[Number.NaN, 0],
[Number.POSITIVE_INFINITY, 0],
[Number.NEGATIVE_INFINITY, 0],
[-1, 0],
[0.5, 0],
[100_000_000, 0],
[0, Number.NaN],
[0, Number.POSITIVE_INFINITY],
[0, Number.NEGATIVE_INFINITY],
[0, -1],
[0, 0.5],
[0, 100_000_000]
] as const
for (const [index, [offsetX, offsetY]] of corruptNestedOffsets.entries()) {
nestedOffsetX = offsetX
nestedOffsetY = offsetY
expect(() => {
instance.rerender(React.createElement(CorruptGeometryHarness, { expose, tick: index + 1 }))
}).not.toThrow()
await delay(5)
}
await delay(40)
expect(output).toContain(`adjacent-valid-${corruptNestedOffsets.length}`)
expect(largestWriteInput).toBeLessThan(10_000)
expect(largestWrite).toBeLessThan(10_000)
expect(output.length).toBeLessThan(50_000)
} finally {
vi.restoreAllMocks()
instance.unmount()
instance.cleanup()
}
})
it('clips corrupt dirty-child DECSTBM repairs before allocating or recursing', async () => {
const expose = {
current: {
adjacent: null,
dirtyChild: null,
overlay: null,
scroll: null,
scrollBox: null
} as FastPathRepairExpose
}
const streams = makeStreams()
let output = ''
const instance = renderSourceSync(
React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 0 }),
{
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
}
)
try {
await delay(20)
const dirtyChild = expose.current!.dirtyChild!
expect(dirtyChild, streams.stderr.read()?.toString()).not.toBeNull()
expect(dirtyChild.yogaNode).toBeDefined()
vi.spyOn(dirtyChild.yogaNode!, 'getComputedTop').mockReturnValue(-99_999_995)
vi.spyOn(dirtyChild.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
vi.spyOn(dirtyChild.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
instance.rerender(React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 1 }))
await delay(20)
streams.stdout.removeAllListeners('data')
streams.stdout.on('data', chunk => {
output += chunk.toString()
})
const observed = guardFastPathRepairAllocations(80, 20)
const fastPathsBefore = sourceScrollFastPathStats.taken
const capturedBefore = sourceScrollFastPathStats.captured
expect(() => expose.current!.scroll!.scrollTo(1)).not.toThrow()
expect(() =>
instance.rerender(React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 2 }))
).not.toThrow()
await delay(40)
expect(sourceScrollFastPathStats.captured, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
capturedBefore
)
expect(sourceScrollFastPathStats.taken, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
fastPathsBefore
)
expect(observed.repairWhitespaceWrites).toBeGreaterThan(0)
expect(observed.largestRepeat).toBeGreaterThan(0)
expect(observed.largestArrayRows).toBeGreaterThan(0)
output = ''
expect(() =>
instance.rerender(
React.createElement(FastPathRepairHarness, {
dirtyTick: 2,
expose,
includeOverlay: false,
tick: 3
})
)
).not.toThrow()
await delay(40)
expect(observed.largestRepeat).toBeLessThanOrEqual(80)
expect(observed.largestArrayRows).toBeLessThanOrEqual(20)
expect(observed.largestBlitWidth).toBeLessThanOrEqual(80)
expect(observed.largestBlitHeight).toBeLessThanOrEqual(20)
expect(observed.largestClearWidth).toBeLessThanOrEqual(80)
expect(observed.largestClearHeight).toBeLessThanOrEqual(20)
expect(observed.largestWrite).toBeLessThanOrEqual(1_620)
expect(expose.current!.scroll!.getScrollTop()).toBe(1)
expect(expose.current!.scroll!.getViewportHeight()).toBeGreaterThan(0)
expect(output).toContain('adjacent-fast-path-3')
expect(streams.stderr.read()?.toString() ?? '').toBe('')
} finally {
vi.restoreAllMocks()
instance.unmount()
instance.cleanup()
}
})
it('clips corrupt absolute-overlay DECSTBM repairs before allocating or recursing', async () => {
const expose = {
current: {
adjacent: null,
dirtyChild: null,
overlay: null,
scroll: null,
scrollBox: null
} as FastPathRepairExpose
}
const streams = makeStreams()
let output = ''
const instance = renderSourceSync(React.createElement(FastPathRepairHarness, { expose, tick: 0 }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const overlay = expose.current!.overlay!
const scrollBox = expose.current!.scrollBox!
expect(overlay, streams.stderr.read()?.toString()).not.toBeNull()
expect(overlay.yogaNode).toBeDefined()
expect(scrollBox.yogaNode).toBeDefined()
vi.spyOn(scrollBox.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
vi.spyOn(overlay.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
vi.spyOn(overlay.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
instance.rerender(React.createElement(FastPathRepairHarness, { expose, tick: 1 }))
await delay(20)
instance.rerender(React.createElement(FastPathRepairHarness, { expose, tick: 1 }))
await delay(20)
streams.stdout.removeAllListeners('data')
streams.stdout.on('data', chunk => {
output += chunk.toString()
})
const observed = guardFastPathRepairAllocations(80, 20)
const fastPathsBefore = sourceScrollFastPathStats.taken
const capturedBefore = sourceScrollFastPathStats.captured
expect(() => expose.current!.scroll!.scrollTo(1)).not.toThrow()
expect(expose.current!.scroll!.getScrollTop()).toBe(1)
await delay(40)
expect(sourceScrollFastPathStats.captured, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
capturedBefore
)
expect(sourceScrollFastPathStats.taken, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
fastPathsBefore
)
expect(observed.repairWhitespaceWrites).toBeGreaterThan(0)
expect(observed.largestRepeat).toBeGreaterThan(0)
expect(observed.largestArrayRows).toBeGreaterThan(0)
output = ''
expect(() =>
instance.rerender(React.createElement(FastPathRepairHarness, { dirtyTick: 1, expose, tick: 2 }))
).not.toThrow()
await delay(40)
expect(observed.largestRepeat).toBeLessThanOrEqual(80)
expect(observed.largestArrayRows).toBeLessThanOrEqual(20)
expect(observed.largestBlitWidth).toBeLessThanOrEqual(80)
expect(observed.largestBlitHeight).toBeLessThanOrEqual(20)
expect(observed.largestClearWidth).toBeLessThanOrEqual(80)
expect(observed.largestClearHeight).toBeLessThanOrEqual(20)
expect(observed.largestWrite).toBeLessThanOrEqual(1_620)
expect(output.length).toBeLessThan(2_000)
expect(expose.current!.scroll!.getViewportHeight()).toBeGreaterThan(0)
expect(output).toContain('adjacent-fast-path-2')
expect(streams.stderr.read()?.toString() ?? '').toBe('')
} finally {
vi.restoreAllMocks()
instance.unmount()
instance.cleanup()
}
})
})
+151
View File
@@ -0,0 +1,151 @@
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'
import { parseSlashCommand } from '../domain/slash.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 = [
'bg',
'btw',
'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')
})
})
describe('parseSlashCommand argument fidelity', () => {
it('keeps a multi-line argument byte-for-byte', () => {
const arg = 'first line\nsecond line\n\n indented tail'
expect(parseSlashCommand(`/pr-triage ${arg}`)).toEqual({
arg,
cmd: `/pr-triage ${arg}`,
name: 'pr-triage'
})
})
it('preserves runs of spaces inside the argument', () => {
expect(parseSlashCommand('/goal ship it').arg).toBe('ship it')
})
it('still splits the command name off a single separator', () => {
expect(parseSlashCommand('/cron add daily')).toEqual({
arg: 'add daily',
cmd: '/cron add daily',
name: 'cron'
})
expect(parseSlashCommand('/exit')).toEqual({ arg: '', cmd: '/exit', name: 'exit' })
expect(parseSlashCommand('/exit ')).toEqual({ arg: '', cmd: '/exit ', name: 'exit' })
})
})
@@ -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)
}
})
})
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import type { StatusBarSegments } from '../components/appChrome.js'
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,
subagents: true,
cacheHit: true,
latency: true,
tps: true
} satisfies StatusBarSegments)
})
it('sheds cache/latency/tps read-outs first as the terminal narrows', () => {
// 96/104/110-col breakpoints: these are the lowest-priority perf
// read-outs, so they disappear before any pre-existing segment.
expect(statusBarSegments(108)).toMatchObject({ cacheHit: true, latency: true, tps: false })
expect(statusBarSegments(100)).toMatchObject({ cacheHit: true, latency: false, tps: false })
expect(statusBarSegments(94)).toMatchObject({ cacheHit: false, latency: false, tps: false, subagents: 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)
})
it('sheds tail segments in priority order as the terminal narrows', () => {
// the context bar is the last of the tail to go.
const order: (keyof ReturnType<typeof statusBarSegments>)[] = [
'bar',
'duration',
'compressions',
'voice',
'bg',
'subagents'
]
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,321 @@
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { Md } from '../components/markdown.js'
import { advanceScan, createScanState, findStableBoundary } from '../components/streamingMarkdown.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
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('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)
})
})
// A corpus exercising every construct the boundary scanner must respect:
// paragraphs, fenced code (with blank lines and $$ bait inside), display
// math ($$ and \[), setext headings, tables, lists, quotes, headings.
const CORPUS = [
'Intro paragraph explaining the plan in some detail.\n',
'\nSection Title\n=============\n',
'\nA paragraph before code.\n',
'\n```ts\nconst a = 1\n\nconst b = 2\n// $$ not math $$\n```\n',
'\nBetween-blocks narration.\n',
'\n$$\nE = mc^2\n\n\\sum_i x_i\n$$\n',
'\n- item one\n- item two\n\n1. first\n2. second\n',
'\n| a | b |\n|---|---|\n| 1 | 2 |\n',
'\n> quoted wisdom\n> second line\n',
'\n\\[\nx^2 + y^2 = z^2\n\\]\n',
'\n## Closing heading\n',
'\nFinal paragraph without a trailing newline'
].join('')
const mulberry32 = (seed: number) => () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
describe('advanceScan (incremental scanner)', () => {
it('reconstructs the input exactly: blocks + tail === text', () => {
const state = createScanState()
advanceScan(CORPUS, state)
expect(state.blocks.join('')).toHaveLength(state.settledLen)
expect(state.blocks.join('') + CORPUS.slice(state.settledLen)).toBe(CORPUS)
expect(state.blocks.length).toBeGreaterThan(5)
})
it('produces identical blocks fed incrementally at arbitrary cut points', () => {
const oneShot = createScanState()
advanceScan(CORPUS, oneShot)
for (let seed = 1; seed <= 8; seed++) {
const rand = mulberry32(seed)
const state = createScanState()
let pos = 0
while (pos < CORPUS.length) {
pos = Math.min(CORPUS.length, pos + 1 + Math.floor(rand() * 7))
const prevBlocks = state.blocks.length
advanceScan(CORPUS.slice(0, pos), state)
// Append-only: previously committed blocks never change.
expect(state.blocks.length).toBeGreaterThanOrEqual(prevBlocks)
}
expect(state.blocks).toEqual(oneShot.blocks)
expect(state.settledLen).toBe(oneShot.settledLen)
}
})
it('is idempotent when called again with the same text', () => {
const state = createScanState()
advanceScan(CORPUS, state)
const blocks = [...state.blocks]
advanceScan(CORPUS, state)
expect(state.blocks).toEqual(blocks)
})
it('holds a partial trailing line in the tail even if it looks fence-like', () => {
// "``" could grow into "```ts" — the scanner must not judge the line
// until its newline arrives.
const state = createScanState()
advanceScan('para\n\n``', state)
expect(state.blocks).toEqual(['para\n\n'])
expect(state.codeOpen).toBe(false)
advanceScan('para\n\n```ts\ncode\n\nstill code', state)
// The blank line inside the now-open fence must not commit a block.
expect(state.blocks).toEqual(['para\n\n'])
expect(state.codeOpen).toBe(true)
advanceScan('para\n\n```ts\ncode\n\nstill code\n```\n\nafter\n\n', state)
expect(state.blocks).toEqual(['para\n\n', '```ts\ncode\n\nstill code\n```\n\n', 'after\n\n'])
expect(state.codeOpen).toBe(false)
})
it('does not commit whitespace-only blocks on 3+ newline runs', () => {
const state = createScanState()
advanceScan('alpha\n\n\n\nbeta\n\n', state)
expect(state.blocks).toEqual(['alpha\n\n', '\n\nbeta\n\n'])
expect(state.blocks.join('')).toBe('alpha\n\n\n\nbeta\n\n')
})
it('keeps a setext heading contiguous with its paragraph', () => {
// A setext underline attaches to the line above; the only committed
// boundary is the blank line, so the pair can never be torn apart or
// retroactively merged (the desktop splitter needed a fix for this,
// #67176 — blank-line boundaries are immune by construction).
const state = createScanState()
advanceScan('Title\n', state)
advanceScan('Title\n====\n', state)
advanceScan('Title\n====\n\nbody\n\n', state)
expect(state.blocks).toEqual(['Title\n====\n\n', 'body\n\n'])
})
})
describe('StreamingMd rendering equivalence', () => {
it('settled blocks + tail render identically to one combined Md', () => {
const state = createScanState()
advanceScan(CORPUS, state)
const tail = CORPUS.slice(state.settledLen)
const t = DEFAULT_THEME
const split = renderPlain(
React.createElement(
Box,
{ flexDirection: 'column' },
...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })),
tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null
)
)
const combined = renderPlain(React.createElement(Md, { t, text: CORPUS }))
expect(split).toEqual(combined)
})
it('renders split/combined identically at every streamed step', () => {
const rand = mulberry32(42)
const state = createScanState()
const t = DEFAULT_THEME
let pos = 0
while (pos < CORPUS.length) {
pos = Math.min(CORPUS.length, pos + 24 + Math.floor(rand() * 200))
const text = CORPUS.slice(0, pos)
advanceScan(text, state)
const tail = text.slice(state.settledLen)
const split = renderPlain(
React.createElement(
Box,
{ flexDirection: 'column' },
...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })),
tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null
)
)
const combined = renderPlain(React.createElement(Md, { t, text }))
expect(split).toEqual(combined)
}
})
})
+407
View File
@@ -0,0 +1,407 @@
import { describe, expect, it } from 'vitest'
import {
buildSubagentTree,
descendantIds,
flattenTree,
fmtCost,
fmtDuration,
fmtTokens,
formatSummary,
hotnessBucket,
peakHotness,
sparkline,
topLevelSubagents,
treeTotals,
widthByDepth
} from '../lib/subagentTree.js'
import type { SubagentProgress } from '../types.js'
const makeItem = (overrides: Partial<SubagentProgress> & Pick<SubagentProgress, 'id' | 'index'>): SubagentProgress => ({
depth: 0,
goal: overrides.id,
notes: [],
parentId: null,
status: 'running',
taskCount: 1,
thinking: [],
toolCount: 0,
tools: [],
...overrides
})
describe('aggregate: tokens, cost, files, hotness', () => {
it('sums tokens and cost across subtree', () => {
const items = [
makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }),
makeItem({
costUsd: 0.005,
depth: 1,
id: 'c1',
index: 0,
inputTokens: 500,
outputTokens: 100,
parentId: 'p'
}),
makeItem({
costUsd: 0.008,
depth: 1,
id: 'c2',
index: 1,
inputTokens: 300,
outputTokens: 200,
parentId: 'p'
})
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate).toMatchObject({
costUsd: 0.023,
inputTokens: 1800,
outputTokens: 800
})
})
it('counts files read + written across subtree', () => {
const items = [
makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }),
makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.filesTouched).toBe(3)
})
it('hotness = totalTools / totalDuration', () => {
const items = [
makeItem({
durationSeconds: 10,
id: 'p',
index: 0,
status: 'completed',
toolCount: 20
})
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.hotness).toBeCloseTo(2)
})
it('hotness is zero when duration is zero', () => {
const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.hotness).toBe(0)
})
})
describe('hotnessBucket + peakHotness', () => {
it('peakHotness walks subtree', () => {
const items = [
makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }),
makeItem({
depth: 1,
durationSeconds: 1,
id: 'c',
index: 0,
parentId: 'p',
status: 'completed',
toolCount: 5
})
]
const tree = buildSubagentTree(items)
expect(peakHotness(tree)).toBeGreaterThan(2)
})
it('hotnessBucket clamps and normalizes', () => {
expect(hotnessBucket(0, 10, 4)).toBe(0)
expect(hotnessBucket(10, 10, 4)).toBe(3)
expect(hotnessBucket(5, 10, 4)).toBe(2)
expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped
expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero
})
})
describe('fmtCost + fmtTokens', () => {
it('fmtCost handles ranges', () => {
expect(fmtCost(0)).toBe('')
expect(fmtCost(0.001)).toBe('<$0.01')
expect(fmtCost(0.42)).toBe('$0.42')
expect(fmtCost(1.23)).toBe('$1.23')
expect(fmtCost(12.5)).toBe('$12.5')
})
it('fmtTokens handles ranges', () => {
expect(fmtTokens(0)).toBe('0')
expect(fmtTokens(542)).toBe('542')
expect(fmtTokens(1234)).toBe('1.2k')
expect(fmtTokens(45678)).toBe('46k')
})
})
describe('formatSummary with tokens', () => {
it('includes tokens but not cost', () => {
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')
})
})
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'])
})
})
+170
View File
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isSessionBusyError, markSubmitting, submitPrompt, type SubmitPromptDeps } from '../app/submissionCore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
import type { GatewayClient } from '../gatewayClient.js'
// A gateway double whose `input.detect_drop` resolution we control, so we can
// observe UI state DURING the async gap — the exact window the queue-mode race
// lived in.
function makeDeferredGateway() {
let resolveDrop: (v: unknown) => void = () => {}
const dropPromise = new Promise(res => {
resolveDrop = res
})
const calls: string[] = []
const gw = {
request: vi.fn((method: string) => {
calls.push(method)
if (method === 'input.detect_drop') {
return dropPromise
}
// prompt.submit et al: resolve immediately with a success shape.
return Promise.resolve({ status: 'streaming' })
})
} as unknown as GatewayClient
return { calls, gw, resolveDrop: (v: unknown = { matched: false }) => resolveDrop(v) }
}
function makeDeps(gw: GatewayClient, over: Partial<SubmitPromptDeps> = {}): SubmitPromptDeps {
return {
appendMessage: vi.fn(),
enqueue: vi.fn(),
expand: (t: string) => t,
gw,
setLastUserMsg: vi.fn(),
sys: vi.fn(),
...over
}
}
describe('submissionCore.submitPrompt — synchronous busy (queue-race fix)', () => {
beforeEach(() => {
resetUiState()
patchUiState({ sid: 'sess-1' })
})
it('flips busy=true SYNCHRONOUSLY, before input.detect_drop resolves', () => {
const { gw, resolveDrop } = makeDeferredGateway()
expect(getUiState().busy).toBe(false)
submitPrompt('hello', makeDeps(gw))
// The critical invariant: busy is already true even though the
// detect_drop RPC has NOT resolved yet. This is what makes a second,
// rapid submit take the local-enqueue branch instead of racing a second
// prompt.submit onto the backend.
expect(getUiState().busy).toBe(true)
expect(getUiState().status).toBe('running…')
resolveDrop()
})
it('regression: two back-to-back sends — the SECOND sees busy=true in the gap', async () => {
const { gw, resolveDrop } = makeDeferredGateway()
// Emulate dispatchSubmission's routing decision: it sends only when
// busy===false, otherwise it would enqueue. We assert the state the
// router reads, which is the real regression.
submitPrompt('first message', makeDeps(gw))
// Before the fix, busy was still false here (set only inside detect_drop's
// .then), so a second Enter would wrongly route into send() again.
const busyWhenSecondArrives = getUiState().busy
expect(busyWhenSecondArrives).toBe(true)
resolveDrop()
await Promise.resolve()
})
it('does not submit when there is no session, and does not mark busy', () => {
resetUiState() // sid: null
const { gw, calls } = makeDeferredGateway()
const sys = vi.fn()
submitPrompt('hello', makeDeps(gw, { sys }))
expect(getUiState().busy).toBe(false)
expect(sys).toHaveBeenCalledWith('session not ready yet')
expect(calls).not.toContain('input.detect_drop')
})
it('after detect_drop resolves (no file), it issues prompt.submit', async () => {
const { calls, gw, resolveDrop } = makeDeferredGateway()
submitPrompt('hi there', makeDeps(gw))
expect(calls).toEqual(['input.detect_drop'])
resolveDrop({ matched: false })
await Promise.resolve()
await Promise.resolve()
expect(calls).toContain('prompt.submit')
})
})
describe('submissionCore.submitPrompt — literal submissions (startup -q queries)', () => {
beforeEach(() => {
resetUiState()
patchUiState({ sid: 'sess-1' })
})
it('skipDetectDrop submits directly without the detect_drop round-trip', async () => {
const { calls, gw } = makeDeferredGateway()
submitPrompt('!echo not-a-shell-escape', makeDeps(gw), true, undefined, { skipDetectDrop: true })
await Promise.resolve()
await Promise.resolve()
expect(calls).not.toContain('input.detect_drop')
expect(calls).toContain('prompt.submit')
})
it('literal text reaches prompt.submit verbatim', async () => {
const submitted: string[] = []
const gw = {
request: vi.fn((method: string, params?: { text?: string }) => {
if (method === 'prompt.submit' && params?.text) {
submitted.push(params.text)
}
return Promise.resolve({ status: 'streaming' })
})
} as unknown as GatewayClient
submitPrompt('/model $(rm -rf ~)', makeDeps(gw), true, undefined, { skipDetectDrop: true })
await Promise.resolve()
await Promise.resolve()
expect(submitted).toEqual(['/model $(rm -rf ~)'])
})
})
describe('submissionCore.markSubmitting', () => {
beforeEach(() => resetUiState())
it('sets busy + running status', () => {
markSubmitting()
expect(getUiState().busy).toBe(true)
expect(getUiState().status).toBe('running…')
})
})
describe('submissionCore.isSessionBusyError', () => {
it('matches the legacy busy rejections but not arbitrary errors', () => {
expect(isSessionBusyError(new Error('session busy'))).toBe(true)
expect(isSessionBusyError(new Error('waiting for model response'))).toBe(true)
expect(isSessionBusyError(new Error('some other failure'))).toBe(false)
expect(isSessionBusyError('not an error')).toBe(false)
})
})
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { subscriptionCommands } from '../app/slash/commands/subscription.js'
import { findSlashCommand } from '../app/slash/registry.js'
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
const subscriptionCommand = subscriptionCommands.find(cmd => cmd.name === 'subscription')!
const loggedInState = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme',
role: 'OWNER',
current: null,
portal_url: 'https://portal.nousresearch.com/billing',
...overrides
})
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const calls: Array<{ method: string; params: unknown }> = []
const rpc = vi.fn((method: string, params: unknown) => {
calls.push({ method, params })
return Promise.resolve(results[method])
})
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), panel: vi.fn(), sys }
}
const run = async (arg: string) => {
subscriptionCommand.run(arg, ctx as any, 'subscription')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { calls, ctx, rpc, run, sys }
}
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
describe('/subscription slash command', () => {
beforeEach(() => {
resetOverlayState()
})
it('fetches subscription.state and opens the overlay', async () => {
const { run } = buildCtx({
'subscription.state': loggedInState()
})
await run('')
const overlay = getOverlayState().subscription
expect(overlay).not.toBeNull()
expect(overlay?.screen).toBe('overview')
})
it('shows portal-login sys line when not logged in', async () => {
const { run, sys } = buildCtx({
'subscription.state': loggedInState({ logged_in: false })
})
await run('')
expect(printed(sys)).toContain('Not logged into Nous Portal')
expect(getOverlayState().subscription).toBeNull()
})
it('/upgrade alias resolves to the same command', () => {
expect(findSlashCommand('upgrade')).toBe(subscriptionCommand)
})
it('/subscription resolves to the same command', () => {
expect(findSlashCommand('subscription')).toBe(subscriptionCommand)
})
})
@@ -0,0 +1,636 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
const inputHarness = vi.hoisted(() => ({
handler: undefined as undefined | ((input: string, key: Record<string, boolean>) => void)
}))
// Stub useInput so the overlay doesn't try to enter raw mode under renderSync
// (PassThrough stdin doesn't support it). Box/Text pass through to real Ink.
vi.mock('@hermes/ink', async importOriginal => {
const mod = await importOriginal()
return {
...mod,
useInput: (handler: (input: string, key: Record<string, boolean>) => void) => {
inputHarness.handler = handler
}
}
})
import type { SubscriptionOverlayState } from '../app/interfaces.js'
import { SubscriptionOverlay } from '../components/subscriptionOverlay.js'
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
/** Mount a SubscriptionOverlay via renderSync + PassThrough. */
function mount(
overlay: SubscriptionOverlayState,
onPatch: (next: Partial<SubscriptionOverlayState>) => void = () => {}
) {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
inputHarness.handler = undefined
const element = React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch, overlay, t })
const instance = renderSync(element, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
return {
cleanup: () => {
instance.unmount()
instance.cleanup()
},
output: () => stripAnsi(output),
rerender: () => instance.rerender(element)
}
}
/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */
function render(overlay: SubscriptionOverlayState): string {
const mounted = mount(overlay)
const output = mounted.output()
mounted.cleanup()
return output
}
const TIERS = [
{
tier_id: 'free',
name: 'Free',
tier_order: 0,
dollars_per_month_display: '$0',
monthly_credits: '0',
is_current: false,
is_enabled: true
},
{
tier_id: 'plus',
name: 'Plus',
tier_order: 1,
dollars_per_month_display: '$20',
monthly_credits: '1000',
is_current: true,
is_enabled: true
},
{
tier_id: 'ultra',
name: 'Ultra',
tier_order: 2,
dollars_per_month_display: '$40',
monthly_credits: '3000',
is_current: false,
is_enabled: true
}
]
const state = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme',
org_id: 'org_acme',
role: 'OWNER',
current: null,
tiers: [],
portal_url: 'https://portal.nousresearch.com/billing',
...overrides
})
const ctx = {
fetchCard: vi.fn(() => Promise.resolve(null)),
openManageLink: vi.fn(() => Promise.resolve(true)),
openPortal: vi.fn(),
preview: vi.fn(() => Promise.resolve(null)),
refreshState: vi.fn(() => Promise.resolve(null)),
requestRemoteSpending: vi.fn(() => Promise.resolve({ granted: true })),
resume: vi.fn(() => Promise.resolve(null)),
scheduleCancellation: vi.fn(() => Promise.resolve(null)),
scheduleChange: vi.fn(() => Promise.resolve(null)),
sys: vi.fn(),
upgrade: vi.fn(() => Promise.resolve(null))
}
const overlay = (s: SubscriptionStateResponse): SubscriptionOverlayState => ({ ctx, screen: 'overview', state: s })
// Overview: the entry screen across every account state (plan + usage + the
// actions that enter the in-terminal change flow).
describe('SubscriptionOverlay — overview', () => {
it('free: upsell + "Start a subscription", no tier list, no "credits"', () => {
const out = render(overlay(state({ current: null, usage: { available: true, status: 'free', plan_name: null } })))
expect(out).toContain('Plan: Free · free models only')
expect(out).toContain('Paid models need a subscription')
expect(out).toContain('Start a subscription')
expect(out).not.toContain('$20/mo')
expect(out.toLowerCase()).not.toContain('credits')
})
it('free with catalog: plans render inline; the generic portal row disappears', () => {
const out = render(overlay(freeWithCatalog()))
expect(out).toContain('Plus · $20/mo · $1,000 credits/mo')
expect(out).toContain('Ultra · $40/mo · $3,000 credits/mo')
expect(out).not.toContain('upgrade') // a start, not a move
expect(out).not.toContain('$0/mo') // free tier is not an option
expect(out).not.toContain('Choose a plan')
expect(out).not.toContain('Start a subscription')
})
it('free with catalog: picking a plan opens the portal once, even on double-Enter', async () => {
const openManageLink = vi.fn(() => Promise.resolve(true))
const preview = vi.fn(() => Promise.resolve(null))
const sys = vi.fn()
const mounted = mount({
ctx: { ...ctx, openManageLink, preview, sys } as SubscriptionOverlayState['ctx'],
screen: 'overview',
state: freeWithCatalog()
})
inputHarness.handler?.('', { return: true }) // first row = Plus
inputHarness.handler?.('', { return: true })
await vi.waitFor(() => expect(openManageLink).toHaveBeenCalled())
mounted.cleanup()
expect(openManageLink).toHaveBeenCalledTimes(1)
expect(openManageLink).toHaveBeenCalledWith('plus')
expect(preview).not.toHaveBeenCalled()
// openManageLink narrates the handoff itself.
expect(sys).not.toHaveBeenCalled()
})
it('subscriber: status line + plan bar + top-up bar, no "credits"', () => {
const out = render(
overlay(
state({
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '700',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
usage: {
available: true,
status: 'healthy',
plan_name: 'Pro',
renews_display: 'Jul 1, 2026',
total_spendable_display: '$26.00',
has_topup: true,
plan_bar: {
kind: 'plan',
remaining_display: '$14.00',
total_display: '$20.00',
spent_display: '$6.00',
pct_used: 30,
fill_fraction: 0.7
},
topup_bar: {
kind: 'topup',
remaining_display: '$12.00',
total_display: '$12.00',
spent_display: '$0.00',
pct_used: null,
fill_fraction: 1
}
}
})
)
)
expect(out).toContain('Plan: Pro')
expect(out).toContain('$14.00 left of $20.00')
expect(out).toContain('30% used')
expect(out).toContain('top-up')
expect(out).toContain('never expires')
expect(out.toLowerCase()).not.toContain('credits')
})
it('low balance: shows alert nudge', () => {
const out = render(
overlay(
state({
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '170',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
usage: {
available: true,
status: 'low',
plan_name: 'Pro',
total_spendable_display: '$3.40',
plan_bar: {
kind: 'plan',
remaining_display: '$3.40',
total_display: '$20.00',
spent_display: '$16.60',
pct_used: 83,
fill_fraction: 0.17
}
}
})
)
)
expect(out).toContain('Plan: Pro · $3.40 left')
expect(out).toContain('Low balance')
})
it('not-admin: shows read-only note', () => {
const out = render(
overlay(
state({
is_admin: false,
can_change_plan: false,
role: 'MEMBER',
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
})
)
)
expect(out).toContain('view only')
expect(out).toContain('Manage on portal')
})
it('downgrade-pending: leads with a Pro ──▶ Free banner + status echo', () => {
const out = render(
overlay(
state({
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: 'Free',
pending_downgrade_at: '2026-07-15',
pending_downgrade_display: 'Jul 15, 2026'
},
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
})
)
)
expect(out).toContain('Scheduled change')
expect(out).toContain('──▶')
expect(out).toContain('Free')
expect(out).toContain('Jul 15, 2026')
// the status line itself echoes the transition
expect(out).toContain('Plan: Pro → Free')
})
it('team context: redirects to /topup, no tier picker', () => {
const out = render(overlay(state({ context: 'team', current: null })))
expect(out).toContain('shared balance')
expect(out).toContain('/topup')
})
})
// In-terminal change flow (V3): picker → confirm → result. useInput is mocked
// (no key simulation), so these assert each screen's rendered content.
const subscriber = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse =>
state({
current: {
tier_id: 'plus',
tier_name: 'Plus',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
tiers: TIERS,
usage: { available: true, status: 'healthy', plan_name: 'Plus' },
...overrides
})
const at = (
screen: SubscriptionOverlayState['screen'],
s: SubscriptionStateResponse,
extra: Partial<SubscriptionOverlayState> = {}
): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra })
// Free account (no current sub) where NAS still returns the tier catalog.
const freeWithCatalog = (): SubscriptionStateResponse =>
state({
current: null,
tiers: TIERS.map(tier => ({ ...tier, is_current: false })),
usage: { available: true, plan_name: null, status: 'free' }
})
describe('SubscriptionOverlay — overview actions', () => {
it('admin subscriber: offers Change plan + Cancel subscription', () => {
const out = render(overlay(subscriber()))
expect(out).toContain('Change plan')
expect(out).toContain('Cancel subscription')
})
it('pending change: offers undo instead of cancel', () => {
const out = render(
overlay(
subscriber({
current: {
tier_id: 'plus',
tier_name: 'Plus',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01',
cancel_at_period_end: true,
cancellation_effective_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
}
})
)
)
// undo is promoted to the first action; the banner shows the pending cancel
expect(out).toContain('Keep Plus (undo this change)')
expect(out).toContain('cancels')
expect(out).not.toContain('Cancel subscription')
})
})
describe('SubscriptionOverlay — step-up', () => {
it('prompts to allow Remote Spending (never leaks the raw scope)', () => {
const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } }))
expect(out).toContain('Remote Spending')
expect(out).toContain('Allow Remote Spending')
expect(out).not.toContain('billing:manage')
})
})
describe('SubscriptionOverlay — picker', () => {
it('lists other paid tiers with direction hints; hides current + free', () => {
const out = render(at('picker', subscriber()))
expect(out).toContain('Ultra')
expect(out).toContain('$40/mo')
expect(out).toContain('upgrade') // ultra (order 2) > plus (order 1)
expect(out).not.toContain('Plus · $20/mo') // current tier is not selectable
expect(out).not.toContain('$0/mo') // free tier excluded — use Cancel instead
})
})
describe('SubscriptionOverlay — confirm', () => {
it('charge_now: shows the prorated charge + upgrade copy', () => {
const out = render(
at('confirm', subscriber(), {
pending: {
kind: 'upgrade',
targetTierId: 'ultra',
preview: {
ok: true,
effect: 'charge_now',
target_tier_name: 'Ultra',
amount_due_now_cents: 1234,
monthly_credits_delta: '2000'
}
}
})
)
expect(out).toContain('Pay $12.34 & upgrade now')
expect(out).toContain('Upgrade to Ultra')
})
it('scheduled: shows effective date + no charge now', () => {
const out = render(
at('confirm', subscriber(), {
pending: {
kind: 'tier_change',
targetTierId: 'plus',
preview: {
ok: true,
effect: 'scheduled',
target_tier_name: 'Plus',
effective_at: '2026-08-01T00:00:00Z',
amount_due_now_cents: null
}
}
})
)
expect(out).toContain('Schedule change to Plus')
expect(out).toContain('2026-08-01')
expect(out).toContain('No charge now')
})
it('cancellation: shows cancel-at-period-end copy', () => {
const out = render(
at('confirm', subscriber(), { pending: { kind: 'cancellation', targetTierId: null, preview: null } })
)
expect(out).toContain('Confirm cancellation')
expect(out).toContain('will not renew')
})
it('blocked: shows the reason + Manage on portal', () => {
const out = render(
at('confirm', subscriber(), {
pending: {
kind: 'tier_change',
targetTierId: 'ultra',
preview: { ok: true, effect: 'blocked', reason: 'Retract the cancellation before upgrading.' }
}
})
)
expect(out).toContain('Retract the cancellation')
expect(out).toContain('Manage on portal')
})
})
describe('SubscriptionOverlay — result', () => {
it('ok: shows Done + the re-run hint', () => {
const out = render(at('result', subscriber(), { result: { ok: true, message: 'Upgraded to Ultra.' } }))
expect(out).toContain('Done')
expect(out).toContain('Upgraded to Ultra.')
expect(out).toContain('Re-run /subscription')
})
it('error with recovery: shows the message + Open the portal', () => {
const out = render(
at('result', subscriber(), {
result: {
ok: false,
message: 'This upgrade needs extra verification (3DS).',
recoveryUrl: 'https://portal.example/x'
}
})
)
expect(out).toContain('Could not complete')
expect(out).toContain('3DS')
expect(out).toContain('Open the portal to finish')
})
})
describe('SubscriptionOverlay — upgrade response mapping', () => {
const applyUpgrade = async (response: unknown) => {
const onPatch = vi.fn()
const upgrade = vi.fn(() => Promise.resolve(response))
const mounted = mount(
at('confirm', subscriber(), {
ctx: { ...ctx, upgrade } as SubscriptionOverlayState['ctx'],
pending: {
idempotencyKey: 'upgrade-key',
kind: 'upgrade',
preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234 },
targetTierId: 'ultra'
}
}),
onPatch
)
inputHarness.handler?.('', { return: true })
await vi.waitFor(() => expect(onPatch).toHaveBeenCalled())
mounted.cleanup()
return onPatch.mock.calls.at(-1)?.[0] as Partial<SubscriptionOverlayState>
}
it.each([
['authentication_required', 'upgraded'],
['subscription_payment_intent_requires_action', 'payment_failed']
])('reason %s routes to card verification regardless of status %s', async (reason, status) => {
const patch = await applyUpgrade({
ok: status === 'upgraded',
reason,
recovery_url: 'https://portal.example/verify',
status,
target_tier_name: 'Ultra'
})
expect(patch.screen).toBe('result')
expect(patch.result?.ok).toBe(false)
expect(patch.result?.message).toContain('verify your card in the portal')
expect(patch.result?.recoveryUrl).toBe('https://portal.example/verify')
})
it('card_declined reason routes to a different-card recovery', async () => {
const patch = await applyUpgrade({
ok: false,
reason: 'card_declined',
recovery_url: 'https://portal.example/card',
status: 'requires_action'
})
expect(patch.result?.message).toContain('try a different card on the portal')
expect(patch.result?.recoveryUrl).toBe('https://portal.example/card')
})
it('already_on_tier remains an immediate success', async () => {
const patch = await applyUpgrade({ ok: true, status: 'already_on_tier', target_tier_name: 'Ultra' })
expect(patch.result).toMatchObject({ message: 'You are already on Ultra.', ok: true })
expect(patch.result).not.toHaveProperty('pendingTierId')
})
it('upgraded marks the result as applying to the target tier', async () => {
const patch = await applyUpgrade({ ok: true, status: 'upgraded', target_tier_name: 'Ultra' })
expect(patch.result).toMatchObject({ ok: true, pendingTierId: 'ultra' })
})
it('shows Applying, then Done once refreshed state reaches the upgraded tier', async () => {
vi.useFakeTimers()
try {
const refreshState = vi.fn(() =>
Promise.resolve(
subscriber({
current: {
tier_id: 'ultra',
tier_name: 'Ultra',
monthly_credits: '3000',
credits_remaining: '3000',
cycle_ends_at: '2026-08-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
}
})
)
)
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))
expect(mounted.output()).toContain('Applying…')
await vi.advanceTimersByTimeAsync(2000)
mounted.rerender()
expect(refreshState).toHaveBeenCalledTimes(1)
expect(mounted.output()).toContain('Done')
expect(mounted.output()).toContain('Upgraded to Ultra.')
mounted.cleanup()
} finally {
vi.useRealTimers()
}
})
it('keeps a successful upgrade soft-pending after the bounded confirmation window', async () => {
vi.useFakeTimers()
try {
const refreshState = vi.fn(() => Promise.resolve(subscriber()))
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))
await vi.advanceTimersByTimeAsync(30_000)
mounted.rerender()
expect(refreshState).toHaveBeenCalledTimes(15)
expect(mounted.output()).toContain('Still applying')
expect(mounted.output()).toContain('refresh in a moment')
expect(mounted.output()).not.toContain('Could not complete')
mounted.cleanup()
} finally {
vi.useRealTimers()
}
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { highlightLine, isHighlightable } from '../lib/syntax.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
describe('syntax highlighter', () => {
it('recognizes supported langs and aliases', () => {
expect(isHighlightable('ts')).toBe(true)
expect(isHighlightable('js')).toBe(true)
expect(isHighlightable('python')).toBe(true)
expect(isHighlightable('rs')).toBe(true)
expect(isHighlightable('bash')).toBe(true)
expect(isHighlightable('whatever')).toBe(false)
expect(isHighlightable('')).toBe(false)
})
it('paints a whole-line comment dim', () => {
const tokens = highlightLine('// hello', 'ts', t)
expect(tokens).toEqual([[t.color.muted, '// hello']])
})
it('paints keywords, strings, and numbers in a ts line', () => {
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
const colors = tokens.map(tok => tok[0])
expect(colors).toContain(t.color.border) // const
expect(colors).toContain(t.color.accent) // 'hi'
expect(colors).toContain(t.color.text) // 42
})
it('falls through unchanged for unknown langs', () => {
const tokens = highlightLine(`const x = 1`, 'zzz', t)
expect(tokens).toEqual([['', 'const x = 1']])
})
it('treats `#` as a python comment, not a selector', () => {
const tokens = highlightLine('# comment', 'py', t)
expect(tokens).toEqual([[t.color.muted, '# comment']])
})
})
+128
View File
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
isPaintableHex,
resetTerminalModes,
setTerminalBackground,
setTerminalForeground,
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)
}
})
})
// Foreground (OSC 10) and background (OSC 11) are the same slot contract —
// assert it once over both. Painting BOTH is what keeps every default-fg
// token legible when a skin flips the terminal's polarity.
describe.each([
{ name: 'foreground', osc: 10, set: setTerminalForeground },
{ name: 'background', osc: 11, set: setTerminalBackground }
])('terminal default $name (OSC $osc)', ({ osc, set }) => {
const paint = `\x1b]${osc};`
const restore = `\x1b]1${osc}\x07`
const tty = (write: ReturnType<typeof vi.fn>) => ({ isTTY: true, write }) as unknown as NodeJS.WriteStream
const written = (fn: (s: NodeJS.WriteStream) => void): string => {
const write = vi.fn()
fn(tty(write))
return (write.mock.calls[0]?.[0] as string) ?? ''
}
// Leave the module's "painted" flag clean so the exact-match reset test above
// (and other files) never see a stray restore.
afterEach(() => set('', tty(vi.fn())))
it('paints the terminal default from a valid hex', () => {
expect(written(s => set('#08201F', s))).toBe(`${paint}#08201F\x07`)
})
it('ignores an invalid hex and non-TTY streams', () => {
expect(written(s => set('teal', s))).toBe('')
const write = vi.fn()
set('#08201f', { isTTY: false, write } as unknown as NodeJS.WriteStream)
expect(write).not.toHaveBeenCalled()
})
it('appends the restore to the exit reset once painted, not before', () => {
expect(written(resetTerminalModes)).not.toContain(restore)
set('#101010', tty(vi.fn()))
expect(written(resetTerminalModes)).toContain(restore)
})
it('clears back to the terminal default when the next skin drops the color', () => {
set('#123456', tty(vi.fn()))
expect(written(s => set('', s))).toBe(restore)
// Cleared: a later reset no longer restores.
expect(written(resetTerminalModes)).not.toContain(restore)
})
})
describe('isPaintableHex', () => {
it('matches exactly what the slot setters paint', () => {
expect(isPaintableHex('#08201F')).toBe(true)
expect(isPaintableHex('#08201f')).toBe(true)
for (const junk of ['', 'teal', '#fff', '#12345', '#1234567']) {
expect(isPaintableHex(junk)).toBe(false)
}
})
})
+128
View File
@@ -0,0 +1,128 @@
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: '\u001b[13;2u' }
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[13;5u' }
},
{
key: 'cmd+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[13;9u' }
},
{
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)
})
it('shows IDE setup hint when keybindings use legacy sequences', 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'
})
// Legacy bindings don't match current CSI u targets, so setup is still needed
expect(hints.some(h => h.key === 'ide-setup')).toBe(true)
})
})
+507
View File
@@ -0,0 +1,507 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
configureDetectedTerminalKeybindings,
configureTerminalKeybindings,
detectVSCodeLikeTerminal,
getVSCodeStyleConfigDir,
shouldPromptForTerminalSetup,
stripJsonComments
} from '../lib/terminalSetup.js'
// Tests run from developer shells as well as CI. An inherited SSH_* variable
// must not silently force every configure call down the remote-session reject
// path; remote behavior is tested explicitly with per-call env objects below.
beforeEach(() => {
vi.stubEnv('SSH_CONNECTION', '')
vi.stubEnv('SSH_TTY', '')
vi.stubEnv('SSH_CLIENT', '')
})
afterEach(() => {
vi.unstubAllEnvs()
})
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('\\u001b[13;2u')
expect(written).toContain('cmd+enter')
expect(written).toContain('\\u001b[13;9u')
expect(written).toContain('ctrl+enter')
expect(written).toContain('\\u001b[13;5u')
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: '\u001b[13;2u' }
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[13;5u' }
},
{
key: 'cmd+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[13;9u' }
},
{
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)
})
it('prompts for setup when legacy \\\r\n bindings are present', async () => {
// Old keybindings using the legacy \\\r\n sequence should be detected as
// incomplete — they need migration to the CSI u encoding.
const readLegacy = 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: readLegacy }
})
).resolves.toBe(true)
})
it('migrates legacy \\\r\n bindings to CSI u sequences', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
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' }
}
])
)
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(result.message).toContain('migrated 3 legacy bindings to CSI u encoding')
const written = writeFile.mock.calls[0]?.[1] as string
const parsed = JSON.parse(written)
// All three Enter bindings should now use CSI u sequences
const enterBindings = parsed.filter((b: { key: string }) =>
['shift+enter', 'ctrl+enter', 'cmd+enter'].includes(b.key)
)
expect(enterBindings).toHaveLength(3)
expect(enterBindings.find((b: { key: string }) => b.key === 'shift+enter')?.args?.text).toBe('\u001b[13;2u')
expect(enterBindings.find((b: { key: string }) => b.key === 'ctrl+enter')?.args?.text).toBe('\u001b[13;5u')
expect(enterBindings.find((b: { key: string }) => b.key === 'cmd+enter')?.args?.text).toBe('\u001b[13;9u')
})
})
+31
View File
@@ -0,0 +1,31 @@
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)
})
})
+267
View File
@@ -0,0 +1,267 @@
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('thinkingPreview over-bound tail', () => {
it('retains the live tail when reasoning exceeds the clean bound', () => {
const TAIL = '<<<LIVE_TAIL_MARKER>>>'
// Slightly above the 24k clean-tail bound, so the implementation must trim.
const reasoning = 'A'.repeat(25_000) + '\n' + TAIL
const result = thinkingPreview(reasoning, 'full')
expect(result).toContain(TAIL)
// The bounded window is shorter than the 25k prefix, but the tail remains.
expect(result.length).toBeLessThanOrEqual(25_000)
})
})
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,100 @@
import { describe, expect, it } from 'vitest'
import { fastAppendEffect, fastBackspaceEffect, resolveCursorLayout } from '../components/textInput.js'
import { cursorLayout } from '../lib/inputMetrics.js'
// 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.
//
// These tests exercise the real, exported `resolveCursorLayout`,
// `fastBackspaceEffect`, and `fastAppendEffect` helpers that
// `textInput.tsx` calls at its render site and fast-echo call sites —
// no source-text regex, no readFileSync.
describe('resolveCursorLayout', () => {
it('uses curRefCurrent (the fresh ref value), not the stale cur state', () => {
// Simulate the exact bug scenario: `cur` (React state) is stale —
// it still reflects the value before a fast-echo append — while
// `curRef.current` has already advanced past it.
const display = 'hello world'
const staleCur = 5
const freshCurRefCurrent = 11
const columns = 80
const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns)
const expected = cursorLayout(display, freshCurRefCurrent, columns)
expect(result).toEqual(expected)
})
it('does not match the layout computed from the stale cur value', () => {
const display = 'hello world'
const staleCur = 5
const freshCurRefCurrent = 11
const columns = 80
const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns)
const staleLayout = cursorLayout(display, staleCur, columns)
expect(result).not.toEqual(staleLayout)
})
it('matches cursorLayout(display, curRefCurrent, columns) even when cur and curRefCurrent agree', () => {
const display = 'hello'
const cur = 5
const columns = 80
expect(resolveCursorLayout(display, cur, cur, columns)).toEqual(cursorLayout(display, cur, columns))
})
})
describe('fastBackspaceEffect', () => {
it('removes the last character, moves the cursor back one, and pairs the write with the advance delta', () => {
const effect = fastBackspaceEffect('hello', 5)
expect(effect.newValue).toBe('hell')
expect(effect.newCursor).toBe(4)
expect(effect.removed).toBe('o')
// Both the stdout write and the noteCursorAdvance delta live on the
// same returned object — a caller cannot apply `write` without also
// having `advanceDelta` in hand, so the pairing can't silently drift.
expect(effect.write).toBe('\b \b')
expect(effect.advanceDelta).toBe(-1)
})
it('handles deleting from the middle of the fast-echo-eligible tail', () => {
const effect = fastBackspaceEffect('abc', 3)
expect(effect.newValue).toBe('ab')
expect(effect.newCursor).toBe(2)
expect(effect.removed).toBe('c')
expect(effect.write).toBe('\b \b')
expect(effect.advanceDelta).toBe(-1)
})
})
describe('fastAppendEffect', () => {
it('appends the text, advances the cursor by the inserted length, and pairs the write with the advance delta', () => {
const effect = fastAppendEffect('hello', 5, ' world')
expect(effect.newValue).toBe('hello world')
expect(effect.newCursor).toBe(11)
// The stdout write is exactly the inserted text, and the
// noteCursorAdvance delta is bundled into the same object.
expect(effect.write).toBe(' world')
expect(effect.advanceDelta).toBe(' world'.length)
})
it('advance delta always matches the inserted text length, not a hardcoded value', () => {
const effect = fastAppendEffect('x', 1, 'abc')
expect(effect.newValue).toBe('xabc')
expect(effect.advanceDelta).toBe(3)
expect(effect.write).toBe('abc')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
import { cutSelection } from '../components/textInput.js'
describe('cutSelection (transactional cut)', () => {
it('removes the selection only after the clipboard write succeeds', async () => {
const write = vi.fn().mockResolvedValue(true)
const removeSelection = vi.fn()
const ok = await cutSelection('hello', write, removeSelection)
expect(ok).toBe(true)
expect(write).toHaveBeenCalledWith('hello')
expect(removeSelection).toHaveBeenCalledOnce()
})
it('keeps the text intact when the clipboard write fails (headless/SSH)', async () => {
const write = vi.fn().mockResolvedValue(false)
const removeSelection = vi.fn()
const ok = await cutSelection('hello', write, removeSelection)
expect(ok).toBe(false)
expect(write).toHaveBeenCalledWith('hello')
// Text must NOT be removed. A failed write would otherwise destroy it with no
// clipboard copy to paste back.
expect(removeSelection).not.toHaveBeenCalled()
})
it('awaits the write before removing (no fire-and-forget removal)', async () => {
let resolveWrite: (value: boolean) => void = () => {}
const write = vi.fn(
() =>
new Promise<boolean>(resolve => {
resolveWrite = resolve
})
)
const removeSelection = vi.fn()
const pending = cutSelection('hello', write, removeSelection)
// While the write is still pending the selection must remain untouched.
await Promise.resolve()
expect(removeSelection).not.toHaveBeenCalled()
resolveWrite(true)
await pending
expect(removeSelection).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,311 @@
import { colorize } from '@hermes/ink'
import { describe, expect, it } from 'vitest'
import {
canFastAppendShape,
canFastBackspaceShape,
colorizeEcho,
colorizeHint,
hintCursorCell,
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('colorizeEcho', () => {
// The fast-echo bypass writes raw cells past Ink, so a themed input must
// carry the theme fg explicitly — a default-fg glyph goes invisible when a
// skin repaints the background to the opposite polarity (dark skin on a
// light terminal ⇒ black-on-black).
it('matches Ink exactly, never a hand-rolled truecolor escape', () => {
// The bypass and the Ink render paint the same cells, so they must agree
// byte-for-byte at whatever depth the terminal supports. Hand-rolling
// `38;2;r;g;b` shipped an escape a 256-color terminal (Apple Terminal)
// cannot parse: the accent fell back to the default fg and read GRAY.
// Asserted as an equality rather than a literal because chalk resolves
// its depth at import time — under vitest that's level 0 (no color).
for (const tone of ['#ff2d95', '#e77fa3', 'ansi256(211)']) {
expect(colorizeEcho('x', tone)).toBe(colorize('x', tone, 'foreground'))
}
})
it('passes through untouched without a color (unthemed keeps terminal default)', () => {
expect(colorizeEcho('x')).toBe('x')
expect(colorizeEcho('x', undefined)).toBe('x')
})
it('passes through on a non-color value (never emit a garbage SGR)', () => {
expect(colorizeEcho('x', 'red')).toBe('x')
expect(colorizeEcho('x', '#fff')).toBe('x')
})
})
describe('colorizeHint / hintCursorCell', () => {
// The placeholder bypass writes raw bytes past Ink too. Hand-rolling
// `38;2;r;g;b` here was WORSE than the gray-accent bug colorizeEcho had:
// legacy Terminal.app walks compound params one by one, so the literal `2`
// in `38;2;…` landed as SGR 2 (dim ON) with no closing `22m` — every
// frame that painted the placeholder left the terminal's dim flag stuck,
// and later unstyled cells rendered randomly dimmed. Both helpers must
// route through Ink's own colorize so depth downgrades with the terminal.
it('hint matches Ink exactly, never a hand-rolled truecolor escape', () => {
for (const tone of ['#8a8094', '#e77fa3']) {
expect(colorizeHint('Try it', tone)).toBe(colorize('Try it', tone, 'foreground'))
}
})
it('hint falls back to the neutral gray on junk, still through colorize', () => {
expect(colorizeHint('x')).toBe(colorize('x', '#808080', 'foreground'))
expect(colorizeHint('x', 'nope')).toBe(colorize('x', '#808080', 'foreground'))
})
it('cursor chip composes bg+fg through colorize only', () => {
expect(hintCursorCell('T', '#8a8094')).toBe(
colorize(colorize('T', '#ffffff', 'foreground'), '#8a8094', 'background')
)
})
it('never emits a raw 38;2/48;2 the depth layer did not choose', () => {
// chalk is level 0 under vitest, so ANY escape byte here means the
// helper bypassed colorize and hand-rolled the sequence.
expect(colorizeHint('x', '#8a8094')).not.toContain('\u001b')
expect(hintCursorCell('x', '#8a8094')).not.toContain('\u001b')
})
})
describe('supportsFastEchoTerminal', () => {
it('disables fast-echo in Apple Terminal', () => {
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
})
it('disables fast-echo inside tmux', () => {
expect(supportsFastEchoTerminal({ TMUX: '/tmp/tmux-1000/default,1234,0' } as NodeJS.ProcessEnv)).toBe(false)
expect(supportsFastEchoTerminal({ TMUX: '/private/tmp/tmux-501/default' } as NodeJS.ProcessEnv)).toBe(false)
})
it('tmux wins over Termux fast-echo opt-in', () => {
expect(
supportsFastEchoTerminal({
TMUX: '/tmp/tmux-1000/default,1234,0',
HERMES_TUI_TERMUX_FAST_ECHO: '1',
TERMUX_VERSION: '0.118.0'
} as NodeJS.ProcessEnv)
).toBe(false)
})
it('keeps fast-echo enabled when TMUX is empty or unset', () => {
expect(supportsFastEchoTerminal({ TMUX: '' } as NodeJS.ProcessEnv)).toBe(true)
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
})
it('disables fast-echo when only a tmux-flavored TERM is present (SSH from tmux, no TMUX forwarded)', () => {
// OpenSSH forwards TERM but not TMUX, so a TUI on a remote host launched
// from inside local tmux sees TERM=tmux-256color with no TMUX var. The
// cursor-drift bug still applies, so fast-echo must stay off.
expect(supportsFastEchoTerminal({ TERM: 'tmux' } as NodeJS.ProcessEnv)).toBe(false)
expect(supportsFastEchoTerminal({ TERM: 'tmux-256color' } as NodeJS.ProcessEnv)).toBe(false)
})
it('does NOT disable fast-echo for screen-flavored TERM (GNU screen out of scope, no reported drift)', () => {
// GNU screen sets TERM=screen/screen-256color and has no reported drift.
// We must not widen the tmux guard to screen* and regress its perf.
expect(supportsFastEchoTerminal({ TERM: 'screen' } as NodeJS.ProcessEnv)).toBe(true)
expect(supportsFastEchoTerminal({ TERM: 'screen-256color' } as NodeJS.ProcessEnv)).toBe(true)
// And an unrelated 256color TERM must stay enabled.
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
})
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,62 @@
import { describe, expect, it } from 'vitest'
import { killToLineEnd, killToLineStart } from '../components/textInput.js'
// Ctrl+U / Ctrl+K are readline motions scoped to the *current logical line*,
// not the whole buffer. Claude Code documents Ctrl+U as "repeat to clear
// across lines in multiline input", which only works if a press at a line
// boundary consumes the newline and makes progress.
describe('killToLineStart', () => {
it('clears the whole value in single-line input', () => {
expect(killToLineStart('hello world', 11)).toEqual({ cursor: 0, value: '' })
})
it('keeps text after the cursor', () => {
expect(killToLineStart('hello world', 6)).toEqual({ cursor: 0, value: 'world' })
})
it('only kills the current line, leaving earlier lines intact', () => {
expect(killToLineStart('one\ntwo', 7)).toEqual({ cursor: 4, value: 'one\n' })
})
it('consumes the newline when already at a line start, so repeats progress', () => {
// Second press from the position the first press left us at.
expect(killToLineStart('one\n', 4)).toEqual({ cursor: 3, value: 'one' })
})
it('repeated presses walk up a multiline draft to empty', () => {
let state = { cursor: 11, value: 'one\ntwo\nsix' }
const seen: string[] = []
for (let i = 0; i < 6 && state.value !== ''; i++) {
state = killToLineStart(state.value, state.cursor)
seen.push(state.value)
}
expect(seen).toEqual(['one\ntwo\n', 'one\ntwo', 'one\n', 'one', ''])
expect(state).toEqual({ cursor: 0, value: '' })
})
it('is a no-op at the very start of the buffer', () => {
expect(killToLineStart('abc', 0)).toEqual({ cursor: 0, value: 'abc' })
})
})
describe('killToLineEnd', () => {
it('kills to end of a single-line value', () => {
expect(killToLineEnd('hello world', 6)).toEqual({ cursor: 6, value: 'hello ' })
})
it('stops at the newline, leaving later lines intact', () => {
expect(killToLineEnd('one\ntwo', 0)).toEqual({ cursor: 0, value: '\ntwo' })
})
it('consumes the newline when already at a line end, joining the next line', () => {
expect(killToLineEnd('one\ntwo', 3)).toEqual({ cursor: 3, value: 'onetwo' })
})
it('is a no-op at the very end of the buffer', () => {
expect(killToLineEnd('abc', 3)).toEqual({ cursor: 3, value: 'abc' })
})
})
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { isLineKillModifier } from '../components/textInput.js'
// Cmd+Backspace should kill to the line boundary, but the modifier it is
// distinguished by matters a great deal. `isActionMod` is the wrong test:
// - on macOS it accepts `key.meta`, and hermes-ink reports Option as
// `meta` — so Option+Backspace (delete-word, the macOS standard) would
// silently become "delete the whole line".
// - on Linux/Windows it is `key.ctrl`, and Ctrl+Backspace is delete-word
// there in readline, VS Code, browsers, and Windows Terminal.
// Only the kitty CSI-u / modifyOtherKeys `super` bit means Cmd.
const key = (over: Partial<{ ctrl: boolean; meta: boolean; super: boolean }> = {}) => ({
ctrl: false,
meta: false,
super: false,
...over
})
describe('isLineKillModifier', () => {
it('accepts the super bit (Cmd via kitty CSI-u / modifyOtherKeys)', () => {
expect(isLineKillModifier(key({ super: true }))).toBe(true)
})
it('accepts super even when the terminal also sets a benign ctrl bit', () => {
// VS Code/Cursor forward Cmd chords as CSI-u with super + ctrl set.
expect(isLineKillModifier(key({ ctrl: true, super: true }))).toBe(true)
})
it('rejects meta so Option+Backspace stays delete-word on macOS', () => {
expect(isLineKillModifier(key({ meta: true }))).toBe(false)
})
it('rejects ctrl so Ctrl+Backspace stays delete-word on Linux/Windows', () => {
expect(isLineKillModifier(key({ ctrl: true }))).toBe(false)
})
it('rejects an unmodified keypress', () => {
expect(isLineKillModifier(key())).toBe(false)
})
it('treats a missing super field as absent rather than truthy', () => {
expect(isLineKillModifier({ ctrl: false, meta: false })).toBe(false)
})
})
@@ -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,53 @@
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('o', 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,89 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalPlatform = process.platform
async function importTextInput(platform: NodeJS.Platform) {
vi.resetModules()
Object.defineProperty(process, 'platform', { value: platform })
return import('../components/textInput.js')
}
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.resetModules()
})
const key = (overrides: Record<string, unknown> = {}) =>
({ ctrl: false, meta: false, return: true, shift: false, super: false, ...overrides }) as any
describe('shouldInsertNewlineOnReturn', () => {
it('keeps plain Enter (CR) as submit on macOS', async () => {
const { shouldInsertNewlineOnReturn } = await importTextInput('darwin')
expect(shouldInsertNewlineOnReturn(key(), '\r')).toBe(false)
})
it('accepts bare LF as a macOS multiline fallback', async () => {
const { shouldInsertNewlineOnReturn } = await importTextInput('darwin')
expect(shouldInsertNewlineOnReturn(key(), '\n')).toBe(true)
})
it('inserts a newline for explicit modified Enter chords on macOS', async () => {
const { shouldInsertNewlineOnReturn } = await importTextInput('darwin')
expect(shouldInsertNewlineOnReturn(key({ ctrl: true }), '\r')).toBe(true)
expect(shouldInsertNewlineOnReturn(key({ shift: true }), '\r')).toBe(true)
expect(shouldInsertNewlineOnReturn(key({ meta: true }), '\r')).toBe(true)
expect(shouldInsertNewlineOnReturn(key({ super: true }), '\r')).toBe(true)
})
it('inserts a newline for Shift/Ctrl Enter on non-macOS', async () => {
const { shouldInsertNewlineOnReturn } = await importTextInput('linux')
expect(shouldInsertNewlineOnReturn(key({ shift: true }), '\r')).toBe(true)
expect(shouldInsertNewlineOnReturn(key({ ctrl: true }), '\r')).toBe(true)
})
it('keeps plain Enter as submit on a plain non-macOS terminal', async () => {
const prev = { ...process.env }
for (const k of [
'SSH_CONNECTION',
'SSH_CLIENT',
'SSH_TTY',
'WT_SESSION',
'GHOSTTY_RESOURCES_DIR',
'GHOSTTY_BIN_DIR',
'WSL_DISTRO_NAME'
]) {
delete process.env[k]
}
process.env.TERM = 'xterm-256color'
process.env.TERM_PROGRAM = ''
try {
const { shouldInsertNewlineOnReturn } = await importTextInput('linux')
expect(shouldInsertNewlineOnReturn(key(), '\n')).toBe(false)
expect(shouldInsertNewlineOnReturn(key(), '\r')).toBe(false)
} finally {
process.env = prev
}
})
it('accepts a bare LF as a multiline fallback over SSH on non-macOS', async () => {
const prev = { ...process.env }
process.env.SSH_CONNECTION = '10.0.0.1 22 10.0.0.2 22'
try {
const { shouldInsertNewlineOnReturn } = await importTextInput('linux')
expect(shouldInsertNewlineOnReturn(key(), '\n')).toBe(true)
} finally {
process.env = prev
}
})
})
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { valueForReturnSubmit } from '../components/textInput.js'
describe('valueForReturnSubmit', () => {
it('includes printable input that arrives in the same keypress as return', () => {
expect(valueForReturnSubmit('为什么打字上屏,', 8, '会丢失内容')).toEqual({
cursor: 13,
value: '为什么打字上屏,会丢失内容'
})
})
it('keeps IME commit text when it arrives in the same burst as return', () => {
expect(valueForReturnSubmit('为什么打字上屏,', 8, '会丢失内容\r')).toEqual({
cursor: 13,
value: '为什么打字上屏,会丢失内容'
})
})
it('leaves the draft unchanged when return carries no printable input', () => {
expect(valueForReturnSubmit('hello', 5, '')).toEqual({ cursor: 5, value: 'hello' })
expect(valueForReturnSubmit('hello', 5, '\r')).toEqual({ cursor: 5, value: 'hello' })
expect(valueForReturnSubmit('hello', 5, '\n')).toEqual({ cursor: 5, value: 'hello' })
})
})
@@ -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,107 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { renderSync } from '@hermes/ink'
import React, { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { TextInput } from '../components/textInput.js'
class FakeInput extends EventEmitter {
chunks: string[] = []
isRaw = false
isTTY = true
readableLength = 0
read() {
const next = this.chunks.shift() ?? null
this.readableLength = this.chunks.length
return next
}
ref = vi.fn()
send(...chunks: string[]) {
this.chunks.push(...chunks)
this.readableLength = this.chunks.length
this.emit('readable')
}
setEncoding = vi.fn()
setRawMode = vi.fn((enabled: boolean) => {
this.isRaw = enabled
})
unref = vi.fn()
}
const settle = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms))
function makeStreams() {
const stdin = new FakeInput()
const stdout = new PassThrough()
const stderr = new PassThrough()
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
Object.assign(stderr, { columns: 80, isTTY: false, rows: 24 })
return { stderr, stdin, stdout }
}
describe('TextInput submit clearing', () => {
it('accepts the parent clear after a Korean IME commit immediately followed by Enter', async () => {
const streams = makeStreams()
const changes: string[] = []
const submits: string[] = []
function Harness() {
const [value, setValue] = useState('')
return (
<TextInput
columns={80}
onChange={next => {
changes.push(next)
setValue(next)
}}
onSubmit={text => {
submits.push(text)
setValue('')
}}
value={value}
/>
)
}
const instance = renderSync(React.createElement(Harness), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as unknown as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
await settle()
const prefix = '한글을 사용하면 마지막 문자가 남아있는 버그가 있어 리포트해'
const finalSyllable = '줘'
const full = prefix + finalSyllable
streams.stdin.send(prefix)
await settle(25)
streams.stdin.send(finalSyllable, '\r')
await settle(25)
streams.stdin.send('x')
await settle(25)
instance.unmount()
instance.cleanup()
expect(submits).toEqual([full])
expect(changes.at(-1)).toBe('x')
expect(changes).not.toContain(`${full}x`)
})
})
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { InputEvent } from '../../packages/hermes-ink/src/ink/events/input-event.js'
import { INITIAL_STATE, parseMultipleKeypresses } from '../../packages/hermes-ink/src/ink/parse-keypress.js'
import { deleteWordForward } from '../components/textInput.js'
function parseOne(sequence: string) {
const [keys] = parseMultipleKeypresses(INITIAL_STATE, sequence)
expect(keys).toHaveLength(1)
return keys[0]!
}
// The web dashboard maps Ctrl+Delete to ESC d (see
// web/src/lib/pty-keyboard-shortcuts.ts). hermes-ink decodes that bare
// meta-letter form via META_KEY_CODE_RE. If this contract ever changes the
// `wordMod && inp === 'd'` binding in textInput.tsx stops firing and
// Ctrl+Delete regresses to typing a literal "d".
describe('Ctrl+Delete → ESC d decode contract', () => {
it('decodes ESC d as meta+"d" so the composer binding is reached', () => {
const event = new InputEvent(parseOne('\x1bd'))
expect(event.key.meta).toBe(true)
expect(event.key.ctrl).toBe(false)
expect(event.input).toBe('d')
})
})
describe('deleteWordForward', () => {
it('deletes the word to the right of the cursor', () => {
// cursor before "hello" → removes "hello" and the trailing space.
expect(deleteWordForward('foo hello world', 4)).toEqual({ cursor: 4, value: 'foo world' })
})
it('deletes from mid-word to the next word boundary', () => {
// cursor inside "hello" (after "he") → removes "llo" + trailing space.
expect(deleteWordForward('foo hello world', 6)).toEqual({ cursor: 6, value: 'foo heworld' })
})
it('keeps the cursor fixed while removing text', () => {
const result = deleteWordForward('alpha beta', 0)
expect(result.cursor).toBe(0)
expect(result.value).toBe('beta')
})
it('is a no-op when the cursor is already at the end', () => {
expect(deleteWordForward('foo bar', 7)).toEqual({ cursor: 7, value: 'foo bar' })
})
it('handles an empty string', () => {
expect(deleteWordForward('', 0)).toEqual({ cursor: 0, value: '' })
})
})
+151
View File
@@ -0,0 +1,151 @@
import { wrapAnsi } from '@hermes/ink'
import { describe, expect, it } from 'vitest'
import { offsetFromPosition } from '../components/textInput.js'
import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
// Helper: compute the "end of text" position that wrap-ansi would render
// the input to. This is what Ink's <Text wrap="wrap"> uses, so cursorLayout
// MUST agree. Disagreement is the cursor-drift bug.
function wrapAnsiEndPosition(text: string, cols: number): { line: number; column: number } {
const wrapped = wrapAnsi(text, cols, { hard: true, trim: false })
const lines = wrapped.split('\n')
const last = lines[lines.length - 1] ?? ''
return { line: lines.length - 1, column: last.length }
}
describe('cursorLayout — word-wrap parity with wrap-ansi', () => {
it('places cursor mid-line at its column', () => {
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
})
it('places cursor at end of a non-full line', () => {
expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 })
})
it('does not push exact-fill text onto a phantom next line', () => {
// Regression: the previous hand-rolled wrap algorithm forced the cursor
// onto (line+1, 0) when the text exactly filled the row. wrap-ansi keeps
// it on the same row (no soft-wrap), so the cursor must too — otherwise
// useDeclaredCursor parks the hardware cursor below the last char and
// the user sees several blank cells between text and cursor block
// (#cursor-drift-multiline).
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 8, line: 0 })
expect(cursorLayout('abcdefgh', 8, 8)).toEqual(wrapAnsiEndPosition('abcdefgh', 8))
})
it('keeps short words on the current line when they fit (no phantom wrap)', () => {
// wrap-ansi: "hello wo" at cols=8 stays as one line "hello wo".
// The old cursorLayout incorrectly pushed to (1,0) because column=8 hit
// the column>=width check, but that disagreed with what Ink actually
// rendered.
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 8, line: 0 })
expect(cursorLayout('hello wo', 8, 8)).toEqual(wrapAnsiEndPosition('hello wo', 8))
})
it('moves words across wrap boundaries instead of splitting them', () => {
// "hello wor" at cols=8: wrap-ansi breaks at the space, "hello \nwor".
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 3, line: 1 })
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 4, line: 1 })
expect(cursorLayout('hello world', 11, 8)).toEqual({ column: 5, line: 1 })
// Each must match what wrap-ansi would actually render.
expect(cursorLayout('hello wor', 9, 8)).toEqual(wrapAnsiEndPosition('hello wor', 8))
expect(cursorLayout('hello worl', 10, 8)).toEqual(wrapAnsiEndPosition('hello worl', 8))
expect(cursorLayout('hello world', 11, 8)).toEqual(wrapAnsiEndPosition('hello world', 8))
})
it('wraps the next word instead of splitting it at the right edge', () => {
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
expect(cursorLayout(text, text.length, 70)).toEqual({ column: 4, line: 1 })
expect(inputVisualHeight(text, 70)).toBe(2)
})
it('honours explicit newlines', () => {
expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 })
expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 })
})
it('does not wrap when cursor is before the right edge', () => {
expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 })
})
it('matches wrap-ansi end-position for typing-style incremental input', () => {
// Pins the actual fix: type a long message char-by-char at a narrow
// width and assert the cursor follows wrap-ansi every step of the way.
// Before the fix, ~5 boundary positions per pass disagreed and Ink
// parked the cursor several cells past the last rendered character.
const MSG = 'on a new bb branch investigate and fix the cursor drift bug here'
for (const cols of [10, 14, 20, 30, 50, 80]) {
let acc = ''
for (const ch of MSG) {
acc += ch
expect(cursorLayout(acc, acc.length, cols)).toEqual(wrapAnsiEndPosition(acc, cols))
}
}
})
})
describe('input metrics helpers', () => {
it('computes visual height matching wrap-ansi line count', () => {
// Exact-fill text stays on one line in wrap-ansi (no phantom wrap), so
// visual height is 1. The previous implementation reported 2 here.
expect(inputVisualHeight('abcdefgh', 8)).toBe(1)
expect(inputVisualHeight('one\ntwo', 40)).toBe(2)
// Multi-line wrap case sanity
expect(inputVisualHeight('hello world', 8)).toBe(2)
})
it('counts the prompt gap as its own cell', () => {
expect(composerPromptWidth('>')).toBe(2)
expect(composerPromptWidth('')).toBe(2)
expect(composerPromptWidth('Ψ >')).toBe(4)
})
it('reserves gutters on wide panes without starving narrow composer width', () => {
expect(stableComposerColumns(100, 3)).toBe(93)
expect(stableComposerColumns(100, 5)).toBe(91)
expect(stableComposerColumns(10, 3)).toBe(5)
expect(stableComposerColumns(6, 3)).toBe(1)
})
})
describe('offsetFromPosition — word-wrap inverse of cursorLayout', () => {
it('returns 0 for empty input', () => {
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
})
it('maps clicks within a single line', () => {
expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3)
})
it('maps clicks past end to value length', () => {
expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2)
})
it('maps clicks on a wrapped second row at cols boundary', () => {
// Long words still hard-wrap when there is no word boundary.
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
})
it('maps clicks on a word-wrapped second row', () => {
// "hello world" at cols=8 wraps to "hello \nworld".
expect(offsetFromPosition('hello world', 1, 0, 8)).toBe(6)
expect(offsetFromPosition('hello world', 1, 3, 8)).toBe(9)
})
it('maps clicks on the moved final word', () => {
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
expect(offsetFromPosition(text, 1, 0, 70)).toBe(text.indexOf('wish'))
expect(offsetFromPosition(text, 1, 3, 70)).toBe(text.indexOf('wish') + 3)
})
it('maps clicks past a \\n into the target line', () => {
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
})
})
+683
View File
@@ -0,0 +1,683 @@
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 (polarity-compatible)', async () => {
// Light terminal + light-authored menu fill: the skin's fill is honored
// and the current-row derivation mixes off it.
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
expect(theme.color.completionBg).toBe('#ffffff')
// Active row = authored surface mixed toward the accent (ladder knob).
expect(theme.color.completionCurrentBg).toBe('#c7c7c7')
})
it('rejects wrong-polarity fills even when skin-authored (terminal owns the canvas)', async () => {
// Dark terminal + white menu fill: unlike the desktop app, the TUI cannot
// paint its own canvas, so cross-polarity fills fall back to the derived
// ladder values, which are mixed from the real background and therefore
// polarity-correct by construction.
const { fromSkin } = await importThemeWithCleanEnv()
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
expect(luminance(theme.color.completionBg)).toBeLessThanOrEqual(0.35)
expect(luminance(theme.color.completionCurrentBg)).toBeLessThanOrEqual(0.35)
})
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)')
})
// ── A skin that authors a background OWNS its polarity ──────────────
// The TUI paints the terminal with the skin's background (OSC-11), so
// every adaptation pass must run against the skin's canvas, not the host
// profile the skin just covered. The real-world failure: a pure-black
// skin on light-mode Apple Terminal got its text ansi256-bucketed for a
// light background that no longer exists — invisible on the painted black.
it('a dark-background skin on light Apple Terminal keeps its truecolor text (no light-mode bucketing)', async () => {
const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' })
const theme = fromSkin({ background: '#000000', ui_accent: '#ff9e18', ui_text: '#ffa726' }, {})
expect(theme.color.text).toBe('#ffa726')
expect(theme.color.prompt).not.toMatch(/^ansi256/)
})
it('a skin background outranks the cached host background for adaptation and tone derivation', async () => {
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const theme = fromSkin({ background: '#000000', ui_text: '#ffa726' }, {})
// Text is not contrast-lifted toward a white host it painted over…
expect(theme.color.text).toBe('#ffa726')
// …and derived fills mix against the skin's black, not the host's white.
expect(luminance(theme.color.completionBg)).toBeLessThanOrEqual(0.35)
expect(luminance(theme.color.statusBg)).toBeLessThanOrEqual(0.35)
})
it('skinIsLight: the authored background decides; host detection only when absent', async () => {
const { skinIsLight } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
expect(skinIsLight({ background: '#000000' })).toBe(false)
expect(skinIsLight({ background: '#f5f5f5' })).toBe(true)
expect(skinIsLight({})).toBe(true) // no canvas of its own → host polarity
})
it('keeps truecolor light Apple Terminal in truecolor (adapting, not ansi256-bucketing)', async () => {
const { contrastRatio, fromSkin } = await importThemeWithEnv({
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Apple_Terminal'
})
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
// No ansi256 bucketing on truecolor terminals — a truly invisible cream
// (1.08:1 on white) still gets the display shim's gentle light-mode rescue
// (floor 1.18: enough to make near-white text appear, not enough to crush
// the vivid golds into mud).
expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i)
expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
})
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' }, {})
// The exact value may be contrast-lifted against the background; the
// contract is the cascade (ok drives statusGood) and the hue surviving.
expect(color.statusGood).toBe(color.ok)
expect(color.ok).toMatch(/^#[0-9a-f]{6}$/i)
expect(luminance(color.ok)).toBeGreaterThan(0)
})
})
// Rec. 709-ish relative luminance, local to the test so assertions are
// independent of the implementation under test.
const luminance = (hex: string): number => {
const n = parseInt(hex.replace('#', ''), 16)
const channel = (v: number) => {
const c = v / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel((n >> 16) & 0xff) + 0.7152 * channel((n >> 8) & 0xff) + 0.0722 * channel(n & 0xff)
}
// The bundled slate skin's actual color block — dark-authored (pale pastels,
// no completion/selection backgrounds defined).
const SLATE_COLORS = {
banner_accent: '#8EA8FF',
banner_border: '#4169e1',
banner_dim: '#4b5563',
banner_text: '#c9d1d9',
banner_title: '#7eb8f6',
prompt: '#c9d1d9',
session_border: '#4b5563',
session_label: '#7eb8f6',
ui_accent: '#7eb8f6',
ui_error: '#F7A072',
ui_label: '#8EA8FF',
ui_ok: '#63D0A6',
ui_warn: '#e6a855'
}
// Max per-channel deviation between two hexes.
const channelDelta = (a: string, b: string) => {
const pa = parseInt(a.replace('#', ''), 16)
const pb = parseInt(b.replace('#', ''), 16)
return Math.max(
Math.abs(((pa >> 16) & 0xff) - ((pb >> 16) & 0xff)),
Math.abs(((pa >> 8) & 0xff) - ((pb >> 8) & 0xff)),
Math.abs((pa & 0xff) - (pb & 0xff))
)
}
describe('derived tone ladder', () => {
it('reproduces the original hand-tuned tones from seeds (reverse-engineered knobs)', async () => {
// The ladder's knobs were grid-search fitted so the MATH lands on the
// pre-refactor hand-tuned literals. Contract: every derived tone stays
// within a-few-RGB-units of the original (imperceptible), so knob edits
// that drift the classic look fail here instead of shipping as vibes.
const dark = await importThemeWithCleanEnv()
const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const cases: Array<[string, string, string]> = [
[dark.DARK_THEME.color.muted, '#CC9B1F', 'dark muted'],
[dark.DARK_THEME.color.label, '#DAA520', 'dark label'],
[dark.DARK_THEME.color.statusFg, '#C0C0C0', 'dark statusFg'],
[dark.DARK_THEME.color.completionBg, '#1a1a2e', 'dark surface'],
[dark.DARK_THEME.color.completionCurrentBg, '#333355', 'dark chip'],
[dark.DARK_THEME.color.selectionBg, '#3a3a55', 'dark selection'],
// Light canon = liftForContrast(dark literal, white, 4.5): the exact
// colors xterm's minimumContrastRatio rendered on light hosts.
[light.LIGHT_THEME.color.muted, '#946C08', 'light muted'],
[light.LIGHT_THEME.color.statusFg, '#6F6F6F', 'light statusFg'],
[light.LIGHT_THEME.color.completionBg, '#F5F5F5', 'light surface'],
[light.LIGHT_THEME.color.completionCurrentBg, '#e0d1bf', 'light chip'],
[light.LIGHT_THEME.color.selectionBg, '#D4E4F7', 'light selection']
]
for (const [got, original, label] of cases) {
expect(channelDelta(got, original), `${label}: ${got} vs original ${original}`).toBeLessThanOrEqual(8)
}
})
it('derives dim/secondary tones from the skin identity, not another palette', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
// A seeds-only skin (no dim/label/menu keys authored at all).
const { color } = fromSkin({ banner_accent: '#DD4A3A', banner_text: '#F1E6CF', banner_title: '#C7A96B' }, {})
// Muted recedes from THIS skin's text toward the background with an
// accent tint — a red-family derivative, never another skin's gold.
expect(color.muted).not.toBe(color.text)
expect(luminance(color.muted)).toBeLessThan(luminance(color.text))
const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
const [mr, , mb] = rgb(color.muted)
expect(mr).toBeGreaterThan(mb!)
// The active-row chip is the surface tinted with the skin accent —
// redder than the plain surface.
const [sr] = rgb(color.completionBg)
expect(rgb(color.completionCurrentBg)[0]).toBeGreaterThan(sr!)
})
it('authored tones still override the ladder', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ banner_dim: '#AA8844', banner_text: '#F1E6CF' }, {})
expect(color.muted).toBe('#AA8844')
expect(color.sessionLabel).toBe('#AA8844')
})
})
describe('background-aware adaptation (OSC-11 light terminals)', () => {
it('renders a dark-authored skin on light like minimumContrastRatio hosts do (the standardized look)', async () => {
const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
const { color } = fromSkin(SLATE_COLORS, {})
// The authored palette IS the design: slate's airy pastels (~1.5:1) pass
// through BYTE-IDENTICAL — that receded look is the standardized
// rendering, not a washout to fix.
expect(color.text.toLowerCase()).toBe('#c9d1d9')
expect(color.accent.toLowerCase()).toBe('#7eb8f6')
expect(color.muted.toLowerCase()).toBe('#4b5563')
// Light mode renders the authored palette essentially RAW: a transparent
// terminal (the common Cursor case) applies no contrast lift of its own,
// and the beloved classic look is the vivid palette, not a WCAG-darkened
// one. Foregrounds only clear the near-invisible floor (1.18).
for (const key of ['text', 'prompt', 'accent', 'label', 'primary', 'muted', 'border'] as const) {
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.18)
}
// Semantic alert colors carry meaning — firmer floor, still gentle on light.
for (const key of ['ok', 'error', 'warn', 'statusGood', 'statusCritical'] as const) {
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.6)
}
// Background roles the skin never defined must be light-polarity fills,
// not the dark base's navy.
for (const key of ['completionBg', 'completionCurrentBg', 'statusBg', 'selectionBg'] as const) {
expect(luminance(color[key]), `${key} ${color[key]}`).toBeGreaterThanOrEqual(0.4)
}
})
it('rescues near-invisible colors with a hue-preserving multiplicative lift', async () => {
const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
// The default dark cream (#FFF8DC, 1.08:1 on white) is genuinely invisible.
const { color } = fromSkin({ banner_text: '#FFF8DC' }, {})
expect(color.text.toLowerCase()).not.toBe('#fff8dc')
expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
// Multiplicative lift preserves channel ordering (warm stays warm).
const [r, g, b] = [1, 3, 5].map(i => parseInt(color.text.slice(i, i + 2), 16))
expect(r).toBeGreaterThanOrEqual(g!)
expect(g).toBeGreaterThanOrEqual(b!)
})
it('leaves the same skin untouched on a dark background', async () => {
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#1e1e2e' })
const { color } = fromSkin(SLATE_COLORS, {})
expect(color.text).toBe('#c9d1d9')
expect(color.accent).toBe('#7eb8f6')
expect(luminance(color.completionBg)).toBeLessThanOrEqual(0.35)
})
it('empty skin on a light background resolves to the light base palette', async () => {
const { fromSkin, LIGHT_THEME } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
expect(fromSkin({}, {}).color).toEqual(LIGHT_THEME.color)
})
it('base palettes are fixed points of the adaptation', async () => {
const dark = await importThemeWithCleanEnv()
expect(dark.fromSkin({}, {}).color).toEqual(dark.DARK_THEME.color)
const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
expect(light.fromSkin({}, {}).color).toEqual(light.LIGHT_THEME.color)
})
it('defaultThemeForCurrentBackground follows a late HERMES_TUI_BACKGROUND write', async () => {
const { DARK_THEME, DEFAULT_THEME, defaultThemeForCurrentBackground, LIGHT_THEME } = await importThemeWithCleanEnv()
// Module loaded dark (clean env)…
expect(DEFAULT_THEME.color.completionBg).toBe(DARK_THEME.color.completionBg)
expect(luminance(DEFAULT_THEME.color.completionBg)).toBeLessThanOrEqual(0.35)
// …then the OSC-11 answer lands and is cached into the env slot.
expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color)
})
it('gives tool + thinking their own keys, defaulting to accent + muted', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
// Independent override: recoloring tool/thinking doesn't leak into accent.
// (Values flow through #20379's contrast adaptation, so assert the
// independence contract, not raw pre-adaptation hexes.)
const themed = fromSkin({ ui_accent: '#3aa0ff', ui_tool: '#ff0000', ui_thinking: '#00ff00' }, {})
const baseline = fromSkin({ ui_accent: '#3aa0ff' }, {})
expect(themed.color.tool).toBe('#ff0000')
expect(themed.color.thinking).toBe('#00ff00')
expect(themed.color.tool).not.toBe(themed.color.accent)
expect(themed.color.accent).toBe(baseline.color.accent) // override didn't touch accent
// Default: tool follows accent, thinking follows muted — same source →
// identical after adaptation.
const fallback = fromSkin({ ui_accent: '#3aa0ff', banner_dim: '#8a8a8a' }, {})
expect(fallback.color.tool).toBe(fallback.color.accent)
expect(fallback.color.thinking).toBe(fallback.color.muted)
})
it('gives code syntax its own keys, defaulting to accent/text/border/muted', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const themed = fromSkin(
{ syntax_string: '#aa0000', syntax_number: '#00aa00', syntax_keyword: '#0000aa', syntax_comment: '#888888' },
{}
)
expect(themed.color.syntaxString).toBe('#aa0000')
expect(themed.color.syntaxNumber).toBe('#00aa00')
expect(themed.color.syntaxKeyword).toBe('#0000aa')
expect(themed.color.syntaxComment).toBe('#888888')
const fallback = fromSkin({ ui_accent: '#abcdef' }, {})
expect(fallback.color.syntaxString).toBe('#abcdef') // string follows accent
})
it('lets skins override diff colors', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin(
{ diff_added: '#0a0', diff_removed: '#a00', diff_added_word: '#0f0', diff_removed_word: '#f00' },
{}
)
expect(color.diffAdded).toBe('#0a0')
expect(color.diffRemoved).toBe('#a00')
expect(color.diffAddedWord).toBe('#0f0')
expect(color.diffRemovedWord).toBe('#f00')
})
it('maps the status bar from skin status_bar_* keys', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin(
{
status_bar_bg: '#101020',
status_bar_text: '#e0e0e0',
status_bar_bad: '#ff8800',
status_bar_critical: '#ff0000'
},
{}
)
expect(color.statusBg).toBe('#101020')
expect(color.statusFg).toBe('#e0e0e0')
expect(color.statusBad).toBe('#ff8800')
expect(color.statusCritical).toBe('#ff0000')
})
it('falls the status bar back to background + semantic colors', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ background: '#0a0a0a', banner_text: '#fafafa', ui_error: '#dd2222' }, {})
// background paints the surface → status/completion bg; banner_text → status
// fg; ui_error → critical. Semantic hues flow through contrast adaptation,
// so `statusCritical` is asserted to track `ui_error` identically rather
// than pinning an adapted hex.
expect(color.statusBg).toBe('#0a0a0a')
expect(color.completionBg).toBe('#0a0a0a')
expect(color.statusFg).toBe('#fafafa')
expect(color.statusCritical).toBe(fromSkin({ ui_error: '#dd2222' }, {}).color.error)
})
})
describe('themeToneHex', () => {
it('resolves a tone to the literal color it paints as', async () => {
const { themeToneHex } = await importThemeWithCleanEnv()
// 232+ is the grayscale ramp (8 + (n-232)*10); 16-231 is the 6x6x6 cube.
expect(themeToneHex('ansi256(238)')).toBe('#444444')
expect(themeToneHex('ansi256(161)')).toBe('#d7005f')
// An authored hex is already literal.
expect(themeToneHex('#e77fa3')).toBe('#e77fa3')
// No paintable color ⇒ '', which releases the terminal default.
expect(themeToneHex('')).toBe('')
expect(themeToneHex('ansi256(999)')).toBe('')
expect(themeToneHex('inherit')).toBe('')
})
it('makes every tone paintable on a quantizing terminal', async () => {
// The contract OSC-10 depends on: whatever the palette normalizer does to
// a tone, themeToneHex still yields a literal `#rrggbb`. Asserted over the
// whole palette so a new tone can't silently regress the default paint.
const { fromSkin, themeToneHex } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' })
const { color } = fromSkin({ background: '#f6f9fd', ui_text: '#4a4550' }, {})
for (const tone of Object.values(color)) {
expect(themeToneHex(tone)).toMatch(/^#[0-9a-f]{6}$/i)
}
})
})
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import { type BootTheme, invalidateBootBackground, seedBootEnvironment } from '../lib/themeBoot.js'
import { defaultTheme } from '../theme.js'
// Review on #20379 (finding 2): the boot cache seeds the previous session's
// background into HERMES_TUI_BACKGROUND, which detectLightMode treats as a
// CURRENT signal. Without provenance, a stale light cache pins a now-dark
// terminal to light indefinitely (the current probe's pure-black answer is
// distrusted, the pure-white foreground is distrusted, and the macOS
// fallback refuses to run while the slot is occupied). These tests cover
// the seed/invalidate contract the gateway handler drives.
const cache = (over: Partial<BootTheme> = {}): BootTheme => ({ theme: defaultTheme, ...over })
describe('seedBootEnvironment', () => {
it('seeds the cached background when no explicit signal outranks it', () => {
const env: NodeJS.ProcessEnv = {}
const seeded = seedBootEnvironment(cache({ background: '#ffffff' }), env)
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
expect(seeded).toEqual({ seededBackground: '#ffffff', seededPin: false })
})
it('never seeds over explicit user signals', () => {
for (const preset of [{ HERMES_TUI_THEME: 'dark' }, { HERMES_TUI_LIGHT: '1' }] as NodeJS.ProcessEnv[]) {
const env = { ...preset }
const seeded = seedBootEnvironment(cache({ background: '#ffffff', mode: 'light' }), env)
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
expect(seeded).toEqual({ seededBackground: null, seededPin: false })
}
// A user-exported background keeps its value; only the pin may seed.
const env: NodeJS.ProcessEnv = { HERMES_TUI_BACKGROUND: '#123456' }
expect(seedBootEnvironment(cache({ background: '#ffffff' }), env).seededBackground).toBeNull()
expect(env.HERMES_TUI_BACKGROUND).toBe('#123456')
})
it('never seeds the untrusted pure-black fingerprint', () => {
const env: NodeJS.ProcessEnv = {}
const seeded = seedBootEnvironment(cache({ background: '#000000' }), env)
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
expect(seeded.seededBackground).toBeNull()
})
it('replays a cached config pin coherently with the physical background', () => {
// "/theme light" pinned while the physical terminal is dark: the cache
// stores BOTH — replaying only the background would resolve the first
// skin dark and recreate the light → dark → light flash.
const env: NodeJS.ProcessEnv = {}
const seeded = seedBootEnvironment(cache({ background: '#1e1e1e', mode: 'light' }), env)
expect(env.HERMES_TUI_THEME).toBe('light')
expect(env.HERMES_TUI_BACKGROUND).toBe('#1e1e1e')
expect(seeded).toEqual({ seededBackground: '#1e1e1e', seededPin: true })
})
it('replays a pinned dark on a light physical background too', () => {
const env: NodeJS.ProcessEnv = {}
const seeded = seedBootEnvironment(cache({ background: '#ffffff', mode: 'dark' }), env)
expect(env.HERMES_TUI_THEME).toBe('dark')
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
expect(seeded.seededPin).toBe(true)
})
it('is a no-op without a cache', () => {
const env: NodeJS.ProcessEnv = {}
expect(seedBootEnvironment(null, env)).toEqual({ seededBackground: null, seededPin: false })
expect(env).toEqual({})
})
})
describe('invalidateBootBackground', () => {
it('clears the slot while it still holds the seeded value (stale light cache, current dark terminal)', () => {
const env: NodeJS.ProcessEnv = {}
seedBootEnvironment(cache({ background: '#ffffff' }), env)
// Current terminal answers OSC-11 with distrusted #000000 → the handler
// invalidates: the slot must clear so foreground / COLORFGBG / macOS
// appearance / the default get their turn.
expect(invalidateBootBackground(env)).toBe(true)
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
// Idempotent: a second distrusted answer has nothing left to clear.
expect(invalidateBootBackground(env)).toBe(false)
})
it('clears a stale dark cache on a current ambiguous terminal the same way', () => {
const env: NodeJS.ProcessEnv = {}
seedBootEnvironment(cache({ background: '#1e1e1e' }), env)
expect(invalidateBootBackground(env)).toBe(true)
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
})
it('leaves a trusted OSC answer that overwrote the seed alone', () => {
const env: NodeJS.ProcessEnv = {}
seedBootEnvironment(cache({ background: '#ffffff' }), env)
// A real OSC-11 measurement replaced the hint — it is authoritative.
env.HERMES_TUI_BACKGROUND = '#282828'
expect(invalidateBootBackground(env)).toBe(false)
expect(env.HERMES_TUI_BACKGROUND).toBe('#282828')
})
it('is a no-op when nothing was seeded', () => {
const env: NodeJS.ProcessEnv = { HERMES_TUI_BACKGROUND: '#ffffff' }
seedBootEnvironment(null, env)
expect(invalidateBootBackground(env)).toBe(false)
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
})
})
@@ -0,0 +1,118 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { ToolTrail } from '../components/thinking.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const flushEffects = async () => {
// Passive effects + the re-render they trigger need a few macrotask
// turns (React's scheduler uses MessageChannel) before the next frame
// paints — setTimeout(0)-class waits, not setImmediate (which can land
// in the wrong phase and observe the pre-effect frame).
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 5))
}
}
const mountTrail = (reasoningActive: boolean, sections?: Record<string, string>) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 60, isTTY: false, rows: 20 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
<ToolTrail
reasoning="Live reasoning text."
reasoningActive={reasoningActive}
sections={sections ?? { thinking: 'collapsed' }}
t={DEFAULT_THEME}
/>,
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
// The PassThrough accumulates every repaint, and a collapsed panel stops
// repainting entirely once settled — so assert on the FINAL chevron state
// in the accumulated output rather than the tail after a clear().
const finalChevronOpen = () => stripAnsi(output).lastIndexOf('▾ ') > stripAnsi(output).lastIndexOf('▸ ')
return { finalChevronOpen, instance }
}
describe('ToolTrail — collapsed mode auto-expands while reasoning is live', () => {
it('opens (▾) when reasoningActive is true under sections.thinking: collapsed', async () => {
const { finalChevronOpen, instance } = mountTrail(true)
await flushEffects()
expect(finalChevronOpen()).toBe(true)
instance.unmount()
instance.cleanup()
})
it('collapses (▸) when reasoningActive is false under sections.thinking: collapsed', async () => {
const { finalChevronOpen, instance } = mountTrail(false)
await flushEffects()
expect(finalChevronOpen()).toBe(false)
instance.unmount()
instance.cleanup()
})
it('closes the panel when the reasoning phase ends mid-turn (rerender)', async () => {
const { finalChevronOpen, instance } = mountTrail(true)
await flushEffects()
expect(finalChevronOpen()).toBe(true)
// Reasoning phase finished (final answer / tool call started) — the
// turn's reasoningActive drops and the panel must collapse.
instance.rerender(
<ToolTrail
reasoning="Live reasoning text."
reasoningActive={false}
sections={{ thinking: 'collapsed' }}
t={DEFAULT_THEME}
/>
)
await flushEffects()
expect(finalChevronOpen()).toBe(false)
instance.unmount()
instance.cleanup()
})
it('leaves expanded-mode panels fully manual (no forced collapse)', async () => {
const { finalChevronOpen, instance } = mountTrail(false, { thinking: 'expanded' })
await flushEffects()
// `expanded` is a manual preference: reasoningActive=false must NOT
// force it closed (the auto behavior only applies to `collapsed`).
expect(finalChevronOpen()).toBe(true)
instance.unmount()
instance.cleanup()
})
})
@@ -0,0 +1,61 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { ToolTrail } from '../components/thinking.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
describe('ToolTrail — MoA reference panel visibility (#64701)', () => {
it('stays expanded after mount effects settle when reasoningAlwaysVisible is set, even under sections.thinking: hidden', async () => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 60, isTTY: false, rows: 20 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
<ToolTrail
reasoning="Reference model output that must stay visible on first paint."
reasoningAlwaysVisible
sections={{ thinking: 'hidden' }}
t={DEFAULT_THEME}
/>,
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
// Let queued passive effects (and any re-render they trigger) flush
// before reading the frame — the #64701 bug is specifically that the
// re-sync effect fires AFTER the first paint and clobbers the
// reasoningAlwaysVisible mount value, so asserting on the pre-effect
// frame alone would miss the regression entirely.
await new Promise(resolve => setImmediate(resolve))
await new Promise(resolve => setImmediate(resolve))
const frame = stripAnsi(output)
instance.unmount()
instance.cleanup()
// Open chevron (▾) means the panel is still expanded once effects have
// settled, as the reasoningAlwaysVisible-seeded useState value intends.
// A collapsed (▸) render here means the re-sync effect fired on mount
// and clobbered it — the exact #64701 regression.
expect(frame).toContain('▾ ')
expect(frame).toContain('Thinking')
expect(frame).not.toContain('▸ ')
})
})
+467
View File
@@ -0,0 +1,467 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { topupCommands } from '../app/slash/commands/topup.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')!
const ownerState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
auto_reload: {
card: { kind: 'canonical' },
enabled: false,
reload_to_display: '—',
reload_to_usd: null,
threshold_display: '—',
threshold_usd: null
},
balance_display: '$142.50',
balance_usd: '142.5',
can_charge: true,
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '10000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: true,
limit_display: '$1000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
ok: true,
org_name: 'Acme',
portal_url: 'https://portal/billing?topup=open',
role: 'OWNER',
...overrides
})
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const calls: Array<{ method: string; params: unknown }> = []
const rpc = vi.fn((method: string, params: unknown) => {
calls.push({ method, params })
return Promise.resolve(results[method])
})
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), panel: vi.fn(), sys }
}
const run = async (arg: string) => {
topupCommand.run(arg, ctx as any, 'topup')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { calls, ctx, rpc, run, sys }
}
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
describe('/billing slash command (overlay-driven)', () => {
beforeEach(() => {
resetOverlayState()
})
it('not logged in → prompts to log in, no overlay', async () => {
const { run, sys } = buildCtx({ 'billing.state': { ...ownerState(), logged_in: false, ok: true } })
await run('')
expect(printed(sys)).toContain('Not logged into Nous Portal')
expect(getOverlayState().billing).toBeNull()
})
it('bare /billing opens the overlay on the overview screen with state', async () => {
const { run, rpc } = buildCtx({ 'billing.state': ownerState() })
await run('')
expect(rpc).toHaveBeenCalledWith('billing.state', {})
const billing = getOverlayState().billing
expect(billing).toBeTruthy()
expect(billing?.screen).toBe('overview')
expect(billing?.state.balance_display).toBe('$142.50')
expect(billing?.state.charge_presets_display).toEqual(['$25', '$50', '$100'])
})
it('any sub-command arg is ignored — still opens the overview overlay', async () => {
const { run } = buildCtx({ 'billing.state': ownerState() })
await run('buy 100')
const billing = getOverlayState().billing
expect(billing?.screen).toBe('overview')
// No confirm overlay armed directly by the command anymore.
expect(getOverlayState().confirm).toBeNull()
})
it('member overview carries the non-admin state for component-side gating', async () => {
const { run } = buildCtx({
'billing.state': ownerState({
is_admin: false,
can_charge: false,
role: 'MEMBER',
card: null,
monthly_cap: null,
auto_reload: null
})
})
await run('')
const billing = getOverlayState().billing
expect(billing?.state.is_admin).toBe(false)
expect(billing?.screen).toBe('overview')
})
// ── Overlay ctx behaviors (RPC + error mapping live in billing.ts) ──
it('ctx.validate rejects out-of-bounds and sub-cent amounts, accepts valid', async () => {
const { run } = buildCtx({ 'billing.state': ownerState() })
await run('')
const ctx = getOverlayState().billing!.ctx
expect(ctx.validate('5').error).toContain('Minimum is $10')
expect(ctx.validate('10.005').error).toContain('2 decimal places')
expect(ctx.validate('100').amount).toBe('100')
expect(ctx.validate('$50').amount).toBe('50')
})
it('ctx.charge → poll → settled', async () => {
vi.useFakeTimers()
try {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
'billing.charge_status': { ok: true, status: 'settled', amount_usd: '100' }
})
await run('')
const ctx = getOverlayState().billing!.ctx
ctx.charge('100')
await vi.runAllTimersAsync()
const out = printed(sys)
expect(out).toContain('Charge submitted')
expect(out).toContain('✅ $100 added.')
} finally {
vi.useRealTimers()
}
})
it('ctx.charge → poll → failed adds the portal funnel line', async () => {
vi.useFakeTimers()
try {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
'billing.charge_status': { ok: true, status: 'failed', reason: 'card_declined' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await vi.runAllTimersAsync()
const out = printed(sys)
expect(out).toContain('Your card was declined')
// Parity with the CLI: a failed poll funnels to the portal (from state.portal_url).
expect(out).toContain('Portal: https://portal/billing?topup=open')
} finally {
vi.useRealTimers()
}
})
it('ctx.charge monthly_cap_exceeded surfaces remaining headroom', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '42.50' },
portal_url: '/billing?topup=open',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('Monthly spend cap reached — $42.50 headroom left.')
expect(out).toContain('Portal: /billing?topup=open')
})
it('ctx.charge no_payment_method → portal funnel copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'no_payment_method',
portal_url: '/billing?topup=open',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('No saved card for terminal charges')
expect(out).toContain('Portal: /billing?topup=open')
})
it('ctx.charge consent_required → one-time portal confirmation copy + portal funnel', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'consent_required',
portal_url: '/billing/consent',
idempotency_key: 'k'
}
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
const out = printed(sys)
expect(out).toContain('one-time card confirmation')
expect(out).toContain('Portal: /billing/consent')
})
it.each([
['org_access_denied', "This token isn't bound to an org you can manage"],
['upgrade_cap_exceeded', 'Daily plan-change limit reached'],
['auto_top_up_disabled_failures', 'Auto-reload was turned off after repeated charge failures']
])('ctx.charge %s → typed recovery copy', async (error, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error, idempotency_key: 'k' }
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
expect(printed(sys)).toContain(copy)
})
it.each([
[undefined, 'Stripe is having trouble right now — try again shortly.'],
[120, 'Stripe is having trouble right now — try again shortly (try again in ~2 min).']
])('ctx.charge stripe_unavailable (retry_after=%s) → transient Stripe copy', async (retryAfter, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'stripe_unavailable',
idempotency_key: 'k',
...(retryAfter == null ? {} : { retry_after: retryAfter })
}
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
const out = printed(sys)
expect(out).toContain(copy)
expect(out).not.toContain('Too many charges')
})
it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => {
const { run } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' }
})
await run('')
const outcome = await getOverlayState().billing!.ctx.charge('100')
// No separate confirm overlay is armed anymore — the overlay's stepup
// screen owns the UX; the ctx just reports the outcome.
expect(outcome).toBe('needs_remote_spending')
expect(getOverlayState().confirm).toBeNull()
})
it.each([[true], [false]])('ctx.requestRemoteSpending → billing.step_up resolves %s', async granted => {
const { run, calls } = buildCtx({ 'billing.state': ownerState(), 'billing.step_up': { ok: true, granted } })
await run('')
expect(await getOverlayState().billing!.ctx.requestRemoteSpending()).toBe(granted)
expect(calls.find(c => c.method === 'billing.step_up')).toBeTruthy()
})
// ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ──
it.each([
['admin', 'An admin stopped remote spending for this terminal'],
['self', 'You stopped remote spending for this terminal']
])(
'ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy',
async (actor, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'remote_spending_revoked',
actor,
recovery: 'reconnect',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain(copy)
expect(getOverlayState().billing).toBeNull()
}
)
it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain('Your session was logged out')
expect(getOverlayState().billing).toBeNull()
})
it('ctx.charge → poll transport loss reports an unconfirmed outcome', async () => {
const { ctx, rpc, run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }
})
await run('')
rpc.mockImplementation((method: string) => {
if (method === 'billing.charge_status') {
return Promise.reject(new Error('socket closed'))
}
return Promise.resolve(method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null)
})
await getOverlayState().billing!.ctx.charge('100')
await vi.waitFor(() => expect(printed(sys)).toContain('outcome is unconfirmed'))
expect(ctx.guardedErr).toHaveBeenCalled()
})
it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'cli_billing_disabled',
code: 'remote_spending_disabled',
recovery: 'enable_account_toggle',
portal_url: '/billing',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('Remote spending is off for this account')
// Account-wide switch is NOT a per-terminal revoke — overlay stays open.
expect(getOverlayState().billing).toBeTruthy()
})
it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => {
const { run, calls } = buildCtx({
'billing.state': ownerState(),
'billing.auto_reload': { ok: true }
})
await run('')
const ok = await getOverlayState().billing!.ctx.applyAutoReload(true, 20, 100)
expect(ok).toBe(true)
const ar = calls.find(c => c.method === 'billing.auto_reload')
expect(ar?.params).toEqual({ enabled: true, threshold: 20, top_up_amount: 100 })
})
it('ctx.applyAutoReload(false) → disables (enabled:false, no amounts)', async () => {
const { run, calls } = buildCtx({
'billing.state': ownerState({
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$20',
threshold_usd: '20'
}
}),
'billing.auto_reload': { ok: true }
})
await run('')
const ok = await getOverlayState().billing!.ctx.applyAutoReload(false)
expect(ok).toBe(true)
const ar = calls.find(c => c.method === 'billing.auto_reload')
expect(ar?.params).toEqual({ enabled: false })
})
it('ctx.applyAutoReload error → resolves false + maps the error', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.auto_reload': { ok: false, error: 'monthly_cap_exceeded', message: 'Monthly spend cap reached.' }
})
await run('')
const ok = await getOverlayState().billing!.ctx.applyAutoReload(true, 20, 100)
expect(ok).toBe(false)
expect(printed(sys)).toContain('Monthly spend cap reached.')
})
it('ctx.charge → poll → processing_error has intentional failure copy', async () => {
vi.useFakeTimers()
try {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
'billing.charge_status': { ok: true, status: 'failed', reason: 'processing_error' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await vi.runAllTimersAsync()
expect(printed(sys)).toContain("The charge didn't go through (processing_error).")
} finally {
vi.useRealTimers()
}
})
it('ctx.openPortal opens the URL + echoes a transcript line', async () => {
const { run, sys } = buildCtx({ 'billing.state': ownerState() })
await run('')
getOverlayState().billing!.ctx.openPortal('https://portal/x')
expect(printed(sys)).toContain('Opening portal: https://portal/x')
})
})
@@ -0,0 +1,48 @@
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 /topup to top up'
}
})
turnController.startMessage()
expect(getUiState().notice?.key).toBe('credits.depleted')
})
})
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
// turnController.recordTodos() parses the raw `todo` tool payload into
// TodoItem[]. Nested subtasks (apps/desktop's `parent` field) must survive
// this parse — the TUI todo panel renders hierarchy from it via todoTree().
describe('turnController.recordTodos — preserves the parent field', () => {
beforeEach(() => {
resetTurnState()
turnController.fullReset()
})
it('keeps parent on a valid nested subtask', () => {
turnController.recordTodos([
{ content: 'Ship feature', id: 'wp1', status: 'in_progress' },
{ content: 'Write tests', id: 't1', parent: 'wp1', status: 'pending' }
])
expect(getTurnState().todos).toEqual([
{ content: 'Ship feature', id: 'wp1', status: 'in_progress' },
{ content: 'Write tests', id: 't1', parent: 'wp1', status: 'pending' }
])
})
it('drops a self-referential parent instead of keeping a self-loop', () => {
turnController.recordTodos([{ content: 'x', id: 'a', parent: 'a', status: 'pending' }])
expect(getTurnState().todos).toEqual([{ content: 'x', id: 'a', status: 'pending' }])
})
it('omits parent entirely when absent, matching pre-nesting payloads', () => {
turnController.recordTodos([{ content: 'x', id: 'a', status: 'pending' }])
expect(getTurnState().todos).toEqual([{ content: 'x', id: 'a', status: 'pending' }])
})
})
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
archiveDoneTodos,
archiveTodosAtTurnEnd,
getTurnState,
patchTurnState,
resetTurnState,
toggleTodoCollapsed
} from '../app/turnStore.js'
describe('turnStore live progress helpers', () => {
beforeEach(() => resetTurnState())
it('archives completed todos into a transcript trail and clears the live anchor', () => {
patchTurnState({
todos: [
{ content: 'prep', id: 'prep', status: 'completed' },
{ content: 'serve', id: 'serve', status: 'completed' }
]
})
expect(archiveTodosAtTurnEnd()).toEqual([
{
kind: 'trail',
role: 'system',
text: '',
todoCollapsedByDefault: true,
todos: [
{ content: 'prep', id: 'prep', status: 'completed' },
{ content: 'serve', id: 'serve', status: 'completed' }
]
}
])
expect(getTurnState().todos).toEqual([])
})
it('archives incomplete todos with an incomplete flag so the hint renders', () => {
patchTurnState({
todos: [
{ content: 'cook', id: 'cook', status: 'completed' },
{ content: 'serve', id: 'serve', status: 'in_progress' },
{ content: 'eat', id: 'eat', status: 'pending' }
]
})
const archived = archiveTodosAtTurnEnd()
expect(archived).toHaveLength(1)
expect(archived[0]!.todoIncomplete).toBe(true)
expect(archived[0]!.todos?.map(t => t.id)).toEqual(['cook', 'serve', 'eat'])
expect(getTurnState().todos).toEqual([])
})
it('returns nothing when there are no todos at turn end', () => {
expect(archiveTodosAtTurnEnd()).toEqual([])
expect(archiveDoneTodos()).toEqual([])
})
it('tracks collapsed state independently of todo content', () => {
toggleTodoCollapsed()
expect(getTurnState().todoCollapsed).toBe(true)
toggleTodoCollapsed()
expect(getTurnState().todoCollapsed).toBe(false)
})
})
+124
View File
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sessionCommands } from '../app/slash/commands/session.js'
import type { SessionUsageResponse } from '../gatewayTypes.js'
const usageCommand = sessionCommands.find(cmd => cmd.name === 'usage')!
const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const panel = vi.fn()
const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method]))
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), panel, sys }
}
const run = async (arg: string) => {
usageCommand.run(arg, ctx as any, 'usage')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { ctx, panel, run, sys }
}
const baseUsage = (overrides: Partial<SessionUsageResponse> = {}): SessionUsageResponse =>
({ calls: 0, input: 0, output: 0, total: 0, ...overrides }) as SessionUsageResponse
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
const balancePanel = (panel: ReturnType<typeof vi.fn>) => {
const sections = panel.mock.calls.find(c => c[0] === 'Balance')?.[1] as { text?: string }[] | undefined
return (sections ?? []).map(s => s.text ?? '').join('\n')
}
describe('/usage slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('always shows the CTA; "no API calls yet" only when there is no balance', async () => {
const empty = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: [] }) })
await empty.run('')
expect(printed(empty.sys)).toContain('no API calls yet')
expect(printed(empty.sys)).toContain(USAGE_CTA)
const withBalance = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] }) })
await withBalance.run('')
expect(printed(withBalance.sys)).not.toContain('no API calls yet')
expect(printed(withBalance.sys)).toContain(USAGE_CTA)
})
it('renders the dollar two-bar model (no "credits" wording) when available', async () => {
const { panel, run } = buildCtx({
'session.usage': baseUsage({
usage: {
available: true,
status: 'healthy',
plan_name: 'Plus',
renews_display: 'Jul 1, 2026',
total_spendable_display: '$26.00',
has_topup: true,
plan_bar: {
kind: 'plan',
remaining_display: '$14.00',
total_display: '$20.00',
spent_display: '$6.00',
pct_used: 30,
fill_fraction: 0.7
},
topup_bar: {
kind: 'topup',
remaining_display: '$12.00',
total_display: '$12.00',
spent_display: '$0.00',
pct_used: null,
fill_fraction: 1
}
}
})
})
await run('')
const body = balancePanel(panel)
expect(body).toContain('Plus')
expect(body).toContain('$14.00 left of $20.00')
expect(body).toContain('30% used')
expect(body).toContain('top-up')
expect(body).toContain('$12.00')
expect(body.toLowerCase()).not.toContain('credits')
})
it('shows the free-models upsell for a free account', async () => {
const { panel, run } = buildCtx({
'session.usage': baseUsage({ usage: { available: true, status: 'free', plan_name: null } })
})
await run('')
const body = balancePanel(panel)
expect(body).toContain('free models only')
expect(body).toContain('/subscription')
})
})
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { toBatteryInfo } from '../app/useBatteryPoll.js'
describe('toBatteryInfo', () => {
it('returns null for a null payload', () => {
expect(toBatteryInfo(null)).toBeNull()
})
it('maps a full reading through faithfully', () => {
expect(toBatteryInfo({ available: true, category: 'warn', percent: 44, plugged: false })).toEqual({
available: true,
category: 'warn',
percent: 44,
plugged: false
})
})
it('clamps and rounds the percent into 0-100', () => {
expect(toBatteryInfo({ available: true, category: 'good', percent: 142.7, plugged: true })?.percent).toBe(100)
expect(toBatteryInfo({ available: true, category: 'critical', percent: -5, plugged: false })?.percent).toBe(0)
expect(toBatteryInfo({ available: true, category: 'warn', percent: 43.4, plugged: false })?.percent).toBe(43)
})
it('coerces a missing/invalid percent to null', () => {
expect(toBatteryInfo({ available: true, category: 'dim' })?.percent).toBeNull()
})
it('falls back to the dim category for an unknown value', () => {
expect(toBatteryInfo({ available: true, category: 'purple', percent: 50, plugged: false })?.category).toBe('dim')
})
it('treats a non-boolean plugged as unknown (null)', () => {
expect(toBatteryInfo({ available: false, category: 'dim', percent: null })?.plugged).toBeNull()
})
})
@@ -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 /
})
})
+593
View File
@@ -0,0 +1,593 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $uiState, resetUiState } from '../app/uiStore.js'
import {
applyDisplay,
hydrateFullConfig,
type McpRevState,
normalizeBusyInputMode,
normalizeIndicatorStyle,
normalizeMouseTracking,
normalizeStatusBar,
syncMcpReload
} from '../app/useConfigSync.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_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.showReasoning).toBe(true)
expect(s.statusBar).toBe('off')
expect(s.streaming).toBe(false)
})
it('hydrates the destructive slash confirmation policy from approvals', () => {
const setBell = vi.fn()
applyDisplay(
{
config: {
approvals: { destructive_slash_confirm: false },
display: {}
}
},
setBell
)
expect($uiState.get().destructiveSlashConfirm).toBe(false)
applyDisplay(
{
config: {
approvals: { destructive_slash_confirm: true },
display: {}
}
},
setBell
)
expect($uiState.get().destructiveSlashConfirm).toBe(true)
})
it('defaults destructive slash confirmation on and preserves it across config RPC failure', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: {} } }, setBell)
expect($uiState.get().destructiveSlashConfirm).toBe(true)
applyDisplay(
{
config: {
approvals: { destructive_slash_confirm: false },
display: {}
}
},
setBell
)
applyDisplay(null, setBell)
expect($uiState.get().destructiveSlashConfirm).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.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)
})
})
// Review on #20379 (finding 1): an MCP config revision must never be acked
// before the server confirms it was LOADED. The old poll advanced its
// accepted revision first and fired reload.mcp second — a reload that failed
// (quietRpc → null) left the revision recorded as applied, and no subsequent
// poll retried it until an unrelated MCP edit.
describe('syncMcpReload (revision-aware ack)', () => {
const gwOk = (payload: unknown) =>
({ request: vi.fn(() => Promise.resolve(payload)), on: vi.fn(), off: vi.fn() }) as any
const freshState = (accepted = 'rev-a'): McpRevState => ({ accepted, inFlight: false })
it('advances accepted only after the server confirms the reload', async () => {
const gw = gwOk({ status: 'reloaded', loaded_rev: 'rev-b' })
const state = freshState()
const onReloaded = vi.fn()
await syncMcpReload(gw, 's1', 'rev-b', state, onReloaded)
expect(gw.request).toHaveBeenCalledWith('reload.mcp', { confirm: true, rev: 'rev-b', session_id: 's1' })
expect(state.accepted).toBe('rev-b')
expect(onReloaded).toHaveBeenCalledTimes(1)
})
it('does NOT advance accepted when the reload RPC fails — next poll retries', async () => {
const gw = { request: vi.fn(() => Promise.reject(new Error('flapping server'))), on: vi.fn(), off: vi.fn() } as any
const state = freshState()
const onReloaded = vi.fn()
await syncMcpReload(gw, 's1', 'rev-b', state, onReloaded)
// The exact failure sequence from the review: reload fails, revision
// must remain un-acked so the next tick retries it.
expect(state.accepted).toBe('rev-a')
expect(state.inFlight).toBe(false)
expect(onReloaded).not.toHaveBeenCalled()
// Next poll tick: the server recovered — the SAME revision goes through.
gw.request = vi.fn(() => Promise.resolve({ status: 'reloaded', loaded_rev: 'rev-b' }))
await syncMcpReload(gw, 's1', 'rev-b', state, onReloaded)
expect(state.accepted).toBe('rev-b')
expect(onReloaded).toHaveBeenCalledTimes(1)
})
it('does not advance on confirm_required (reload did not happen)', async () => {
const gw = gwOk({ message: 'confirm first', status: 'confirm_required' })
const state = freshState()
await syncMcpReload(gw, 's1', 'rev-b', state)
expect(state.accepted).toBe('rev-a')
})
it('records the server-reported loaded_rev, not the requested rev', async () => {
// A config edit raced the reload: the server re-hashed after discovery
// and loaded rev-c. Recording rev-c (not rev-b) makes the next poll a
// no-op instead of an immediate redundant reload.
const gw = gwOk({ status: 'reloaded', loaded_rev: 'rev-c' })
const state = freshState()
await syncMcpReload(gw, 's1', 'rev-b', state)
expect(state.accepted).toBe('rev-c')
})
it('is a no-op when the revision is already accepted or empty', async () => {
const gw = gwOk({ status: 'reloaded' })
const state = freshState()
await syncMcpReload(gw, 's1', 'rev-a', state)
await syncMcpReload(gw, 's1', '', state)
expect(gw.request).not.toHaveBeenCalled()
})
it('does not stack requests while one is in flight', async () => {
let resolveFirst!: (v: unknown) => void
const gw = {
request: vi.fn(() => new Promise(res => (resolveFirst = res))),
on: vi.fn(),
off: vi.fn()
} as any
const state = freshState()
const first = syncMcpReload(gw, 's1', 'rev-b', state)
// Second tick while the first RPC is outstanding: swallowed.
await syncMcpReload(gw, 's1', 'rev-b', state)
expect(gw.request).toHaveBeenCalledTimes(1)
resolveFirst({ status: 'reloaded', loaded_rev: 'rev-b' })
await first
expect(state.accepted).toBe('rev-b')
})
})
// 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,186 @@
import { describe, expect, it, vi } from 'vitest'
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
import {
applyVoiceRecordResponse,
dismissSensitivePrompt,
handleIdleHotkeyExit,
resolveCtrlCComposerAction,
shouldAllowIdleHotkeyExit,
shouldDetachEditedHistoryInput,
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('shouldAllowIdleHotkeyExit', () => {
it('keeps idle exit hotkeys enabled in normal terminals', () => {
expect(shouldAllowIdleHotkeyExit(false)).toBe(true)
})
it('disables idle exit hotkeys in dashboard chat', () => {
expect(shouldAllowIdleHotkeyExit(true)).toBe(false)
})
})
describe('shouldDetachEditedHistoryInput', () => {
const history = ['older message', 'line one\nline two']
it('detaches a recalled entry as soon as the user edits it', () => {
expect(shouldDetachEditedHistoryInput(1, history, 'line one edited\nline two')).toBe(true)
})
it('keeps unchanged recalled entries in history navigation', () => {
expect(shouldDetachEditedHistoryInput(1, history, 'line one\nline two')).toBe(false)
})
it('does not detach an ordinary current draft', () => {
expect(shouldDetachEditedHistoryInput(null, history, 'new draft')).toBe(false)
})
})
describe('resolveCtrlCComposerAction — draft wins over interrupt', () => {
it('clears a non-empty composer even while the agent is streaming', () => {
expect(resolveCtrlCComposerAction({ busy: true, hasDraft: true, hasSession: true })).toBe('clear')
})
it('interrupts a running turn when the composer is empty', () => {
expect(resolveCtrlCComposerAction({ busy: true, hasDraft: false, hasSession: true })).toBe('interrupt')
})
it('clears an idle composer instead of exiting', () => {
expect(resolveCtrlCComposerAction({ busy: false, hasDraft: true, hasSession: true })).toBe('clear')
})
it('exits when idle with an empty composer', () => {
expect(resolveCtrlCComposerAction({ busy: false, hasDraft: false, hasSession: true })).toBe('exit')
})
it('does not interrupt a busy session that has no sid yet', () => {
expect(resolveCtrlCComposerAction({ busy: true, hasDraft: false, hasSession: false })).toBe('exit')
})
})
describe('handleIdleHotkeyExit', () => {
it('exits in normal terminals', () => {
const actions = { die: vi.fn(), sys: vi.fn() }
handleIdleHotkeyExit(actions, false)
expect(actions.die).toHaveBeenCalledTimes(1)
expect(actions.sys).not.toHaveBeenCalled()
})
it('asks the dashboard for a fresh chat instead of leaving a ghost session', () => {
const actions = { die: vi.fn(), sys: vi.fn() }
const requestDashboardNewSession = vi.fn()
handleIdleHotkeyExit(actions, true, requestDashboardNewSession)
expect(actions.die).not.toHaveBeenCalled()
expect(requestDashboardNewSession).toHaveBeenCalledTimes(1)
expect(actions.sys).toHaveBeenCalledWith('starting a fresh dashboard chat...')
})
})
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)
})
})
describe('dismissSensitivePrompt', () => {
it('clears a sudo overlay before a stale cancel RPC resolves', async () => {
resetOverlayState()
patchOverlayState({ sudo: { requestId: 'sudo-1' } })
const rpc = vi.fn().mockResolvedValue(null)
const sys = vi.fn()
const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
expect(getOverlayState().sudo).toBeNull()
expect(sys).toHaveBeenCalledWith('sudo cancelled')
expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })
await pending
})
it('clears a secret overlay before a stale cancel RPC resolves', async () => {
resetOverlayState()
patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } })
const rpc = vi.fn().mockResolvedValue(null)
const sys = vi.fn()
const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
expect(getOverlayState().secret).toBeNull()
expect(sys).toHaveBeenCalledWith('secret entry cancelled')
expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })
await pending
})
})

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