diff --git a/hermes_cli/main.py b/hermes_cli/main.py index bd8fe6c5cff..662bc57b78d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1080,7 +1080,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: return [node, str(bundled)], bundled.parent # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. - # --dev flow: npm install if needed, then tsx src/entry.tsx (no build). + # --dev flow: npm install if needed, then tsx src/entry.tsx. if _tui_need_npm_install(tui_dir): npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): @@ -1102,10 +1102,30 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: sys.exit(1) if tui_dev: + # Keep the local @hermes/ink package exports in sync with source. + # --dev runs src/entry.tsx directly, but @hermes/ink resolves through + # packages/hermes-ink/dist/entry-exports.js. If that dist bundle is + # stale after a pull, newer hooks/components can exist in src while + # being missing at runtime (e.g. useCursorAdvance). Prebuild it here. + npm = _node_bin("npm") + ink_dir = tui_dir / "packages" / "hermes-ink" + result = subprocess.run( + [npm, "run", "build"], + cwd=str(ink_dir), + capture_output=True, + text=True, + ) + if result.returncode != 0: + combined = f"{result.stdout or ''}{result.stderr or ''}".strip() + preview = "\n".join(combined.splitlines()[-30:]) + print("TUI dev prebuild failed.") + if preview: + print(preview) + sys.exit(1) + tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): return [str(tsx), "src/entry.tsx"], tui_dir - npm = _node_bin("npm") return [npm, "start"], tui_dir # Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs. diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index 451bb3b4964..4cf3198cb40 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -58,8 +58,8 @@ def _read_message_body( if file_path == "-": return sys.stdin.read() try: - return Path(file_path).read_text() - except OSError as exc: + return Path(file_path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr) sys.exit(_USAGE_EXIT) diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py index 9202315e3d4..802cff88c90 100644 --- a/tests/hermes_cli/test_send_cmd.py +++ b/tests/hermes_cli/test_send_cmd.py @@ -173,6 +173,19 @@ def test_file_not_found_is_usage_error(fake_tool, capsys, monkeypatch): assert "cannot read" in err.lower() +def test_file_decode_error_is_usage_error(fake_tool, capsys, monkeypatch, tmp_path): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + bad = tmp_path / "bad-bytes.bin" + bad.write_bytes(b"\xff\xfe\x00") + + args = _parse(["--to", "telegram", "--file", str(bad)]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "cannot read" in err.lower() + + def test_tool_error_returns_failure_exit(monkeypatch, capsys): import sys as _sys import types as _types diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index fe6f0358069..25e478ccd2c 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -523,6 +523,34 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): assert env["NODE_ENV"] == "production" +def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path): + tui_dir = tmp_path / "ui-tui" + tsx = tui_dir / "node_modules" / ".bin" / "tsx" + ink_dir = tui_dir / "packages" / "hermes-ink" + tsx.parent.mkdir(parents=True) + ink_dir.mkdir(parents=True) + tsx.write_text("#!/usr/bin/env node\n", encoding="utf-8") + + monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _tui_dir: False) + monkeypatch.delenv("HERMES_TUI_DIR", raising=False) + monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}") + + calls = [] + + def fake_run(cmd, cwd=None, **_kwargs): + calls.append((cmd, cwd)) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(main_mod.subprocess, "run", fake_run) + + argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=True) + + assert argv == [str(tsx), "src/entry.tsx"] + assert cwd == tui_dir + assert calls == [(["/usr/bin/npm", "run", "build"], str(ink_dir))] + + def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys): import hermes_cli.main as main_mod diff --git a/ui-tui/src/__tests__/forceTruecolor.test.ts b/ui-tui/src/__tests__/forceTruecolor.test.ts index 4d978328152..03d30fa69b7 100644 --- a/ui-tui/src/__tests__/forceTruecolor.test.ts +++ b/ui-tui/src/__tests__/forceTruecolor.test.ts @@ -52,6 +52,50 @@ describe('forceTruecolor', () => { ) }) + it('downgrades Apple Terminal when truecolor is only advertised by env', async () => { + await withCleanEnv( + () => { + process.env.TERM_PROGRAM = 'Apple_Terminal' + process.env.COLORTERM = 'truecolor' + process.env.FORCE_COLOR = '3' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=downgrade-' + importId++) + expect( + mod.shouldDowngradeAppleTerminalTruecolor({ + TERM_PROGRAM: 'Apple_Terminal', + COLORTERM: 'truecolor', + FORCE_COLOR: '3' + } as NodeJS.ProcessEnv) + ).toBe(true) + expect(process.env.COLORTERM).toBeUndefined() + expect(process.env.FORCE_COLOR).toBeUndefined() + } + ) + }) + + it('keeps non-Apple terminals untouched when they advertise truecolor', async () => { + await withCleanEnv( + () => { + process.env.TERM_PROGRAM = 'vscode' + process.env.COLORTERM = 'truecolor' + process.env.FORCE_COLOR = '3' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=keep-non-apple-' + importId++) + expect( + mod.shouldDowngradeAppleTerminalTruecolor({ + TERM_PROGRAM: 'vscode', + COLORTERM: 'truecolor', + FORCE_COLOR: '3' + } as NodeJS.ProcessEnv) + ).toBe(false) + expect(process.env.COLORTERM).toBe('truecolor') + expect(process.env.FORCE_COLOR).toBe('3') + } + ) + }) + it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => { await withCleanEnv( () => { @@ -79,6 +123,30 @@ describe('forceTruecolor', () => { ) }) + it('lets explicit opt-in keep Apple truecolor advertisement', async () => { + await withCleanEnv( + () => { + process.env.TERM_PROGRAM = 'Apple_Terminal' + process.env.COLORTERM = 'truecolor' + process.env.FORCE_COLOR = '3' + process.env.HERMES_TUI_TRUECOLOR = '1' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=apple-explicit-on-' + importId++) + expect( + mod.shouldDowngradeAppleTerminalTruecolor({ + TERM_PROGRAM: 'Apple_Terminal', + COLORTERM: 'truecolor', + FORCE_COLOR: '3', + HERMES_TUI_TRUECOLOR: '1' + } as NodeJS.ProcessEnv) + ).toBe(false) + expect(process.env.COLORTERM).toBe('truecolor') + expect(process.env.FORCE_COLOR).toBe('3') + } + ) + }) + it('respects NO_COLOR', async () => { await withCleanEnv( () => { diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 92afd1513df..047ad67912f 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -8,12 +8,15 @@ import { estimateRows, estimateTokensRough, fmtK, + hasAnsi, isToolTrailResultLine, lastCotTrailIndex, parseToolTrailResultLine, pasteTokenLabel, + sanitizeAnsiForRender, sameToolTrailGroup, splitToolDuration, + stripAnsi, thinkingPreview } from '../lib/text.js' @@ -84,6 +87,46 @@ describe('estimateTokensRough', () => { }) }) +describe('ANSI sanitizers', () => { + const ESC = String.fromCharCode(27) + const BEL = String.fromCharCode(7) + + it('strips CSI/OSC/control bytes from plain previews', () => { + const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD` + + expect(stripAnsi(sample)).toBe('ABCD') + }) + + it('strips incomplete CSI prefixes and carriage returns', () => { + const sample = `A${ESC}[31mB${ESC}[12;${ESC}[CD\rE` + + expect(stripAnsi(sample)).toBe('ABDE') + }) + + it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => { + const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC` + + expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`) + }) + + it('keeps valid SGR while removing dangling CSI and carriage returns', () => { + const sample = `A${ESC}[31mB${ESC}[12;${ESC}[39mC\rD` + + expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`) + }) + + it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => { + const sample = `A${ESC}(0B${ESC}%GC${ESC})0D` + + expect(stripAnsi(sample)).toBe('ABCD') + expect(sanitizeAnsiForRender(sample)).toBe('ABCD') + }) + + it('detects non-CSI escape prefixes too', () => { + expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true) + }) +}) + describe('thinkingPreview', () => { it('adds paragraph breaks before markdown thinking headings', () => { const raw = diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts index 2e08111ffb4..83b5c511940 100644 --- a/ui-tui/src/__tests__/textInputFastEcho.test.ts +++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { canFastAppendShape, canFastBackspaceShape } from '../components/textInput.js' +import { canFastAppendShape, canFastBackspaceShape, supportsFastEchoTerminal } from '../components/textInput.js' // The fast-echo path bypasses Ink and writes characters directly to stdout // for the common case of typing plain English at the end of the line. These @@ -172,3 +172,14 @@ describe('canFastBackspaceShape', () => { expect(canFastBackspaceShape('hello ', 'hello '.length)).toBe(true) }) }) + +describe('supportsFastEchoTerminal', () => { + it('disables fast-echo in Apple Terminal', () => { + expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false) + }) + + it('keeps fast-echo enabled in VS Code and unknown terminals', () => { + expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true) + expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true) + }) +}) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 238b551ae97..f44f1813804 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -12,6 +12,7 @@ import { compactPreview, hasAnsi, isPasteBackedText, + sanitizeAnsiForRender, stripAnsi } from '../lib/text.js' import type { Theme } from '../theme.js' @@ -85,13 +86,14 @@ export const MessageLine = memo(function MessageLine({ if (msg.role === 'tool') { const maxChars = Math.max(24, cols - 14) const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text + const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text const preview = compactPreview(stripped, maxChars) || '(empty tool result)' return ( {hasAnsi(msg.text) ? ( - {msg.text} + {safeAnsi} ) : ( @@ -129,13 +131,13 @@ export const MessageLine = memo(function MessageLine({ {msg.text.length.toLocaleString()} chars - {systemOpen && {msg.text}} + {systemOpen && {sanitizeAnsiForRender(msg.text)}} ) } if (msg.role !== 'user' && hasAnsi(msg.text)) { - return {msg.text} + return {sanitizeAnsiForRender(msg.text)} } if (msg.role === 'assistant') { diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index b3c79357368..ace2f479dc1 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -283,6 +283,12 @@ export function canFastBackspaceShape(current: string, cursor: number, columns?: return ASCII_PRINTABLE_RE.test(removed) } +export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + // Terminal.app still shows paint/cursor artifacts under the fast-echo + // bypass path. Fall back to the normal Ink render path there. + return (env.TERM_PROGRAM ?? '').trim() !== 'Apple_Terminal' +} + function renderWithCursor(value: string, cursor: number) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -559,7 +565,7 @@ export function TextInput({ }, 16) } - const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY + const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY const canFastAppend = (current: string, cursor: number, text: string) => canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current) diff --git a/ui-tui/src/lib/forceTruecolor.ts b/ui-tui/src/lib/forceTruecolor.ts index 25de7b2dc34..cd63154e040 100644 --- a/ui-tui/src/lib/forceTruecolor.ts +++ b/ui-tui/src/lib/forceTruecolor.ts @@ -19,12 +19,42 @@ export function shouldForceTruecolor(env: NodeJS.ProcessEnv = process.env): bool return TRUE_RE.test(override) } +const isAppleTerminal = (env: NodeJS.ProcessEnv = process.env) => (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' + +const isAdvertisedTruecolor = (env: NodeJS.ProcessEnv = process.env) => { + const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase() + const forceColor = (env.FORCE_COLOR ?? '').trim() + + return colorTerm === 'truecolor' || colorTerm === '24bit' || forceColor === '3' +} + +export function shouldDowngradeAppleTerminalTruecolor(env: NodeJS.ProcessEnv = process.env): boolean { + if (!isAppleTerminal(env)) { + return false + } + + if (shouldForceTruecolor(env)) { + return false + } + + return isAdvertisedTruecolor(env) +} + if (shouldForceTruecolor()) { if (!process.env.COLORTERM) { process.env.COLORTERM = 'truecolor' } process.env.FORCE_COLOR = '3' +} else if (shouldDowngradeAppleTerminalTruecolor()) { + // Terminal.app may advertise truecolor even when RGB SGR paths render + // incorrectly. Keep Hermes on the safer TERM-driven 256-color path unless + // users explicitly opt back in via HERMES_TUI_TRUECOLOR=1. + delete process.env.COLORTERM + + if ((process.env.FORCE_COLOR ?? '').trim() === '3') { + delete process.env.FORCE_COLOR + } } export {} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 744046f6be4..ef3a1816975 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -9,12 +9,40 @@ import { VERBS } from '../content/verbs.js' import type { ThinkingMode } from '../types.js' const ESC = String.fromCharCode(27) -const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g') +const BEL = String.fromCharCode(7) +const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g') +const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g') +const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g') +const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') +const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') +const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g') +const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g') +const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g const WS_RE = /\s+/g -export const stripAnsi = (s: string) => s.replace(ANSI_RE, '') +export const stripAnsi = (s: string) => + s + .replace(ANSI_OSC_RE, '') + .replace(ANSI_STRING_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_CSI_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_NON_CSI_ESC_SEQ_RE, '') + .replace(ANSI_STRAY_ESC_RE, '') + .replace(CONTROL_RE, '') -export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`) +export const sanitizeAnsiForRender = (s: string) => + s + .replace(ANSI_OSC_RE, '') + .replace(ANSI_STRING_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : '')) + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_NON_CSI_ESC_SEQ_RE, '') + .replace(ANSI_STRAY_ESC_RE, '') + .replace(CONTROL_RE, '') + +export const hasAnsi = (s: string) => s.includes(ESC) const renderEstimateLine = (line: string) => { const trimmed = line.trim()