fix(tui): restore macOS copy behavior and theme polish (#17131)
This PR groups the TUI fixes that restore macOS Terminal usability and clean up the theme/composer regressions: - copy transcript selections on macOS drag-release so Terminal.app users can copy while mouse tracking is enabled - copy composer selections on macOS drag-release; composer selection is internal to TextInput and does not use the global Ink selection bus - keep IDE Cmd+C forwarding setup macOS-only, and make keybinding conflict checks respect simple when-clause overlap/negation - force truecolor before chalk initializes (unless NO_COLOR / FORCE_COLOR / HERMES_TUI_TRUECOLOR opt-outs apply) so the default banner keeps its gold/amber/bronze gradient in Terminal.app - move TUI surfaces onto semantic theme tokens and preserve skin prompt symbols as bare tokens with renderer-owned spacing - render focused placeholders as dim hint text in TTY mode instead of inverse/selected-looking synthetic cursor text
This commit is contained in:
parent
a9efa46b69
commit
6b09df39be
@ -494,7 +494,7 @@ branding:
|
|||||||
agent_name: "My Agent"
|
agent_name: "My Agent"
|
||||||
welcome: "Welcome message"
|
welcome: "Welcome message"
|
||||||
response_label: " ⚔ Agent "
|
response_label: " ⚔ Agent "
|
||||||
prompt_symbol: "⚔ ❯ "
|
prompt_symbol: "⚔"
|
||||||
|
|
||||||
tool_prefix: "╎" # Tool output line prefix
|
tool_prefix: "╎" # Tool output line prefix
|
||||||
```
|
```
|
||||||
|
|||||||
@ -927,7 +927,7 @@ display:
|
|||||||
# agent_name: "My Agent" # Banner title and branding
|
# agent_name: "My Agent" # Banner title and branding
|
||||||
# welcome: "Welcome message" # Shown at CLI startup
|
# welcome: "Welcome message" # Shown at CLI startup
|
||||||
# response_label: " ⚔ Agent " # Response box header label
|
# 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: ┊)
|
# tool_prefix: "╎" # Tool output line prefix (default: ┊)
|
||||||
#
|
#
|
||||||
skin: default
|
skin: default
|
||||||
|
|||||||
@ -68,7 +68,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
|||||||
welcome: "Welcome message" # Shown at CLI startup
|
welcome: "Welcome message" # Shown at CLI startup
|
||||||
goodbye: "Goodbye! ⚕" # Shown on exit
|
goodbye: "Goodbye! ⚕" # Shown on exit
|
||||||
response_label: " ⚕ Hermes " # Response box header label
|
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
|
help_header: "(^_^)? Commands" # /help header text
|
||||||
|
|
||||||
# Tool prefix: character for tool output lines (default: ┊)
|
# 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.",
|
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "Goodbye! ⚕",
|
"goodbye": "Goodbye! ⚕",
|
||||||
"response_label": " ⚕ Hermes ",
|
"response_label": " ⚕ Hermes ",
|
||||||
"prompt_symbol": "❯ ",
|
"prompt_symbol": "❯",
|
||||||
"help_header": "(^_^)? Available Commands",
|
"help_header": "(^_^)? Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "┊",
|
"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.",
|
"welcome": "Welcome to Ares Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "Farewell, warrior! ⚔",
|
"goodbye": "Farewell, warrior! ⚔",
|
||||||
"response_label": " ⚔ Ares ",
|
"response_label": " ⚔ Ares ",
|
||||||
"prompt_symbol": "⚔ ❯ ",
|
"prompt_symbol": "⚔",
|
||||||
"help_header": "(⚔) Available Commands",
|
"help_header": "(⚔) Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "╎",
|
"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.",
|
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "Goodbye! ⚕",
|
"goodbye": "Goodbye! ⚕",
|
||||||
"response_label": " ⚕ Hermes ",
|
"response_label": " ⚕ Hermes ",
|
||||||
"prompt_symbol": "❯ ",
|
"prompt_symbol": "❯",
|
||||||
"help_header": "[?] Available Commands",
|
"help_header": "[?] Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "┊",
|
"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.",
|
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "Goodbye! ⚕",
|
"goodbye": "Goodbye! ⚕",
|
||||||
"response_label": " ⚕ Hermes ",
|
"response_label": " ⚕ Hermes ",
|
||||||
"prompt_symbol": "❯ ",
|
"prompt_symbol": "❯",
|
||||||
"help_header": "(^_^)? Available Commands",
|
"help_header": "(^_^)? Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "┊",
|
"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.",
|
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "Goodbye! ⚕",
|
"goodbye": "Goodbye! ⚕",
|
||||||
"response_label": " ⚕ Hermes ",
|
"response_label": " ⚕ Hermes ",
|
||||||
"prompt_symbol": "❯ ",
|
"prompt_symbol": "❯",
|
||||||
"help_header": "[?] Available Commands",
|
"help_header": "[?] Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "│",
|
"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.",
|
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "Goodbye! \u2695",
|
"goodbye": "Goodbye! \u2695",
|
||||||
"response_label": " \u2695 Hermes ",
|
"response_label": " \u2695 Hermes ",
|
||||||
"prompt_symbol": "\u276f ",
|
"prompt_symbol": "\u276f",
|
||||||
"help_header": "(^_^)? Available Commands",
|
"help_header": "(^_^)? Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "\u250a",
|
"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.",
|
"welcome": "Welcome to Poseidon Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "Fair winds! Ψ",
|
"goodbye": "Fair winds! Ψ",
|
||||||
"response_label": " Ψ Poseidon ",
|
"response_label": " Ψ Poseidon ",
|
||||||
"prompt_symbol": "Ψ ❯ ",
|
"prompt_symbol": "Ψ",
|
||||||
"help_header": "(Ψ) Available Commands",
|
"help_header": "(Ψ) Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "│",
|
"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.",
|
"welcome": "Welcome to Sisyphus Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "The boulder waits. ◉",
|
"goodbye": "The boulder waits. ◉",
|
||||||
"response_label": " ◉ Sisyphus ",
|
"response_label": " ◉ Sisyphus ",
|
||||||
"prompt_symbol": "◉ ❯ ",
|
"prompt_symbol": "◉",
|
||||||
"help_header": "(◉) Available Commands",
|
"help_header": "(◉) Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "│",
|
"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.",
|
"welcome": "Welcome to Charizard Agent! Type your message or /help for commands.",
|
||||||
"goodbye": "Flame out! ✦",
|
"goodbye": "Flame out! ✦",
|
||||||
"response_label": " ✦ Charizard ",
|
"response_label": " ✦ Charizard ",
|
||||||
"prompt_symbol": "✦ ❯ ",
|
"prompt_symbol": "✦",
|
||||||
"help_header": "(✦) Available Commands",
|
"help_header": "(✦) Available Commands",
|
||||||
},
|
},
|
||||||
"tool_prefix": "│",
|
"tool_prefix": "│",
|
||||||
@ -780,12 +780,21 @@ def init_skin_from_config(config: dict) -> None:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def get_active_prompt_symbol(fallback: str = "❯ ") -> str:
|
def get_active_prompt_symbol(fallback: str = "❯") -> str:
|
||||||
"""Get the interactive prompt symbol from the active skin."""
|
"""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:
|
try:
|
||||||
return get_active_skin().get_branding("prompt_symbol", fallback)
|
raw = get_active_skin().get_branding("prompt_symbol", fallback)
|
||||||
except Exception:
|
except Exception:
|
||||||
return fallback
|
raw = fallback
|
||||||
|
|
||||||
|
cleaned = (raw or fallback).strip()
|
||||||
|
|
||||||
|
return f"{cleaned or fallback.strip()} "
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration:
|
|||||||
cli = _make_cli_stub()
|
cli = _make_cli_stub()
|
||||||
|
|
||||||
set_active_skin("ares")
|
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):
|
def test_secret_prompt_fragments_preserve_secret_state(self):
|
||||||
cli = _make_cli_stub()
|
cli = _make_cli_stub()
|
||||||
cli._secret_state = {"response_queue": object()}
|
cli._secret_state = {"response_queue": object()}
|
||||||
|
|
||||||
set_active_skin("ares")
|
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):
|
def test_narrow_terminals_compact_voice_prompt_fragments(self):
|
||||||
cli = _make_cli_stub()
|
cli = _make_cli_stub()
|
||||||
|
|||||||
@ -252,7 +252,7 @@ class TestCliBrandingHelpers:
|
|||||||
from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol
|
from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol
|
||||||
|
|
||||||
set_active_skin("ares")
|
set_active_skin("ares")
|
||||||
assert get_active_prompt_symbol() == "⚔ ❯ "
|
assert get_active_prompt_symbol() == "⚔ "
|
||||||
|
|
||||||
def test_active_help_header_ares(self):
|
def test_active_help_header_ares(self):
|
||||||
from hermes_cli.skin_engine import set_active_skin, get_active_help_header
|
from hermes_cli.skin_engine import set_active_skin, get_active_help_header
|
||||||
|
|||||||
@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration:
|
|||||||
cli = _make_cli_stub()
|
cli = _make_cli_stub()
|
||||||
|
|
||||||
set_active_skin("ares")
|
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):
|
def test_secret_prompt_fragments_preserve_secret_state(self):
|
||||||
cli = _make_cli_stub()
|
cli = _make_cli_stub()
|
||||||
cli._secret_state = {"response_queue": object()}
|
cli._secret_state = {"response_queue": object()}
|
||||||
|
|
||||||
set_active_skin("ares")
|
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):
|
def test_icon_only_skin_symbol_still_visible_in_special_states(self):
|
||||||
cli = _make_cli_stub()
|
cli = _make_cli_stub()
|
||||||
|
|||||||
@ -39,6 +39,15 @@ describe('enhanced keyboard modifier parsing', () => {
|
|||||||
expect(event.key.super).toBe(true)
|
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', () => {
|
it('preserves Cmd on word-delete and word-navigation sequences', () => {
|
||||||
const backspace = new InputEvent(parseOne('\u001b[127;9u'))
|
const backspace = new InputEvent(parseOne('\u001b[127;9u'))
|
||||||
const left = new InputEvent(parseOne('\u001b[1;9D'))
|
const left = new InputEvent(parseOne('\u001b[1;9D'))
|
||||||
|
|||||||
@ -35,6 +35,8 @@ export function useSelection(): {
|
|||||||
* replaces the old SGR-7 inverse so syntax highlighting stays readable
|
* replaces the old SGR-7 inverse so syntax highlighting stays readable
|
||||||
* under selection). Call once on mount + whenever theme changes. */
|
* under selection). Call once on mount + whenever theme changes. */
|
||||||
setSelectionBgColor: (color: string) => void
|
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.
|
// Look up the Ink instance via stdout — same pattern as instances map.
|
||||||
// StdinContext is available (it's always provided), and the Ink instance
|
// StdinContext is available (it's always provided), and the Ink instance
|
||||||
@ -58,7 +60,8 @@ export function useSelection(): {
|
|||||||
shiftSelection: () => {},
|
shiftSelection: () => {},
|
||||||
moveFocus: () => {},
|
moveFocus: () => {},
|
||||||
captureScrolledRows: () => {},
|
captureScrolledRows: () => {},
|
||||||
setSelectionBgColor: () => {}
|
setSelectionBgColor: () => {},
|
||||||
|
version: () => 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,7 +76,8 @@ export function useSelection(): {
|
|||||||
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
|
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
|
||||||
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
|
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
|
||||||
captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side),
|
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])
|
}, [ink])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,7 @@ import {
|
|||||||
hasSelection,
|
hasSelection,
|
||||||
moveFocus,
|
moveFocus,
|
||||||
selectionBounds,
|
selectionBounds,
|
||||||
|
selectionSignature,
|
||||||
type SelectionState,
|
type SelectionState,
|
||||||
selectLineAt,
|
selectLineAt,
|
||||||
selectWordAt,
|
selectWordAt,
|
||||||
@ -213,7 +214,8 @@ export default class Ink {
|
|||||||
// Fired alongside the terminal repaint whenever the selection mutates
|
// Fired alongside the terminal repaint whenever the selection mutates
|
||||||
// so UI (e.g. footer hints) can react to selection appearing/clearing.
|
// so UI (e.g. footer hints) can react to selection appearing/clearing.
|
||||||
private readonly selectionListeners = new Set<() => void>()
|
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
|
// DOM nodes currently under the pointer (mode-1003 motion). Held here
|
||||||
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
|
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
|
||||||
// against this set and mutates it in place.
|
// against this set and mutates it in place.
|
||||||
@ -1661,9 +1663,16 @@ export default class Ink {
|
|||||||
return hasSelection(this.selection)
|
return hasSelection(this.selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSelectionVersion(): number {
|
||||||
|
return this.selectionVersion
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to selection state changes. Fires whenever the selection
|
* 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 {
|
subscribeToSelectionChange(cb: () => void): () => void {
|
||||||
this.selectionListeners.add(cb)
|
this.selectionListeners.add(cb)
|
||||||
@ -1673,14 +1682,18 @@ export default class Ink {
|
|||||||
private notifySelectionChange(): void {
|
private notifySelectionChange(): void {
|
||||||
this.scheduleRender()
|
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) {
|
if (sig !== this.lastSelectionSignature) {
|
||||||
this.selectionWasActive = active
|
this.lastSelectionSignature = sig
|
||||||
|
this.selectionVersion += 1
|
||||||
|
}
|
||||||
|
|
||||||
for (const cb of this.selectionListeners) {
|
for (const cb of this.selectionListeners) {
|
||||||
cb()
|
cb()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -799,6 +799,20 @@ export function hasSelection(s: SelectionState): boolean {
|
|||||||
return s.anchor !== null && s.focus !== null
|
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.
|
* Normalized selection bounds: start is always before end in reading order.
|
||||||
* Returns null if no active selection.
|
* Returns null if no active selection.
|
||||||
|
|||||||
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)
|
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', () => {
|
describe('isVoiceToggleKey', () => {
|
||||||
|
|||||||
@ -74,6 +74,6 @@ describe('streaming theme assumption', () => {
|
|||||||
// Sanity that the theme we pass doesn't change shape. Component import
|
// Sanity that the theme we pass doesn't change shape. Component import
|
||||||
// already happens above — this is a smoke test that the module graph
|
// already happens above — this is a smoke test that the module graph
|
||||||
// for streamingMarkdown wires up without cycles.
|
// 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', () => {
|
it('paints a whole-line comment dim', () => {
|
||||||
const tokens = highlightLine('// hello', 'ts', t)
|
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', () => {
|
it('paints keywords, strings, and numbers in a ts line', () => {
|
||||||
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
|
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
|
||||||
const colors = tokens.map(tok => tok[0])
|
const colors = tokens.map(tok => tok[0])
|
||||||
|
|
||||||
expect(colors).toContain(t.color.bronze) // const
|
expect(colors).toContain(t.color.border) // const
|
||||||
expect(colors).toContain(t.color.amber) // 'hi'
|
expect(colors).toContain(t.color.accent) // 'hi'
|
||||||
expect(colors).toContain(t.color.cornsilk) // 42
|
expect(colors).toContain(t.color.text) // 42
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls through unchanged for unknown langs', () => {
|
it('falls through unchanged for unknown langs', () => {
|
||||||
@ -40,6 +40,6 @@ describe('syntax highlighter', () => {
|
|||||||
it('treats `#` as a python comment, not a selector', () => {
|
it('treats `#` as a python comment, not a selector', () => {
|
||||||
const tokens = highlightLine('# comment', 'py', t)
|
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 () => {
|
it('suppresses IDE setup hint when keybindings are already configured', async () => {
|
||||||
const readFile = vi.fn().mockResolvedValue(
|
const readFile = vi.fn().mockResolvedValue(
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
key: 'cmd+c',
|
||||||
|
command: 'workbench.action.terminal.sendSequence',
|
||||||
|
when: 'terminalFocus && terminalTextSelected',
|
||||||
|
args: { text: '\u001b[99;13u' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'shift+enter',
|
key: 'shift+enter',
|
||||||
command: 'workbench.action.terminal.sendSequence',
|
command: 'workbench.action.terminal.sendSequence',
|
||||||
|
|||||||
@ -79,11 +79,34 @@ describe('configureTerminalKeybindings', () => {
|
|||||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||||
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
|
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
|
||||||
const written = writeFile.mock.calls[0]?.[1] as string
|
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('shift+enter')
|
||||||
expect(written).toContain('cmd+enter')
|
expect(written).toContain('cmd+enter')
|
||||||
expect(written).toContain('cmd+z')
|
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 () => {
|
it('reports conflicts without overwriting existing bindings', async () => {
|
||||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
@ -113,6 +136,118 @@ describe('configureTerminalKeybindings', () => {
|
|||||||
expect(copyFile).not.toHaveBeenCalled() // no backup when not writing
|
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 () => {
|
it('backs up existing keybindings.json only when writing changes', async () => {
|
||||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||||
const readFile = vi.fn().mockResolvedValue(JSON.stringify([]))
|
const readFile = vi.fn().mockResolvedValue(JSON.stringify([]))
|
||||||
@ -186,6 +321,12 @@ describe('configureTerminalKeybindings', () => {
|
|||||||
|
|
||||||
const readComplete = vi.fn().mockResolvedValue(
|
const readComplete = vi.fn().mockResolvedValue(
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
key: 'cmd+c',
|
||||||
|
command: 'workbench.action.terminal.sendSequence',
|
||||||
|
when: 'terminalFocus && terminalTextSelected',
|
||||||
|
args: { text: '\u001b[99;13u' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'shift+enter',
|
key: 'shift+enter',
|
||||||
command: 'workbench.action.terminal.sendSequence',
|
command: 'workbench.action.terminal.sendSequence',
|
||||||
|
|||||||
@ -44,6 +44,7 @@ describe('input metrics helpers', () => {
|
|||||||
|
|
||||||
it('reserves gutters on wide panes without starving narrow composer width', () => {
|
it('reserves gutters on wide panes without starving narrow composer width', () => {
|
||||||
expect(stableComposerColumns(100, 3)).toBe(93)
|
expect(stableComposerColumns(100, 3)).toBe(93)
|
||||||
|
expect(stableComposerColumns(100, 5)).toBe(91)
|
||||||
expect(stableComposerColumns(10, 3)).toBe(5)
|
expect(stableComposerColumns(10, 3)).toBe(5)
|
||||||
expect(stableComposerColumns(6, 3)).toBe(1)
|
expect(stableComposerColumns(6, 3)).toBe(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -44,7 +44,7 @@ describe('DEFAULT_THEME', () => {
|
|||||||
it('has color palette', async () => {
|
it('has color palette', async () => {
|
||||||
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
|
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')
|
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 () => {
|
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', async () => {
|
||||||
const { LIGHT_THEME } = await importThemeWithCleanEnv()
|
const { LIGHT_THEME } = await importThemeWithCleanEnv()
|
||||||
|
|
||||||
expect(LIGHT_THEME.color.gold).not.toBe('#FFD700')
|
expect(LIGHT_THEME.color.primary).not.toBe('#FFD700')
|
||||||
expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00')
|
expect(LIGHT_THEME.color.accent).not.toBe('#FFBF00')
|
||||||
expect(LIGHT_THEME.color.dim).not.toBe('#B8860B')
|
expect(LIGHT_THEME.color.muted).not.toBe('#B8860B')
|
||||||
expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700')
|
expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -180,13 +180,22 @@ describe('fromSkin', () => {
|
|||||||
it('overrides banner colors', async () => {
|
it('overrides banner colors', async () => {
|
||||||
const { fromSkin } = await importThemeWithCleanEnv()
|
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 () => {
|
it('preserves unset colors', async () => {
|
||||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
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 () => {
|
it('overrides branding', async () => {
|
||||||
@ -197,6 +206,14 @@ describe('fromSkin', () => {
|
|||||||
expect(brand.prompt).toBe('$')
|
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 () => {
|
it('defaults for empty skin', async () => {
|
||||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,9 @@ export interface SelectionApi {
|
|||||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
copySelection: () => Promise<string>
|
copySelection: () => Promise<string>
|
||||||
|
copySelectionNoClear: () => Promise<string>
|
||||||
getState: () => unknown
|
getState: () => unknown
|
||||||
|
version: () => number
|
||||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import type {
|
|||||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||||
|
import { isMac } from '../lib/platform.js'
|
||||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||||
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.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)
|
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') {
|
if (status === 'ready') {
|
||||||
return t.ok
|
return t.ok
|
||||||
}
|
}
|
||||||
@ -65,7 +66,7 @@ const statusColorOf = (status: string, t: { dim: string; error: string; ok: stri
|
|||||||
return t.warn
|
return t.warn
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.dim
|
return t.muted
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMainApp(gw: GatewayClient) {
|
export function useMainApp(gw: GatewayClient) {
|
||||||
@ -143,11 +144,47 @@ export function useMainApp(gw: GatewayClient) {
|
|||||||
|
|
||||||
const hasSelection = useHasSelection()
|
const hasSelection = useHasSelection()
|
||||||
const selection = useSelection()
|
const selection = useSelection()
|
||||||
|
const lastCopiedVersionRef = useRef(-1)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selection.setSelectionBgColor(ui.theme.color.selectionBg)
|
selection.setSelectionBgColor(ui.theme.color.selectionBg)
|
||||||
}, [selection, 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(() => {
|
const clearSelection = useCallback(() => {
|
||||||
selection.clearSelection()
|
selection.clearSelection()
|
||||||
getInputSelection()?.collapseToEnd()
|
getInputSelection()?.collapseToEnd()
|
||||||
|
|||||||
@ -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 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 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
|
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 }> = {
|
const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string }> = {
|
||||||
running: { color: t => t.color.amber, glyph: '●' },
|
running: { color: t => t.color.accent, glyph: '●' },
|
||||||
queued: { color: t => t.color.dim, glyph: '○' },
|
queued: { color: t => t.color.muted, glyph: '○' },
|
||||||
completed: { color: t => t.color.statusGood, glyph: '✓' },
|
completed: { color: t => t.color.statusGood, glyph: '✓' },
|
||||||
interrupted: { color: t => t.color.warn, glyph: '■' },
|
interrupted: { color: t => t.color.warn, glyph: '■' },
|
||||||
failed: { color: t => t.color.error, glyph: '✗' }
|
failed: { color: t => t.color.error, glyph: '✗' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heatmap palette — cold → hot, resolved against the active theme.
|
// 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 ─────────────────────────────────────────────────────
|
// ── Pure helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -160,8 +160,8 @@ function OverlayScrollbar({
|
|||||||
|
|
||||||
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}│` : '')
|
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}│` : '')
|
||||||
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃`
|
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃`
|
||||||
const thumbColor = grab !== null ? t.color.gold : t.color.amber
|
const thumbColor = grab !== null ? t.color.primary : t.color.accent
|
||||||
const trackColor = hover ? t.color.bronze : t.color.dim
|
const trackColor = hover ? t.color.border : t.color.muted
|
||||||
|
|
||||||
const jump = (row: number, offset: number) => {
|
const jump = (row: number, offset: number) => {
|
||||||
if (!s || !scrollable) {
|
if (!s || !scrollable) {
|
||||||
@ -301,7 +301,7 @@ function GanttStrip({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))}
|
Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))}
|
||||||
{windowLabel}
|
{windowLabel}
|
||||||
</Text>
|
</Text>
|
||||||
@ -309,7 +309,7 @@ function GanttStrip({
|
|||||||
{shown.map(({ endAt, idx, node, startAt }) => {
|
{shown.map(({ endAt, idx, node, startAt }) => {
|
||||||
const active = idx === cursor
|
const active = idx === cursor
|
||||||
const { color } = statusGlyph(node.item, t)
|
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 elSec = displayElapsedSeconds(node.item, now)
|
||||||
const elLabel = elSec != null ? fmtElapsedLabel(elSec) : ''
|
const elLabel = elSec != null ? fmtElapsedLabel(elSec) : ''
|
||||||
@ -321,7 +321,7 @@ function GanttStrip({
|
|||||||
{' '}
|
{' '}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={active ? t.color.amber : color}>{bar(startAt, endAt)}</Text>
|
<Text color={active ? t.color.accent : color}>{bar(startAt, endAt)}</Text>
|
||||||
|
|
||||||
{elLabel ? (
|
{elLabel ? (
|
||||||
<Text color={accent}>
|
<Text color={accent}>
|
||||||
@ -333,13 +333,13 @@ function GanttStrip({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Text color={t.color.dim} dim>
|
<Text color={t.color.muted} dim>
|
||||||
{' '}
|
{' '}
|
||||||
{ruler}
|
{ruler}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{totalSeconds > 0 ? (
|
{totalSeconds > 0 ? (
|
||||||
<Text color={t.color.dim} dim>
|
<Text color={t.color.muted} dim>
|
||||||
{' '}
|
{' '}
|
||||||
{rulerLabels}
|
{rulerLabels}
|
||||||
</Text>
|
</Text>
|
||||||
@ -368,7 +368,7 @@ function OverlaySection({
|
|||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Box onClick={() => toggleOverlaySection(title, defaultOpen)}>
|
<Box onClick={() => toggleOverlaySection(title, defaultOpen)}>
|
||||||
<Text color={t.color.label}>
|
<Text color={t.color.label}>
|
||||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
|
||||||
{title}
|
{title}
|
||||||
{typeof count === 'number' ? ` (${count})` : ''}
|
{typeof count === 'number' ? ` (${count})` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
@ -383,7 +383,7 @@ function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode })
|
|||||||
return (
|
return (
|
||||||
<Text wrap="truncate-end">
|
<Text wrap="truncate-end">
|
||||||
<Text color={t.color.label}>{name} · </Text>
|
<Text color={t.color.label}>{name} · </Text>
|
||||||
<Text color={t.color.cornsilk}>{value}</Text>
|
<Text color={t.color.text}>{value}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -411,8 +411,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text bold color={t.color.cornsilk} wrap="wrap">
|
<Text bold color={t.color.text} wrap="wrap">
|
||||||
{id ? <Text color={t.color.amber}>#{id} </Text> : null}
|
{id ? <Text color={t.color.accent}>#{id} </Text> : null}
|
||||||
<Text color={color}>{glyph}</Text> {item.goal}
|
<Text color={color}>{glyph}</Text> {item.goal}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@ -472,20 +472,20 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{filesRead.slice(0, 8).map((p, i) => (
|
{filesRead.slice(0, 8).map((p, i) => (
|
||||||
<Text color={t.color.cornsilk} key={`r-${i}`} wrap="truncate-end">
|
<Text color={t.color.text} key={`r-${i}`} wrap="truncate-end">
|
||||||
<Text color={t.color.dim}>·</Text> {p}
|
<Text color={t.color.muted}>·</Text> {p}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{filesOverflow > 0 ? <Text color={t.color.dim}>…+{filesOverflow} more</Text> : null}
|
{filesOverflow > 0 ? <Text color={t.color.muted}>…+{filesOverflow} more</Text> : null}
|
||||||
</OverlaySection>
|
</OverlaySection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{toolLines.length > 0 ? (
|
{toolLines.length > 0 ? (
|
||||||
<OverlaySection count={toolLines.length} defaultOpen t={t} title="Tool calls">
|
<OverlaySection count={toolLines.length} defaultOpen t={t} title="Tool calls">
|
||||||
{toolLines.map((line, i) => (
|
{toolLines.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.dim}>·</Text> {line}
|
<Text color={t.color.muted}>·</Text> {line}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</OverlaySection>
|
</OverlaySection>
|
||||||
@ -494,8 +494,8 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
|||||||
{outputTail.length > 0 ? (
|
{outputTail.length > 0 ? (
|
||||||
<OverlaySection count={outputTail.length} defaultOpen t={t} title="Output">
|
<OverlaySection count={outputTail.length} defaultOpen t={t} title="Output">
|
||||||
{outputTail.map((entry, i) => (
|
{outputTail.map((entry, i) => (
|
||||||
<Text color={entry.isError ? t.color.error : t.color.cornsilk} key={i} wrap="wrap">
|
<Text color={entry.isError ? t.color.error : t.color.text} key={i} wrap="wrap">
|
||||||
<Text bold color={entry.isError ? t.color.error : t.color.amber}>
|
<Text bold color={entry.isError ? t.color.error : t.color.accent}>
|
||||||
{entry.tool}
|
{entry.tool}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
{entry.preview}
|
{entry.preview}
|
||||||
@ -507,7 +507,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
|||||||
{item.notes.length ? (
|
{item.notes.length ? (
|
||||||
<OverlaySection count={item.notes.length} t={t} title="Progress">
|
<OverlaySection count={item.notes.length} t={t} title="Progress">
|
||||||
{item.notes.slice(-6).map((line, i) => (
|
{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 color={t.color.label}>·</Text> {line}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
@ -516,7 +516,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme })
|
|||||||
|
|
||||||
{item.summary ? (
|
{item.summary ? (
|
||||||
<OverlaySection defaultOpen t={t} title="Summary">
|
<OverlaySection defaultOpen t={t} title="Summary">
|
||||||
<Text color={t.color.cornsilk} wrap="wrap">
|
<Text color={t.color.text} wrap="wrap">
|
||||||
{item.summary}
|
{item.summary}
|
||||||
</Text>
|
</Text>
|
||||||
</OverlaySection>
|
</OverlaySection>
|
||||||
@ -552,16 +552,16 @@ function ListRow({
|
|||||||
const paren = line ? line.indexOf('(') : -1
|
const paren = line ? line.indexOf('(') : -1
|
||||||
const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : ''
|
const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : ''
|
||||||
const trailing = toolShort ? ` · ${compactPreview(toolShort, 14)}` : ''
|
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 (
|
return (
|
||||||
<Text bold={active} color={fg} inverse={active} wrap="truncate-end">
|
<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)}
|
{indentFor(node.item.depth)}
|
||||||
{heatMarker ? <Text color={heatMarker}>▍</Text> : null}
|
{heatMarker ? <Text color={heatMarker}>▍</Text> : null}
|
||||||
<Text color={active ? fg : color}>{glyph}</Text> {goal}
|
<Text color={active ? fg : color}>{glyph}</Text> {goal}
|
||||||
<Text color={active ? fg : t.color.dim}>
|
<Text color={active ? fg : t.color.muted}>
|
||||||
{toolsCount}
|
{toolsCount}
|
||||||
{kids}
|
{kids}
|
||||||
{trailing}
|
{trailing}
|
||||||
@ -585,16 +585,16 @@ function DiffPane({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={width}>
|
<Box flexDirection="column" width={width}>
|
||||||
<Text bold color={t.color.cornsilk}>
|
<Text bold color={t.color.text}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{snapshot.label}
|
{snapshot.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{formatSummary(totals)}
|
{formatSummary(totals)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@ -606,7 +606,7 @@ function DiffPane({
|
|||||||
const { color, glyph } = statusGlyph(s, t)
|
const { color, glyph } = statusGlyph(s, t)
|
||||||
|
|
||||||
return (
|
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 color={color}>{glyph}</Text> {s.goal || 'subagent'}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -644,10 +644,10 @@ function DiffView({
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text bold color={t.color.bronze}>
|
<Text bold color={t.color.border}>
|
||||||
Replay diff
|
Replay diff
|
||||||
</Text>
|
</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>
|
||||||
|
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
@ -657,24 +657,24 @@ function DiffView({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.accent}>
|
||||||
Δ
|
Δ
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.cornsilk}>
|
<Text color={t.color.text}>
|
||||||
{diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)}
|
{diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={t.color.cornsilk}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
|
<Text color={t.color.text}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
|
||||||
<Text color={t.color.cornsilk}>
|
<Text color={t.color.text}>
|
||||||
{diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)}
|
{diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={t.color.cornsilk}>
|
<Text color={t.color.text}>
|
||||||
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
|
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={t.color.cornsilk}>
|
<Text color={t.color.text}>
|
||||||
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
|
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
|
||||||
</Text>
|
</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>
|
||||||
</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 alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text wrap="truncate-end">
|
<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}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{metaLine ? (
|
{metaLine ? (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
{' '}
|
{' '}
|
||||||
{metaLine}
|
{metaLine}
|
||||||
</Text>
|
</Text>
|
||||||
@ -999,7 +999,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
|||||||
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<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>
|
</Box>
|
||||||
) : mode === 'list' ? (
|
) : mode === 'list' ? (
|
||||||
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
|
<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}>
|
<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' ? (
|
{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:
|
↑↓/jk move · g/G top/bottom · Enter/→ open detail{controlsHint} · s sort:{SORT_LABEL[sort]} · f filter:
|
||||||
{FILTER_LABEL[filter]}
|
{FILTER_LABEL[filter]}
|
||||||
{history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''}
|
{history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''}
|
||||||
{' · q close'}
|
{' · q close'}
|
||||||
</Text>
|
</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
|
↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back to list{controlsHint} · q close
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -116,7 +116,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu
|
|||||||
|
|
||||||
function ctxBarColor(pct: number | undefined, t: Theme) {
|
function ctxBarColor(pct: number | undefined, t: Theme) {
|
||||||
if (pct == null) {
|
if (pct == null) {
|
||||||
return t.color.dim
|
return t.color.muted
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pct >= 95) {
|
if (pct >= 95) {
|
||||||
@ -169,7 +169,7 @@ function SpawnHud({ t }: { t: Theme }) {
|
|||||||
const concRatio = maxConc ? widestLevel / maxConc : 0
|
const concRatio = maxConc ? widestLevel / maxConc : 0
|
||||||
const ratio = Math.max(depthRatio, concRatio)
|
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[] = []
|
const pieces: string[] = []
|
||||||
|
|
||||||
@ -238,21 +238,21 @@ const modelLabel = (model: string, effort?: string, fast?: boolean) =>
|
|||||||
|
|
||||||
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
||||||
const [active, setActive] = useState(false)
|
const [active, setActive] = useState(false)
|
||||||
const [color, setColor] = useState(t.color.amber)
|
const [color, setColor] = useState(t.color.accent)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tick <= 0) {
|
if (tick <= 0) {
|
||||||
return
|
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)]!)
|
setColor(palette[Math.floor(Math.random() * palette.length)]!)
|
||||||
setActive(true)
|
setActive(true)
|
||||||
|
|
||||||
const id = setTimeout(() => setActive(false), 650)
|
const id = setTimeout(() => setActive(false), 650)
|
||||||
|
|
||||||
return () => clearTimeout(id)
|
return () => clearTimeout(id)
|
||||||
}, [t.color.amber, tick])
|
}, [t.color.accent, tick])
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return null
|
return null
|
||||||
@ -293,23 +293,23 @@ export function StatusRule({
|
|||||||
return (
|
return (
|
||||||
<Box height={1}>
|
<Box height={1}>
|
||||||
<Box flexShrink={1} width={leftWidth}>
|
<Box flexShrink={1} width={leftWidth}>
|
||||||
<Text color={t.color.bronze} wrap="truncate-end">
|
<Text color={t.color.border} wrap="truncate-end">
|
||||||
{'─ '}
|
{'─ '}
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
|
<FaceTicker color={statusColor} startedAt={turnStartedAt} />
|
||||||
) : (
|
) : (
|
||||||
<Text color={statusColor}>{status}</Text>
|
<Text color={statusColor}>{status}</Text>
|
||||||
)}
|
)}
|
||||||
<Text color={t.color.dim}> │ {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
|
<Text color={t.color.muted}> │ {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
|
||||||
{ctxLabel ? <Text color={t.color.dim}> │ {ctxLabel}</Text> : null}
|
{ctxLabel ? <Text color={t.color.muted}> │ {ctxLabel}</Text> : null}
|
||||||
{bar ? (
|
{bar ? (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
{' │ '}
|
{' │ '}
|
||||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{sessionStartedAt ? (
|
{sessionStartedAt ? (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
{' │ '}
|
{' │ '}
|
||||||
<SessionDuration startedAt={sessionStartedAt} />
|
<SessionDuration startedAt={sessionStartedAt} />
|
||||||
</Text>
|
</Text>
|
||||||
@ -318,21 +318,21 @@ export function StatusRule({
|
|||||||
{voiceLabel ? (
|
{voiceLabel ? (
|
||||||
<Text
|
<Text
|
||||||
color={
|
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}
|
{voiceLabel}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : 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' ? (
|
{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}
|
) : null}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text color={t.color.bronze}> ─ </Text>
|
<Text color={t.color.border}> ─ </Text>
|
||||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
<Text color={t.color.label}>{cwdLabel}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@ -377,8 +377,8 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
|
|||||||
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
|
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
|
||||||
const travel = Math.max(1, vp - thumb)
|
const travel = Math.max(1, vp - thumb)
|
||||||
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
|
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 thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
|
||||||
const trackColor = hover ? t.color.bronze : t.color.dim
|
const trackColor = hover ? t.color.border : t.color.muted
|
||||||
|
|
||||||
const jump = (row: number, offset: number) => {
|
const jump = (row: number, offset: number) => {
|
||||||
if (!s || !scrollable) {
|
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 { useStore } from '@nanostores/react'
|
||||||
import { Fragment, memo, useMemo, useRef } from 'react'
|
import { Fragment, memo, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
@ -124,8 +124,10 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
const isBlocked = useStore($isBlocked)
|
const isBlocked = useStore($isBlocked)
|
||||||
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
||||||
const pw = 2
|
const promptText = sh ? '$' : ui.theme.brand.prompt
|
||||||
const inputColumns = stableComposerColumns(composer.cols, pw)
|
const promptLabel = `${promptText} `
|
||||||
|
const promptWidth = Math.max(1, stringWidth(promptLabel))
|
||||||
|
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
|
||||||
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
||||||
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
||||||
|
|
||||||
@ -146,7 +148,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.stopImmediatePropagation?.()
|
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
|
// Spacer rows live on a different vertical origin; only the column is
|
||||||
@ -158,7 +160,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.stopImmediatePropagation?.()
|
e.stopImmediatePropagation?.()
|
||||||
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - pw)
|
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
const endInputDrag = () => inputMouseRef.current?.end()
|
const endInputDrag = () => inputMouseRef.current?.end()
|
||||||
@ -183,13 +185,13 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{ui.bgTasks.size > 0 && (
|
{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
|
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.showStickyPrompt ? (
|
{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>
|
<Text color={ui.theme.color.label}>↳ </Text>
|
||||||
|
|
||||||
{status.stickyPrompt}
|
{status.stickyPrompt}
|
||||||
@ -214,21 +216,21 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
<>
|
<>
|
||||||
{composer.inputBuf.map((line, i) => (
|
{composer.inputBuf.map((line, i) => (
|
||||||
<Box key={i}>
|
<Box key={i}>
|
||||||
<Box width={2}>
|
<Box width={promptWidth}>
|
||||||
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
|
<Text color={ui.theme.color.text}>{line || ' '}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
|
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
|
||||||
<Box width={pw}>
|
<Box width={promptWidth}>
|
||||||
{sh ? (
|
{sh ? (
|
||||||
<Text color={ui.theme.color.shellDollar}>$ </Text>
|
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text bold color={ui.theme.color.prompt}>
|
<Text bold color={ui.theme.color.prompt}>
|
||||||
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@ -254,7 +256,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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} />
|
<StatusRulePane at="bottom" composer={composer} status={status} />
|
||||||
</NoSelect>
|
</NoSelect>
|
||||||
@ -319,6 +321,7 @@ export const AppLayout = memo(function AppLayout({
|
|||||||
transcript
|
transcript
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
const overlay = useStore($overlayState)
|
const overlay = useStore($overlayState)
|
||||||
|
const ui = useStore($uiState)
|
||||||
|
|
||||||
// Inline mode skips AlternateScreen so the host terminal's native
|
// Inline mode skips AlternateScreen so the host terminal's native
|
||||||
// scrollback captures rows scrolled off the top; composer + progress
|
// scrollback captures rows scrolled off the top; composer + progress
|
||||||
@ -359,7 +362,7 @@ export const AppLayout = memo(function AppLayout({
|
|||||||
|
|
||||||
{SHOW_FPS && (
|
{SHOW_FPS && (
|
||||||
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
|
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
|
||||||
<FpsOverlay />
|
<FpsOverlay t={ui.theme} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -119,7 +119,7 @@ export function FloatingOverlays({
|
|||||||
return (
|
return (
|
||||||
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||||
{overlay.picker && (
|
{overlay.picker && (
|
||||||
<FloatBox color={ui.theme.color.bronze}>
|
<FloatBox color={ui.theme.color.border}>
|
||||||
<SessionPicker
|
<SessionPicker
|
||||||
gw={gw}
|
gw={gw}
|
||||||
onCancel={() => patchOverlayState({ picker: false })}
|
onCancel={() => patchOverlayState({ picker: false })}
|
||||||
@ -130,7 +130,7 @@ export function FloatingOverlays({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{overlay.modelPicker && (
|
{overlay.modelPicker && (
|
||||||
<FloatBox color={ui.theme.color.bronze}>
|
<FloatBox color={ui.theme.color.border}>
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
gw={gw}
|
gw={gw}
|
||||||
onCancel={() => patchOverlayState({ modelPicker: false })}
|
onCancel={() => patchOverlayState({ modelPicker: false })}
|
||||||
@ -142,17 +142,17 @@ export function FloatingOverlays({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{overlay.skillsHub && (
|
{overlay.skillsHub && (
|
||||||
<FloatBox color={ui.theme.color.bronze}>
|
<FloatBox color={ui.theme.color.border}>
|
||||||
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
|
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
|
||||||
</FloatBox>
|
</FloatBox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{overlay.pager && (
|
{overlay.pager && (
|
||||||
<FloatBox color={ui.theme.color.bronze}>
|
<FloatBox color={ui.theme.color.border}>
|
||||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||||
{overlay.pager.title && (
|
{overlay.pager.title && (
|
||||||
<Box justifyContent="center" marginBottom={1}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
<Text bold color={ui.theme.color.gold}>
|
<Text bold color={ui.theme.color.primary}>
|
||||||
{overlay.pager.title}
|
{overlay.pager.title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@ -174,7 +174,7 @@ export function FloatingOverlays({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!!completions.length && (
|
{!!completions.length && (
|
||||||
<FloatBox color={ui.theme.color.gold}>
|
<FloatBox color={ui.theme.color.primary}>
|
||||||
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
|
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
|
||||||
{completions.slice(start, start + viewportSize).map((item, i) => {
|
{completions.slice(start, start + viewportSize).map((item, i) => {
|
||||||
const active = start + i === compIdx
|
const active = start + i === compIdx
|
||||||
@ -190,7 +190,7 @@ export function FloatingOverlays({
|
|||||||
{' '}
|
{' '}
|
||||||
{item.display}
|
{item.display}
|
||||||
</Text>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -26,12 +26,12 @@ export function Banner({ t }: { t: Theme }) {
|
|||||||
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
||||||
<ArtLines lines={logoLines} />
|
<ArtLines lines={logoLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text bold color={t.color.gold}>
|
<Text bold color={t.color.primary}>
|
||||||
{t.brand.icon} NOUS HERMES
|
{t.brand.icon} NOUS HERMES
|
||||||
</Text>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -70,19 +70,19 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.accent}>
|
||||||
Available {title}
|
Available {title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{shown.map(([k, vs]) => (
|
{shown.map(([k, vs]) => (
|
||||||
<Text key={k} wrap="truncate">
|
<Text key={k} wrap="truncate">
|
||||||
<Text color={t.color.dim}>{strip(k)}: </Text>
|
<Text color={t.color.muted}>{strip(k)}: </Text>
|
||||||
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
|
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
(and {overflow} {overflowLabel})
|
(and {overflow} {overflowLabel})
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -91,18 +91,18 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 && (
|
{wide && (
|
||||||
<Box flexDirection="column" marginRight={2} width={leftW}>
|
<Box flexDirection="column" marginRight={2} width={leftW}>
|
||||||
<ArtLines lines={heroLines} />
|
<ArtLines lines={heroLines} />
|
||||||
<Text />
|
<Text />
|
||||||
|
|
||||||
<Text color={t.color.amber}>
|
<Text color={t.color.accent}>
|
||||||
{info.model.split('/').pop()}
|
{info.model.split('/').pop()}
|
||||||
<Text color={t.color.dim}> · Nous Research</Text>
|
<Text color={t.color.muted}> · Nous Research</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{info.cwd || process.cwd()}
|
{info.cwd || process.cwd()}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
|||||||
|
|
||||||
<Box flexDirection="column" width={w}>
|
<Box flexDirection="column" width={w}>
|
||||||
<Box justifyContent="center" marginBottom={1}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
<Text bold color={t.color.gold}>
|
<Text bold color={t.color.primary}>
|
||||||
{t.brand.name}
|
{t.brand.name}
|
||||||
{info.version ? ` v${info.version}` : ''}
|
{info.version ? ` v${info.version}` : ''}
|
||||||
{info.release_date ? ` (${info.release_date})` : ''}
|
{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 && (
|
{info.mcp_servers && info.mcp_servers.length > 0 && (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.accent}>
|
||||||
MCP Servers
|
MCP Servers
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{info.mcp_servers.map(s => (
|
{info.mcp_servers.map(s => (
|
||||||
<Text key={s.name} wrap="truncate">
|
<Text key={s.name} wrap="truncate">
|
||||||
<Text color={t.color.dim}>{` ${s.name} `}</Text>
|
<Text color={t.color.muted}>{` ${s.name} `}</Text>
|
||||||
<Text color={t.color.dim}>{`[${s.transport}]`}</Text>
|
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
|
||||||
<Text color={t.color.dim}>: </Text>
|
<Text color={t.color.muted}>: </Text>
|
||||||
{s.connected ? (
|
{s.connected ? (
|
||||||
<Text color={t.color.cornsilk}>
|
<Text color={t.color.text}>
|
||||||
{s.tools} tool{s.tools === 1 ? '' : 's'}
|
{s.tools} tool{s.tools === 1 ? '' : 's'}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@ -152,12 +152,12 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
|||||||
|
|
||||||
<Text />
|
<Text />
|
||||||
|
|
||||||
<Text color={t.color.cornsilk}>
|
<Text color={t.color.text}>
|
||||||
{flat(info.tools).length} tools{' · '}
|
{flat(info.tools).length} tools{' · '}
|
||||||
{flat(info.skills).length} skills
|
{flat(info.skills).length} skills
|
||||||
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
|
{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>
|
</Text>
|
||||||
|
|
||||||
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
{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) {
|
export function Panel({ sections, t, title }: PanelProps) {
|
||||||
return (
|
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}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
<Text bold color={t.color.gold}>
|
<Text bold color={t.color.primary}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@ -193,25 +193,25 @@ export function Panel({ sections, t, title }: PanelProps) {
|
|||||||
{sections.map((sec, si) => (
|
{sections.map((sec, si) => (
|
||||||
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
|
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
|
||||||
{sec.title && (
|
{sec.title && (
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.accent}>
|
||||||
{sec.title}
|
{sec.title}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sec.rows?.map(([k, v], ri) => (
|
{sec.rows?.map(([k, v], ri) => (
|
||||||
<Text key={ri} wrap="truncate">
|
<Text key={ri} wrap="truncate">
|
||||||
<Text color={t.color.dim}>{k.padEnd(20)}</Text>
|
<Text color={t.color.muted}>{k.padEnd(20)}</Text>
|
||||||
<Text color={t.color.cornsilk}>{v}</Text>
|
<Text color={t.color.text}>{v}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{sec.items?.map((item, ii) => (
|
{sec.items?.map((item, ii) => (
|
||||||
<Text color={t.color.cornsilk} key={ii} wrap="truncate">
|
<Text color={t.color.text} key={ii} wrap="truncate">
|
||||||
{item}
|
{item}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{sec.text && <Text color={t.color.dim}>{sec.text}</Text>}
|
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -5,23 +5,25 @@ import { useStore } from '@nanostores/react'
|
|||||||
|
|
||||||
import { SHOW_FPS } from '../config/env.js'
|
import { SHOW_FPS } from '../config/env.js'
|
||||||
import { $fpsState } from '../lib/fpsStore.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) {
|
if (!SHOW_FPS) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <FpsOverlayInner />
|
return <FpsOverlayInner t={t} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function FpsOverlayInner() {
|
function FpsOverlayInner({ t }: { t: Theme }) {
|
||||||
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
|
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
|
||||||
|
|
||||||
// Zero-pad widths so digit churn doesn't jitter the corner.
|
// Zero-pad widths so digit churn doesn't jitter the corner.
|
||||||
return (
|
return (
|
||||||
<Text color={fpsColor(fps)}>
|
<Text color={fpsColor(fps, t)}>
|
||||||
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
|
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -72,7 +72,7 @@ const autolinkUrl = (raw: string) =>
|
|||||||
|
|
||||||
const renderAutolink = (k: number, t: Theme, raw: string) => (
|
const renderAutolink = (k: number, t: Theme, raw: string) => (
|
||||||
<Link key={k} url={autolinkUrl(raw)}>
|
<Link key={k} url={autolinkUrl(raw)}>
|
||||||
<Text color={t.color.amber} underline>
|
<Text color={t.color.accent} underline>
|
||||||
{raw.replace(/^mailto:/, '')}
|
{raw.replace(/^mailto:/, '')}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
@ -113,7 +113,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
|
|||||||
<Fragment key={ri}>
|
<Fragment key={ri}>
|
||||||
<Box>
|
<Box>
|
||||||
{widths.map((w, ci) => (
|
{widths.map((w, ci) => (
|
||||||
<Text bold={ri === 0} color={ri === 0 ? t.color.amber : undefined} key={ci}>
|
<Text bold={ri === 0} color={ri === 0 ? t.color.accent : undefined} key={ci}>
|
||||||
<MdInline t={t} text={row[ci] ?? ''} />
|
<MdInline t={t} text={row[ci] ?? ''} />
|
||||||
{' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))}
|
{' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))}
|
||||||
{ci < widths.length - 1 ? ' ' : ''}
|
{ci < widths.length - 1 ? ' ' : ''}
|
||||||
@ -121,7 +121,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
{ri === 0 && rows.length > 1 ? (
|
{ri === 0 && rows.length > 1 ? (
|
||||||
<Text color={t.color.dim} dimColor>
|
<Text color={t.color.muted} dimColor>
|
||||||
{sep}
|
{sep}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@ -146,14 +146,14 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
|||||||
|
|
||||||
if (m[1] && m[2]) {
|
if (m[1] && m[2]) {
|
||||||
parts.push(
|
parts.push(
|
||||||
<Text color={t.color.dim} key={parts.length}>
|
<Text color={t.color.muted} key={parts.length}>
|
||||||
[image: {m[1]}] {m[2]}
|
[image: {m[1]}] {m[2]}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
} else if (m[3] && m[4]) {
|
} else if (m[3] && m[4]) {
|
||||||
parts.push(
|
parts.push(
|
||||||
<Link key={parts.length} url={m[4]}>
|
<Link key={parts.length} url={m[4]}>
|
||||||
<Text color={t.color.amber} underline>
|
<Text color={t.color.accent} underline>
|
||||||
{m[3]}
|
{m[3]}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
@ -168,7 +168,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
|||||||
)
|
)
|
||||||
} else if (m[7]) {
|
} else if (m[7]) {
|
||||||
parts.push(
|
parts.push(
|
||||||
<Text color={t.color.amber} dimColor key={parts.length}>
|
<Text color={t.color.accent} dimColor key={parts.length}>
|
||||||
{m[7]}
|
{m[7]}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -192,19 +192,19 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
|||||||
)
|
)
|
||||||
} else if (m[13]) {
|
} else if (m[13]) {
|
||||||
parts.push(
|
parts.push(
|
||||||
<Text color={t.color.dim} key={parts.length}>
|
<Text color={t.color.muted} key={parts.length}>
|
||||||
[{m[13]}]
|
[{m[13]}]
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
} else if (m[14]) {
|
} else if (m[14]) {
|
||||||
parts.push(
|
parts.push(
|
||||||
<Text color={t.color.dim} key={parts.length}>
|
<Text color={t.color.muted} key={parts.length}>
|
||||||
^{m[14]}
|
^{m[14]}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
} else if (m[15]) {
|
} else if (m[15]) {
|
||||||
parts.push(
|
parts.push(
|
||||||
<Text color={t.color.dim} key={parts.length}>
|
<Text color={t.color.muted} key={parts.length}>
|
||||||
_{m[15]}
|
_{m[15]}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -324,11 +324,11 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
if (media) {
|
if (media) {
|
||||||
start('paragraph')
|
start('paragraph')
|
||||||
nodes.push(
|
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}>
|
<Link url={/^(?:\/|[a-z]:[\\/])/i.test(media) ? `file://${media}` : media}>
|
||||||
<Text color={t.color.amber} underline>
|
<Text color={t.color.accent} underline>
|
||||||
{media}
|
{media}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
@ -375,7 +375,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
<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) => {
|
{block.map((l, j) => {
|
||||||
if (highlighted) {
|
if (highlighted) {
|
||||||
@ -401,7 +401,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
|
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(' ')}
|
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
|
||||||
key={j}
|
key={j}
|
||||||
>
|
>
|
||||||
@ -432,10 +432,10 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||||
<Text color={t.color.dim}>─ math</Text>
|
<Text color={t.color.muted}>─ math</Text>
|
||||||
|
|
||||||
{block.map((l, j) => (
|
{block.map((l, j) => (
|
||||||
<Text color={t.color.amber} key={j}>
|
<Text color={t.color.accent} key={j}>
|
||||||
{l}
|
{l}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
@ -450,7 +450,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
if (heading) {
|
if (heading) {
|
||||||
start('heading')
|
start('heading')
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text bold color={t.color.amber} key={key}>
|
<Text bold color={t.color.accent} key={key}>
|
||||||
{heading}
|
{heading}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -462,7 +462,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
|
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
|
||||||
start('heading')
|
start('heading')
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text bold color={t.color.amber} key={key}>
|
<Text bold color={t.color.accent} key={key}>
|
||||||
{line.trim()}
|
{line.trim()}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -474,7 +474,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
if (HR_RE.test(line)) {
|
if (HR_RE.test(line)) {
|
||||||
start('rule')
|
start('rule')
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text color={t.color.dim} key={key}>
|
<Text color={t.color.muted} key={key}>
|
||||||
{'─'.repeat(36)}
|
{'─'.repeat(36)}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -488,7 +488,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
if (footnote) {
|
if (footnote) {
|
||||||
start('list')
|
start('list')
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text color={t.color.dim} key={key}>
|
<Text color={t.color.muted} key={key}>
|
||||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -497,7 +497,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
<MdInline t={t} text={lines[i]!.trim()} />
|
<MdInline t={t} text={lines[i]!.trim()} />
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@ -526,7 +526,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text key={`${key}-def-${i}`}>
|
<Text key={`${key}-def-${i}`}>
|
||||||
<Text color={t.color.dim}> · </Text>
|
<Text color={t.color.muted}> · </Text>
|
||||||
<MdInline t={t} text={def} />
|
<MdInline t={t} text={def} />
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -546,7 +546,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text key={key}>
|
<Text key={key}>
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
{' '.repeat(indentDepth(bullet[1]!) * 2)}
|
{' '.repeat(indentDepth(bullet[1]!) * 2)}
|
||||||
{marker}{' '}
|
{marker}{' '}
|
||||||
</Text>
|
</Text>
|
||||||
@ -565,7 +565,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
start('list')
|
start('list')
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text key={key}>
|
<Text key={key}>
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
{' '.repeat(indentDepth(numbered[1]!) * 2)}
|
{' '.repeat(indentDepth(numbered[1]!) * 2)}
|
||||||
{numbered[2]}.{' '}
|
{numbered[2]}.{' '}
|
||||||
</Text>
|
</Text>
|
||||||
@ -593,7 +593,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
nodes.push(
|
nodes.push(
|
||||||
<Box flexDirection="column" key={key}>
|
<Box flexDirection="column" key={key}>
|
||||||
{quoteLines.map((ql, qi) => (
|
{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)}
|
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||||
{'│ '}
|
{'│ '}
|
||||||
<MdInline t={t} text={ql.text} />
|
<MdInline t={t} text={ql.text} />
|
||||||
@ -630,7 +630,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
if (summary) {
|
if (summary) {
|
||||||
start('paragraph')
|
start('paragraph')
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text color={t.color.dim} key={key}>
|
<Text color={t.color.muted} key={key}>
|
||||||
▶ {summary}
|
▶ {summary}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@ -642,7 +642,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||||
start('paragraph')
|
start('paragraph')
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Text color={t.color.dim} key={key}>
|
<Text color={t.color.muted} key={key}>
|
||||||
{line.trim()}
|
{line.trim()}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: Maske
|
|||||||
{icon} {label}
|
{icon} {label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{sub && <Text color={t.color.dim}> {sub}</Text>}
|
{sub && <Text color={t.color.muted}> {sub}</Text>}
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={t.color.label}>{'> '}</Text>
|
<Text color={t.color.label}>{'> '}</Text>
|
||||||
|
|||||||
@ -80,13 +80,13 @@ export const MessageLine = memo(function MessageLine({
|
|||||||
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
|
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
|
||||||
|
|
||||||
return (
|
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) ? (
|
{hasAnsi(msg.text) ? (
|
||||||
<Text wrap="truncate-end">
|
<Text wrap="truncate-end">
|
||||||
<Ansi>{msg.text}</Ansi>
|
<Ansi>{msg.text}</Ansi>
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{preview}
|
{preview}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -101,7 +101,7 @@ export const MessageLine = memo(function MessageLine({
|
|||||||
|
|
||||||
const content = (() => {
|
const content = (() => {
|
||||||
if (msg.kind === 'slash') {
|
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)) {
|
if (msg.role !== 'user' && hasAnsi(msg.text)) {
|
||||||
@ -125,7 +125,7 @@ export const MessageLine = memo(function MessageLine({
|
|||||||
return (
|
return (
|
||||||
<Text color={body}>
|
<Text color={body}>
|
||||||
{head}
|
{head}
|
||||||
<Text color={t.color.dim} dimColor>
|
<Text color={t.color.muted} dimColor>
|
||||||
[long message]
|
[long message]
|
||||||
</Text>
|
</Text>
|
||||||
{rest.join('')}
|
{rest.join('')}
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Text color={t.color.dim}>loading models…</Text>
|
return <Text color={t.color.muted}>loading models…</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
@ -161,7 +161,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
if (!providers.length) {
|
if (!providers.length) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<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>
|
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@ -176,21 +176,21 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={width}>
|
<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)
|
Select provider (step 1/2)
|
||||||
</Text>
|
</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
|
Full model IDs on the next step · Enter to continue
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
Current: {currentModel || '(unknown)'}
|
Current: {currentModel || '(unknown)'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={t.color.label} wrap="truncate-end">
|
<Text color={t.color.label} wrap="truncate-end">
|
||||||
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@ -201,7 +201,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
return row ? (
|
return row ? (
|
||||||
<Text
|
<Text
|
||||||
bold={providerIdx === idx}
|
bold={providerIdx === idx}
|
||||||
color={providerIdx === idx ? t.color.amber : t.color.dim}
|
color={providerIdx === idx ? t.color.accent : t.color.muted}
|
||||||
inverse={providerIdx === idx}
|
inverse={providerIdx === idx}
|
||||||
key={providers[idx]?.slug ?? `row-${idx}`}
|
key={providers[idx]?.slug ?? `row-${idx}`}
|
||||||
wrap="truncate-end"
|
wrap="truncate-end"
|
||||||
@ -210,17 +210,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
{i + 1}. {row}
|
{i + 1}. {row}
|
||||||
</Text>
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<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` : ' '}
|
{offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
||||||
</Text>
|
</Text>
|
||||||
<OverlayHint t={t}>↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayHint>
|
<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 (
|
return (
|
||||||
<Box flexDirection="column" width={width}>
|
<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)
|
Select model (step 2/2)
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{names[providerIdx] || '(unknown provider)'} · Esc back
|
{names[providerIdx] || '(unknown provider)'} · Esc back
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={t.color.label} wrap="truncate-end">
|
<Text color={t.color.label} wrap="truncate-end">
|
||||||
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@ -252,11 +252,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return !models.length && i === 0 ? (
|
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
|
no models listed for this provider
|
||||||
</Text>
|
</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>
|
||||||
)
|
)
|
||||||
@ -267,7 +267,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
bold={modelIdx === idx}
|
bold={modelIdx === idx}
|
||||||
color={modelIdx === idx ? t.color.amber : t.color.dim}
|
color={modelIdx === idx ? t.color.accent : t.color.muted}
|
||||||
inverse={modelIdx === idx}
|
inverse={modelIdx === idx}
|
||||||
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
|
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
|
||||||
wrap="truncate-end"
|
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` : ' '}
|
{offset + VISIBLE < models.length ? ` ↓ ${models.length - offset - VISIBLE} more` : ' '}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
||||||
</Text>
|
</Text>
|
||||||
<OverlayHint t={t}>
|
<OverlayHint t={t}>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKey
|
|||||||
|
|
||||||
export function OverlayHint({ children, t }: OverlayHintProps) {
|
export function OverlayHint({ children, t }: OverlayHintProps) {
|
||||||
return (
|
return (
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -48,13 +48,13 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
|||||||
|
|
||||||
<Box flexDirection="column" paddingLeft={1}>
|
<Box flexDirection="column" paddingLeft={1}>
|
||||||
{shown.map((line, i) => (
|
{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 || ' '}
|
{line || ' '}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{overflow > 0 ? (
|
{overflow > 0 ? (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
… +{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
|
… +{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@ -64,14 +64,14 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
|||||||
|
|
||||||
{OPTS.map((o, i) => (
|
{OPTS.map((o, i) => (
|
||||||
<Text key={o}>
|
<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 ? '▸ ' : ' '}
|
{sel === i ? '▸ ' : ' '}
|
||||||
{i + 1}. {LABELS[o]}
|
{i + 1}. {LABELS[o]}
|
||||||
</Text>
|
</Text>
|
||||||
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -84,8 +84,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
|
|||||||
|
|
||||||
const heading = (
|
const heading = (
|
||||||
<Text bold>
|
<Text bold>
|
||||||
<Text color={t.color.amber}>ask</Text>
|
<Text color={t.color.accent}>ask</Text>
|
||||||
<Text color={t.color.cornsilk}> {req.question}</Text>
|
<Text color={t.color.text}> {req.question}</Text>
|
||||||
</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} />
|
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '}
|
Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '}
|
||||||
{isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
|
{isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
|
||||||
</Text>
|
</Text>
|
||||||
@ -143,14 +143,14 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
|
|||||||
|
|
||||||
{[...choices, 'Other (type your answer)'].map((c, i) => (
|
{[...choices, 'Other (type your answer)'].map((c, i) => (
|
||||||
<Text key={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 ? '▸ ' : ' '}
|
{sel === i ? '▸ ' : ' '}
|
||||||
{i + 1}. {c}
|
{i + 1}. {c}
|
||||||
</Text>
|
</Text>
|
||||||
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -185,8 +185,8 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
|||||||
const accent = req.danger ? t.color.error : t.color.warn
|
const accent = req.danger ? t.color.error : t.color.warn
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
{ color: t.color.cornsilk, label: req.cancelLabel ?? 'No' },
|
{ color: t.color.text, label: req.cancelLabel ?? 'No' },
|
||||||
{ color: req.danger ? t.color.error : t.color.cornsilk, label: req.confirmLabel ?? 'Yes' }
|
{ color: req.danger ? t.color.error : t.color.text, label: req.confirmLabel ?? 'Yes' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -197,7 +197,7 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
|||||||
|
|
||||||
{req.detail ? (
|
{req.detail ? (
|
||||||
<Box paddingLeft={1}>
|
<Box paddingLeft={1}>
|
||||||
<Text color={t.color.cornsilk} wrap="truncate-end">
|
<Text color={t.color.text} wrap="truncate-end">
|
||||||
{req.detail}
|
{req.detail}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@ -207,12 +207,12 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp
|
|||||||
|
|
||||||
{rows.map((row, i) => (
|
{rows.map((row, i) => (
|
||||||
<Text key={row.label}>
|
<Text key={row.label}>
|
||||||
<Text color={sel === i ? accent : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
<Text color={sel === i ? accent : t.color.muted}>{sel === i ? '▸ ' : ' '}</Text>
|
||||||
<Text color={sel === i ? row.color : t.color.dim}>{row.label}</Text>
|
<Text color={sel === i ? row.color : t.color.muted}>{row.label}</Text>
|
||||||
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,14 +23,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Text color={t.color.dim} dimColor>
|
<Text color={t.color.muted} dimColor>
|
||||||
{`queued (${queued.length})${
|
{`queued (${queued.length})${
|
||||||
queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : ''
|
queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : ''
|
||||||
}`}
|
}`}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{q.showLead && (
|
{q.showLead && (
|
||||||
<Text color={t.color.dim} dimColor>
|
<Text color={t.color.muted} dimColor>
|
||||||
{' '}
|
{' '}
|
||||||
…
|
…
|
||||||
</Text>
|
</Text>
|
||||||
@ -41,14 +41,14 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
|
|||||||
const active = queueEditIdx === idx
|
const active = queueEditIdx === idx
|
||||||
|
|
||||||
return (
|
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))}
|
{active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{q.showTail && (
|
{q.showTail && (
|
||||||
<Text color={t.color.dim} dimColor>
|
<Text color={t.color.muted} dimColor>
|
||||||
{' '}…and {queued.length - q.end} more
|
{' '}…and {queued.length - q.end} more
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Text color={t.color.dim}>loading sessions…</Text>
|
return <Text color={t.color.muted}>loading sessions…</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
@ -95,7 +95,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
|||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<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>
|
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@ -105,11 +105,11 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={width}>
|
<Box flexDirection="column" width={width}>
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.accent}>
|
||||||
Resume Session
|
Resume Session
|
||||||
</Text>
|
</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) => {
|
{items.slice(offset, offset + VISIBLE).map((s, vi) => {
|
||||||
const i = offset + vi
|
const i = offset + vi
|
||||||
@ -117,30 +117,30 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={s.id}>
|
<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 ? '▸ ' : ' '}
|
{selected ? '▸ ' : ' '}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box width={30}>
|
<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}]
|
{String(i + 1).padStart(2)}. [{s.id}]
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box width={30}>
|
<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'})
|
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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)'}
|
{s.title || s.preview || '(untitled)'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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>
|
<OverlayHint t={t}>↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel</OverlayHint>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -179,7 +179,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Text color={t.color.dim}>loading skills…</Text>
|
return <Text color={t.color.muted}>loading skills…</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err && stage === 'category') {
|
if (err && stage === 'category') {
|
||||||
@ -194,7 +194,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
|||||||
if (!cats.length) {
|
if (!cats.length) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={width}>
|
<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>
|
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@ -206,12 +206,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={width}>
|
<Box flexDirection="column" width={width}>
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.accent}>
|
||||||
Skills Hub
|
Skills Hub
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim}>select a category</Text>
|
<Text color={t.color.muted}>select a category</Text>
|
||||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||||
|
|
||||||
{items.map((row, i) => {
|
{items.map((row, i) => {
|
||||||
const idx = offset + i
|
const idx = offset + i
|
||||||
@ -219,7 +219,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
|||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
bold={catIdx === idx}
|
bold={catIdx === idx}
|
||||||
color={catIdx === idx ? t.color.amber : t.color.dim}
|
color={catIdx === idx ? t.color.accent : t.color.muted}
|
||||||
inverse={catIdx === idx}
|
inverse={catIdx === idx}
|
||||||
key={row}
|
key={row}
|
||||||
wrap="truncate-end"
|
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>
|
<OverlayHint t={t}>↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayHint>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@ -241,13 +241,13 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={width}>
|
<Box flexDirection="column" width={width}>
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.accent}>
|
||||||
{selectedCat}
|
{selectedCat}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim}>{skills.length} skill(s)</Text>
|
<Text color={t.color.muted}>{skills.length} skill(s)</Text>
|
||||||
{!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null}
|
{!skills.length ? <Text color={t.color.muted}>no skills in this category</Text> : null}
|
||||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||||
|
|
||||||
{items.map((row, i) => {
|
{items.map((row, i) => {
|
||||||
const idx = offset + i
|
const idx = offset + i
|
||||||
@ -255,7 +255,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
|||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
bold={skillIdx === idx}
|
bold={skillIdx === idx}
|
||||||
color={skillIdx === idx ? t.color.amber : t.color.dim}
|
color={skillIdx === idx ? t.color.accent : t.color.muted}
|
||||||
inverse={skillIdx === idx}
|
inverse={skillIdx === idx}
|
||||||
key={row}
|
key={row}
|
||||||
wrap="truncate-end"
|
wrap="truncate-end"
|
||||||
@ -267,7 +267,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{offset + VISIBLE < skills.length && (
|
{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}>
|
<OverlayHint t={t}>
|
||||||
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'}
|
{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 (
|
return (
|
||||||
<Box flexDirection="column" width={width}>
|
<Box flexDirection="column" width={width}>
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.accent}>
|
||||||
{info?.name ?? skillName}
|
{info?.name ?? skillName}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim}>{info?.category ?? selectedCat}</Text>
|
<Text color={t.color.muted}>{info?.category ?? selectedCat}</Text>
|
||||||
{info?.description ? <Text color={t.color.cornsilk}>{info.description}</Text> : null}
|
{info?.description ? <Text color={t.color.text}>{info.description}</Text> : null}
|
||||||
{info?.path ? <Text color={t.color.dim}>path: {info.path}</Text> : null}
|
{info?.path ? <Text color={t.color.muted}>path: {info.path}</Text> : null}
|
||||||
{!info && !err ? <Text color={t.color.dim}>loading…</Text> : null}
|
{!info && !err ? <Text color={t.color.muted}>loading…</Text> : null}
|
||||||
{err ? <Text color={t.color.label}>error: {err}</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>
|
<OverlayHint t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayHint>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -360,6 +360,10 @@ export function TextInput({
|
|||||||
|
|
||||||
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
|
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(() => {
|
const rendered = useMemo(() => {
|
||||||
if (!focus) {
|
if (!focus) {
|
||||||
return display || dim(placeholder)
|
return display || dim(placeholder)
|
||||||
@ -711,6 +715,14 @@ export function TextInput({
|
|||||||
if (range && range.start === range.end) {
|
if (range && range.start === range.end) {
|
||||||
selRef.current = null
|
selRef.current = null
|
||||||
setSel(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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
|
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
|
||||||
<Text color={stemColor ?? t.color.dim} dim={stemDim}>
|
<Text color={stemColor ?? t.color.muted} dim={stemDim}>
|
||||||
{lead}
|
{lead}
|
||||||
</Text>
|
</Text>
|
||||||
</NoSelect>
|
</NoSelect>
|
||||||
@ -246,12 +246,12 @@ function Chevron({
|
|||||||
title: string
|
title: string
|
||||||
tone?: 'dim' | 'error' | 'warn'
|
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 (
|
return (
|
||||||
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
||||||
<Text color={color} dim={tone === 'dim'}>
|
<Text color={color} dim={tone === 'dim'}>
|
||||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
|
||||||
{title}
|
{title}
|
||||||
{typeof count === 'number' ? ` (${count})` : ''}
|
{typeof count === 'number' ? ` (${count})` : ''}
|
||||||
{suffix ? (
|
{suffix ? (
|
||||||
@ -266,7 +266,7 @@ function Chevron({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined {
|
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)
|
const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
|
||||||
|
|
||||||
// Below the median bucket we keep the default dim stem so cool branches
|
// 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 hasTools = item.tools.length > 0
|
||||||
const noteRows = [...(summary ? [summary] : []), ...item.notes]
|
const noteRows = [...(summary ? [summary] : []), ...item.notes]
|
||||||
const hasNotes = noteRows.length > 0
|
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: {
|
const sections: {
|
||||||
header: ReactNode
|
header: ReactNode
|
||||||
@ -460,10 +460,10 @@ function SubagentAccordion({
|
|||||||
{item.tools.map((line, index) => (
|
{item.tools.map((line, index) => (
|
||||||
<TreeTextRow
|
<TreeTextRow
|
||||||
branch={index === item.tools.length - 1 ? 'last' : 'mid'}
|
branch={index === item.tools.length - 1 ? 'last' : 'mid'}
|
||||||
color={t.color.cornsilk}
|
color={t.color.text}
|
||||||
content={
|
content={
|
||||||
<>
|
<>
|
||||||
<Text color={t.color.amber}>● </Text>
|
<Text color={t.color.accent}>● </Text>
|
||||||
{line}
|
{line}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -649,22 +649,22 @@ export const Thinking = memo(function Thinking({
|
|||||||
{preview ? (
|
{preview ? (
|
||||||
mode === 'full' ? (
|
mode === 'full' ? (
|
||||||
lines.map((line, index) => (
|
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 || ' '}
|
{line || ' '}
|
||||||
{index === lines.length - 1 ? (
|
{index === lines.length - 1 ? (
|
||||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||||
) : null}
|
) : null}
|
||||||
</Text>
|
</Text>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{preview}
|
{preview}
|
||||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
<StreamCursor color={t.color.dim} streaming={streaming} visible={active} />
|
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@ -792,7 +792,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
|
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
groups.push({
|
groups.push({
|
||||||
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
|
color: parsed.mark === '✗' ? t.color.error : t.color.text,
|
||||||
content: parsed.call,
|
content: parsed.call,
|
||||||
details: [],
|
details: [],
|
||||||
key: `tr-${i}`,
|
key: `tr-${i}`,
|
||||||
@ -801,7 +801,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
|
|
||||||
if (parsed.detail) {
|
if (parsed.detail) {
|
||||||
pushDetail({
|
pushDetail({
|
||||||
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
|
color: parsed.mark === '✗' ? t.color.error : t.color.muted,
|
||||||
content: parsed.detail,
|
content: parsed.detail,
|
||||||
dimColor: parsed.mark !== '✗',
|
dimColor: parsed.mark !== '✗',
|
||||||
key: `tr-${i}-d`
|
key: `tr-${i}-d`
|
||||||
@ -815,9 +815,9 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim())
|
const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim())
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
color: t.color.cornsilk,
|
color: t.color.text,
|
||||||
content: label,
|
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}`,
|
key: `tr-${i}`,
|
||||||
label
|
label
|
||||||
})
|
})
|
||||||
@ -827,12 +827,12 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
|
|
||||||
if (line === 'analyzing tool output…') {
|
if (line === 'analyzing tool output…') {
|
||||||
pushDetail({
|
pushDetail({
|
||||||
color: t.color.dim,
|
color: t.color.muted,
|
||||||
dimColor: true,
|
dimColor: true,
|
||||||
key: `tr-${i}`,
|
key: `tr-${i}`,
|
||||||
content: groups.length ? (
|
content: groups.length ? (
|
||||||
<>
|
<>
|
||||||
<Spinner color={t.color.amber} variant="think" /> {line}
|
<Spinner color={t.color.accent} variant="think" /> {line}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
line
|
line
|
||||||
@ -842,20 +842,20 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
continue
|
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) {
|
for (const tool of tools) {
|
||||||
const label = formatToolCall(tool.name, tool.context || '')
|
const label = formatToolCall(tool.name, tool.context || '')
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
color: t.color.cornsilk,
|
color: t.color.text,
|
||||||
key: tool.id,
|
key: tool.id,
|
||||||
label,
|
label,
|
||||||
details: [],
|
details: [],
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<Spinner color={t.color.amber} variant="tool" /> {label}
|
<Spinner color={t.color.accent} variant="tool" /> {label}
|
||||||
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
|
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -864,7 +864,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
|
|
||||||
for (const item of activity.slice(-4)) {
|
for (const item of activity.slice(-4)) {
|
||||||
const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·'
|
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}` })
|
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.muted} dim={!thinkingLive}>
|
||||||
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text>
|
<Text color={t.color.accent}>{openThinking ? '▾ ' : '▸ '}</Text>
|
||||||
{thinkingLive ? (
|
{thinkingLive ? (
|
||||||
<Text bold color={t.color.cornsilk}>
|
<Text bold color={t.color.text}>
|
||||||
Thinking
|
Thinking
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text color={t.color.dim} dim>
|
<Text color={t.color.muted} dim>
|
||||||
Thinking
|
Thinking
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -1068,7 +1068,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
color={group.color}
|
color={group.color}
|
||||||
content={
|
content={
|
||||||
<>
|
<>
|
||||||
<Text color={t.color.amber}>● </Text>
|
<Text color={t.color.accent}>● </Text>
|
||||||
{toolLabel(group)}
|
{toolLabel(group)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -1182,7 +1182,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
color={t.color.statusFg}
|
color={t.color.statusFg}
|
||||||
content={
|
content={
|
||||||
<>
|
<>
|
||||||
<Text color={t.color.amber}>Σ </Text>
|
<Text color={t.color.accent}>Σ </Text>
|
||||||
{totalTokensLabel}
|
{totalTokensLabel}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -1192,7 +1192,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
) : null}
|
) : null}
|
||||||
{outcome ? (
|
{outcome ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={t.color.dim} dim>
|
<Text color={t.color.muted} dim>
|
||||||
· {outcome}
|
· {outcome}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type { TodoItem } from '../types.js'
|
|||||||
const rowColor = (t: Theme, status: TodoItem['status']) => {
|
const rowColor = (t: Theme, status: TodoItem['status']) => {
|
||||||
const tone = todoTone(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({
|
export const TodoPanel = memo(function TodoPanel({
|
||||||
@ -56,16 +56,16 @@ export const TodoPanel = memo(function TodoPanel({
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Box onClick={handleToggle}>
|
<Box onClick={handleToggle}>
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.muted}>
|
||||||
<Text color={t.color.amber}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text>
|
<Text color={t.color.accent}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text>
|
||||||
<Text bold color={t.color.cornsilk}>
|
<Text bold color={t.color.text}>
|
||||||
Todo
|
Todo
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
<Text color={t.color.statusFg} dim>
|
<Text color={t.color.statusFg} dim>
|
||||||
({done}/{todos.length})
|
({done}/{todos.length})
|
||||||
</Text>
|
</Text>
|
||||||
{incomplete && pending > 0 && (
|
{incomplete && pending > 0 && (
|
||||||
<Text color={t.color.dim} dim>
|
<Text color={t.color.muted} dim>
|
||||||
{' '}
|
{' '}
|
||||||
· incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'}
|
· incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import type { Theme } from '../theme.js'
|
|||||||
import type { Role } from '../types.js'
|
import type { Role } from '../types.js'
|
||||||
|
|
||||||
export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; prefix: string }> = {
|
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 }),
|
assistant: t => ({ body: t.color.text, glyph: t.brand.tool, prefix: t.color.border }),
|
||||||
system: t => ({ body: '', glyph: '·', prefix: t.color.dim }),
|
system: t => ({ body: '', glyph: '·', prefix: t.color.muted }),
|
||||||
tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }),
|
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 })
|
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
|
#!/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 type { FrameEvent } from '@hermes/ink'
|
||||||
|
|
||||||
import { GatewayClient } from './gatewayClient.js'
|
import { GatewayClient } from './gatewayClient.js'
|
||||||
|
|||||||
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,
|
ch: string,
|
||||||
env: NodeJS.ProcessEnv = process.env
|
env: NodeJS.ProcessEnv = process.env
|
||||||
): boolean =>
|
): 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).
|
* 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)) {
|
if (spec.comment && line.trimStart().startsWith(spec.comment)) {
|
||||||
return [[t.color.dim, line]]
|
return [[t.color.muted, line]]
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens: Token[] = []
|
const tokens: Token[] = []
|
||||||
@ -97,11 +97,11 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] {
|
|||||||
const ch = tok[0]!
|
const ch = tok[0]!
|
||||||
|
|
||||||
if (ch === '"' || ch === "'" || ch === '`') {
|
if (ch === '"' || ch === "'" || ch === '`') {
|
||||||
tokens.push([t.color.amber, tok])
|
tokens.push([t.color.accent, tok])
|
||||||
} else if (ch >= '0' && ch <= '9') {
|
} else if (ch >= '0' && ch <= '9') {
|
||||||
tokens.push([t.color.cornsilk, tok])
|
tokens.push([t.color.text, tok])
|
||||||
} else if (spec.keywords.has(tok)) {
|
} else if (spec.keywords.has(tok)) {
|
||||||
tokens.push([t.color.bronze, tok])
|
tokens.push([t.color.border, tok])
|
||||||
} else {
|
} else {
|
||||||
tokens.push(['', tok])
|
tokens.push(['', tok])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export type TerminalSetupResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
|
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
|
||||||
|
const COPY_SEQUENCE = '\u001b[99;13u'
|
||||||
const MULTILINE_SEQUENCE = '\\\r\n'
|
const MULTILINE_SEQUENCE = '\\\r\n'
|
||||||
|
|
||||||
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = {
|
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' }
|
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',
|
key: 'shift+enter',
|
||||||
command: 'workbench.action.terminal.sendSequence',
|
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 {
|
export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
|
||||||
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? ''
|
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
|
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> {
|
async function backupFile(filePath: string, ops: FileOps): Promise<void> {
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
|
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
|
||||||
@ -240,10 +335,9 @@ export async function configureTerminalKeybindings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conflicts = TARGET_BINDINGS.filter(target =>
|
const targets = targetBindings(platform)
|
||||||
keybindings.some(
|
const conflicts = targets.filter(target =>
|
||||||
existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target)
|
keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target))
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (conflicts.length) {
|
if (conflicts.length) {
|
||||||
@ -256,7 +350,7 @@ export async function configureTerminalKeybindings(
|
|||||||
|
|
||||||
let added = 0
|
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))
|
const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@ -340,7 +434,7 @@ export async function shouldPromptForTerminalSetup(options?: {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return TARGET_BINDINGS.some(
|
return targetBindings(platform).some(
|
||||||
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
|
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
export interface ThemeColors {
|
export interface ThemeColors {
|
||||||
gold: string
|
primary: string
|
||||||
amber: string
|
accent: string
|
||||||
bronze: string
|
border: string
|
||||||
cornsilk: string
|
text: string
|
||||||
dim: string
|
muted: string
|
||||||
completionBg: string
|
completionBg: string
|
||||||
completionCurrentBg: string
|
completionCurrentBg: string
|
||||||
|
|
||||||
@ -88,18 +88,26 @@ const BRAND: ThemeBrand = {
|
|||||||
helpHeader: '(^_^)? Commands'
|
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 = {
|
export const DARK_THEME: Theme = {
|
||||||
color: {
|
color: {
|
||||||
gold: '#FFD700',
|
primary: '#FFD700',
|
||||||
amber: '#FFBF00',
|
accent: '#FFBF00',
|
||||||
bronze: '#CD7F32',
|
border: '#CD7F32',
|
||||||
cornsilk: '#FFF8DC',
|
text: '#FFF8DC',
|
||||||
|
muted: '#CC9B1F',
|
||||||
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
|
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
|
||||||
// read as barely-visible on dark terminals for long body text. The
|
// read as barely-visible on dark terminals for long body text. The
|
||||||
// new value sits ~60% luminance — readable without losing the "muted /
|
// new value sits ~60% luminance — readable without losing the "muted /
|
||||||
// secondary" semantic. Field labels still use `label` (65%) which
|
// secondary" semantic. Field labels still use `label` (65%) which
|
||||||
// stays brighter so hierarchy holds.
|
// stays brighter so hierarchy holds.
|
||||||
dim: '#CC9B1F',
|
|
||||||
completionBg: '#FFFFFF',
|
completionBg: '#FFFFFF',
|
||||||
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
||||||
|
|
||||||
@ -141,11 +149,11 @@ export const DARK_THEME: Theme = {
|
|||||||
// cleanly (#11300).
|
// cleanly (#11300).
|
||||||
export const LIGHT_THEME: Theme = {
|
export const LIGHT_THEME: Theme = {
|
||||||
color: {
|
color: {
|
||||||
gold: '#8B6914',
|
primary: '#8B6914',
|
||||||
amber: '#A0651C',
|
accent: '#A0651C',
|
||||||
bronze: '#7A4F1F',
|
border: '#7A4F1F',
|
||||||
cornsilk: '#3D2F13',
|
text: '#3D2F13',
|
||||||
dim: '#7A5A0F',
|
muted: '#7A5A0F',
|
||||||
completionBg: '#F5F5F5',
|
completionBg: '#F5F5F5',
|
||||||
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
|
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
|
||||||
|
|
||||||
@ -319,19 +327,20 @@ export function fromSkin(
|
|||||||
const d = DEFAULT_THEME
|
const d = DEFAULT_THEME
|
||||||
const c = (k: string) => colors[k]
|
const c = (k: string) => colors[k]
|
||||||
|
|
||||||
const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber
|
const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent
|
||||||
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
|
const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent
|
||||||
const dim = c('banner_dim') ?? d.color.dim
|
const muted = c('banner_dim') ?? d.color.muted
|
||||||
|
const completionBg = c('completion_menu_bg') ?? d.color.completionBg
|
||||||
|
|
||||||
return {
|
return {
|
||||||
color: {
|
color: {
|
||||||
gold: c('banner_title') ?? d.color.gold,
|
primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
|
||||||
amber,
|
accent,
|
||||||
bronze: c('banner_border') ?? d.color.bronze,
|
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
|
||||||
cornsilk: c('banner_text') ?? d.color.cornsilk,
|
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
|
||||||
dim,
|
muted,
|
||||||
completionBg: c('completion_menu_bg') ?? '#FFFFFF',
|
completionBg,
|
||||||
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
|
completionCurrentBg: c('completion_menu_current_bg') ?? mix(completionBg, bannerAccent, 0.25),
|
||||||
|
|
||||||
label: c('ui_label') ?? d.color.label,
|
label: c('ui_label') ?? d.color.label,
|
||||||
ok: c('ui_ok') ?? d.color.ok,
|
ok: c('ui_ok') ?? d.color.ok,
|
||||||
@ -339,8 +348,8 @@ export function fromSkin(
|
|||||||
warn: c('ui_warn') ?? d.color.warn,
|
warn: c('ui_warn') ?? d.color.warn,
|
||||||
|
|
||||||
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
|
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
|
||||||
sessionLabel: c('session_label') ?? dim,
|
sessionLabel: c('session_label') ?? muted,
|
||||||
sessionBorder: c('session_border') ?? dim,
|
sessionBorder: c('session_border') ?? muted,
|
||||||
|
|
||||||
statusBg: d.color.statusBg,
|
statusBg: d.color.statusBg,
|
||||||
statusFg: d.color.statusFg,
|
statusFg: d.color.statusFg,
|
||||||
@ -360,7 +369,7 @@ export function fromSkin(
|
|||||||
brand: {
|
brand: {
|
||||||
name: branding.agent_name ?? d.brand.name,
|
name: branding.agent_name ?? d.brand.name,
|
||||||
icon: d.brand.icon,
|
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,
|
welcome: branding.welcome ?? d.brand.welcome,
|
||||||
goodbye: branding.goodbye ?? d.brand.goodbye,
|
goodbye: branding.goodbye ?? d.brand.goodbye,
|
||||||
tool: toolPrefix || d.brand.tool,
|
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 clearSelection: () => void
|
||||||
readonly hasSelection: () => boolean
|
readonly hasSelection: () => boolean
|
||||||
readonly getState: () => unknown
|
readonly getState: () => unknown
|
||||||
|
readonly version: () => number
|
||||||
readonly subscribe: (cb: () => void) => () => void
|
readonly subscribe: (cb: () => void) => () => void
|
||||||
readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||||
readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||||
|
|||||||
@ -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.` |
|
| `welcome` | Welcome message shown at CLI startup | `Welcome to Hermes Agent! Type your message or /help for commands.` |
|
||||||
| `goodbye` | Message shown on exit | `Goodbye! ⚕` |
|
| `goodbye` | Message shown on exit | `Goodbye! ⚕` |
|
||||||
| `response_label` | Label on the response box header | ` ⚕ Hermes ` |
|
| `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` |
|
| `help_header` | Header text for the `/help` command output | `(^_^)? Available Commands` |
|
||||||
|
|
||||||
### Other top-level keys
|
### Other top-level keys
|
||||||
@ -167,7 +167,7 @@ branding:
|
|||||||
welcome: "Welcome to My Agent! Type your message or /help for commands."
|
welcome: "Welcome to My Agent! Type your message or /help for commands."
|
||||||
goodbye: "See you later! ⚡"
|
goodbye: "See you later! ⚡"
|
||||||
response_label: " ⚡ My Agent "
|
response_label: " ⚡ My Agent "
|
||||||
prompt_symbol: "⚡ ❯ "
|
prompt_symbol: "⚡"
|
||||||
help_header: "(⚡) Available Commands"
|
help_header: "(⚡) Available Commands"
|
||||||
|
|
||||||
tool_prefix: "┊"
|
tool_prefix: "┊"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user