Merge remote-tracking branch 'origin/main' into fix/markdown
Made-with: Cursor # Conflicts: # ui-tui/src/components/markdown.tsx
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { STREAM_BATCH_MS } from '../config/timing.js'
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import type { CommandsCatalogResponse, DelegationStatusResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
|
||||
import type {
|
||||
CommandsCatalogResponse,
|
||||
ConfigFullResponse,
|
||||
DelegationStatusResponse,
|
||||
GatewayEvent,
|
||||
GatewaySkin,
|
||||
SessionMostRecentResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { topLevelSubagents } from '../lib/subagentTree.js'
|
||||
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
||||
@@ -171,15 +178,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
})
|
||||
.catch((e: unknown) => turnController.pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'info'))
|
||||
|
||||
if (!STARTUP_RESUME_ID) {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
if (STARTUP_RESUME_ID) {
|
||||
patchUiState({ status: 'resuming…' })
|
||||
resumeById(STARTUP_RESUME_ID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
patchUiState({ status: 'resuming…' })
|
||||
resumeById(STARTUP_RESUME_ID)
|
||||
// Opt-in: when `display.tui_auto_resume_recent` is true, look up
|
||||
// the most recent human-facing session and resume it instead of
|
||||
// forging a brand-new one. Mirrors classic CLI's `hermes -c` /
|
||||
// `hermes --tui` muscle memory and addresses the audit's "session
|
||||
// unrecoverable after disconnection" gap. Default off so existing
|
||||
// users aren't surprised.
|
||||
rpc<ConfigFullResponse>('config.get', { key: 'full' })
|
||||
.then(cfg => {
|
||||
if (!cfg?.config?.display?.tui_auto_resume_recent) {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return rpc<SessionMostRecentResponse>('session.most_recent', {}).then(r => {
|
||||
const target = r?.session_id
|
||||
|
||||
if (target) {
|
||||
patchUiState({ status: 'resuming most recent…' })
|
||||
resumeById(target)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
})
|
||||
}
|
||||
|
||||
return (ev: GatewayEvent) => {
|
||||
@@ -321,12 +359,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
}
|
||||
|
||||
case 'gateway.start_timeout': {
|
||||
const { cwd, python } = ev.payload ?? {}
|
||||
const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {}
|
||||
const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : ''
|
||||
|
||||
setStatus('gateway startup timeout')
|
||||
turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error')
|
||||
|
||||
// Surface the most useful stderr lines inline so users can tell
|
||||
// "wrong python", "missing dep", and "config parse failure"
|
||||
// apart without leaving the TUI. Filter blank rows BEFORE
|
||||
// taking the last N so trailing empty lines in the buffer
|
||||
// don't crowd out actual content; truncate to match the
|
||||
// 120-char clip used for `gateway.stderr` activity entries.
|
||||
const STDERR_LINE_CAP = 120
|
||||
const STDERR_LINES_MAX = 8
|
||||
const tailLines = (stderrTail ?? '')
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-STDERR_LINES_MAX)
|
||||
|
||||
for (const line of tailLines) {
|
||||
turnController.pushActivity(line.slice(0, STDERR_LINE_CAP), 'error')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,23 @@ export interface StateSetter<T> {
|
||||
|
||||
export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
|
||||
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
||||
|
||||
// Single source of truth for indicator style names. Union type is
|
||||
// derived from this tuple so adding/removing a style only touches one
|
||||
// line — `useConfigSync` (validation) and `session.ts` (slash arg
|
||||
// validation + usage hint) both import it.
|
||||
export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const
|
||||
export type IndicatorStyle = (typeof INDICATOR_STYLES)[number]
|
||||
export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji'
|
||||
|
||||
export interface SelectionApi {
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
clearSelection: () => void
|
||||
copySelection: () => Promise<string>
|
||||
copySelectionNoClear: () => Promise<string>
|
||||
getState: () => unknown
|
||||
version: () => number
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
}
|
||||
@@ -85,6 +97,7 @@ export interface TranscriptRow {
|
||||
export interface UiState {
|
||||
bgTasks: Set<string>
|
||||
busy: boolean
|
||||
busyInputMode: BusyInputMode
|
||||
compact: boolean
|
||||
detailsMode: DetailsMode
|
||||
detailsModeCommandOverride: boolean
|
||||
@@ -94,6 +107,7 @@ export interface UiState {
|
||||
sections: SectionVisibility
|
||||
showCost: boolean
|
||||
showReasoning: boolean
|
||||
indicatorStyle: IndicatorStyle
|
||||
sid: null | string
|
||||
status: string
|
||||
statusBar: StatusBarMode
|
||||
|
||||
@@ -98,13 +98,16 @@ export const opsCommands: SlashCommand[] = [
|
||||
const action = (rawAction || 'status').toLowerCase()
|
||||
|
||||
if (!['connect', 'disconnect', 'status'].includes(action)) {
|
||||
return ctx.transcript.sys('usage: /browser [connect|disconnect|status] [url]')
|
||||
return ctx.transcript.sys(
|
||||
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
|
||||
)
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = { action }
|
||||
const requested = rest.join(' ').trim()
|
||||
|
||||
if (action === 'connect') {
|
||||
payload.url = rest.join(' ').trim() || 'http://localhost:9222'
|
||||
payload.url = requested || 'http://localhost:9222'
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
@@ -113,14 +116,21 @@ export const opsCommands: SlashCommand[] = [
|
||||
ctx.guarded<BrowserManageResponse>(r => {
|
||||
if (action === 'status') {
|
||||
return ctx.transcript.sys(
|
||||
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected'
|
||||
r.connected
|
||||
? `browser connected: ${r.url || '(url unavailable)'}`
|
||||
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
|
||||
)
|
||||
}
|
||||
|
||||
if (action === 'connect') {
|
||||
return ctx.transcript.sys(
|
||||
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser connect failed'
|
||||
)
|
||||
if (r.connected) {
|
||||
ctx.transcript.sys(`browser connected: ${r.url || '(url unavailable)'}`)
|
||||
ctx.transcript.sys('next browser tool call will use this CDP endpoint')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return ctx.transcript.sys('browser connect failed')
|
||||
}
|
||||
|
||||
ctx.transcript.sys('browser disconnected')
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { fmtK } from '../../../lib/text.js'
|
||||
import type { PanelSection } from '../../../types.js'
|
||||
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import { patchUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
@@ -268,6 +269,45 @@ export const sessionCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'pick the busy indicator: kaomoji (default), emoji, unicode (braille), or ascii',
|
||||
name: 'indicator',
|
||||
usage: `/indicator [${INDICATOR_STYLES.join('|')}]`,
|
||||
run: (arg, ctx) => {
|
||||
const value = arg.trim().toLowerCase()
|
||||
|
||||
if (!value) {
|
||||
return ctx.gateway
|
||||
.rpc<ConfigGetValueResponse>('config.get', { key: 'indicator' })
|
||||
.then(
|
||||
ctx.guarded<ConfigGetValueResponse>(r =>
|
||||
ctx.transcript.sys(`indicator: ${r.value || DEFAULT_INDICATOR_STYLE}`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!(INDICATOR_STYLES as readonly string[]).includes(value)) {
|
||||
return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`)
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value })
|
||||
.then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (!r.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hot-swap the running TUI immediately so the next render
|
||||
// uses the new style without waiting for the 5s mtime poll
|
||||
// to re-apply config.full.
|
||||
patchUiState({ indicatorStyle: value as IndicatorStyle })
|
||||
ctx.transcript.sys(`indicator → ${r.value}`)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'toggle yolo mode (per-session approvals)',
|
||||
name: 'yolo',
|
||||
|
||||
@@ -316,6 +316,10 @@ class TurnController {
|
||||
}
|
||||
|
||||
recordTodos(value: unknown) {
|
||||
if (this.interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
const todos = parseTodos(value)
|
||||
|
||||
if (todos !== null) {
|
||||
@@ -397,6 +401,10 @@ class TurnController {
|
||||
}
|
||||
|
||||
pushTrail(line: string) {
|
||||
if (this.interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
patchTurnState(state => {
|
||||
if (state.turnTrail.at(-1) === line) {
|
||||
return state
|
||||
@@ -423,7 +431,13 @@ class TurnController {
|
||||
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
|
||||
this.closeReasoningSegment()
|
||||
|
||||
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
|
||||
// Ink renders markdown via <Md>; the gateway's Rich-rendered ANSI
|
||||
// (`payload.rendered`) is for terminals that can't. Prioritising
|
||||
// `rendered` here garbles output whenever a user opts into
|
||||
// `display.final_response_markdown: render` because raw ANSI escapes
|
||||
// pass through into the React tree. Prefer raw text and fall back
|
||||
// only when the gateway elected not to send any (#16391).
|
||||
const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart()
|
||||
const split = splitReasoning(rawText)
|
||||
const finalText = finalTail(split.text, this.segmentMessages)
|
||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||
@@ -508,15 +522,20 @@ class TurnController {
|
||||
return { finalMessages, finalText, wasInterrupted }
|
||||
}
|
||||
|
||||
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
|
||||
this.pruneTransient()
|
||||
this.endReasoningPhase()
|
||||
|
||||
if (!text || this.interrupted) {
|
||||
recordMessageDelta({ text }: { rendered?: string; text?: string }) {
|
||||
if (this.interrupted || !text) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bufRef = rendered ?? this.bufRef + text
|
||||
this.pruneTransient()
|
||||
this.endReasoningPhase()
|
||||
|
||||
// Always accumulate the raw text delta. The pre-#16391 path replaced
|
||||
// the entire buffer with `rendered` (an *incremental* Rich ANSI
|
||||
// fragment), which on every tick discarded everything streamed so far
|
||||
// — visible as overlapping coloured text and lost prose under
|
||||
// `display.final_response_markdown: render`.
|
||||
this.bufRef += text
|
||||
|
||||
if (getUiState().streaming) {
|
||||
this.scheduleStreaming()
|
||||
@@ -524,7 +543,7 @@ class TurnController {
|
||||
}
|
||||
|
||||
recordReasoningAvailable(text: string) {
|
||||
if (!getUiState().showReasoning) {
|
||||
if (this.interrupted || !getUiState().showReasoning) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -542,7 +561,7 @@ class TurnController {
|
||||
}
|
||||
|
||||
recordReasoningDelta(text: string) {
|
||||
if (!getUiState().showReasoning) {
|
||||
if (this.interrupted || !getUiState().showReasoning) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -570,6 +589,10 @@ class TurnController {
|
||||
duration?: number,
|
||||
todos?: unknown
|
||||
) {
|
||||
if (this.interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.recordTodos(todos)
|
||||
const line = this.completeTool(toolId, fallbackName, error, summary, duration)
|
||||
|
||||
@@ -585,6 +608,10 @@ class TurnController {
|
||||
error?: string,
|
||||
duration?: number
|
||||
) {
|
||||
if (this.interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flushStreamingSegment()
|
||||
this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '', duration)])
|
||||
this.publishToolState()
|
||||
@@ -626,6 +653,10 @@ class TurnController {
|
||||
}
|
||||
|
||||
recordToolProgress(toolName: string, preview: string) {
|
||||
if (this.interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = this.activeTools.findIndex(tool => tool.name === toolName)
|
||||
|
||||
if (index < 0) {
|
||||
@@ -645,6 +676,10 @@ class TurnController {
|
||||
}
|
||||
|
||||
recordToolStart(toolId: string, name: string, context: string) {
|
||||
if (this.interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flushStreamingSegment()
|
||||
this.closeReasoningSegment()
|
||||
this.pruneTransient()
|
||||
@@ -716,6 +751,7 @@ class TurnController {
|
||||
this.reasoningSegmentIndex = null
|
||||
this.turnTools = []
|
||||
this.toolTokenAcc = 0
|
||||
this.interrupted = false
|
||||
this.persistedToolLabels.clear()
|
||||
patchUiState({ busy: true })
|
||||
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
||||
|
||||
@@ -4,14 +4,16 @@ import { MOUSE_TRACKING } from '../config/env.js'
|
||||
import { ZERO } from '../domain/usage.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
import type { UiState } from './interfaces.js'
|
||||
import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
|
||||
|
||||
const buildUiState = (): UiState => ({
|
||||
bgTasks: new Set(),
|
||||
busy: false,
|
||||
busyInputMode: 'queue',
|
||||
compact: false,
|
||||
detailsMode: 'collapsed',
|
||||
detailsModeCommandOverride: false,
|
||||
indicatorStyle: DEFAULT_INDICATOR_STYLE,
|
||||
info: null,
|
||||
inlineDiffs: true,
|
||||
mouseTracking: MOUSE_TRACKING,
|
||||
|
||||
@@ -10,7 +10,13 @@ import type {
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
||||
import type { StatusBarMode } from './interfaces.js'
|
||||
import {
|
||||
DEFAULT_INDICATOR_STYLE,
|
||||
INDICATOR_STYLES,
|
||||
type BusyInputMode,
|
||||
type IndicatorStyle,
|
||||
type StatusBarMode,
|
||||
} from './interfaces.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
|
||||
@@ -24,6 +30,52 @@ const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
|
||||
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
|
||||
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
|
||||
|
||||
const BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
|
||||
|
||||
// TUI defaults to `queue` even though the framework default
|
||||
// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen
|
||||
// TUI you're typically authoring the next prompt while the agent is
|
||||
// still streaming, and an unintended interrupt loses work. Set
|
||||
// `display.busy_input_mode: interrupt` (or `steer`) explicitly to
|
||||
// opt out per-config; CLI / messaging adapters keep their `interrupt`
|
||||
// default unchanged.
|
||||
const TUI_BUSY_DEFAULT: BusyInputMode = 'queue'
|
||||
|
||||
export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
|
||||
if (typeof raw !== 'string') {
|
||||
return TUI_BUSY_DEFAULT
|
||||
}
|
||||
|
||||
const v = raw.trim().toLowerCase() as BusyInputMode
|
||||
|
||||
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
|
||||
}
|
||||
|
||||
const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
|
||||
|
||||
export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
|
||||
if (typeof raw !== 'string') {
|
||||
return DEFAULT_INDICATOR_STYLE
|
||||
}
|
||||
|
||||
const v = raw.trim().toLowerCase() as IndicatorStyle
|
||||
|
||||
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
|
||||
}
|
||||
|
||||
const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off'])
|
||||
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)
|
||||
|
||||
export const normalizeMouseTracking = (display: { mouse_tracking?: unknown; tui_mouse?: unknown }): boolean => {
|
||||
const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse
|
||||
|
||||
if (raw === false || raw === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof raw === 'string' ? !FALSEY_MOUSE.has(raw.trim().toLowerCase()) : true
|
||||
}
|
||||
|
||||
const MTIME_POLL_MS = 5000
|
||||
|
||||
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
||||
@@ -43,11 +95,13 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
|
||||
|
||||
setBell(!!d.bell_on_complete)
|
||||
patchUiState({
|
||||
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
|
||||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
detailsModeCommandOverride: false,
|
||||
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
|
||||
inlineDiffs: d.inline_diffs !== false,
|
||||
mouseTracking: d.tui_mouse !== false,
|
||||
mouseTracking: normalizeMouseTracking(d),
|
||||
sections: resolveSections(d.sections),
|
||||
showCost: !!d.show_cost,
|
||||
showReasoning: !!d.show_reasoning,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||
import { isMac } from '../lib/platform.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
||||
@@ -52,7 +53,7 @@ const capHistory = (items: Msg[]): Msg[] => {
|
||||
return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY)
|
||||
}
|
||||
|
||||
const statusColorOf = (status: string, t: { dim: string; error: string; ok: string; warn: string }) => {
|
||||
const statusColorOf = (status: string, t: { error: string; muted: string; ok: string; warn: string }) => {
|
||||
if (status === 'ready') {
|
||||
return t.ok
|
||||
}
|
||||
@@ -65,7 +66,7 @@ const statusColorOf = (status: string, t: { dim: string; error: string; ok: stri
|
||||
return t.warn
|
||||
}
|
||||
|
||||
return t.dim
|
||||
return t.muted
|
||||
}
|
||||
|
||||
export function useMainApp(gw: GatewayClient) {
|
||||
@@ -143,11 +144,47 @@ export function useMainApp(gw: GatewayClient) {
|
||||
|
||||
const hasSelection = useHasSelection()
|
||||
const selection = useSelection()
|
||||
const lastCopiedVersionRef = useRef(-1)
|
||||
|
||||
useEffect(() => {
|
||||
selection.setSelectionBgColor(ui.theme.color.selectionBg)
|
||||
}, [selection, ui.theme.color.selectionBg])
|
||||
|
||||
// macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable
|
||||
// mouse tracking, so the only reliable native-feeling path is iTerm-style
|
||||
// copy-on-select: once a drag creates a stable TUI selection, write it to
|
||||
// the system clipboard while keeping the highlight visible.
|
||||
//
|
||||
// Subscribe directly via the ink selection bus (not useSyncExternalStore)
|
||||
// so React doesn't re-render MainApp on every drag-move tick. The version
|
||||
// ref de-dupes against re-entrant notifications.
|
||||
useEffect(() => {
|
||||
if (!isMac) {
|
||||
return
|
||||
}
|
||||
|
||||
return selection.subscribe(() => {
|
||||
if (!selection.hasSelection()) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = selection.getState() as { isDragging?: boolean } | null
|
||||
|
||||
if (state?.isDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
const version = selection.version()
|
||||
|
||||
if (version === lastCopiedVersionRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCopiedVersionRef.current = version
|
||||
void selection.copySelectionNoClear()
|
||||
})
|
||||
}, [selection])
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
selection.clearSelection()
|
||||
getInputSelection()?.collapseToEnd()
|
||||
|
||||
@@ -4,7 +4,12 @@ import { TYPING_IDLE_MS } from '../config/timing.js'
|
||||
import { attachedImageNotice } from '../domain/messages.js'
|
||||
import { looksLikeSlashCommand } from '../domain/slash.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js'
|
||||
import type {
|
||||
InputDetectDropResponse,
|
||||
PromptSubmitResponse,
|
||||
SessionSteerResponse,
|
||||
ShellExecResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
|
||||
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
|
||||
@@ -207,6 +212,70 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
[interpolate, send, shellExec]
|
||||
)
|
||||
|
||||
// Honors `display.busy_input_mode` from config.yaml (CLI parity):
|
||||
// - 'queue' (legacy): append to queueRef; drains on busy → false
|
||||
// - 'steer' : inject into the current turn via session.steer; falls
|
||||
// back to queue when steer is rejected (no agent / no
|
||||
// tool window).
|
||||
// - 'interrupt' (default): cancel the in-flight turn, then send the
|
||||
// new text as a fresh prompt so it actually moves.
|
||||
//
|
||||
// `opts.fallbackToFront` controls whether a steer fallback re-inserts
|
||||
// at the front of the queue (used by the queue-edit path to preserve
|
||||
// a picked item's position); the mainline submit path always appends.
|
||||
const handleBusyInput = useCallback(
|
||||
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
|
||||
const live = getUiState()
|
||||
const mode = live.busyInputMode
|
||||
const fallback = (note: string) => {
|
||||
if (opts.fallbackToFront) {
|
||||
composerRefs.queueRef.current.unshift(full)
|
||||
composerActions.syncQueue()
|
||||
} else {
|
||||
composerActions.enqueue(full)
|
||||
}
|
||||
sys(note)
|
||||
}
|
||||
|
||||
if (mode === 'queue') {
|
||||
return composerActions.enqueue(full)
|
||||
}
|
||||
|
||||
if (mode === 'steer' && live.sid) {
|
||||
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: full })
|
||||
.then(raw => {
|
||||
const r = asRpcResult<SessionSteerResponse>(raw)
|
||||
|
||||
if (r?.status !== 'queued') {
|
||||
fallback('steer rejected — message queued for next turn')
|
||||
}
|
||||
})
|
||||
.catch(() => fallback('steer failed — message queued for next turn'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 'interrupt' (default): tear down the current turn, then send.
|
||||
// `interruptTurn` fires `session.interrupt` without awaiting; if
|
||||
// the gateway is still mid-response when `prompt.submit` lands,
|
||||
// `send()`'s catch path re-queues with a "queued: ..." sys note
|
||||
// (`isSessionBusyError`) — so a lost race degrades to queue
|
||||
// semantics, not a dropped message.
|
||||
if (live.sid) {
|
||||
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
|
||||
}
|
||||
|
||||
if (hasInterpolation(full)) {
|
||||
patchUiState({ busy: true })
|
||||
|
||||
return interpolate(full, send)
|
||||
}
|
||||
|
||||
send(full)
|
||||
},
|
||||
[appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
|
||||
)
|
||||
|
||||
const dispatchSubmission = useCallback(
|
||||
(full: string) => {
|
||||
if (!full.trim()) {
|
||||
@@ -252,9 +321,16 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
}
|
||||
|
||||
if (getUiState().busy) {
|
||||
composerRefs.queueRef.current.unshift(picked)
|
||||
// 'interrupt' / 'steer' should reach the live turn instead of
|
||||
// silently going back to the queue. handleBusyInput resolves
|
||||
// mode-specific behavior (interrupt-and-send, steer, or queue).
|
||||
if (getUiState().busyInputMode === 'queue') {
|
||||
composerRefs.queueRef.current.unshift(picked)
|
||||
|
||||
return composerActions.syncQueue()
|
||||
return composerActions.syncQueue()
|
||||
}
|
||||
|
||||
return handleBusyInput(picked, { fallbackToFront: true })
|
||||
}
|
||||
|
||||
return sendQueued(picked)
|
||||
@@ -263,7 +339,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
composerActions.pushHistory(full)
|
||||
|
||||
if (getUiState().busy) {
|
||||
return composerActions.enqueue(full)
|
||||
return handleBusyInput(full)
|
||||
}
|
||||
|
||||
if (hasInterpolation(full)) {
|
||||
@@ -274,7 +350,17 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
|
||||
send(full)
|
||||
},
|
||||
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef]
|
||||
[
|
||||
appendMessage,
|
||||
composerActions,
|
||||
composerRefs,
|
||||
handleBusyInput,
|
||||
interpolate,
|
||||
send,
|
||||
sendQueued,
|
||||
shellExec,
|
||||
slashRef
|
||||
]
|
||||
)
|
||||
|
||||
const submit = useCallback(
|
||||
|
||||
@@ -74,9 +74,9 @@ const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
|
||||
const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
|
||||
|
||||
const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => {
|
||||
const p = [c.gold, c.amber, c.bronze, c.dim]
|
||||
const p = [c.primary, c.accent, c.border, c.muted]
|
||||
|
||||
return art.map((text, i) => [p[gradient[i]!] ?? c.dim, text])
|
||||
return art.map((text, i) => [p[gradient[i]!] ?? c.muted, text])
|
||||
}
|
||||
|
||||
export const LOGO_WIDTH = 98
|
||||
|
||||
@@ -79,15 +79,15 @@ const FILTER_PREDICATES: Record<FilterMode, (n: SubagentNode) => boolean> = {
|
||||
}
|
||||
|
||||
const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string }> = {
|
||||
running: { color: t => t.color.amber, glyph: '●' },
|
||||
queued: { color: t => t.color.dim, glyph: '○' },
|
||||
running: { color: t => t.color.accent, glyph: '●' },
|
||||
queued: { color: t => t.color.muted, glyph: '○' },
|
||||
completed: { color: t => t.color.statusGood, glyph: '✓' },
|
||||
interrupted: { color: t => t.color.warn, glyph: '■' },
|
||||
failed: { color: t => t.color.error, glyph: '✗' }
|
||||
}
|
||||
|
||||
// Heatmap palette — cold → hot, resolved against the active theme.
|
||||
const heatPalette = (t: Theme) => [t.color.bronze, t.color.amber, t.color.gold, t.color.warn, t.color.error]
|
||||
const heatPalette = (t: Theme) => [t.color.border, t.color.accent, t.color.primary, t.color.warn, t.color.error]
|
||||
|
||||
// ── Pure helpers ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -160,8 +160,8 @@ function OverlayScrollbar({
|
||||
|
||||
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}│` : '')
|
||||
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃`
|
||||
const thumbColor = grab !== null ? t.color.gold : t.color.amber
|
||||
const trackColor = hover ? t.color.bronze : t.color.dim
|
||||
const thumbColor = grab !== null ? t.color.primary : t.color.accent
|
||||
const trackColor = hover ? t.color.border : t.color.muted
|
||||
|
||||
const jump = (row: number, offset: number) => {
|
||||
if (!s || !scrollable) {
|
||||
@@ -301,7 +301,7 @@ function GanttStrip({
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))}
|
||||
{windowLabel}
|
||||
</Text>
|
||||
@@ -309,7 +309,7 @@ function GanttStrip({
|
||||
{shown.map(({ endAt, idx, node, startAt }) => {
|
||||
const active = idx === cursor
|
||||
const { color } = statusGlyph(node.item, t)
|
||||
const accent = active ? t.color.amber : t.color.dim
|
||||
const accent = active ? t.color.accent : t.color.muted
|
||||
|
||||
const elSec = displayElapsedSeconds(node.item, now)
|
||||
const elLabel = elSec != null ? fmtElapsedLabel(elSec) : ''
|
||||
@@ -321,7 +321,7 @@ function GanttStrip({
|
||||
{' '}
|
||||
</Text>
|
||||
|
||||
<Text color={active ? t.color.amber : color}>{bar(startAt, endAt)}</Text>
|
||||
<Text color={active ? t.color.accent : color}>{bar(startAt, endAt)}</Text>
|
||||
|
||||
{elLabel ? (
|
||||
<Text color={accent}>
|
||||
@@ -333,13 +333,13 @@ function GanttStrip({
|
||||
)
|
||||
})}
|
||||
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
{' '}
|
||||
{ruler}
|
||||
</Text>
|
||||
|
||||
{totalSeconds > 0 ? (
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
{' '}
|
||||
{rulerLabels}
|
||||
</Text>
|
||||
@@ -368,7 +368,7 @@ function OverlaySection({
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box onClick={() => toggleOverlaySection(title, defaultOpen)}>
|
||||
<Text color={t.color.label}>
|
||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||||
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
|
||||
{title}
|
||||
{typeof count === 'number' ? ` (${count})` : ''}
|
||||
</Text>
|
||||
@@ -383,7 +383,7 @@ function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode })
|
||||
return (
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={t.color.label}>{name} · </Text>
|
||||
<Text color={t.color.cornsilk}>{value}</Text>
|
||||
<Text color={t.color.text}>{value}</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -411,8 +411,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.cornsilk} wrap="wrap">
|
||||
{id ? <Text color={t.color.amber}>#{id} </Text> : null}
|
||||
<Text bold color={t.color.text} wrap="wrap">
|
||||
{id ? <Text color={t.color.accent}>#{id} </Text> : null}
|
||||
<Text color={color}>{glyph}</Text> {item.goal}
|
||||
</Text>
|
||||
|
||||
@@ -472,20 +472,20 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
))}
|
||||
|
||||
{filesRead.slice(0, 8).map((p, i) => (
|
||||
<Text color={t.color.cornsilk} key={`r-${i}`} wrap="truncate-end">
|
||||
<Text color={t.color.dim}>·</Text> {p}
|
||||
<Text color={t.color.text} key={`r-${i}`} wrap="truncate-end">
|
||||
<Text color={t.color.muted}>·</Text> {p}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{filesOverflow > 0 ? <Text color={t.color.dim}>…+{filesOverflow} more</Text> : null}
|
||||
{filesOverflow > 0 ? <Text color={t.color.muted}>…+{filesOverflow} more</Text> : null}
|
||||
</OverlaySection>
|
||||
) : null}
|
||||
|
||||
{toolLines.length > 0 ? (
|
||||
<OverlaySection count={toolLines.length} defaultOpen t={t} title="Tool calls">
|
||||
{toolLines.map((line, i) => (
|
||||
<Text color={t.color.cornsilk} key={i} wrap="wrap">
|
||||
<Text color={t.color.dim}>·</Text> {line}
|
||||
<Text color={t.color.text} key={i} wrap="wrap">
|
||||
<Text color={t.color.muted}>·</Text> {line}
|
||||
</Text>
|
||||
))}
|
||||
</OverlaySection>
|
||||
@@ -494,8 +494,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
{outputTail.length > 0 ? (
|
||||
<OverlaySection count={outputTail.length} defaultOpen t={t} title="Output">
|
||||
{outputTail.map((entry, i) => (
|
||||
<Text color={entry.isError ? t.color.error : t.color.cornsilk} key={i} wrap="wrap">
|
||||
<Text bold color={entry.isError ? t.color.error : t.color.amber}>
|
||||
<Text color={entry.isError ? t.color.error : t.color.text} key={i} wrap="wrap">
|
||||
<Text bold color={entry.isError ? t.color.error : t.color.accent}>
|
||||
{entry.tool}
|
||||
</Text>{' '}
|
||||
{entry.preview}
|
||||
@@ -507,7 +507,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
{item.notes.length ? (
|
||||
<OverlaySection count={item.notes.length} t={t} title="Progress">
|
||||
{item.notes.slice(-6).map((line, i) => (
|
||||
<Text color={t.color.cornsilk} key={i} wrap="wrap">
|
||||
<Text color={t.color.text} key={i} wrap="wrap">
|
||||
<Text color={t.color.label}>·</Text> {line}
|
||||
</Text>
|
||||
))}
|
||||
@@ -516,7 +516,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
||||
|
||||
{item.summary ? (
|
||||
<OverlaySection defaultOpen t={t} title="Summary">
|
||||
<Text color={t.color.cornsilk} wrap="wrap">
|
||||
<Text color={t.color.text} wrap="wrap">
|
||||
{item.summary}
|
||||
</Text>
|
||||
</OverlaySection>
|
||||
@@ -552,16 +552,16 @@ function ListRow({
|
||||
const paren = line ? line.indexOf('(') : -1
|
||||
const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : ''
|
||||
const trailing = toolShort ? ` · ${compactPreview(toolShort, 14)}` : ''
|
||||
const fg = active ? t.color.amber : t.color.cornsilk
|
||||
const fg = active ? t.color.accent : t.color.text
|
||||
|
||||
return (
|
||||
<Text bold={active} color={fg} inverse={active} wrap="truncate-end">
|
||||
{' '}
|
||||
<Text color={active ? fg : t.color.dim}>{formatRowId(index)} </Text>
|
||||
<Text color={active ? fg : t.color.muted}>{formatRowId(index)} </Text>
|
||||
{indentFor(node.item.depth)}
|
||||
{heatMarker ? <Text color={heatMarker}>▍</Text> : null}
|
||||
<Text color={active ? fg : color}>{glyph}</Text> {goal}
|
||||
<Text color={active ? fg : t.color.dim}>
|
||||
<Text color={active ? fg : t.color.muted}>
|
||||
{toolsCount}
|
||||
{kids}
|
||||
{trailing}
|
||||
@@ -585,16 +585,16 @@ function DiffPane({
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
<Text bold color={t.color.text}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{snapshot.label}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{formatSummary(totals)}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -606,7 +606,7 @@ function DiffPane({
|
||||
const { color, glyph } = statusGlyph(s, t)
|
||||
|
||||
return (
|
||||
<Text color={t.color.dim} key={s.id} wrap="truncate-end">
|
||||
<Text color={t.color.muted} key={s.id} wrap="truncate-end">
|
||||
<Text color={color}>{glyph}</Text> {s.goal || 'subagent'}
|
||||
</Text>
|
||||
)
|
||||
@@ -644,10 +644,10 @@ function DiffView({
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={t.color.bronze}>
|
||||
<Text bold color={t.color.border}>
|
||||
Replay diff
|
||||
</Text>
|
||||
<Text color={t.color.dim}>baseline vs candidate · esc/q close</Text>
|
||||
<Text color={t.color.muted}>baseline vs candidate · esc/q close</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
@@ -657,24 +657,24 @@ function DiffView({
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Δ
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
|
||||
<Text color={t.color.text}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
@@ -985,11 +985,11 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
||||
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text bold color={replayMode ? t.color.bronze : t.color.gold}>
|
||||
<Text bold color={replayMode ? t.color.border : t.color.primary}>
|
||||
{title}
|
||||
</Text>
|
||||
{metaLine ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' '}
|
||||
{metaLine}
|
||||
</Text>
|
||||
@@ -999,7 +999,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text color={t.color.dim}>No subagents this turn. Trigger delegate_task to populate the tree.</Text>
|
||||
<Text color={t.color.muted}>No subagents this turn. Trigger delegate_task to populate the tree.</Text>
|
||||
</Box>
|
||||
) : mode === 'list' ? (
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
|
||||
@@ -1034,17 +1034,17 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{flash ? <Text color={t.color.amber}>{flash}</Text> : null}
|
||||
{flash ? <Text color={t.color.accent}>{flash}</Text> : null}
|
||||
|
||||
{mode === 'list' ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
↑↓/jk move · g/G top/bottom · Enter/→ open detail{controlsHint} · s sort:{SORT_LABEL[sort]} · f filter:
|
||||
{FILTER_LABEL[filter]}
|
||||
{history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''}
|
||||
{' · q close'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back to list{controlsHint} · q close
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
|
||||
import unicodeSpinners from 'unicode-animations'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
import type { IndicatorStyle } from '../app/interfaces.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { useTurnSelector } from '../app/turnStore.js'
|
||||
import { FACES } from '../content/faces.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
@@ -17,30 +20,103 @@ import type { Msg, Usage } from '../types.js'
|
||||
const FACE_TICK_MS = 2500
|
||||
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
||||
|
||||
// Compact alternates for the `emoji` and `ascii` indicator styles.
|
||||
// Each entry is a fixed-width (display-width) glyph.
|
||||
const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮']
|
||||
const ASCII_FRAMES = ['|', '/', '-', '\\']
|
||||
|
||||
// Faster tick for spinner-style indicators — they read as motion only
|
||||
// at frame rates closer to their authored interval.
|
||||
const SPINNER_TICK_MS = 100
|
||||
|
||||
interface IndicatorRender {
|
||||
frame: string
|
||||
intervalMs: number
|
||||
// When false, FaceTicker hides the rotating verb and just shows the
|
||||
// glyph + duration. Lets `unicode` stay minimal while the other
|
||||
// styles keep the verb-rotation flavour users associate with the
|
||||
// running… status.
|
||||
showVerb: boolean
|
||||
}
|
||||
|
||||
const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => {
|
||||
if (style === 'kaomoji') {
|
||||
return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true }
|
||||
}
|
||||
|
||||
if (style === 'emoji') {
|
||||
return {
|
||||
frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? '⚕ ',
|
||||
intervalMs: SPINNER_TICK_MS * 6,
|
||||
showVerb: true
|
||||
}
|
||||
}
|
||||
|
||||
if (style === 'ascii') {
|
||||
return {
|
||||
frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|',
|
||||
intervalMs: SPINNER_TICK_MS,
|
||||
showVerb: true
|
||||
}
|
||||
}
|
||||
|
||||
// 'unicode' — braille spinner (fixed 1-col). Authored interval is
|
||||
// ~80ms; honour it but bound below at a safe minimum so React
|
||||
// re-renders stay reasonable. This style is for users who want
|
||||
// the cleanest possible status, so no verb rotation either.
|
||||
const spinner = unicodeSpinners.braille
|
||||
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
|
||||
|
||||
return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false }
|
||||
}
|
||||
|
||||
function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) {
|
||||
const ui = useStore($uiState)
|
||||
const style = ui.indicatorStyle
|
||||
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
||||
const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length))
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
// Pre-compute cadence + verb-visibility for the active style so an
|
||||
// `/indicator` switch re-arms the interval (and skips the verb timer
|
||||
// for verb-less styles like `unicode`) without leaving the previous
|
||||
// timer dangling.
|
||||
const { intervalMs, showVerb } = renderIndicator(style, 0)
|
||||
|
||||
useEffect(() => {
|
||||
const face = setInterval(() => setTick(n => n + 1), FACE_TICK_MS)
|
||||
const glyph = setInterval(() => setTick(n => n + 1), intervalMs)
|
||||
const clock = setInterval(() => setNow(Date.now()), 1000)
|
||||
// Verb timer is gated on `showVerb` — `unicode` style hides the verb
|
||||
// entirely, so cycling `verbTick` would be an avoidable re-render.
|
||||
const verb = showVerb ? setInterval(() => setVerbTick(n => n + 1), FACE_TICK_MS) : null
|
||||
|
||||
return () => {
|
||||
clearInterval(face)
|
||||
clearInterval(glyph)
|
||||
clearInterval(clock)
|
||||
|
||||
if (verb !== null) {
|
||||
clearInterval(verb)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [intervalMs, showVerb])
|
||||
|
||||
const { frame } = renderIndicator(style, tick)
|
||||
const verb = VERBS[verbTick % VERBS.length] ?? ''
|
||||
const verbSegment = showVerb ? ` ${verb}…` : ''
|
||||
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}…{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
|
||||
{frame}
|
||||
{verbSegment}
|
||||
{durationSegment}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function ctxBarColor(pct: number | undefined, t: Theme) {
|
||||
if (pct == null) {
|
||||
return t.color.dim
|
||||
return t.color.muted
|
||||
}
|
||||
|
||||
if (pct >= 95) {
|
||||
@@ -93,7 +169,7 @@ function SpawnHud({ t }: { t: Theme }) {
|
||||
const concRatio = maxConc ? widestLevel / maxConc : 0
|
||||
const ratio = Math.max(depthRatio, concRatio)
|
||||
|
||||
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim
|
||||
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.muted
|
||||
|
||||
const pieces: string[] = []
|
||||
|
||||
@@ -162,21 +238,21 @@ const modelLabel = (model: string, effort?: string, fast?: boolean) =>
|
||||
|
||||
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
||||
const [active, setActive] = useState(false)
|
||||
const [color, setColor] = useState(t.color.amber)
|
||||
const [color, setColor] = useState(t.color.accent)
|
||||
|
||||
useEffect(() => {
|
||||
if (tick <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const palette = [...HEART_COLORS, t.color.amber]
|
||||
const palette = [t.color.error, t.color.warn, t.color.accent]
|
||||
setColor(palette[Math.floor(Math.random() * palette.length)]!)
|
||||
setActive(true)
|
||||
|
||||
const id = setTimeout(() => setActive(false), 650)
|
||||
|
||||
return () => clearTimeout(id)
|
||||
}, [t.color.amber, tick])
|
||||
}, [t.color.accent, tick])
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
@@ -217,23 +293,23 @@ export function StatusRule({
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Box flexShrink={1} width={leftWidth}>
|
||||
<Text color={t.color.bronze} wrap="truncate-end">
|
||||
<Text color={t.color.border} wrap="truncate-end">
|
||||
{'─ '}
|
||||
{busy ? (
|
||||
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
|
||||
) : (
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
)}
|
||||
<Text color={t.color.dim}> │ {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
|
||||
{ctxLabel ? <Text color={t.color.dim}> │ {ctxLabel}</Text> : null}
|
||||
<Text color={t.color.muted}> │ {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
|
||||
{ctxLabel ? <Text color={t.color.muted}> │ {ctxLabel}</Text> : null}
|
||||
{bar ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' │ '}
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{sessionStartedAt ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' │ '}
|
||||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
</Text>
|
||||
@@ -242,21 +318,21 @@ export function StatusRule({
|
||||
{voiceLabel ? (
|
||||
<Text
|
||||
color={
|
||||
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.dim
|
||||
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.muted
|
||||
}
|
||||
>
|
||||
{' │ '}
|
||||
{voiceLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.muted}> │ {bgCount} bg</Text> : null}
|
||||
{showCost && typeof usage.cost_usd === 'number' ? (
|
||||
<Text color={t.color.dim}> │ ${usage.cost_usd.toFixed(4)}</Text>
|
||||
<Text color={t.color.muted}> │ ${usage.cost_usd.toFixed(4)}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.bronze}> ─ </Text>
|
||||
<Text color={t.color.border}> ─ </Text>
|
||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -301,8 +377,8 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
|
||||
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
|
||||
const travel = Math.max(1, vp - thumb)
|
||||
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
|
||||
const thumbColor = grab !== null ? t.color.gold : hover ? t.color.amber : t.color.bronze
|
||||
const trackColor = hover ? t.color.bronze : t.color.dim
|
||||
const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
|
||||
const trackColor = hover ? t.color.border : t.color.muted
|
||||
|
||||
const jump = (row: number, offset: number) => {
|
||||
if (!s || !scrollable) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, stringWidth, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Fragment, memo, useMemo, useRef } from 'react'
|
||||
|
||||
@@ -124,8 +124,10 @@ const ComposerPane = memo(function ComposerPane({
|
||||
const ui = useStore($uiState)
|
||||
const isBlocked = useStore($isBlocked)
|
||||
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
||||
const pw = 2
|
||||
const inputColumns = stableComposerColumns(composer.cols, pw)
|
||||
const promptText = sh ? '$' : ui.theme.brand.prompt
|
||||
const promptLabel = `${promptText} `
|
||||
const promptWidth = Math.max(1, stringWidth(promptLabel))
|
||||
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
|
||||
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
||||
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
||||
|
||||
@@ -146,7 +148,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||
}
|
||||
|
||||
e.stopImmediatePropagation?.()
|
||||
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - pw)
|
||||
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth)
|
||||
}
|
||||
|
||||
// Spacer rows live on a different vertical origin; only the column is
|
||||
@@ -158,7 +160,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||
}
|
||||
|
||||
e.stopImmediatePropagation?.()
|
||||
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - pw)
|
||||
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
|
||||
}
|
||||
|
||||
const endInputDrag = () => inputMouseRef.current?.end()
|
||||
@@ -183,13 +185,13 @@ const ComposerPane = memo(function ComposerPane({
|
||||
/>
|
||||
|
||||
{ui.bgTasks.size > 0 && (
|
||||
<Text color={ui.theme.color.dim}>
|
||||
<Text color={ui.theme.color.muted}>
|
||||
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{status.showStickyPrompt ? (
|
||||
<Text color={ui.theme.color.dim} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.muted} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.label}>↳ </Text>
|
||||
|
||||
{status.stickyPrompt}
|
||||
@@ -214,21 +216,21 @@ const ComposerPane = memo(function ComposerPane({
|
||||
<>
|
||||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={2}>
|
||||
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
||||
<Box width={promptWidth}>
|
||||
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
|
||||
<Text color={ui.theme.color.text}>{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
|
||||
<Box width={pw}>
|
||||
<Box width={promptWidth}>
|
||||
{sh ? (
|
||||
<Text color={ui.theme.color.shellDollar}>$ </Text>
|
||||
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
|
||||
) : (
|
||||
<Text bold color={ui.theme.color.prompt}>
|
||||
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
||||
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -254,7 +256,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}>⚕ {ui.status}</Text>}
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}>⚕ {ui.status}</Text>}
|
||||
|
||||
<StatusRulePane at="bottom" composer={composer} status={status} />
|
||||
</NoSelect>
|
||||
@@ -319,6 +321,7 @@ export const AppLayout = memo(function AppLayout({
|
||||
transcript
|
||||
}: AppLayoutProps) {
|
||||
const overlay = useStore($overlayState)
|
||||
const ui = useStore($uiState)
|
||||
|
||||
// Inline mode skips AlternateScreen so the host terminal's native
|
||||
// scrollback captures rows scrolled off the top; composer + progress
|
||||
@@ -359,7 +362,7 @@ export const AppLayout = memo(function AppLayout({
|
||||
|
||||
{SHOW_FPS && (
|
||||
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
|
||||
<FpsOverlay />
|
||||
<FpsOverlay t={ui.theme} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -119,7 +119,7 @@ export function FloatingOverlays({
|
||||
return (
|
||||
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
{overlay.picker && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<FloatBox color={ui.theme.color.border}>
|
||||
<SessionPicker
|
||||
gw={gw}
|
||||
onCancel={() => patchOverlayState({ picker: false })}
|
||||
@@ -130,7 +130,7 @@ export function FloatingOverlays({
|
||||
)}
|
||||
|
||||
{overlay.modelPicker && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<FloatBox color={ui.theme.color.border}>
|
||||
<ModelPicker
|
||||
gw={gw}
|
||||
onCancel={() => patchOverlayState({ modelPicker: false })}
|
||||
@@ -142,17 +142,17 @@ export function FloatingOverlays({
|
||||
)}
|
||||
|
||||
{overlay.skillsHub && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<FloatBox color={ui.theme.color.border}>
|
||||
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.pager && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<FloatBox color={ui.theme.color.border}>
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
{overlay.pager.title && (
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={ui.theme.color.gold}>
|
||||
<Text bold color={ui.theme.color.primary}>
|
||||
{overlay.pager.title}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -174,7 +174,7 @@ export function FloatingOverlays({
|
||||
)}
|
||||
|
||||
{!!completions.length && (
|
||||
<FloatBox color={ui.theme.color.gold}>
|
||||
<FloatBox color={ui.theme.color.primary}>
|
||||
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
|
||||
{completions.slice(start, start + viewportSize).map((item, i) => {
|
||||
const active = start + i === compIdx
|
||||
@@ -190,7 +190,7 @@ export function FloatingOverlays({
|
||||
{' '}
|
||||
{item.display}
|
||||
</Text>
|
||||
{item.meta ? <Text color={ui.theme.color.dim}> {item.meta}</Text> : null}
|
||||
{item.meta ? <Text color={ui.theme.color.muted}> {item.meta}</Text> : null}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -26,12 +26,12 @@ export function Banner({ t }: { t: Theme }) {
|
||||
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
||||
<ArtLines lines={logoLines} />
|
||||
) : (
|
||||
<Text bold color={t.color.gold}>
|
||||
<Text bold color={t.color.primary}>
|
||||
{t.brand.icon} NOUS HERMES
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text color={t.color.dim}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
|
||||
<Text color={t.color.muted}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -70,19 +70,19 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Available {title}
|
||||
</Text>
|
||||
|
||||
{shown.map(([k, vs]) => (
|
||||
<Text key={k} wrap="truncate">
|
||||
<Text color={t.color.dim}>{strip(k)}: </Text>
|
||||
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||
<Text color={t.color.muted}>{strip(k)}: </Text>
|
||||
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{overflow > 0 && (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
(and {overflow} {overflowLabel})
|
||||
</Text>
|
||||
)}
|
||||
@@ -91,18 +91,18 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
|
||||
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
|
||||
{wide && (
|
||||
<Box flexDirection="column" marginRight={2} width={leftW}>
|
||||
<ArtLines lines={heroLines} />
|
||||
<Text />
|
||||
|
||||
<Text color={t.color.amber}>
|
||||
<Text color={t.color.accent}>
|
||||
{info.model.split('/').pop()}
|
||||
<Text color={t.color.dim}> · Nous Research</Text>
|
||||
<Text color={t.color.muted}> · Nous Research</Text>
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{info.cwd || process.cwd()}
|
||||
</Text>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
<Box flexDirection="column" width={w}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={t.color.gold}>
|
||||
<Text bold color={t.color.primary}>
|
||||
{t.brand.name}
|
||||
{info.version ? ` v${info.version}` : ''}
|
||||
{info.release_date ? ` (${info.release_date})` : ''}
|
||||
@@ -129,17 +129,17 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
{info.mcp_servers && info.mcp_servers.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
MCP Servers
|
||||
</Text>
|
||||
|
||||
{info.mcp_servers.map(s => (
|
||||
<Text key={s.name} wrap="truncate">
|
||||
<Text color={t.color.dim}>{` ${s.name} `}</Text>
|
||||
<Text color={t.color.dim}>{`[${s.transport}]`}</Text>
|
||||
<Text color={t.color.dim}>: </Text>
|
||||
<Text color={t.color.muted}>{` ${s.name} `}</Text>
|
||||
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
|
||||
<Text color={t.color.muted}>: </Text>
|
||||
{s.connected ? (
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{s.tools} tool{s.tools === 1 ? '' : 's'}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -152,12 +152,12 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
<Text />
|
||||
|
||||
<Text color={t.color.cornsilk}>
|
||||
<Text color={t.color.text}>
|
||||
{flat(info.tools).length} tools{' · '}
|
||||
{flat(info.skills).length} skills
|
||||
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
|
||||
{' · '}
|
||||
<Text color={t.color.dim}>/help for commands</Text>
|
||||
<Text color={t.color.muted}>/help for commands</Text>
|
||||
</Text>
|
||||
|
||||
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
||||
@@ -183,9 +183,9 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
export function Panel({ sections, t, title }: PanelProps) {
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box borderColor={t.color.border} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={t.color.gold}>
|
||||
<Text bold color={t.color.primary}>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -193,25 +193,25 @@ export function Panel({ sections, t, title }: PanelProps) {
|
||||
{sections.map((sec, si) => (
|
||||
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
|
||||
{sec.title && (
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
{sec.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{sec.rows?.map(([k, v], ri) => (
|
||||
<Text key={ri} wrap="truncate">
|
||||
<Text color={t.color.dim}>{k.padEnd(20)}</Text>
|
||||
<Text color={t.color.cornsilk}>{v}</Text>
|
||||
<Text color={t.color.muted}>{k.padEnd(20)}</Text>
|
||||
<Text color={t.color.text}>{v}</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{sec.items?.map((item, ii) => (
|
||||
<Text color={t.color.cornsilk} key={ii} wrap="truncate">
|
||||
<Text color={t.color.text} key={ii} wrap="truncate">
|
||||
{item}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{sec.text && <Text color={t.color.dim}>{sec.text}</Text>}
|
||||
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -5,23 +5,25 @@ import { useStore } from '@nanostores/react'
|
||||
|
||||
import { SHOW_FPS } from '../config/env.js'
|
||||
import { $fpsState } from '../lib/fpsStore.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
const fpsColor = (fps: number) => (fps >= 50 ? 'green' : fps >= 30 ? 'yellow' : 'red')
|
||||
const fpsColor = (fps: number, t: Theme) =>
|
||||
fps >= 50 ? t.color.statusGood : fps >= 30 ? t.color.statusWarn : t.color.error
|
||||
|
||||
export function FpsOverlay() {
|
||||
export function FpsOverlay({ t }: { t: Theme }) {
|
||||
if (!SHOW_FPS) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <FpsOverlayInner />
|
||||
return <FpsOverlayInner t={t} />
|
||||
}
|
||||
|
||||
function FpsOverlayInner() {
|
||||
function FpsOverlayInner({ t }: { t: Theme }) {
|
||||
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
|
||||
|
||||
// Zero-pad widths so digit churn doesn't jitter the corner.
|
||||
return (
|
||||
<Text color={fpsColor(fps)}>
|
||||
<Text color={fpsColor(fps, t)}>
|
||||
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Link, Text } from '@hermes/ink'
|
||||
import { memo, type ReactNode, useMemo } from 'react'
|
||||
import { Fragment, memo, type ReactNode, useMemo } from 'react'
|
||||
|
||||
import { ensureEmojiPresentation } from '../lib/emoji.js'
|
||||
import { BOX_CLOSE, BOX_OPEN, texToUnicode } from '../lib/mathUnicode.js'
|
||||
@@ -9,9 +9,10 @@ import type { Theme } from '../theme.js'
|
||||
// `\boxed{X}` regions in `texToUnicode` output are marked with the
|
||||
// non-printable U+0001 / U+0002 sentinels. Split on them and render the
|
||||
// boxed segment with `inverse + bold` so it reads as a highlighter-pen
|
||||
// emphasis on top of whatever color the parent `<Text>` is using (amber
|
||||
// for math). The leading / trailing space inside the highlight gives a
|
||||
// one-cell visual margin so the highlight reads as a block, not a hug.
|
||||
// emphasis on top of whatever color the parent `<Text>` is using (the
|
||||
// theme accent for math). The leading / trailing space inside the
|
||||
// highlight gives a one-cell visual margin so the highlight reads as a
|
||||
// block, not a hug.
|
||||
const renderMath = (text: string): ReactNode => {
|
||||
if (!text.includes(BOX_OPEN)) {
|
||||
return text
|
||||
@@ -144,7 +145,7 @@ const autolinkUrl = (raw: string) =>
|
||||
|
||||
const renderAutolink = (k: number, t: Theme, raw: string) => (
|
||||
<Link key={k} url={autolinkUrl(raw)}>
|
||||
<Text color={t.color.amber} underline>
|
||||
<Text color={t.color.accent} underline>
|
||||
{raw.replace(/^mailto:/, '')}
|
||||
</Text>
|
||||
</Link>
|
||||
@@ -171,18 +172,35 @@ export const stripInlineMarkup = (v: string) =>
|
||||
const renderTable = (k: number, rows: string[][], t: Theme) => {
|
||||
const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => stripInlineMarkup(r[ci] ?? '').length)))
|
||||
|
||||
// Thin divider under the header. Without it tables look like prose
|
||||
// with extra spacing because the header is just accent-coloured text
|
||||
// (#15534). We avoid full borders on purpose — column widths come
|
||||
// from `stripInlineMarkup(...).length` (UTF-16 code units, not
|
||||
// display width), so a real outline often misaligns on emoji and
|
||||
// East-Asian wide characters; one dim solid rule (`─`) under row 0
|
||||
// plus tab-style column gaps reads cleanly on every terminal we
|
||||
// tested.
|
||||
const sep = widths.map(w => '─'.repeat(Math.max(1, w))).join(' ')
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={k} paddingLeft={2}>
|
||||
{rows.map((row, ri) => (
|
||||
<Box key={ri}>
|
||||
{widths.map((w, ci) => (
|
||||
<Text color={ri === 0 ? t.color.amber : undefined} key={ci}>
|
||||
<MdInline t={t} text={row[ci] ?? ''} />
|
||||
{' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))}
|
||||
{ci < widths.length - 1 ? ' ' : ''}
|
||||
<Fragment key={ri}>
|
||||
<Box>
|
||||
{widths.map((w, ci) => (
|
||||
<Text bold={ri === 0} color={ri === 0 ? t.color.accent : undefined} key={ci}>
|
||||
<MdInline t={t} text={row[ci] ?? ''} />
|
||||
{' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))}
|
||||
{ci < widths.length - 1 ? ' ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
{ri === 0 && rows.length > 1 ? (
|
||||
<Text color={t.color.muted} dimColor>
|
||||
{sep}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
@@ -203,14 +221,14 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
|
||||
if (m[1] && m[2]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
[image: {m[1]}] {m[2]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[3] && m[4]) {
|
||||
parts.push(
|
||||
<Link key={parts.length} url={m[4]}>
|
||||
<Text color={t.color.amber} underline>
|
||||
<Text color={t.color.accent} underline>
|
||||
{m[3]}
|
||||
</Text>
|
||||
</Link>
|
||||
@@ -228,7 +246,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
// are verbatim by definition. Letting MdInline reprocess them
|
||||
// would corrupt regex examples and shell snippets.
|
||||
parts.push(
|
||||
<Text color={t.color.amber} dimColor key={parts.length}>
|
||||
<Text color={t.color.accent} dimColor key={parts.length}>
|
||||
{m[7]}
|
||||
</Text>
|
||||
)
|
||||
@@ -257,19 +275,19 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
)
|
||||
} else if (m[13]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
[{m[13]}]
|
||||
</Text>
|
||||
)
|
||||
} else if (m[14]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
^{m[14]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[15]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
<Text color={t.color.muted} key={parts.length}>
|
||||
_{m[15]}
|
||||
</Text>
|
||||
)
|
||||
@@ -286,13 +304,13 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
} else if (m[17] ?? m[18]) {
|
||||
// Inline math is run through `texToUnicode` (Greek letters, ℕℤℚℝ,
|
||||
// operators, sub/superscripts, fractions) and rendered in italic
|
||||
// amber. Italic is the disambiguator — links use amber+underline,
|
||||
// accent. Italic is the disambiguator — links use accent+underline,
|
||||
// so without italic readers can't tell `\mathbb{R}` (math) from a
|
||||
// hyperlinked word. Anything `texToUnicode` doesn't recognise is
|
||||
// preserved verbatim, so unfamiliar commands just look like their
|
||||
// raw LaTeX rather than vanishing.
|
||||
parts.push(
|
||||
<Text color={t.color.amber} italic key={parts.length}>
|
||||
<Text color={t.color.accent} italic key={parts.length}>
|
||||
{renderMath(texToUnicode(m[17] ?? m[18]!))}
|
||||
</Text>
|
||||
)
|
||||
@@ -402,11 +420,11 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (media) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
{'▸ '}
|
||||
|
||||
<Link url={/^(?:\/|[a-z]:[\\/])/i.test(media) ? `file://${media}` : media}>
|
||||
<Text color={t.color.amber} underline>
|
||||
<Text color={t.color.accent} underline>
|
||||
{media}
|
||||
</Text>
|
||||
</Link>
|
||||
@@ -453,7 +471,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{lang && !isDiff && <Text color={t.color.muted}>{'─ ' + lang}</Text>}
|
||||
|
||||
{block.map((l, j) => {
|
||||
if (highlighted) {
|
||||
@@ -479,7 +497,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
return (
|
||||
<Text
|
||||
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
|
||||
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
|
||||
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.muted : undefined}
|
||||
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
|
||||
key={j}
|
||||
>
|
||||
@@ -513,7 +531,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
start('code')
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{inner ? <Text color={t.color.amber}>{renderMath(texToUnicode(inner))}</Text> : null}
|
||||
{inner ? <Text color={t.color.accent}>{renderMath(texToUnicode(inner))}</Text> : null}
|
||||
</Box>
|
||||
)
|
||||
i++
|
||||
@@ -560,7 +578,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.amber} key={j}>
|
||||
<Text color={t.color.accent} key={j}>
|
||||
{renderMath(texToUnicode(l))}
|
||||
</Text>
|
||||
))}
|
||||
@@ -576,7 +594,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (heading) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
<Text bold color={t.color.accent} key={key}>
|
||||
<MdInline t={t} text={heading} />
|
||||
</Text>
|
||||
)
|
||||
@@ -588,7 +606,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
<Text bold color={t.color.accent} key={key}>
|
||||
<MdInline t={t} text={line.trim()} />
|
||||
</Text>
|
||||
)
|
||||
@@ -600,7 +618,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (HR_RE.test(line)) {
|
||||
start('rule')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
{'─'.repeat(36)}
|
||||
</Text>
|
||||
)
|
||||
@@ -614,7 +632,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (footnote) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||
</Text>
|
||||
)
|
||||
@@ -623,7 +641,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||
nodes.push(
|
||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
<MdInline t={t} text={lines[i]!.trim()} />
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -652,7 +670,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
nodes.push(
|
||||
<Text key={`${key}-def-${i}`}>
|
||||
<Text color={t.color.dim}> · </Text>
|
||||
<Text color={t.color.muted}> · </Text>
|
||||
<MdInline t={t} text={def} />
|
||||
</Text>
|
||||
)
|
||||
@@ -672,7 +690,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' '.repeat(indentDepth(bullet[1]!) * 2)}
|
||||
{marker}{' '}
|
||||
</Text>
|
||||
@@ -691,7 +709,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
{' '.repeat(indentDepth(numbered[1]!) * 2)}
|
||||
{numbered[2]}.{' '}
|
||||
</Text>
|
||||
@@ -719,7 +737,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key}>
|
||||
{quoteLines.map((ql, qi) => (
|
||||
<Text color={t.color.dim} key={qi}>
|
||||
<Text color={t.color.muted} key={qi}>
|
||||
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||
{'│ '}
|
||||
<MdInline t={t} text={ql.text} />
|
||||
@@ -756,7 +774,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (summary) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
▶ {summary}
|
||||
</Text>
|
||||
)
|
||||
@@ -768,7 +786,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
<Text color={t.color.muted} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: Maske
|
||||
{icon} {label}
|
||||
</Text>
|
||||
|
||||
{sub && <Text color={t.color.dim}> {sub}</Text>}
|
||||
{sub && <Text color={t.color.muted}> {sub}</Text>}
|
||||
|
||||
<Box>
|
||||
<Text color={t.color.label}>{'> '}</Text>
|
||||
|
||||
@@ -80,13 +80,13 @@ export const MessageLine = memo(function MessageLine({
|
||||
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
|
||||
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
{hasAnsi(msg.text) ? (
|
||||
<Text wrap="truncate-end">
|
||||
<Ansi>{msg.text}</Ansi>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{preview}
|
||||
</Text>
|
||||
)}
|
||||
@@ -101,7 +101,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
|
||||
const content = (() => {
|
||||
if (msg.kind === 'slash') {
|
||||
return <Text color={t.color.dim}>{msg.text}</Text>
|
||||
return <Text color={t.color.muted}>{msg.text}</Text>
|
||||
}
|
||||
|
||||
if (msg.role !== 'user' && hasAnsi(msg.text)) {
|
||||
@@ -125,7 +125,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
return (
|
||||
<Text color={body}>
|
||||
{head}
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
[long message]
|
||||
</Text>
|
||||
{rest.join('')}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.dim}>loading models…</Text>
|
||||
return <Text color={t.color.muted}>loading models…</Text>
|
||||
}
|
||||
|
||||
if (err) {
|
||||
@@ -161,7 +161,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
if (!providers.length) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim}>no authenticated providers</Text>
|
||||
<Text color={t.color.muted}>no authenticated providers</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
@@ -176,21 +176,21 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber} wrap="truncate-end">
|
||||
<Text bold color={t.color.accent} wrap="truncate-end">
|
||||
Select provider (step 1/2)
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
Full model IDs on the next step · Enter to continue
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
Current: {currentModel || '(unknown)'}
|
||||
</Text>
|
||||
<Text color={t.color.label} wrap="truncate-end">
|
||||
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
||||
</Text>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||
</Text>
|
||||
|
||||
@@ -201,7 +201,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
return row ? (
|
||||
<Text
|
||||
bold={providerIdx === idx}
|
||||
color={providerIdx === idx ? t.color.amber : t.color.dim}
|
||||
color={providerIdx === idx ? t.color.accent : t.color.muted}
|
||||
inverse={providerIdx === idx}
|
||||
key={providers[idx]?.slug ?? `row-${idx}`}
|
||||
wrap="truncate-end"
|
||||
@@ -210,17 +210,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
{i + 1}. {row}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
|
||||
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
||||
</Text>
|
||||
<OverlayHint t={t}>↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayHint>
|
||||
@@ -232,17 +232,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber} wrap="truncate-end">
|
||||
<Text bold color={t.color.accent} wrap="truncate-end">
|
||||
Select model (step 2/2)
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{names[providerIdx] || '(unknown provider)'} · Esc back
|
||||
</Text>
|
||||
<Text color={t.color.label} wrap="truncate-end">
|
||||
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
||||
</Text>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||
</Text>
|
||||
|
||||
@@ -252,11 +252,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
|
||||
if (!row) {
|
||||
return !models.length && i === 0 ? (
|
||||
<Text color={t.color.dim} key="empty" wrap="truncate-end">
|
||||
<Text color={t.color.muted} key="empty" wrap="truncate-end">
|
||||
no models listed for this provider
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
|
||||
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
)
|
||||
@@ -267,7 +267,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
return (
|
||||
<Text
|
||||
bold={modelIdx === idx}
|
||||
color={modelIdx === idx ? t.color.amber : t.color.dim}
|
||||
color={modelIdx === idx ? t.color.accent : t.color.muted}
|
||||
inverse={modelIdx === idx}
|
||||
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
|
||||
wrap="truncate-end"
|
||||
@@ -278,11 +278,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
)
|
||||
})}
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{offset + VISIBLE < models.length ? ` ↓ ${models.length - offset - VISIBLE} more` : ' '}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
||||
</Text>
|
||||
<OverlayHint t={t}>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKey
|
||||
|
||||
export function OverlayHint({ children, t }: OverlayHintProps) {
|
||||
return (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -48,13 +48,13 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
{shown.map((line, i) => (
|
||||
<Text color={t.color.cornsilk} key={i} wrap="truncate-end">
|
||||
<Text color={t.color.text} key={i} wrap="truncate-end">
|
||||
{line || ' '}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{overflow > 0 ? (
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
… +{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -64,14 +64,14 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
|
||||
{OPTS.map((o, i) => (
|
||||
<Text key={o}>
|
||||
<Text bold={sel === i} color={sel === i ? t.color.warn : t.color.dim} inverse={sel === i}>
|
||||
<Text bold={sel === i} color={sel === i ? t.color.warn : t.color.muted} inverse={sel === i}>
|
||||
{sel === i ? '▸ ' : ' '}
|
||||
{i + 1}. {LABELS[o]}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
||||
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -84,8 +84,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
|
||||
|
||||
const heading = (
|
||||
<Text bold>
|
||||
<Text color={t.color.amber}>ask</Text>
|
||||
<Text color={t.color.cornsilk}> {req.question}</Text>
|
||||
<Text color={t.color.accent}>ask</Text>
|
||||
<Text color={t.color.text}> {req.question}</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -129,7 +129,7 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
|
||||
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.muted}>
|
||||
Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '}
|
||||
{isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
|
||||
</Text>
|
||||
@@ -143,14 +143,14 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
|
||||
|
||||
{[...choices, 'Other (type your answer)'].map((c, i) => (
|
||||
<Text key={i}>
|
||||
<Text bold={sel === i} color={sel === i ? t.color.label : t.color.dim} inverse={sel === i}>
|
||||
<Text bold={sel === i} color={sel === i ? t.color.label : t.color.muted} inverse={sel === i}>
|
||||
{sel === i ? '▸ ' : ' '}
|
||||
{i + 1}. {c}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel</Text>
|
||||
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -185,8 +185,8 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
||||
const accent = req.danger ? t.color.error : t.color.warn
|
||||
|
||||
const rows = [
|
||||
{ color: t.color.cornsilk, label: req.cancelLabel ?? 'No' },
|
||||
{ color: req.danger ? t.color.error : t.color.cornsilk, label: req.confirmLabel ?? 'Yes' }
|
||||
{ color: t.color.text, label: req.cancelLabel ?? 'No' },
|
||||
{ color: req.danger ? t.color.error : t.color.text, label: req.confirmLabel ?? 'Yes' }
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -197,7 +197,7 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
||||
|
||||
{req.detail ? (
|
||||
<Box paddingLeft={1}>
|
||||
<Text color={t.color.cornsilk} wrap="truncate-end">
|
||||
<Text color={t.color.text} wrap="truncate-end">
|
||||
{req.detail}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -207,12 +207,12 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
||||
|
||||
{rows.map((row, i) => (
|
||||
<Text key={row.label}>
|
||||
<Text color={sel === i ? accent : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? row.color : t.color.dim}>{row.label}</Text>
|
||||
<Text color={sel === i ? accent : t.color.muted}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? row.color : t.color.muted}>{row.label}</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · Y/N quick · Esc cancel</Text>
|
||||
<Text color={t.color.muted}>↑/↓ select · Enter confirm · Y/N quick · Esc cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
{`queued (${queued.length})${
|
||||
queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : ''
|
||||
}`}
|
||||
</Text>
|
||||
|
||||
{q.showLead && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
{' '}
|
||||
…
|
||||
</Text>
|
||||
@@ -41,14 +41,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
|
||||
const active = queueEditIdx === idx
|
||||
|
||||
return (
|
||||
<Text color={active ? t.color.amber : t.color.dim} dimColor key={`${idx}-${item.slice(0, 16)}`}>
|
||||
<Text color={active ? t.color.accent : t.color.muted} dimColor key={`${idx}-${item.slice(0, 16)}`}>
|
||||
{active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{q.showTail && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
<Text color={t.color.muted} dimColor>
|
||||
{' '}…and {queued.length - q.end} more
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.dim}>loading sessions…</Text>
|
||||
return <Text color={t.color.muted}>loading sessions…</Text>
|
||||
}
|
||||
|
||||
if (err) {
|
||||
@@ -95,7 +95,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
if (!items.length) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim}>no previous sessions</Text>
|
||||
<Text color={t.color.muted}>no previous sessions</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
@@ -105,11 +105,11 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Resume Session
|
||||
</Text>
|
||||
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.slice(offset, offset + VISIBLE).map((s, vi) => {
|
||||
const i = offset + vi
|
||||
@@ -117,30 +117,30 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
|
||||
return (
|
||||
<Box key={s.id}>
|
||||
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected}>
|
||||
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
|
||||
{selected ? '▸ ' : ' '}
|
||||
</Text>
|
||||
|
||||
<Box width={30}>
|
||||
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected}>
|
||||
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
|
||||
{String(i + 1).padStart(2)}. [{s.id}]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box width={30}>
|
||||
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected}>
|
||||
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
|
||||
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected} wrap="truncate-end">
|
||||
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected} wrap="truncate-end">
|
||||
{s.title || s.preview || '(untitled)'}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{offset + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - offset - VISIBLE} more</Text>}
|
||||
{offset + VISIBLE < items.length && <Text color={t.color.muted}> ↓ {items.length - offset - VISIBLE} more</Text>}
|
||||
<OverlayHint t={t}>↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.dim}>loading skills…</Text>
|
||||
return <Text color={t.color.muted}>loading skills…</Text>
|
||||
}
|
||||
|
||||
if (err && stage === 'category') {
|
||||
@@ -194,7 +194,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
if (!cats.length) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text color={t.color.dim}>no skills available</Text>
|
||||
<Text color={t.color.muted}>no skills available</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
@@ -206,12 +206,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Skills Hub
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>select a category</Text>
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
<Text color={t.color.muted}>select a category</Text>
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = offset + i
|
||||
@@ -219,7 +219,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
return (
|
||||
<Text
|
||||
bold={catIdx === idx}
|
||||
color={catIdx === idx ? t.color.amber : t.color.dim}
|
||||
color={catIdx === idx ? t.color.accent : t.color.muted}
|
||||
inverse={catIdx === idx}
|
||||
key={row}
|
||||
wrap="truncate-end"
|
||||
@@ -230,7 +230,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
)
|
||||
})}
|
||||
|
||||
{offset + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - offset - VISIBLE} more</Text>}
|
||||
{offset + VISIBLE < rows.length && <Text color={t.color.muted}> ↓ {rows.length - offset - VISIBLE} more</Text>}
|
||||
<OverlayHint t={t}>↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
@@ -241,13 +241,13 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
{selectedCat}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>{skills.length} skill(s)</Text>
|
||||
{!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null}
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
<Text color={t.color.muted}>{skills.length} skill(s)</Text>
|
||||
{!skills.length ? <Text color={t.color.muted}>no skills in this category</Text> : null}
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = offset + i
|
||||
@@ -255,7 +255,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
return (
|
||||
<Text
|
||||
bold={skillIdx === idx}
|
||||
color={skillIdx === idx ? t.color.amber : t.color.dim}
|
||||
color={skillIdx === idx ? t.color.accent : t.color.muted}
|
||||
inverse={skillIdx === idx}
|
||||
key={row}
|
||||
wrap="truncate-end"
|
||||
@@ -267,7 +267,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
})}
|
||||
|
||||
{offset + VISIBLE < skills.length && (
|
||||
<Text color={t.color.dim}> ↓ {skills.length - offset - VISIBLE} more</Text>
|
||||
<Text color={t.color.muted}> ↓ {skills.length - offset - VISIBLE} more</Text>
|
||||
)}
|
||||
<OverlayHint t={t}>
|
||||
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'}
|
||||
@@ -278,16 +278,16 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.amber}>
|
||||
<Text bold color={t.color.accent}>
|
||||
{info?.name ?? skillName}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>{info?.category ?? selectedCat}</Text>
|
||||
{info?.description ? <Text color={t.color.cornsilk}>{info.description}</Text> : null}
|
||||
{info?.path ? <Text color={t.color.dim}>path: {info.path}</Text> : null}
|
||||
{!info && !err ? <Text color={t.color.dim}>loading…</Text> : null}
|
||||
<Text color={t.color.muted}>{info?.category ?? selectedCat}</Text>
|
||||
{info?.description ? <Text color={t.color.text}>{info.description}</Text> : null}
|
||||
{info?.path ? <Text color={t.color.muted}>path: {info.path}</Text> : null}
|
||||
{!info && !err ? <Text color={t.color.muted}>loading…</Text> : null}
|
||||
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
||||
{installing ? <Text color={t.color.amber}>installing…</Text> : null}
|
||||
{installing ? <Text color={t.color.accent}>installing…</Text> : null}
|
||||
|
||||
<OverlayHint t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayHint>
|
||||
</Box>
|
||||
|
||||
@@ -360,6 +360,10 @@ export function TextInput({
|
||||
|
||||
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
|
||||
|
||||
// Placeholder text is just a hint, not a selection — render it dim
|
||||
// without inverse styling. In a TTY the hardware cursor parks at column
|
||||
// 0 and visually marks the input start. Non-TTY surfaces still need the
|
||||
// synthetic inverse first-char to draw a cursor at all.
|
||||
const rendered = useMemo(() => {
|
||||
if (!focus) {
|
||||
return display || dim(placeholder)
|
||||
@@ -711,6 +715,14 @@ export function TextInput({
|
||||
if (range && range.start === range.end) {
|
||||
selRef.current = null
|
||||
setSel(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = selRange()
|
||||
|
||||
if (isMac && normalized) {
|
||||
void writeClipboardText(vRef.current.slice(normalized.start, normalized.end))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ function TreeRow({
|
||||
return (
|
||||
<Box>
|
||||
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
|
||||
<Text color={stemColor ?? t.color.dim} dim={stemDim}>
|
||||
<Text color={stemColor ?? t.color.muted} dim={stemDim}>
|
||||
{lead}
|
||||
</Text>
|
||||
</NoSelect>
|
||||
@@ -246,12 +246,12 @@ function Chevron({
|
||||
title: string
|
||||
tone?: 'dim' | 'error' | 'warn'
|
||||
}) {
|
||||
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
|
||||
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.muted
|
||||
|
||||
return (
|
||||
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
||||
<Text color={color} dim={tone === 'dim'}>
|
||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||||
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
|
||||
{title}
|
||||
{typeof count === 'number' ? ` (${count})` : ''}
|
||||
{suffix ? (
|
||||
@@ -266,7 +266,7 @@ function Chevron({
|
||||
}
|
||||
|
||||
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined {
|
||||
const palette = [theme.color.bronze, theme.color.amber, theme.color.gold, theme.color.warn, theme.color.error]
|
||||
const palette = [theme.color.border, theme.color.accent, theme.color.primary, theme.color.warn, theme.color.error]
|
||||
const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
|
||||
|
||||
// Below the median bucket we keep the default dim stem so cool branches
|
||||
@@ -394,7 +394,7 @@ function SubagentAccordion({
|
||||
const hasTools = item.tools.length > 0
|
||||
const noteRows = [...(summary ? [summary] : []), ...item.notes]
|
||||
const hasNotes = noteRows.length > 0
|
||||
const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.dim
|
||||
const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.muted
|
||||
|
||||
const sections: {
|
||||
header: ReactNode
|
||||
@@ -460,10 +460,10 @@ function SubagentAccordion({
|
||||
{item.tools.map((line, index) => (
|
||||
<TreeTextRow
|
||||
branch={index === item.tools.length - 1 ? 'last' : 'mid'}
|
||||
color={t.color.cornsilk}
|
||||
color={t.color.text}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
<Text color={t.color.accent}>● </Text>
|
||||
{line}
|
||||
</>
|
||||
}
|
||||
@@ -649,22 +649,22 @@ export const Thinking = memo(function Thinking({
|
||||
{preview ? (
|
||||
mode === 'full' ? (
|
||||
lines.map((line, index) => (
|
||||
<Text color={t.color.dim} key={index} wrap="wrap-trim">
|
||||
<Text color={t.color.muted} key={index} wrap="wrap-trim">
|
||||
{line || ' '}
|
||||
{index === lines.length - 1 ? (
|
||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
) : null}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{preview}
|
||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text color={t.color.dim}>
|
||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
||||
<Text color={t.color.muted}>
|
||||
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -792,7 +792,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
if (parsed) {
|
||||
groups.push({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.text,
|
||||
content: parsed.call,
|
||||
details: [],
|
||||
key: `tr-${i}`,
|
||||
@@ -801,7 +801,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
if (parsed.detail) {
|
||||
pushDetail({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.muted,
|
||||
content: parsed.detail,
|
||||
dimColor: parsed.mark !== '✗',
|
||||
key: `tr-${i}-d`
|
||||
@@ -815,9 +815,9 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim())
|
||||
|
||||
groups.push({
|
||||
color: t.color.cornsilk,
|
||||
color: t.color.text,
|
||||
content: label,
|
||||
details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
|
||||
details: [{ color: t.color.muted, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
|
||||
key: `tr-${i}`,
|
||||
label
|
||||
})
|
||||
@@ -827,12 +827,12 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
if (line === 'analyzing tool output…') {
|
||||
pushDetail({
|
||||
color: t.color.dim,
|
||||
color: t.color.muted,
|
||||
dimColor: true,
|
||||
key: `tr-${i}`,
|
||||
content: groups.length ? (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="think" /> {line}
|
||||
<Spinner color={t.color.accent} variant="think" /> {line}
|
||||
</>
|
||||
) : (
|
||||
line
|
||||
@@ -842,20 +842,20 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
continue
|
||||
}
|
||||
|
||||
meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` })
|
||||
meta.push({ color: t.color.muted, content: line, dimColor: true, key: `tr-${i}` })
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
const label = formatToolCall(tool.name, tool.context || '')
|
||||
|
||||
groups.push({
|
||||
color: t.color.cornsilk,
|
||||
color: t.color.text,
|
||||
key: tool.id,
|
||||
label,
|
||||
details: [],
|
||||
content: (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="tool" /> {label}
|
||||
<Spinner color={t.color.accent} variant="tool" /> {label}
|
||||
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
|
||||
</>
|
||||
)
|
||||
@@ -864,7 +864,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
for (const item of activity.slice(-4)) {
|
||||
const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·'
|
||||
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
|
||||
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.muted
|
||||
meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` })
|
||||
}
|
||||
|
||||
@@ -998,14 +998,14 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text color={t.color.dim} dim={!thinkingLive}>
|
||||
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text>
|
||||
<Text color={t.color.muted} dim={!thinkingLive}>
|
||||
<Text color={t.color.accent}>{openThinking ? '▾ ' : '▸ '}</Text>
|
||||
{thinkingLive ? (
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
<Text bold color={t.color.text}>
|
||||
Thinking
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
Thinking
|
||||
</Text>
|
||||
)}
|
||||
@@ -1068,7 +1068,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
color={group.color}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
<Text color={t.color.accent}>● </Text>
|
||||
{toolLabel(group)}
|
||||
</>
|
||||
}
|
||||
@@ -1182,7 +1182,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
color={t.color.statusFg}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber}>Σ </Text>
|
||||
<Text color={t.color.accent}>Σ </Text>
|
||||
{totalTokensLabel}
|
||||
</>
|
||||
}
|
||||
@@ -1192,7 +1192,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
) : null}
|
||||
{outcome ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
· {outcome}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TodoItem } from '../types.js'
|
||||
const rowColor = (t: Theme, status: TodoItem['status']) => {
|
||||
const tone = todoTone(status)
|
||||
|
||||
return tone === 'active' ? t.color.cornsilk : tone === 'body' ? t.color.statusFg : t.color.dim
|
||||
return tone === 'active' ? t.color.text : tone === 'body' ? t.color.statusFg : t.color.muted
|
||||
}
|
||||
|
||||
export const TodoPanel = memo(function TodoPanel({
|
||||
@@ -56,16 +56,16 @@ export const TodoPanel = memo(function TodoPanel({
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box onClick={handleToggle}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.amber}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text>
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
<Text color={t.color.muted}>
|
||||
<Text color={t.color.accent}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text>
|
||||
<Text bold color={t.color.text}>
|
||||
Todo
|
||||
</Text>{' '}
|
||||
<Text color={t.color.statusFg} dim>
|
||||
({done}/{todos.length})
|
||||
</Text>
|
||||
{incomplete && pending > 0 && (
|
||||
<Text color={t.color.dim} dim>
|
||||
<Text color={t.color.muted} dim>
|
||||
{' '}
|
||||
· incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'}
|
||||
</Text>
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Theme } from '../theme.js'
|
||||
import type { Role } from '../types.js'
|
||||
|
||||
export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; prefix: string }> = {
|
||||
assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }),
|
||||
system: t => ({ body: '', glyph: '·', prefix: t.color.dim }),
|
||||
tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }),
|
||||
assistant: t => ({ body: t.color.text, glyph: t.brand.tool, prefix: t.color.border }),
|
||||
system: t => ({ body: '', glyph: '·', prefix: t.color.muted }),
|
||||
tool: t => ({ body: t.color.muted, glyph: '⚡', prefix: t.color.muted }),
|
||||
user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc
|
||||
// Must be first import — mutates process.env.FORCE_COLOR / COLORTERM before
|
||||
// any chalk / supports-color import so the banner gradient renders in
|
||||
// truecolor instead of being downsampled to 256-color (which collapses
|
||||
// gold #FFD700 and amber #FFBF00 to the same slot).
|
||||
import './lib/forceTruecolor.js'
|
||||
|
||||
import type { FrameEvent } from '@hermes/ink'
|
||||
|
||||
import { GatewayClient } from './gatewayClient.js'
|
||||
|
||||
@@ -117,8 +117,18 @@ export class GatewayClient extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
// Append the most recent gateway stderr/log lines to the timeout
|
||||
// event so users can tell apart "wrong python", "missing dep",
|
||||
// and "config parse failure" from one glance instead of having
|
||||
// to dig through `/logs`. Capped to keep the activity feed
|
||||
// readable on slow boots.
|
||||
const stderrTail = this.getLogTail(20)
|
||||
|
||||
this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`)
|
||||
this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } })
|
||||
this.publish({
|
||||
type: 'gateway.start_timeout',
|
||||
payload: { cwd, python, stderr_tail: stderrTail }
|
||||
})
|
||||
}, STARTUP_TIMEOUT_MS)
|
||||
|
||||
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
|
||||
@@ -53,15 +53,25 @@ export type CommandDispatchResponse =
|
||||
|
||||
export interface ConfigDisplayConfig {
|
||||
bell_on_complete?: boolean
|
||||
busy_input_mode?: string
|
||||
details_mode?: string
|
||||
inline_diffs?: boolean
|
||||
mouse_tracking?: boolean | null | number | string
|
||||
sections?: Record<string, string>
|
||||
show_cost?: boolean
|
||||
show_reasoning?: boolean
|
||||
streaming?: boolean
|
||||
thinking_mode?: string
|
||||
tui_auto_resume_recent?: boolean
|
||||
tui_compact?: boolean
|
||||
tui_mouse?: boolean
|
||||
/** Legacy alias for display.mouse_tracking. */
|
||||
tui_mouse?: boolean | null | number | string
|
||||
// Forward-compat: backend may send styles this client doesn't know yet —
|
||||
// `normalizeIndicatorStyle` falls back to 'kaomoji' for those — but the
|
||||
// wire type is documented as `string` so consumers don't get a false
|
||||
// narrowing-and-autocomplete contract on a value that requires runtime
|
||||
// validation anyway.
|
||||
tui_status_indicator?: string
|
||||
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
|
||||
}
|
||||
|
||||
@@ -119,6 +129,13 @@ export interface SessionListResponse {
|
||||
sessions?: SessionListItem[]
|
||||
}
|
||||
|
||||
export interface SessionMostRecentResponse {
|
||||
session_id?: null | string
|
||||
source?: string
|
||||
started_at?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface SessionTitleResponse {
|
||||
pending?: boolean
|
||||
session_key?: string
|
||||
@@ -415,7 +432,11 @@ export type GatewayEvent =
|
||||
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
|
||||
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
|
||||
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
|
||||
| { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' }
|
||||
| {
|
||||
payload?: { cwd?: string; python?: string; stderr_tail?: string }
|
||||
session_id?: string
|
||||
type: 'gateway.start_timeout'
|
||||
}
|
||||
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
|
||||
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
|
||||
|
||||
35
ui-tui/src/lib/forceTruecolor.ts
Normal file
35
ui-tui/src/lib/forceTruecolor.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Force 24-bit truecolor output before any chalk / supports-color import.
|
||||
*
|
||||
* Why this exists:
|
||||
* The base CLI (Python/Rich) emits banner colors as truecolor ANSI
|
||||
* (`\033[38;2;R;G;Bm`). The TUI renders through Ink → chalk, whose
|
||||
* supports-color auto-detection defaults to 256-color on macOS Terminal.app
|
||||
* and any terminal that does NOT set `COLORTERM=truecolor`. In 256-color
|
||||
* mode, chalk downsamples `#FFD700` (gold) and `#FFBF00` (amber) to the
|
||||
* *same* xterm-256 palette slot (220) — collapsing the banner gradient
|
||||
* into a single flat yellow band. The bronze and dim rows also lose
|
||||
* contrast against each other.
|
||||
*
|
||||
* Terminal.app (macOS 12+), iTerm2, kitty, Alacritty, VS Code, Cursor,
|
||||
* and WezTerm all render truecolor correctly. The few that don't
|
||||
* (ancient xterm, some CI environments) can set `HERMES_TUI_TRUECOLOR=0`
|
||||
* to opt out.
|
||||
*
|
||||
* This MUST run before any `chalk` or `supports-color` import. supports-color
|
||||
* caches its level on first load, so nudging env vars after that point has
|
||||
* no effect.
|
||||
*/
|
||||
|
||||
if (
|
||||
process.env.HERMES_TUI_TRUECOLOR !== '0' &&
|
||||
!process.env.NO_COLOR &&
|
||||
!process.env.FORCE_COLOR
|
||||
) {
|
||||
if (!process.env.COLORTERM) {
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
}
|
||||
process.env.FORCE_COLOR = '3'
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -42,7 +42,13 @@ export const isCopyShortcut = (
|
||||
ch: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): boolean =>
|
||||
isAction(key, ch, 'c') || (isRemoteShell(env) && (key.meta || key.super === true) && ch.toLowerCase() === 'c')
|
||||
ch.toLowerCase() === 'c' &&
|
||||
(isAction(key, ch, 'c') ||
|
||||
(isRemoteShell(env) && (key.meta || key.super === true)) ||
|
||||
// VS Code/Cursor/Windsurf terminal setup forwards Cmd+C as a CSI-u
|
||||
// sequence with the super bit plus a benign ctrl bit. Accept that shape
|
||||
// even though raw Ctrl+C should remain interrupt on local macOS.
|
||||
(isMac && key.ctrl && (key.meta || key.super === true)))
|
||||
|
||||
/**
|
||||
* Voice recording toggle key (Ctrl+B).
|
||||
|
||||
@@ -80,7 +80,7 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] {
|
||||
}
|
||||
|
||||
if (spec.comment && line.trimStart().startsWith(spec.comment)) {
|
||||
return [[t.color.dim, line]]
|
||||
return [[t.color.muted, line]]
|
||||
}
|
||||
|
||||
const tokens: Token[] = []
|
||||
@@ -97,11 +97,11 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] {
|
||||
const ch = tok[0]!
|
||||
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
tokens.push([t.color.amber, tok])
|
||||
tokens.push([t.color.accent, tok])
|
||||
} else if (ch >= '0' && ch <= '9') {
|
||||
tokens.push([t.color.cornsilk, tok])
|
||||
tokens.push([t.color.text, tok])
|
||||
} else if (spec.keywords.has(tok)) {
|
||||
tokens.push([t.color.bronze, tok])
|
||||
tokens.push([t.color.border, tok])
|
||||
} else {
|
||||
tokens.push(['', tok])
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export type TerminalSetupResult = {
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
|
||||
const COPY_SEQUENCE = '\u001b[99;13u'
|
||||
const MULTILINE_SEQUENCE = '\\\r\n'
|
||||
|
||||
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = {
|
||||
@@ -33,7 +34,14 @@ const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string
|
||||
windsurf: { appName: 'Windsurf', label: 'Windsurf' }
|
||||
}
|
||||
|
||||
const TARGET_BINDINGS: Keybinding[] = [
|
||||
const MAC_COPY_BINDING: Keybinding = {
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: COPY_SEQUENCE }
|
||||
}
|
||||
|
||||
const BASE_BINDINGS: Keybinding[] = [
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
@@ -66,6 +74,9 @@ const TARGET_BINDINGS: Keybinding[] = [
|
||||
}
|
||||
]
|
||||
|
||||
const targetBindings = (platform: NodeJS.Platform): Keybinding[] =>
|
||||
platform === 'darwin' ? [MAC_COPY_BINDING, ...BASE_BINDINGS] : BASE_BINDINGS
|
||||
|
||||
export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
|
||||
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? ''
|
||||
|
||||
@@ -172,6 +183,90 @@ function sameBinding(a: Keybinding, b: Keybinding): boolean {
|
||||
return a.key === b.key && a.command === b.command && a.when === b.when && a.args?.text === b.args?.text
|
||||
}
|
||||
|
||||
type WhenRequirements = {
|
||||
forbidden: Set<string>
|
||||
required: Set<string>
|
||||
}
|
||||
|
||||
const WHEN_TOKEN_RE = /!?[A-Za-z_][\w.]*/g
|
||||
|
||||
function parseWhenRequirements(when: string): WhenRequirements {
|
||||
const required = new Set<string>()
|
||||
const forbidden = new Set<string>()
|
||||
|
||||
for (const [token] of when.matchAll(WHEN_TOKEN_RE)) {
|
||||
if (token.startsWith('!')) {
|
||||
forbidden.add(token.slice(1))
|
||||
} else {
|
||||
required.add(token)
|
||||
}
|
||||
}
|
||||
|
||||
return { forbidden, required }
|
||||
}
|
||||
|
||||
function requirementsContradict(a: WhenRequirements, b: WhenRequirements): boolean {
|
||||
for (const token of a.required) {
|
||||
if (b.forbidden.has(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of b.required) {
|
||||
if (a.forbidden.has(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function whensOverlap(a: string, b: string): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty when = global, overlaps every context.
|
||||
if (!a || !b) {
|
||||
return true
|
||||
}
|
||||
|
||||
const left = parseWhenRequirements(a)
|
||||
const right = parseWhenRequirements(b)
|
||||
|
||||
if (requirementsContradict(left, right)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// This intentionally avoids a full VS Code when-clause parser. If two
|
||||
// same-key bindings share a positive context token and don't explicitly
|
||||
// contradict each other, they can fire together in that context.
|
||||
for (const token of left.required) {
|
||||
if (right.required.has(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// VS Code allows multiple bindings on the same key as long as their `when`
|
||||
// clauses don't overlap. We flag a conflict when the contexts overlap but
|
||||
// the bindings differ — e.g. existing `terminalFocus` cmd+c overlaps with
|
||||
// our `terminalFocus && terminalTextSelected`, so the existing binding
|
||||
// would shadow ours when text isn't selected.
|
||||
function bindingsConflict(existing: Keybinding, target: Keybinding): boolean {
|
||||
if (existing.key !== target.key) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!whensOverlap(existing.when ?? '', target.when ?? '')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !sameBinding(existing, target)
|
||||
}
|
||||
|
||||
async function backupFile(filePath: string, ops: FileOps): Promise<void> {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
|
||||
@@ -240,10 +335,9 @@ export async function configureTerminalKeybindings(
|
||||
}
|
||||
}
|
||||
|
||||
const conflicts = TARGET_BINDINGS.filter(target =>
|
||||
keybindings.some(
|
||||
existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target)
|
||||
)
|
||||
const targets = targetBindings(platform)
|
||||
const conflicts = targets.filter(target =>
|
||||
keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target))
|
||||
)
|
||||
|
||||
if (conflicts.length) {
|
||||
@@ -256,7 +350,7 @@ export async function configureTerminalKeybindings(
|
||||
|
||||
let added = 0
|
||||
|
||||
for (const target of TARGET_BINDINGS.slice().reverse()) {
|
||||
for (const target of targets.slice().reverse()) {
|
||||
const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
|
||||
|
||||
if (!exists) {
|
||||
@@ -340,7 +434,7 @@ export async function shouldPromptForTerminalSetup(options?: {
|
||||
return true
|
||||
}
|
||||
|
||||
return TARGET_BINDINGS.some(
|
||||
return targetBindings(platform).some(
|
||||
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
|
||||
)
|
||||
} catch {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export interface ThemeColors {
|
||||
gold: string
|
||||
amber: string
|
||||
bronze: string
|
||||
cornsilk: string
|
||||
dim: string
|
||||
primary: string
|
||||
accent: string
|
||||
border: string
|
||||
text: string
|
||||
muted: string
|
||||
completionBg: string
|
||||
completionCurrentBg: string
|
||||
|
||||
@@ -88,18 +88,26 @@ const BRAND: ThemeBrand = {
|
||||
helpHeader: '(^_^)? Commands'
|
||||
}
|
||||
|
||||
const cleanPromptSymbol = (s: string | undefined, fallback: string) => {
|
||||
const cleaned = String(s ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
export const DARK_THEME: Theme = {
|
||||
color: {
|
||||
gold: '#FFD700',
|
||||
amber: '#FFBF00',
|
||||
bronze: '#CD7F32',
|
||||
cornsilk: '#FFF8DC',
|
||||
primary: '#FFD700',
|
||||
accent: '#FFBF00',
|
||||
border: '#CD7F32',
|
||||
text: '#FFF8DC',
|
||||
muted: '#CC9B1F',
|
||||
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
|
||||
// read as barely-visible on dark terminals for long body text. The
|
||||
// new value sits ~60% luminance — readable without losing the "muted /
|
||||
// secondary" semantic. Field labels still use `label` (65%) which
|
||||
// stays brighter so hierarchy holds.
|
||||
dim: '#CC9B1F',
|
||||
completionBg: '#FFFFFF',
|
||||
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
||||
|
||||
@@ -141,11 +149,11 @@ export const DARK_THEME: Theme = {
|
||||
// cleanly (#11300).
|
||||
export const LIGHT_THEME: Theme = {
|
||||
color: {
|
||||
gold: '#8B6914',
|
||||
amber: '#A0651C',
|
||||
bronze: '#7A4F1F',
|
||||
cornsilk: '#3D2F13',
|
||||
dim: '#7A5A0F',
|
||||
primary: '#8B6914',
|
||||
accent: '#A0651C',
|
||||
border: '#7A4F1F',
|
||||
text: '#3D2F13',
|
||||
muted: '#7A5A0F',
|
||||
completionBg: '#F5F5F5',
|
||||
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
|
||||
|
||||
@@ -179,23 +187,129 @@ export const LIGHT_THEME: Theme = {
|
||||
bannerHero: ''
|
||||
}
|
||||
|
||||
// Pick light vs dark. Explicit `HERMES_TUI_LIGHT` wins; otherwise sniff
|
||||
// `COLORFGBG` (set by XFCE Terminal, rxvt, Terminal.app, etc.) — last field is the
|
||||
// background ANSI index; 7/15 are the "white" slots most light themes emit (#11300).
|
||||
export function detectLightMode(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const explicit = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
|
||||
const TRUE_RE = /^(?:1|true|yes|on)$/
|
||||
const FALSE_RE = /^(?:0|false|no|off)$/
|
||||
|
||||
if (/^(?:1|true|yes|on)$/.test(explicit)) {
|
||||
// Reserved for future TERM_PROGRAM-based heuristics. Empty by default:
|
||||
// most modern terminals (Ghostty, Warp, iTerm2, Apple_Terminal) ship a
|
||||
// dark profile out of the box, so guessing wrong here is more annoying
|
||||
// than missing a light user — light users can always set
|
||||
// `HERMES_TUI_LIGHT=1` or `HERMES_TUI_THEME=light`.
|
||||
const LIGHT_DEFAULT_TERM_PROGRAMS = new Set<string>()
|
||||
|
||||
// Best-effort RGB → luminance check. Currently only accepts a 3- or
|
||||
// 6-digit hex value (with or without a leading `#`); the env var name
|
||||
// `HERMES_TUI_BACKGROUND` is intentionally generic so a future OSC11
|
||||
// query helper can cache its answer there too, but additional formats
|
||||
// (rgb()/hsl()/named colours) would need explicit parsing here first.
|
||||
const LUMA_LIGHT_THRESHOLD = 0.6
|
||||
|
||||
// Strict allow-list: parseInt(..., 16) silently truncates at the first
|
||||
// non-hex character (e.g. `fffgff` would parse as `fff` and yield a
|
||||
// false-positive "white" reading), so reject anything that doesn't match
|
||||
// the canonical 3- or 6-digit shape up front.
|
||||
const HEX_3_RE = /^[0-9a-f]{3}$/
|
||||
const HEX_6_RE = /^[0-9a-f]{6}$/
|
||||
|
||||
function backgroundLuminance(raw: string): null | number {
|
||||
const v = raw.trim().toLowerCase()
|
||||
|
||||
if (!v) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hex = v.startsWith('#') ? v.slice(1) : v
|
||||
const rgb = HEX_6_RE.test(hex)
|
||||
? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
|
||||
: HEX_3_RE.test(hex)
|
||||
? [parseInt(hex[0]! + hex[0]!, 16), parseInt(hex[1]! + hex[1]!, 16), parseInt(hex[2]! + hex[2]!, 16)]
|
||||
: null
|
||||
|
||||
if (!rgb) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Rec. 709 luma — close enough for "is this background bright".
|
||||
return (0.2126 * rgb[0]! + 0.7152 * rgb[1]! + 0.0722 * rgb[2]!) / 255
|
||||
}
|
||||
|
||||
// Pick light vs dark with ordered, explainable signals (#11300):
|
||||
//
|
||||
// 1. `HERMES_TUI_LIGHT` boolean — `1`/`true`/`yes`/`on` → light;
|
||||
// `0`/`false`/`no`/`off` → dark. Either explicit value wins
|
||||
// regardless of any later signal.
|
||||
// 2. `HERMES_TUI_THEME` named override — `light` / `dark` win over
|
||||
// every signal below.
|
||||
// 3. `HERMES_TUI_BACKGROUND` hex hint (3- or 6-digit) — luminance
|
||||
// ≥ LUMA_LIGHT_THRESHOLD → light.
|
||||
// 4. `COLORFGBG` last field — XFCE / rxvt / Terminal.app emit
|
||||
// slot 7 or 15 on light profiles; 0–15 ranges are otherwise
|
||||
// treated as authoritatively dark so the TERM_PROGRAM
|
||||
// allow-list below cannot override an explicit dark profile.
|
||||
// 5. `TERM_PROGRAM` light-default allow-list (currently empty).
|
||||
//
|
||||
// Anything we can't decide stays dark — the default Hermes palette
|
||||
// is the dark one.
|
||||
export function detectLightMode(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
// Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM
|
||||
// precedence rule even though the production allow-list is empty.
|
||||
lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS,
|
||||
): boolean {
|
||||
const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
|
||||
|
||||
if (TRUE_RE.test(lightFlag)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^(?:0|false|no|off)$/.test(explicit)) {
|
||||
if (FALSE_RE.test(lightFlag)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const bg = Number((env.COLORFGBG ?? '').trim().split(';').at(-1))
|
||||
const themeFlag = (env.HERMES_TUI_THEME ?? '').trim().toLowerCase()
|
||||
|
||||
return bg === 7 || bg === 15
|
||||
if (themeFlag === 'light') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (themeFlag === 'dark') {
|
||||
return false
|
||||
}
|
||||
|
||||
const bgHint = backgroundLuminance(env.HERMES_TUI_BACKGROUND ?? '')
|
||||
|
||||
if (bgHint !== null) {
|
||||
return bgHint >= LUMA_LIGHT_THRESHOLD
|
||||
}
|
||||
|
||||
const colorfgbg = (env.COLORFGBG ?? '').trim()
|
||||
|
||||
if (colorfgbg) {
|
||||
// Validate as a decimal integer before coercing — `Number('')` is 0,
|
||||
// so a malformed `COLORFGBG='15;'` would otherwise look like an
|
||||
// authoritative dark slot and incorrectly block the TERM_PROGRAM
|
||||
// allow-list. Anything that isn't pure digits falls through.
|
||||
const lastField = colorfgbg.split(';').at(-1) ?? ''
|
||||
|
||||
if (/^\d+$/.test(lastField)) {
|
||||
const bg = Number(lastField)
|
||||
|
||||
if (bg === 7 || bg === 15) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Slots 0–6 and 8–14 are the dark half of the 0–15 ANSI range.
|
||||
// When COLORFGBG is set we trust it as authoritative — a non-light
|
||||
// value here shouldn't get overridden by the TERM_PROGRAM allow-list.
|
||||
if (bg >= 0 && bg < 16) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const termProgram = (env.TERM_PROGRAM ?? '').trim()
|
||||
|
||||
return lightDefaultTermPrograms.has(termProgram)
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME
|
||||
@@ -213,19 +327,20 @@ export function fromSkin(
|
||||
const d = DEFAULT_THEME
|
||||
const c = (k: string) => colors[k]
|
||||
|
||||
const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber
|
||||
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
|
||||
const dim = c('banner_dim') ?? d.color.dim
|
||||
const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent
|
||||
const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent
|
||||
const muted = c('banner_dim') ?? d.color.muted
|
||||
const completionBg = c('completion_menu_bg') ?? d.color.completionBg
|
||||
|
||||
return {
|
||||
color: {
|
||||
gold: c('banner_title') ?? d.color.gold,
|
||||
amber,
|
||||
bronze: c('banner_border') ?? d.color.bronze,
|
||||
cornsilk: c('banner_text') ?? d.color.cornsilk,
|
||||
dim,
|
||||
completionBg: c('completion_menu_bg') ?? '#FFFFFF',
|
||||
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
|
||||
primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
|
||||
accent,
|
||||
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
|
||||
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
|
||||
muted,
|
||||
completionBg,
|
||||
completionCurrentBg: c('completion_menu_current_bg') ?? mix(completionBg, bannerAccent, 0.25),
|
||||
|
||||
label: c('ui_label') ?? d.color.label,
|
||||
ok: c('ui_ok') ?? d.color.ok,
|
||||
@@ -233,8 +348,8 @@ export function fromSkin(
|
||||
warn: c('ui_warn') ?? d.color.warn,
|
||||
|
||||
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
|
||||
sessionLabel: c('session_label') ?? dim,
|
||||
sessionBorder: c('session_border') ?? dim,
|
||||
sessionLabel: c('session_label') ?? muted,
|
||||
sessionBorder: c('session_border') ?? muted,
|
||||
|
||||
statusBg: d.color.statusBg,
|
||||
statusFg: d.color.statusFg,
|
||||
@@ -254,7 +369,7 @@ export function fromSkin(
|
||||
brand: {
|
||||
name: branding.agent_name ?? d.brand.name,
|
||||
icon: d.brand.icon,
|
||||
prompt: branding.prompt_symbol ?? d.brand.prompt,
|
||||
prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
|
||||
welcome: branding.welcome ?? d.brand.welcome,
|
||||
goodbye: branding.goodbye ?? d.brand.goodbye,
|
||||
tool: toolPrefix || d.brand.tool,
|
||||
|
||||
1
ui-tui/src/types/hermes-ink.d.ts
vendored
1
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -145,6 +145,7 @@ declare module '@hermes/ink' {
|
||||
readonly clearSelection: () => void
|
||||
readonly hasSelection: () => boolean
|
||||
readonly getState: () => unknown
|
||||
readonly version: () => number
|
||||
readonly subscribe: (cb: () => void) => () => void
|
||||
readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
|
||||
Reference in New Issue
Block a user