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/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..ffd8f849da2 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,27 @@ 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('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('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..46dd1f67e15 100644
--- a/ui-tui/src/lib/text.ts
+++ b/ui-tui/src/lib/text.ts
@@ -9,12 +9,27 @@ 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_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_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g')
+const CONTROL_RE = /[\x00-\x08\x0B\x0C\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_CSI_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_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : ''))
+ .replace(ANSI_STRAY_ESC_RE, '')
+ .replace(CONTROL_RE, '')
+
+export const hasAnsi = (s: string) => s.includes(ESC)
const renderEstimateLine = (line: string) => {
const trimmed = line.trim()