diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 146cb1161b..30d171543b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -494,7 +494,7 @@ branding: agent_name: "My Agent" welcome: "Welcome message" response_label: " ⚔ Agent " - prompt_symbol: "⚔ ❯ " + prompt_symbol: "⚔" tool_prefix: "╎" # Tool output line prefix ``` diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 56b925f8ff..b2a6868604 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -927,7 +927,7 @@ display: # agent_name: "My Agent" # Banner title and branding # welcome: "Welcome message" # Shown at CLI startup # response_label: " ⚔ Agent " # Response box header label - # prompt_symbol: "⚔ ❯ " # Prompt symbol + # prompt_symbol: "⚔" # Prompt symbol (bare token; renderers add trailing space) # tool_prefix: "╎" # Tool output line prefix (default: ┊) # skin: default diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 5619e7405c..6ca6f8adf3 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -68,7 +68,7 @@ All fields are optional. Missing values inherit from the ``default`` skin. welcome: "Welcome message" # Shown at CLI startup goodbye: "Goodbye! ⚕" # Shown on exit response_label: " ⚕ Hermes " # Response box header label - prompt_symbol: "❯ " # Input prompt symbol + prompt_symbol: "❯" # Input prompt symbol (bare token; renderers add trailing space) help_header: "(^_^)? Commands" # /help header text # Tool prefix: character for tool output lines (default: ┊) @@ -190,7 +190,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "goodbye": "Goodbye! ⚕", "response_label": " ⚕ Hermes ", - "prompt_symbol": "❯ ", + "prompt_symbol": "❯", "help_header": "(^_^)? Available Commands", }, "tool_prefix": "┊", @@ -242,7 +242,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Ares Agent! Type your message or /help for commands.", "goodbye": "Farewell, warrior! ⚔", "response_label": " ⚔ Ares ", - "prompt_symbol": "⚔ ❯ ", + "prompt_symbol": "⚔", "help_header": "(⚔) Available Commands", }, "tool_prefix": "╎", @@ -301,7 +301,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "goodbye": "Goodbye! ⚕", "response_label": " ⚕ Hermes ", - "prompt_symbol": "❯ ", + "prompt_symbol": "❯", "help_header": "[?] Available Commands", }, "tool_prefix": "┊", @@ -340,7 +340,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "goodbye": "Goodbye! ⚕", "response_label": " ⚕ Hermes ", - "prompt_symbol": "❯ ", + "prompt_symbol": "❯", "help_header": "(^_^)? Available Commands", }, "tool_prefix": "┊", @@ -377,7 +377,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "goodbye": "Goodbye! ⚕", "response_label": " ⚕ Hermes ", - "prompt_symbol": "❯ ", + "prompt_symbol": "❯", "help_header": "[?] Available Commands", }, "tool_prefix": "│", @@ -414,7 +414,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "goodbye": "Goodbye! \u2695", "response_label": " \u2695 Hermes ", - "prompt_symbol": "\u276f ", + "prompt_symbol": "\u276f", "help_header": "(^_^)? Available Commands", }, "tool_prefix": "\u250a", @@ -467,7 +467,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Poseidon Agent! Type your message or /help for commands.", "goodbye": "Fair winds! Ψ", "response_label": " Ψ Poseidon ", - "prompt_symbol": "Ψ ❯ ", + "prompt_symbol": "Ψ", "help_header": "(Ψ) Available Commands", }, "tool_prefix": "│", @@ -539,7 +539,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Sisyphus Agent! Type your message or /help for commands.", "goodbye": "The boulder waits. ◉", "response_label": " ◉ Sisyphus ", - "prompt_symbol": "◉ ❯ ", + "prompt_symbol": "◉", "help_header": "(◉) Available Commands", }, "tool_prefix": "│", @@ -612,7 +612,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "welcome": "Welcome to Charizard Agent! Type your message or /help for commands.", "goodbye": "Flame out! ✦", "response_label": " ✦ Charizard ", - "prompt_symbol": "✦ ❯ ", + "prompt_symbol": "✦", "help_header": "(✦) Available Commands", }, "tool_prefix": "│", @@ -780,12 +780,21 @@ def init_skin_from_config(config: dict) -> None: # ============================================================================= -def get_active_prompt_symbol(fallback: str = "❯ ") -> str: - """Get the interactive prompt symbol from the active skin.""" +def get_active_prompt_symbol(fallback: str = "❯") -> str: + """Return the interactive prompt symbol with a single trailing space. + + Skins store ``prompt_symbol`` as a bare token (no spaces). The trailing + space is appended here so callers can drop it straight into a rendered + prompt without hand-rolling whitespace. + """ try: - return get_active_skin().get_branding("prompt_symbol", fallback) + raw = get_active_skin().get_branding("prompt_symbol", fallback) except Exception: - return fallback + raw = fallback + + cleaned = (raw or fallback).strip() + + return f"{cleaned or fallback.strip()} " diff --git a/tests/cli/test_cli_skin_integration.py b/tests/cli/test_cli_skin_integration.py index 08a86782d8..8f58cfdc43 100644 --- a/tests/cli/test_cli_skin_integration.py +++ b/tests/cli/test_cli_skin_integration.py @@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration: cli = _make_cli_stub() set_active_skin("ares") - assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ❯ ")] + assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ")] def test_secret_prompt_fragments_preserve_secret_state(self): cli = _make_cli_stub() cli._secret_state = {"response_queue": object()} set_active_skin("ares") - assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")] + assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")] def test_narrow_terminals_compact_voice_prompt_fragments(self): cli = _make_cli_stub() diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index b3fbb8deec..6c23824b9e 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -252,7 +252,7 @@ class TestCliBrandingHelpers: from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol set_active_skin("ares") - assert get_active_prompt_symbol() == "⚔ ❯ " + assert get_active_prompt_symbol() == "⚔ " def test_active_help_header_ares(self): from hermes_cli.skin_engine import set_active_skin, get_active_help_header diff --git a/tests/test_cli_skin_integration.py b/tests/test_cli_skin_integration.py index 3a876f777a..ad99358ab1 100644 --- a/tests/test_cli_skin_integration.py +++ b/tests/test_cli_skin_integration.py @@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration: cli = _make_cli_stub() set_active_skin("ares") - assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ❯ ")] + assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ")] def test_secret_prompt_fragments_preserve_secret_state(self): cli = _make_cli_stub() cli._secret_state = {"response_queue": object()} set_active_skin("ares") - assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")] + assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")] def test_icon_only_skin_symbol_still_visible_in_special_states(self): cli = _make_cli_stub() diff --git a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts index 250b262e8d..3f1c5109be 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts @@ -39,6 +39,15 @@ describe('enhanced keyboard modifier parsing', () => { expect(event.key.super).toBe(true) }) + it('preserves forwarded VS Code/Cursor Cmd+C copy sequence as ctrl+super+c', () => { + const parsed = parseOne('\u001b[99;13u') + const event = new InputEvent(parsed) + + expect(parsed.name).toBe('c') + expect(event.key.ctrl).toBe(true) + expect(event.key.super).toBe(true) + }) + it('preserves Cmd on word-delete and word-navigation sequences', () => { const backspace = new InputEvent(parseOne('\u001b[127;9u')) const left = new InputEvent(parseOne('\u001b[1;9D')) diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts index bd4ef87fc7..ffd833d343 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts @@ -35,6 +35,8 @@ export function useSelection(): { * replaces the old SGR-7 inverse so syntax highlighting stays readable * under selection). Call once on mount + whenever theme changes. */ setSelectionBgColor: (color: string) => void + /** Monotonic counter incremented on every selection mutation. */ + version: () => number } { // Look up the Ink instance via stdout — same pattern as instances map. // StdinContext is available (it's always provided), and the Ink instance @@ -58,7 +60,8 @@ export function useSelection(): { shiftSelection: () => {}, moveFocus: () => {}, captureScrolledRows: () => {}, - setSelectionBgColor: () => {} + setSelectionBgColor: () => {}, + version: () => 0 } } @@ -73,7 +76,8 @@ export function useSelection(): { shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow), moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move), captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side), - setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color) + setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color), + version: () => ink.getSelectionVersion() } }, [ink]) } diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 4a26fbafba..fec8b8ad04 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -63,6 +63,7 @@ import { hasSelection, moveFocus, selectionBounds, + selectionSignature, type SelectionState, selectLineAt, selectWordAt, @@ -213,7 +214,8 @@ export default class Ink { // Fired alongside the terminal repaint whenever the selection mutates // so UI (e.g. footer hints) can react to selection appearing/clearing. private readonly selectionListeners = new Set<() => void>() - private selectionWasActive = false + private selectionVersion = 0 + private lastSelectionSignature = '' // DOM nodes currently under the pointer (mode-1003 motion). Held here // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs // against this set and mutates it in place. @@ -1661,9 +1663,16 @@ export default class Ink { return hasSelection(this.selection) } + getSelectionVersion(): number { + return this.selectionVersion + } + /** * Subscribe to selection state changes. Fires whenever the selection - * is started, updated, cleared, or copied. Returns an unsubscribe fn. + * mutates — anchor/focus moves, drag updates, programmatic clears. + * Does NOT fire on `copySelectionNoClear()` (no mutation, no notify), + * which is why version-based subscribers don't risk re-entrant copies. + * Returns an unsubscribe fn. */ subscribeToSelectionChange(cb: () => void): () => void { this.selectionListeners.add(cb) @@ -1673,14 +1682,18 @@ export default class Ink { private notifySelectionChange(): void { this.scheduleRender() - const active = hasSelection(this.selection) + // Only bump version when the selection range actually mutated. + // Listeners still fire unconditionally — useHasSelection() snapshots + // through React, which dedupes via Object.is on the boolean value. + const sig = selectionSignature(this.selection) - if (active !== this.selectionWasActive) { - this.selectionWasActive = active + if (sig !== this.lastSelectionSignature) { + this.lastSelectionSignature = sig + this.selectionVersion += 1 + } - for (const cb of this.selectionListeners) { - cb() - } + for (const cb of this.selectionListeners) { + cb() } } diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts index 76e776c22e..364a607464 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -799,6 +799,20 @@ export function hasSelection(s: SelectionState): boolean { return s.anchor !== null && s.focus !== null } +/** + * Stable fingerprint of the user-visible selection state. Used by Ink + * to skip incrementing the mutation counter when notifySelectionChange() + * fires without an actual change to anchor/focus/isDragging — protects + * version-based subscribers (copy-on-select) from re-running for the + * same stable selection. + */ +export function selectionSignature(s: SelectionState): string { + const a = s.anchor ? `${s.anchor.row},${s.anchor.col}` : 'null' + const f = s.focus ? `${s.focus.row},${s.focus.col}` : 'null' + + return `${a}|${f}|${s.isDragging ? 1 : 0}` +} + /** * Normalized selection bounds: start is always before end in reading order. * Returns null if no active selection. diff --git a/ui-tui/src/__tests__/forceTruecolor.test.ts b/ui-tui/src/__tests__/forceTruecolor.test.ts new file mode 100644 index 0000000000..7cbf46d2b6 --- /dev/null +++ b/ui-tui/src/__tests__/forceTruecolor.test.ts @@ -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) { + const saved: Record = {} + + 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() + } + ) + }) +}) diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index e3035a79b2..4166f0b71f 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -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', () => { diff --git a/ui-tui/src/__tests__/streamingMarkdown.test.ts b/ui-tui/src/__tests__/streamingMarkdown.test.ts index cd283d8a9e..5389d56e42 100644 --- a/ui-tui/src/__tests__/streamingMarkdown.test.ts +++ b/ui-tui/src/__tests__/streamingMarkdown.test.ts @@ -74,6 +74,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() }) }) diff --git a/ui-tui/src/__tests__/syntax.test.ts b/ui-tui/src/__tests__/syntax.test.ts index 505988b2ab..7978adcaef 100644 --- a/ui-tui/src/__tests__/syntax.test.ts +++ b/ui-tui/src/__tests__/syntax.test.ts @@ -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']]) }) }) diff --git a/ui-tui/src/__tests__/terminalParity.test.ts b/ui-tui/src/__tests__/terminalParity.test.ts index 0054343968..e103b0b68c 100644 --- a/ui-tui/src/__tests__/terminalParity.test.ts +++ b/ui-tui/src/__tests__/terminalParity.test.ts @@ -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', diff --git a/ui-tui/src/__tests__/terminalSetup.test.ts b/ui-tui/src/__tests__/terminalSetup.test.ts index de23176f26..5508e94ce1 100644 --- a/ui-tui/src/__tests__/terminalSetup.test.ts +++ b/ui-tui/src/__tests__/terminalSetup.test.ts @@ -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', diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts index a05ed42ffe..5521012e9c 100644 --- a/ui-tui/src/__tests__/textInputWrap.test.ts +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -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) }) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index b73251188f..e3e3cea889 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -44,7 +44,7 @@ describe('DEFAULT_THEME', () => { it('has color palette', async () => { const { DEFAULT_THEME } = await importThemeWithCleanEnv() - expect(DEFAULT_THEME.color.gold).toBe('#FFD700') + expect(DEFAULT_THEME.color.primary).toBe('#FFD700') expect(DEFAULT_THEME.color.error).toBe('#ef5350') }) }) @@ -53,9 +53,9 @@ describe('LIGHT_THEME', () => { it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', async () => { const { LIGHT_THEME } = await importThemeWithCleanEnv() - expect(LIGHT_THEME.color.gold).not.toBe('#FFD700') - expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00') - expect(LIGHT_THEME.color.dim).not.toBe('#B8860B') + 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') }) @@ -180,13 +180,22 @@ describe('fromSkin', () => { it('overrides banner colors', async () => { const { fromSkin } = await importThemeWithCleanEnv() - expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000') + expect(fromSkin({ banner_title: '#FF0000' }, {}).color.primary).toBe('#FF0000') }) it('preserves unset colors', async () => { const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv() - expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber) + expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent) + }) + + it('derives completion current background from resolved completion background', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {}) + + expect(theme.color.completionBg).toBe('#ffffff') + expect(theme.color.completionCurrentBg).toBe('#bfbfbf') }) it('overrides branding', async () => { @@ -197,6 +206,14 @@ describe('fromSkin', () => { expect(brand.prompt).toBe('$') }) + it('normalizes skin prompt symbols to trimmed single-line text', async () => { + const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv() + + expect(fromSkin({}, { prompt_symbol: ' ⚔ ❯ \n' }).brand.prompt).toBe('⚔ ❯') + expect(fromSkin({}, { prompt_symbol: ' Ψ > \n' }).brand.prompt).toBe('Ψ >') + expect(fromSkin({}, { prompt_symbol: '\n\t' }).brand.prompt).toBe(DEFAULT_THEME.brand.prompt) + }) + it('defaults for empty skin', async () => { const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv() diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 502b052e10..baf637aa25 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -41,7 +41,9 @@ export interface SelectionApi { captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void clearSelection: () => void copySelection: () => Promise + copySelectionNoClear: () => Promise getState: () => unknown + version: () => number shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void shiftSelection: (dRow: number, minRow: number, maxRow: number) => void } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index c18deb47bb..70dc96fec3 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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() diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts index d048b7dac8..80da8f43d7 100644 --- a/ui-tui/src/banner.ts +++ b/ui-tui/src/banner.ts @@ -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 diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index 6d3917bf73..a3a075afb9 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -79,15 +79,15 @@ const FILTER_PREDICATES: Record boolean> = { } const STATUS_GLYPH: Record 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 ( - + Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))} {windowLabel} @@ -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({ {' '} - {bar(startAt, endAt)} + {bar(startAt, endAt)} {elLabel ? ( @@ -333,13 +333,13 @@ function GanttStrip({ ) })} - + {' '} {ruler} {totalSeconds > 0 ? ( - + {' '} {rulerLabels} @@ -368,7 +368,7 @@ function OverlaySection({ toggleOverlaySection(title, defaultOpen)}> - {open ? '▾ ' : '▸ '} + {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} @@ -383,7 +383,7 @@ function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode }) return ( {name} · - {value} + {value} ) } @@ -411,8 +411,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) return ( - - {id ? #{id} : null} + + {id ? #{id} : null} {glyph} {item.goal} @@ -472,20 +472,20 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) ))} {filesRead.slice(0, 8).map((p, i) => ( - - · {p} + + · {p} ))} - {filesOverflow > 0 ? …+{filesOverflow} more : null} + {filesOverflow > 0 ? …+{filesOverflow} more : null} ) : null} {toolLines.length > 0 ? ( {toolLines.map((line, i) => ( - - · {line} + + · {line} ))} @@ -494,8 +494,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) {outputTail.length > 0 ? ( {outputTail.map((entry, i) => ( - - + + {entry.tool} {' '} {entry.preview} @@ -507,7 +507,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) {item.notes.length ? ( {item.notes.slice(-6).map((line, i) => ( - + · {line} ))} @@ -516,7 +516,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) {item.summary ? ( - + {item.summary} @@ -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 ( {' '} - {formatRowId(index)} + {formatRowId(index)} {indentFor(node.item.depth)} {heatMarker ? : null} {glyph} {goal} - + {toolsCount} {kids} {trailing} @@ -585,16 +585,16 @@ function DiffPane({ }) { return ( - + {label} - + {snapshot.label} - + {formatSummary(totals)} @@ -606,7 +606,7 @@ function DiffPane({ const { color, glyph } = statusGlyph(s, t) return ( - + {glyph} {s.goal || 'subagent'} ) @@ -644,10 +644,10 @@ function DiffView({ return ( - + Replay diff - baseline vs candidate · esc/q close + baseline vs candidate · esc/q close @@ -657,24 +657,24 @@ function DiffView({ - + Δ - + {diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)} - {diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)} - + {diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)} + {diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)} - + {diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)} - + {diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} - {diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)} + {diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)} ) @@ -985,11 +985,11 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent - + {title} {metaLine ? ( - + {' '} {metaLine} @@ -999,7 +999,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent {rows.length === 0 ? ( - No subagents this turn. Trigger delegate_task to populate the tree. + No subagents this turn. Trigger delegate_task to populate the tree. ) : mode === 'list' ? ( @@ -1034,17 +1034,17 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent )} - {flash ? {flash} : null} + {flash ? {flash} : null} {mode === 'list' ? ( - + ↑↓/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'} ) : ( - + ↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back to list{controlsHint} · q close )} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 392c5ff972..e85a0fb718 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -116,7 +116,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu function ctxBarColor(pct: number | undefined, t: Theme) { if (pct == null) { - return t.color.dim + return t.color.muted } if (pct >= 95) { @@ -169,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[] = [] @@ -238,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 @@ -293,23 +293,23 @@ export function StatusRule({ return ( - + {'─ '} {busy ? ( ) : ( {status} )} - │ {modelLabel(model, modelReasoningEffort, modelFast)} - {ctxLabel ? │ {ctxLabel} : null} + │ {modelLabel(model, modelReasoningEffort, modelFast)} + {ctxLabel ? │ {ctxLabel} : null} {bar ? ( - + {' │ '} [{bar}] {pct != null ? `${pct}%` : ''} ) : null} {sessionStartedAt ? ( - + {' │ '} @@ -318,21 +318,21 @@ export function StatusRule({ {voiceLabel ? ( {' │ '} {voiceLabel} ) : null} - {bgCount > 0 ? │ {bgCount} bg : null} + {bgCount > 0 ? │ {bgCount} bg : null} {showCost && typeof usage.cost_usd === 'number' ? ( - │ ${usage.cost_usd.toFixed(4)} + │ ${usage.cost_usd.toFixed(4)} ) : null} - + {cwdLabel} ) @@ -377,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) { diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 7d39d571c3..84470c4ccf 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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) @@ -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 && ( - + {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running )} {status.showStickyPrompt ? ( - + {status.stickyPrompt} @@ -214,21 +216,21 @@ const ComposerPane = memo(function ComposerPane({ <> {composer.inputBuf.map((line, i) => ( - - {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} + + {i === 0 ? promptLabel : ' '.repeat(promptWidth)} - {line || ' '} + {line || ' '} ))} - + {sh ? ( - $ + {promptLabel} ) : ( - {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} + {composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel} )} @@ -254,7 +256,7 @@ const ComposerPane = memo(function ComposerPane({ )} - {!composer.empty && !ui.sid && ⚕ {ui.status}} + {!composer.empty && !ui.sid && ⚕ {ui.status}} @@ -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 && ( - + )} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 25342598b8..1e33559f0a 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -119,7 +119,7 @@ export function FloatingOverlays({ return ( {overlay.picker && ( - + patchOverlayState({ picker: false })} @@ -130,7 +130,7 @@ export function FloatingOverlays({ )} {overlay.modelPicker && ( - + patchOverlayState({ modelPicker: false })} @@ -142,17 +142,17 @@ export function FloatingOverlays({ )} {overlay.skillsHub && ( - + patchOverlayState({ skillsHub: false })} t={ui.theme} /> )} {overlay.pager && ( - + {overlay.pager.title && ( - + {overlay.pager.title} @@ -174,7 +174,7 @@ export function FloatingOverlays({ )} {!!completions.length && ( - + {completions.slice(start, start + viewportSize).map((item, i) => { const active = start + i === compIdx @@ -190,7 +190,7 @@ export function FloatingOverlays({ {' '} {item.display} - {item.meta ? {item.meta} : null} + {item.meta ? {item.meta} : null} ) })} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 5922e71ba7..25e161fd71 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -26,12 +26,12 @@ export function Banner({ t }: { t: Theme }) { {cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? ( ) : ( - + {t.brand.icon} NOUS HERMES )} - {t.brand.icon} Nous Research · Messenger of the Digital Gods + {t.brand.icon} Nous Research · Messenger of the Digital Gods ) } @@ -70,19 +70,19 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { return ( - + Available {title} {shown.map(([k, vs]) => ( - {strip(k)}: - {truncLine(strip(k) + ': ', vs)} + {strip(k)}: + {truncLine(strip(k) + ': ', vs)} ))} {overflow > 0 && ( - + (and {overflow} {overflowLabel}) )} @@ -91,18 +91,18 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { } return ( - + {wide && ( - + {info.model.split('/').pop()} - · Nous Research + · Nous Research - + {info.cwd || process.cwd()} @@ -117,7 +117,7 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { - + {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 && ( - + MCP Servers {info.mcp_servers.map(s => ( - {` ${s.name} `} - {`[${s.transport}]`} - : + {` ${s.name} `} + {`[${s.transport}]`} + : {s.connected ? ( - + {s.tools} tool{s.tools === 1 ? '' : 's'} ) : ( @@ -152,12 +152,12 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { - + {flat(info.tools).length} tools{' · '} {flat(info.skills).length} skills {info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''} {' · '} - /help for commands + /help for commands {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 ( - + - + {title} @@ -193,25 +193,25 @@ export function Panel({ sections, t, title }: PanelProps) { {sections.map((sec, si) => ( 0 ? 1 : 0}> {sec.title && ( - + {sec.title} )} {sec.rows?.map(([k, v], ri) => ( - {k.padEnd(20)} - {v} + {k.padEnd(20)} + {v} ))} {sec.items?.map((item, ii) => ( - + {item} ))} - {sec.text && {sec.text}} + {sec.text && {sec.text}} ))} diff --git a/ui-tui/src/components/fpsOverlay.tsx b/ui-tui/src/components/fpsOverlay.tsx index f6fc748656..4400c9d44a 100644 --- a/ui-tui/src/components/fpsOverlay.tsx +++ b/ui-tui/src/components/fpsOverlay.tsx @@ -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 + return } -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 ( - + {fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames} ) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 9c18086147..3b38b25558 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -72,7 +72,7 @@ const autolinkUrl = (raw: string) => const renderAutolink = (k: number, t: Theme, raw: string) => ( - + {raw.replace(/^mailto:/, '')} @@ -113,7 +113,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => { {widths.map((w, ci) => ( - + {' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))} {ci < widths.length - 1 ? ' ' : ''} @@ -121,7 +121,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => { ))} {ri === 0 && rows.length > 1 ? ( - + {sep} ) : null} @@ -146,14 +146,14 @@ function MdInline({ t, text }: { t: Theme; text: string }) { if (m[1] && m[2]) { parts.push( - + [image: {m[1]}] {m[2]} ) } else if (m[3] && m[4]) { parts.push( - + {m[3]} @@ -168,7 +168,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[7]) { parts.push( - + {m[7]} ) @@ -192,19 +192,19 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[13]) { parts.push( - + [{m[13]}] ) } else if (m[14]) { parts.push( - + ^{m[14]} ) } else if (m[15]) { parts.push( - + _{m[15]} ) @@ -324,11 +324,11 @@ function MdImpl({ compact, t, text }: MdProps) { if (media) { start('paragraph') nodes.push( - + {'▸ '} - + {media} @@ -375,7 +375,7 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push( - {lang && !isDiff && {'─ ' + lang}} + {lang && !isDiff && {'─ ' + lang}} {block.map((l, j) => { if (highlighted) { @@ -401,7 +401,7 @@ function MdImpl({ compact, t, text }: MdProps) { return ( @@ -432,10 +432,10 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push( - ─ math + ─ math {block.map((l, j) => ( - + {l} ))} @@ -450,7 +450,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (heading) { start('heading') nodes.push( - + {heading} ) @@ -462,7 +462,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) { start('heading') nodes.push( - + {line.trim()} ) @@ -474,7 +474,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (HR_RE.test(line)) { start('rule') nodes.push( - + {'─'.repeat(36)} ) @@ -488,7 +488,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (footnote) { start('list') nodes.push( - + [{footnote[1]}] ) @@ -497,7 +497,7 @@ function MdImpl({ compact, t, text }: MdProps) { while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { nodes.push( - + @@ -526,7 +526,7 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push( - · + · ) @@ -546,7 +546,7 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push( - + {' '.repeat(indentDepth(bullet[1]!) * 2)} {marker}{' '} @@ -565,7 +565,7 @@ function MdImpl({ compact, t, text }: MdProps) { start('list') nodes.push( - + {' '.repeat(indentDepth(numbered[1]!) * 2)} {numbered[2]}.{' '} @@ -593,7 +593,7 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push( {quoteLines.map((ql, qi) => ( - + {' '.repeat(Math.max(0, ql.depth - 1) * 2)} {'│ '} @@ -630,7 +630,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (summary) { start('paragraph') nodes.push( - + ▶ {summary} ) @@ -642,7 +642,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (/^<\/?[^>]+>$/.test(line.trim())) { start('paragraph') nodes.push( - + {line.trim()} ) diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index 3739326bcc..27a9021f69 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -14,7 +14,7 @@ export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: Maske {icon} {label} - {sub && {sub}} + {sub && {sub}} {'> '} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 99312b0618..0bf9ba6d9b 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -80,13 +80,13 @@ export const MessageLine = memo(function MessageLine({ const preview = compactPreview(stripped, maxChars) || '(empty tool result)' return ( - + {hasAnsi(msg.text) ? ( {msg.text} ) : ( - + {preview} )} @@ -101,7 +101,7 @@ export const MessageLine = memo(function MessageLine({ const content = (() => { if (msg.kind === 'slash') { - return {msg.text} + return {msg.text} } if (msg.role !== 'user' && hasAnsi(msg.text)) { @@ -125,7 +125,7 @@ export const MessageLine = memo(function MessageLine({ return ( {head} - + [long message] {rest.join('')} diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 8164147fa8..833496e4ff 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -146,7 +146,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke }) if (loading) { - return loading models… + return loading models… } if (err) { @@ -161,7 +161,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke if (!providers.length) { return ( - no authenticated providers + no authenticated providers Esc/q cancel ) @@ -176,21 +176,21 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( - + Select provider (step 1/2) - + Full model IDs on the next step · Enter to continue - + Current: {currentModel || '(unknown)'} {provider?.warning ? `warning: ${provider.warning}` : ' '} - + {offset > 0 ? ` ↑ ${offset} more` : ' '} @@ -201,7 +201,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return row ? ( ) : ( - + {' '} ) })} - + {offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '} - + persist: {persistGlobal ? 'global' : 'session'} · g toggle ↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel @@ -232,17 +232,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( - + Select model (step 2/2) - + {names[providerIdx] || '(unknown provider)'} · Esc back {provider?.warning ? `warning: ${provider.warning}` : ' '} - + {offset > 0 ? ` ↑ ${offset} more` : ' '} @@ -252,11 +252,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke if (!row) { return !models.length && i === 0 ? ( - + no models listed for this provider ) : ( - + {' '} ) @@ -267,7 +267,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( + {offset + VISIBLE < models.length ? ` ↓ ${models.length - offset - VISIBLE} more` : ' '} - + persist: {persistGlobal ? 'global' : 'session'} · g toggle diff --git a/ui-tui/src/components/overlayControls.tsx b/ui-tui/src/components/overlayControls.tsx index 3087d4aecd..6d9fa5d9ec 100644 --- a/ui-tui/src/components/overlayControls.tsx +++ b/ui-tui/src/components/overlayControls.tsx @@ -20,7 +20,7 @@ export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKey export function OverlayHint({ children, t }: OverlayHintProps) { return ( - + {children} ) diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 1be68da178..e9d42485d9 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -48,13 +48,13 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { {shown.map((line, i) => ( - + {line || ' '} ))} {overflow > 0 ? ( - + … +{overflow} more line{overflow === 1 ? '' : 's'} (full text above) ) : null} @@ -64,14 +64,14 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { {OPTS.map((o, i) => ( - + {sel === i ? '▸ ' : ' '} {i + 1}. {LABELS[o]} ))} - ↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny + ↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny ) } @@ -84,8 +84,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify const heading = ( - ask - {req.question} + ask + {req.question} ) @@ -129,7 +129,7 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify - + Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '} {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} @@ -143,14 +143,14 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify {[...choices, 'Other (type your answer)'].map((c, i) => ( - + {sel === i ? '▸ ' : ' '} {i + 1}. {c} ))} - ↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel + ↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel ) } @@ -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 ? ( - + {req.detail} @@ -207,12 +207,12 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp {rows.map((row, i) => ( - {sel === i ? '▸ ' : ' '} - {row.label} + {sel === i ? '▸ ' : ' '} + {row.label} ))} - ↑/↓ select · Enter confirm · Y/N quick · Esc cancel + ↑/↓ select · Enter confirm · Y/N quick · Esc cancel ) } diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index f66b6fd314..f4ae8fbdf9 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -23,14 +23,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages return ( - + {`queued (${queued.length})${ queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : '' }`} {q.showLead && ( - + {' '} … @@ -41,14 +41,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages const active = queueEditIdx === idx return ( - + {active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))} ) })} {q.showTail && ( - + {' '}…and {queued.length - q.end} more )} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index e9bd64d018..c5696a068a 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -80,7 +80,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) }) if (loading) { - return loading sessions… + return loading sessions… } if (err) { @@ -95,7 +95,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) if (!items.length) { return ( - no previous sessions + no previous sessions Esc/q cancel ) @@ -105,11 +105,11 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) return ( - + Resume Session - {offset > 0 && ↑ {offset} more} + {offset > 0 && ↑ {offset} more} {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 ( - + {selected ? '▸ ' : ' '} - + {String(i + 1).padStart(2)}. [{s.id}] - + ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) - + {s.title || s.preview || '(untitled)'} ) })} - {offset + VISIBLE < items.length && ↓ {items.length - offset - VISIBLE} more} + {offset + VISIBLE < items.length && ↓ {items.length - offset - VISIBLE} more} ↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel ) diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 3284b145f5..941ee0b275 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -179,7 +179,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { }) if (loading) { - return loading skills… + return loading skills… } if (err && stage === 'category') { @@ -194,7 +194,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { if (!cats.length) { return ( - no skills available + no skills available Esc/q cancel ) @@ -206,12 +206,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( - + Skills Hub - select a category - {offset > 0 && ↑ {offset} more} + select a category + {offset > 0 && ↑ {offset} more} {items.map((row, i) => { const idx = offset + i @@ -219,7 +219,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( ↓ {rows.length - offset - VISIBLE} more} + {offset + VISIBLE < rows.length && ↓ {rows.length - offset - VISIBLE} more} ↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel ) @@ -241,13 +241,13 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( - + {selectedCat} - {skills.length} skill(s) - {!skills.length ? no skills in this category : null} - {offset > 0 && ↑ {offset} more} + {skills.length} skill(s) + {!skills.length ? no skills in this category : null} + {offset > 0 && ↑ {offset} more} {items.map((row, i) => { const idx = offset + i @@ -255,7 +255,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( ↓ {skills.length - offset - VISIBLE} more + ↓ {skills.length - offset - VISIBLE} more )} {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 ( - + {info?.name ?? skillName} - {info?.category ?? selectedCat} - {info?.description ? {info.description} : null} - {info?.path ? path: {info.path} : null} - {!info && !err ? loading… : null} + {info?.category ?? selectedCat} + {info?.description ? {info.description} : null} + {info?.path ? path: {info.path} : null} + {!info && !err ? loading… : null} {err ? error: {err} : null} - {installing ? installing… : null} + {installing ? installing… : null} i reinspect · x reinstall · Enter/Esc back · q close diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 216c89b78b..0052e69ed7 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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)) } } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 0c2b9549c8..38232220a5 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -77,7 +77,7 @@ function TreeRow({ return ( - + {lead} @@ -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 ( onClick(!!e?.shiftKey || !!e?.ctrlKey)}> - {open ? '▾ ' : '▸ '} + {open ? '▾ ' : '▸ '} {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) => ( - + {line} } @@ -649,22 +649,22 @@ export const Thinking = memo(function Thinking({ {preview ? ( mode === 'full' ? ( lines.map((line, index) => ( - + {line || ' '} {index === lines.length - 1 ? ( - + ) : null} )) ) : ( - + {preview} - + ) ) : ( - - + + )} @@ -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 ? ( <> - {line} + {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: ( <> - {label} + {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({ } }} > - - {openThinking ? '▾ ' : '▸ '} + + {openThinking ? '▾ ' : '▸ '} {thinkingLive ? ( - + Thinking ) : ( - + Thinking )} @@ -1068,7 +1068,7 @@ export const ToolTrail = memo(function ToolTrail({ color={group.color} content={ <> - + {toolLabel(group)} } @@ -1182,7 +1182,7 @@ export const ToolTrail = memo(function ToolTrail({ color={t.color.statusFg} content={ <> - Σ + Σ {totalTokensLabel} } @@ -1192,7 +1192,7 @@ export const ToolTrail = memo(function ToolTrail({ ) : null} {outcome ? ( - + · {outcome} diff --git a/ui-tui/src/components/todoPanel.tsx b/ui-tui/src/components/todoPanel.tsx index 9480ee0af8..41196b060b 100644 --- a/ui-tui/src/components/todoPanel.tsx +++ b/ui-tui/src/components/todoPanel.tsx @@ -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 ( - - {effectiveCollapsed ? '▸ ' : '▾ '} - + + {effectiveCollapsed ? '▸ ' : '▾ '} + Todo {' '} ({done}/{todos.length}) {incomplete && pending > 0 && ( - + {' '} · incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'} diff --git a/ui-tui/src/domain/roles.ts b/ui-tui/src/domain/roles.ts index f92d175e65..9e33aa094e 100644 --- a/ui-tui/src/domain/roles.ts +++ b/ui-tui/src/domain/roles.ts @@ -2,8 +2,8 @@ import type { Theme } from '../theme.js' import type { Role } from '../types.js' export const ROLE: Record { 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 }) } diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 85e4d7e112..f1ce52bab5 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -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' diff --git a/ui-tui/src/lib/forceTruecolor.ts b/ui-tui/src/lib/forceTruecolor.ts new file mode 100644 index 0000000000..78ff5bd719 --- /dev/null +++ b/ui-tui/src/lib/forceTruecolor.ts @@ -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 {} diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index c8b38b0d5f..343d8f8683 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -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). diff --git a/ui-tui/src/lib/syntax.ts b/ui-tui/src/lib/syntax.ts index 06173b63e9..3b66f6ddc7 100644 --- a/ui-tui/src/lib/syntax.ts +++ b/ui-tui/src/lib/syntax.ts @@ -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]) } diff --git a/ui-tui/src/lib/terminalSetup.ts b/ui-tui/src/lib/terminalSetup.ts index 3c17734c63..dc00512b6b 100644 --- a/ui-tui/src/lib/terminalSetup.ts +++ b/ui-tui/src/lib/terminalSetup.ts @@ -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 = { @@ -33,7 +34,14 @@ const TERMINAL_META: Record + 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 + required: Set +} + +const WHEN_TOKEN_RE = /!?[A-Za-z_][\w.]*/g + +function parseWhenRequirements(when: string): WhenRequirements { + const required = new Set() + const forbidden = new Set() + + 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 { 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 { diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index baff80abf1..e87e6eeb61 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -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), @@ -319,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, @@ -339,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, @@ -360,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, diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 7bd436bd1c..4a0bd75f1c 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -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 diff --git a/website/docs/user-guide/features/skins.md b/website/docs/user-guide/features/skins.md index 793040c8e6..5648c46e03 100644 --- a/website/docs/user-guide/features/skins.md +++ b/website/docs/user-guide/features/skins.md @@ -95,7 +95,7 @@ Text strings used throughout the CLI interface. | `welcome` | Welcome message shown at CLI startup | `Welcome to Hermes Agent! Type your message or /help for commands.` | | `goodbye` | Message shown on exit | `Goodbye! ⚕` | | `response_label` | Label on the response box header | ` ⚕ Hermes ` | -| `prompt_symbol` | Symbol before the user input prompt | `❯ ` | +| `prompt_symbol` | Symbol before the user input prompt (bare token, renderers add a trailing space) | `❯` | | `help_header` | Header text for the `/help` command output | `(^_^)? Available Commands` | ### Other top-level keys @@ -167,7 +167,7 @@ branding: welcome: "Welcome to My Agent! Type your message or /help for commands." goodbye: "See you later! ⚡" response_label: " ⚡ My Agent " - prompt_symbol: "⚡ ❯ " + prompt_symbol: "⚡" help_header: "(⚡) Available Commands" tool_prefix: "┊"