mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
Merge remote-tracking branch 'origin/main' into fix/markdown
Made-with: Cursor # Conflicts: # ui-tui/src/components/markdown.tsx
This commit is contained in:
commit
e4120d1e6d
82 changed files with 3565 additions and 491 deletions
|
|
@ -293,6 +293,69 @@ describe('createGatewayEventHandler', () => {
|
|||
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
|
||||
})
|
||||
|
||||
it('annotates gateway.start_timeout with stderr tail lines so users can diagnose without /logs', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
onEvent({
|
||||
payload: {
|
||||
cwd: '/repo',
|
||||
python: '/opt/venv/bin/python',
|
||||
stderr_tail:
|
||||
'[startup] timed out\nModuleNotFoundError: No module named openai\nFileNotFoundError: ~/.hermes/config.yaml'
|
||||
},
|
||||
type: 'gateway.start_timeout'
|
||||
} as any)
|
||||
|
||||
const messages = getTurnState().activity.map(a => a.text)
|
||||
|
||||
expect(messages.some(m => m.includes('gateway startup timed out'))).toBe(true)
|
||||
expect(messages.some(m => m.includes('ModuleNotFoundError'))).toBe(true)
|
||||
expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers raw text over Rich-rendered ANSI on message.complete (#16391)', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const raw = 'Hermes here.\n\nLine two.'
|
||||
// Rich-rendered ANSI (`final_response_markdown: render`) used to win,
|
||||
// which left visible escape codes in Ink output. Raw text must win.
|
||||
const rendered = '\u001b[33mHermes here.\u001b[0m\n\n\u001b[2mLine two.\u001b[0m'
|
||||
|
||||
onEvent({ payload: { rendered, text: raw }, type: 'message.complete' } as any)
|
||||
|
||||
const assistant = appended.find(msg => msg.role === 'assistant')
|
||||
expect(assistant?.text).toBe(raw)
|
||||
expect(assistant?.text).not.toContain('\u001b[')
|
||||
})
|
||||
|
||||
it('falls back to payload.rendered when text is missing on message.complete', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const rendered = 'fallback when gateway omitted text'
|
||||
|
||||
onEvent({ payload: { rendered }, type: 'message.complete' } as any)
|
||||
|
||||
const assistant = appended.find(msg => msg.role === 'assistant')
|
||||
expect(assistant?.text).toBe(rendered)
|
||||
})
|
||||
|
||||
it('always accumulates raw text in message.delta and ignores `rendered` (#16391)', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
// Stream of partial text deltas; each delta carries an incremental
|
||||
// Rich-ANSI fragment. Pre-fix code would replace the whole bufRef
|
||||
// with the latest fragment, dropping prior text.
|
||||
onEvent({ payload: { rendered: '\u001b[33mFi\u001b[0m', text: 'Fi' }, type: 'message.delta' } as any)
|
||||
onEvent({ payload: { rendered: '\u001b[33mrst.\u001b[0m', text: 'rst.' }, type: 'message.delta' } as any)
|
||||
onEvent({ payload: { text: ' second.' }, type: 'message.delta' } as any)
|
||||
onEvent({ payload: {}, type: 'message.complete' } as any)
|
||||
|
||||
const assistant = appended.find(msg => msg.role === 'assistant')
|
||||
expect(assistant?.text).toBe('First. second.')
|
||||
})
|
||||
|
||||
it('anchors inline_diff as its own segment where the edit happened', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
|
@ -437,6 +500,152 @@ describe('createGatewayEventHandler', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
|
||||
const appended: Msg[] = []
|
||||
const newSession = vi.fn()
|
||||
const resumeById = vi.fn()
|
||||
const ctx = buildCtx(appended)
|
||||
|
||||
ctx.session.newSession = newSession
|
||||
ctx.session.resumeById = resumeById
|
||||
ctx.session.STARTUP_RESUME_ID = ''
|
||||
ctx.gateway.rpc = vi.fn(async (method: string) => {
|
||||
if (method === 'config.get') {
|
||||
return { config: { display: { tui_auto_resume_recent: false } } }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
|
||||
|
||||
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
|
||||
expect(resumeById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('on gateway.ready with auto_resume on and a recent session, resumes it', async () => {
|
||||
const appended: Msg[] = []
|
||||
const newSession = vi.fn()
|
||||
const resumeById = vi.fn()
|
||||
const ctx = buildCtx(appended)
|
||||
|
||||
ctx.session.newSession = newSession
|
||||
ctx.session.resumeById = resumeById
|
||||
ctx.session.STARTUP_RESUME_ID = ''
|
||||
ctx.gateway.rpc = vi.fn(async (method: string) => {
|
||||
if (method === 'config.get') {
|
||||
return { config: { display: { tui_auto_resume_recent: true } } }
|
||||
}
|
||||
|
||||
if (method === 'session.most_recent') {
|
||||
return { session_id: 'sess-most-recent' }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
|
||||
|
||||
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('sess-most-recent'))
|
||||
expect(newSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('on gateway.ready with auto_resume on but no eligible session, falls back to new', async () => {
|
||||
const appended: Msg[] = []
|
||||
const newSession = vi.fn()
|
||||
const resumeById = vi.fn()
|
||||
const ctx = buildCtx(appended)
|
||||
|
||||
ctx.session.newSession = newSession
|
||||
ctx.session.resumeById = resumeById
|
||||
ctx.session.STARTUP_RESUME_ID = ''
|
||||
ctx.gateway.rpc = vi.fn(async (method: string) => {
|
||||
if (method === 'config.get') {
|
||||
return { config: { display: { tui_auto_resume_recent: true } } }
|
||||
}
|
||||
|
||||
if (method === 'session.most_recent') {
|
||||
return { session_id: null }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
|
||||
|
||||
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
|
||||
expect(resumeById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('on gateway.ready when config.get rejects, falls back to new session', async () => {
|
||||
const appended: Msg[] = []
|
||||
const newSession = vi.fn()
|
||||
const resumeById = vi.fn()
|
||||
const ctx = buildCtx(appended)
|
||||
|
||||
ctx.session.newSession = newSession
|
||||
ctx.session.resumeById = resumeById
|
||||
ctx.session.STARTUP_RESUME_ID = ''
|
||||
ctx.gateway.rpc = vi.fn(async (method: string) => {
|
||||
if (method === 'config.get') {
|
||||
throw new Error('gateway timeout')
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
|
||||
|
||||
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
|
||||
expect(resumeById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('on gateway.ready when session.most_recent rejects, falls back to new session', async () => {
|
||||
const appended: Msg[] = []
|
||||
const newSession = vi.fn()
|
||||
const resumeById = vi.fn()
|
||||
const ctx = buildCtx(appended)
|
||||
|
||||
ctx.session.newSession = newSession
|
||||
ctx.session.resumeById = resumeById
|
||||
ctx.session.STARTUP_RESUME_ID = ''
|
||||
ctx.gateway.rpc = vi.fn(async (method: string) => {
|
||||
if (method === 'config.get') {
|
||||
return { config: { display: { tui_auto_resume_recent: true } } }
|
||||
}
|
||||
|
||||
if (method === 'session.most_recent') {
|
||||
throw new Error('db locked')
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
|
||||
|
||||
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
|
||||
expect(resumeById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('on gateway.ready with STARTUP_RESUME_ID set, the env wins over config auto_resume', async () => {
|
||||
const appended: Msg[] = []
|
||||
const newSession = vi.fn()
|
||||
const resumeById = vi.fn()
|
||||
const ctx = buildCtx(appended)
|
||||
|
||||
ctx.session.newSession = newSession
|
||||
ctx.session.resumeById = resumeById
|
||||
ctx.session.STARTUP_RESUME_ID = 'env-explicit'
|
||||
ctx.gateway.rpc = vi.fn(async () => ({
|
||||
config: { display: { tui_auto_resume_recent: true } }
|
||||
}))
|
||||
|
||||
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
|
||||
|
||||
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('env-explicit'))
|
||||
expect(newSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps gateway noise informational and approval out of Activity', async () => {
|
||||
const appended: Msg[] = []
|
||||
const ctx = buildCtx(appended)
|
||||
|
|
@ -474,4 +683,87 @@ describe('createGatewayEventHandler', () => {
|
|||
|
||||
expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }])
|
||||
})
|
||||
|
||||
it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => {
|
||||
// Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
|
||||
// events from the still-winding-down agent loop kept populating the UI for
|
||||
// ~1s, making it look like the interrupt had been ignored.
|
||||
//
|
||||
// Fake timers because `interruptTurn` schedules a real setTimeout for
|
||||
// its cooldown — without flushing it inside this test, the timeout
|
||||
// can fire later and mutate uiStore/turnState during unrelated tests
|
||||
// (cross-file flake).
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const appended: Msg[] = []
|
||||
const ctx = buildCtx(appended)
|
||||
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
|
||||
const onEvent = createGatewayEventHandler(ctx)
|
||||
|
||||
patchUiState({ sid: 'sess-1' })
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({
|
||||
payload: {
|
||||
context: 'pre',
|
||||
name: 'search',
|
||||
todos: [{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }],
|
||||
tool_id: 't-1'
|
||||
},
|
||||
type: 'tool.start'
|
||||
} as any)
|
||||
|
||||
// Pre-interrupt todos should land in turn state.
|
||||
expect(getTurnState().todos).toEqual([
|
||||
{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }
|
||||
])
|
||||
|
||||
turnController.interruptTurn({
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
gw: ctx.gateway.gw,
|
||||
sid: 'sess-1',
|
||||
sys: ctx.system.sys
|
||||
})
|
||||
|
||||
onEvent({ payload: { text: 'still thinking…' }, type: 'reasoning.delta' } as any)
|
||||
// Post-interrupt tool.start with a todos payload — must NOT mutate todos.
|
||||
onEvent({
|
||||
payload: {
|
||||
context: 'post',
|
||||
name: 'browser',
|
||||
todos: [{ content: 'late ghost', id: 'todo-ghost', status: 'pending' }],
|
||||
tool_id: 't-2'
|
||||
},
|
||||
type: 'tool.start'
|
||||
} as any)
|
||||
// Late tool.generating must NOT push a 'drafting …' line into the trail.
|
||||
const trailBefore = getTurnState().turnTrail.length
|
||||
onEvent({ payload: { name: 'browser' }, type: 'tool.generating' } as any)
|
||||
expect(getTurnState().turnTrail.length).toBe(trailBefore)
|
||||
onEvent({ payload: { name: 'browser', preview: 'loading' }, type: 'tool.progress' } as any)
|
||||
onEvent({ payload: { summary: 'done', tool_id: 't-2' }, type: 'tool.complete' } as any)
|
||||
onEvent({ payload: { text: 'late chunk' }, type: 'message.delta' } as any)
|
||||
|
||||
expect(getTurnState().tools).toEqual([])
|
||||
expect(turnController.reasoningText).toBe('')
|
||||
expect(turnController.bufRef).toBe('')
|
||||
expect(getTurnState().streamPendingTools).toEqual([])
|
||||
expect(getTurnState().streamSegments).toEqual([])
|
||||
// Stale post-interrupt todos must not have leaked through.
|
||||
// (This test does not assert that pre-interrupt todos are cleared —
|
||||
// current interrupt path leaves them visible until the next message.)
|
||||
expect(getTurnState().todos.find(t => t.content === 'late ghost')).toBeUndefined()
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({ payload: { text: 'fresh' }, type: 'reasoning.delta' } as any)
|
||||
|
||||
expect(turnController.reasoningText).toBe('fresh')
|
||||
} finally {
|
||||
// Drain pending fake timers BEFORE restoring real timers so a mid-
|
||||
// test assertion failure can't leak the interrupt-cooldown setTimeout
|
||||
// across test files (the original Copilot concern).
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -195,7 +195,8 @@ describe('createSlashHandler', () => {
|
|||
['/reload-mcp', 'reload.mcp', { session_id: null }],
|
||||
['/stop', 'process.stop', {}],
|
||||
['/fast status', 'config.get', { key: 'fast', session_id: null }],
|
||||
['/busy status', 'config.get', { key: 'busy' }]
|
||||
['/busy status', 'config.get', { key: 'busy' }],
|
||||
['/indicator', 'config.get', { key: 'indicator' }]
|
||||
])('routes %s through native RPC (no slash worker)', (command, method, params) => {
|
||||
const rpc = vi.fn(() => Promise.resolve({}))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
|
@ -215,6 +216,24 @@ describe('createSlashHandler', () => {
|
|||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hot-swaps the live indicator when /indicator <style> succeeds', async () => {
|
||||
const rpc = vi.fn(() => Promise.resolve({ value: 'emoji' }))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/indicator emoji')).toBe(true)
|
||||
expect(rpc).toHaveBeenCalledWith('config.set', { key: 'indicator', value: 'emoji' })
|
||||
await vi.waitFor(() => expect(getUiState().indicatorStyle).toBe('emoji'))
|
||||
})
|
||||
|
||||
it('rejects unknown indicator styles before hitting the gateway', () => {
|
||||
const rpc = vi.fn(() => Promise.resolve({}))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/indicator sparkle')).toBe(true)
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /indicator [ascii|emoji|kaomoji|unicode]')
|
||||
})
|
||||
|
||||
it('drops stale slash.exec output after a newer slash', async () => {
|
||||
let resolveLate: (v: { output?: string }) => void
|
||||
let slashExecCalls = 0
|
||||
|
|
|
|||
64
ui-tui/src/__tests__/forceTruecolor.test.ts
Normal file
64
ui-tui/src/__tests__/forceTruecolor.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR'] as const
|
||||
|
||||
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('sets COLORTERM=truecolor and FORCE_COLOR=3 when unset', async () => {
|
||||
await withCleanEnv(
|
||||
() => {},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=' + Date.now())
|
||||
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'
|
||||
},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=optout-' + Date.now())
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('respects NO_COLOR', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.NO_COLOR = '1'
|
||||
},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=no-color-' + Date.now())
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -51,6 +51,12 @@ describe('isCopyShortcut', () => {
|
|||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -116,6 +116,6 @@ describe('streaming theme assumption', () => {
|
|||
// Sanity that the theme we pass doesn't change shape. Component import
|
||||
// already happens above — this is a smoke test that the module graph
|
||||
// for streamingMarkdown wires up without cycles.
|
||||
expect(DEFAULT_THEME.color.amber).toBeTruthy()
|
||||
expect(DEFAULT_THEME.color.accent).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ describe('syntax highlighter', () => {
|
|||
it('paints a whole-line comment dim', () => {
|
||||
const tokens = highlightLine('// hello', 'ts', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.dim, '// hello']])
|
||||
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.bronze) // const
|
||||
expect(colors).toContain(t.color.amber) // 'hi'
|
||||
expect(colors).toContain(t.color.cornsilk) // 42
|
||||
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', () => {
|
||||
|
|
@ -40,6 +40,6 @@ describe('syntax highlighter', () => {
|
|||
it('treats `#` as a python comment, not a selector', () => {
|
||||
const tokens = highlightLine('# comment', 'py', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.dim, '# comment']])
|
||||
expect(tokens).toEqual([[t.color.muted, '# comment']])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ describe('terminalParityHints', () => {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -79,11 +79,34 @@ describe('configureTerminalKeybindings', () => {
|
|||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
expect(written).toContain('cmd+c')
|
||||
expect(written).toContain('terminalTextSelected')
|
||||
expect(written).toContain('\\u001b[99;13u')
|
||||
expect(written).toContain('shift+enter')
|
||||
expect(written).toContain('cmd+enter')
|
||||
expect(written).toContain('cmd+z')
|
||||
})
|
||||
|
||||
it('only adds the Cmd+C forwarding binding on macOS', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/home/me',
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
expect(written).not.toContain('cmd+c')
|
||||
expect(written).not.toContain('terminalTextSelected')
|
||||
expect(written).not.toContain('\\u001b[99;13u')
|
||||
expect(written).toContain('shift+enter')
|
||||
})
|
||||
|
||||
it('reports conflicts without overwriting existing bindings', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
|
|
@ -113,6 +136,118 @@ describe('configureTerminalKeybindings', () => {
|
|||
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([]))
|
||||
|
|
@ -186,6 +321,12 @@ describe('configureTerminalKeybindings', () => {
|
|||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ describe('input metrics helpers', () => {
|
|||
|
||||
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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,46 +1,90 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DARK_THEME, DEFAULT_THEME, detectLightMode, fromSkin, LIGHT_THEME } from '../theme.js'
|
||||
// `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',
|
||||
'TERM_PROGRAM',
|
||||
] as const
|
||||
|
||||
async function importThemeWithCleanEnv() {
|
||||
for (const key of RELEVANT_ENV) {
|
||||
vi.stubEnv(key, '')
|
||||
}
|
||||
vi.resetModules()
|
||||
return import('../theme.js')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('DEFAULT_THEME', () => {
|
||||
it('has brand defaults', () => {
|
||||
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', () => {
|
||||
expect(DEFAULT_THEME.color.gold).toBe('#FFD700')
|
||||
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)', () => {
|
||||
expect(LIGHT_THEME.color.gold).not.toBe('#FFD700')
|
||||
expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00')
|
||||
expect(LIGHT_THEME.color.dim).not.toBe('#B8860B')
|
||||
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', () => {
|
||||
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', () => {
|
||||
expect(DEFAULT_THEME).toBe(DARK_THEME)
|
||||
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', () => {
|
||||
it('returns false on empty env', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({})).toBe(false)
|
||||
})
|
||||
|
||||
it('honors HERMES_TUI_LIGHT on/off', () => {
|
||||
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)
|
||||
|
|
@ -48,7 +92,9 @@ describe('detectLightMode', () => {
|
|||
expect(detectLightMode({ HERMES_TUI_LIGHT: 'off' })).toBe(false)
|
||||
})
|
||||
|
||||
it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', () => {
|
||||
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)
|
||||
|
|
@ -56,38 +102,136 @@ describe('detectLightMode', () => {
|
|||
expect(detectLightMode({ COLORFGBG: '7;default;0' })).toBe(false)
|
||||
})
|
||||
|
||||
it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', () => {
|
||||
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()
|
||||
// Inject a light-default allow-list so the precedence test is
|
||||
// meaningful even though the production allow-list is empty.
|
||||
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', () => {
|
||||
it('overrides banner colors', () => {
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000')
|
||||
// `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', () => {
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber)
|
||||
it('preserves unset colors', async () => {
|
||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent)
|
||||
})
|
||||
|
||||
it('overrides branding', () => {
|
||||
it('derives completion current background from resolved completion background', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
|
||||
|
||||
expect(theme.color.completionBg).toBe('#ffffff')
|
||||
expect(theme.color.completionCurrentBg).toBe('#bfbfbf')
|
||||
})
|
||||
|
||||
it('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('defaults for empty skin', () => {
|
||||
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('passes banner logo/hero', () => {
|
||||
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', () => {
|
||||
it('maps ui_ color keys + cascades to status', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
const { color } = fromSkin({ ui_ok: '#008000' }, {})
|
||||
|
||||
expect(color.ok).toBe('#008000')
|
||||
expect(color.statusGood).toBe('#008000')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||
import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js'
|
||||
import {
|
||||
applyDisplay,
|
||||
normalizeBusyInputMode,
|
||||
normalizeIndicatorStyle,
|
||||
normalizeMouseTracking,
|
||||
normalizeStatusBar
|
||||
} from '../app/useConfigSync.js'
|
||||
|
||||
describe('applyDisplay', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -65,6 +71,19 @@ describe('applyDisplay', () => {
|
|||
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(false)
|
||||
|
||||
applyDisplay({ config: { display: { mouse_tracking: true, tui_mouse: false } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe(true)
|
||||
|
||||
applyDisplay({ config: { display: { tui_mouse: false } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe(false)
|
||||
})
|
||||
|
||||
it('parses display.sections into per-section overrides', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
|
|
@ -160,3 +179,116 @@ describe('normalizeStatusBar', () => {
|
|||
expect(normalizeStatusBar('OFF')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMouseTracking', () => {
|
||||
it('defaults on and prefers canonical mouse_tracking over legacy tui_mouse', () => {
|
||||
expect(normalizeMouseTracking({})).toBe(true)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: false })).toBe(false)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe(false)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe(false)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe(false)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe(true)
|
||||
expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe(true)
|
||||
expect(normalizeMouseTracking({ tui_mouse: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue