diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e53694d1d4..5f50ab6302 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -483,14 +483,36 @@ def _(rid, params: dict) -> dict: os.environ["HERMES_INTERACTIVE"] = "1" try: db.reopen_session(target) - messages = [ - {"role": m["role"], "text": m["content"] or ""} - for m in db.get_messages(target) - if m.get("role") in ("user", "assistant", "tool", "system") - and isinstance(m.get("content"), str) - and (m.get("content") or "").strip() - ] - history = [{"role": m["role"], "content": m["text"]} for m in messages] + history = db.get_messages_as_conversation(target) + messages = [] + tool_call_args = {} + for m in history: + role = m.get("role") + if role not in ("user", "assistant", "tool", "system"): + continue + if role == "assistant" and m.get("tool_calls"): + for tc in m["tool_calls"]: + fn = tc.get("function", {}) + tc_id = tc.get("id", "") + if tc_id and fn.get("name"): + try: + args = json.loads(fn.get("arguments", "{}")) + except (json.JSONDecodeError, TypeError): + args = {} + tool_call_args[tc_id] = (fn["name"], args) + if not (m.get("content") or "").strip(): + continue + if role == "tool": + tc_id = m.get("tool_call_id", "") + tc_info = tool_call_args.get(tc_id) if tc_id else None + name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool" + args = (tc_info[1] if tc_info else None) or {} + ctx = _tool_ctx(name, args) + messages.append({"role": "tool", "name": name, "context": ctx}) + continue + if not (m.get("content") or "").strip(): + continue + messages.append({"role": role, "text": m.get("content") or ""}) agent = _make_agent(sid, target, session_id=target) _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: diff --git a/ui-tui/packages/hermes-ink/src/ink/colorize.ts b/ui-tui/packages/hermes-ink/src/ink/colorize.ts index ebc3159b78..2229f70a97 100644 --- a/ui-tui/packages/hermes-ink/src/ink/colorize.ts +++ b/ui-tui/packages/hermes-ink/src/ink/colorize.ts @@ -8,8 +8,8 @@ import type { Color, TextStyles } from './styles.js' * COLORTERM=truecolor. chalk's supports-color doesn't recognize * TERM_PROGRAM=vscode (it only knows iTerm.app/Apple_Terminal), so it falls * through to the -256color regex → level 2. At level 2, chalk.rgb() - * downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) (Claude - * orange) → idx 174 rgb(215,135,135) — washed-out salmon. + * downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) → idx 174 + * rgb(215,135,135) — washed-out salmon. * * Gated on level === 2 (not < 3) to respect NO_COLOR / FORCE_COLOR=0 — * those yield level 0 and are an explicit "no colors" request. Desktop VS @@ -48,13 +48,6 @@ function boostChalkLevelForXtermJs(): boolean { * this clamps ALL truecolor output (fg+bg+hex) across the entire app. */ function clampChalkLevelForTmux(): boolean { - // bg.ts sets terminal-overrides :Tc before attach, so truecolor passes - // through — skip the clamp. General escape hatch for anyone who's - // configured their tmux correctly. - if (process.env.CLAUDE_CODE_TMUX_TRUECOLOR) { - return false - } - if (process.env.TMUX && chalk.level > 2) { chalk.level = 2 diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 121cd8b9b5..20b72968aa 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -1,7 +1,7 @@ import type { FocusManager } from './focus.js' import { createLayoutNode } from './layout/engine.js' import type { LayoutNode } from './layout/node.js' -import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js' +import { LayoutMeasureMode } from './layout/node.js' import measureText from './measure-text.js' import { addPendingClear, nodeCache } from './node-cache.js' import squashTextNodes from './squash-text-nodes.js' @@ -82,11 +82,6 @@ export type DOMElement = { // Only set on ink-root. The document owns focus — any node can // reach it by walking parentNode, like browser getRootNode(). focusManager?: FocusManager - // React component stack captured at createInstance time (reconciler.ts), - // e.g. ['ToolUseLoader', 'Messages', 'REPL']. Only populated when - // CLAUDE_CODE_DEBUG_REPAINTS is set. Used by findOwnerChainAtRow to - // attribute scrollback-diff full-resets to the component that caused them. - debugOwnerChain?: string[] } & InkNode export type TextNode = { @@ -442,44 +437,3 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { node.yogaNode = undefined } -/** - * Find the React component stack responsible for content at screen row `y`. - * - * DFS the DOM tree accumulating yoga offsets. Returns the debugOwnerChain of - * the deepest node whose bounding box contains `y`. Called from ink.tsx when - * log-update triggers a full reset, to attribute the flicker to its source. - * - * Only useful when CLAUDE_CODE_DEBUG_REPAINTS is set (otherwise chains are - * undefined and this returns []). - */ -export function findOwnerChainAtRow(root: DOMElement, y: number): string[] { - let best: string[] = [] - walk(root, 0) - - return best - - function walk(node: DOMElement, offsetY: number): void { - const yoga = node.yogaNode - - if (!yoga || yoga.getDisplay() === LayoutDisplay.None) { - return - } - - const top = offsetY + yoga.getComputedTop() - const height = yoga.getComputedHeight() - - if (y < top || y >= top + height) { - return - } - - if (node.debugOwnerChain) { - best = node.debugOwnerChain - } - - for (const child of node.childNodes) { - if (isDOMElement(child)) { - walk(child, top) - } - } - } -} diff --git a/ui-tui/packages/hermes-ink/src/ink/frame.ts b/ui-tui/packages/hermes-ink/src/ink/frame.ts index 869afa5f9d..873b703d92 100644 --- a/ui-tui/packages/hermes-ink/src/ink/frame.ts +++ b/ui-tui/packages/hermes-ink/src/ink/frame.ts @@ -71,8 +71,6 @@ export type Patch = type: 'clearTerminal' reason: FlickerReason // Populated by log-update when a scrollback diff triggers the reset. - // ink.tsx uses triggerY with findOwnerChainAtRow to attribute the - // flicker to its source React component. debug?: { triggerY: number; prevLine: string; nextLine: string } } | { type: 'cursorHide' } diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 5b15167d5b..e0163f5065 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -33,7 +33,6 @@ import reconciler, { dispatcher, getLastCommitMs, getLastYogaMs, - isDebugRepaintsEnabled, recordYogaMs, resetProfileCounters } from './reconciler.js' @@ -509,7 +508,6 @@ export default class Ink { '\x1b[H' + // cursor home (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + - // re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE) (this.altScreenActive ? '' : '\x1b[?1049l') + // exit alt (non-fullscreen only) '\x1b[?25l' // hide cursor (Ink manages) @@ -762,19 +760,6 @@ export default class Ink { availableHeight: frame.viewport.height, reason: patch.reason }) - - if (isDebugRepaintsEnabled() && patch.debug) { - const chain = dom.findOwnerChainAtRow(this.rootNode, patch.debug.triggerY) - logForDebugging( - `[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` + - ` prev: "${patch.debug.prevLine}"\n` + - ` next: "${patch.debug.nextLine}"\n` + - ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`, - { - level: 'warn' - } - ) - } } } diff --git a/ui-tui/packages/hermes-ink/src/ink/reconciler.ts b/ui-tui/packages/hermes-ink/src/ink/reconciler.ts index 7d50aedabe..2be8a7d7ca 100644 --- a/ui-tui/packages/hermes-ink/src/ink/reconciler.ts +++ b/ui-tui/packages/hermes-ink/src/ink/reconciler.ts @@ -1,10 +1,5 @@ -import { appendFileSync } from 'fs' - import createReconciler from 'react-reconciler' -import { getYogaCounters } from '../native-ts/yoga-layout/index.js' -import { isEnvTruthy } from '../utils/envUtils.js' - import { appendChildNode, clearYogaNodeReferences, @@ -150,71 +145,8 @@ function applyProp(node: DOMElement, key: string, value: unknown): void { // -- -// react-reconciler's Fiber shape — only the fields we walk. The 5th arg to -// createInstance is the Fiber (`workInProgress` in react-reconciler.dev.js). -// _debugOwner is the component that rendered this element (dev builds only); -// return is the parent fiber (always present). We prefer _debugOwner since it -// skips past Box/Text wrappers to the actual named component. -type FiberLike = { - elementType?: { displayName?: string; name?: string } | string | null - _debugOwner?: FiberLike | null - return?: FiberLike | null -} - -export function getOwnerChain(fiber: unknown): string[] { - const chain: string[] = [] - const seen = new Set() - let cur = fiber as FiberLike | null | undefined - - for (let i = 0; cur && i < 50; i++) { - if (seen.has(cur)) { - break - } - - seen.add(cur) - const t = cur.elementType - - const name = - typeof t === 'function' - ? (t as { displayName?: string; name?: string }).displayName || - (t as { displayName?: string; name?: string }).name - : typeof t === 'string' - ? undefined // host element (ink-box etc) — skip - : t?.displayName || t?.name - - if (name && name !== chain[chain.length - 1]) { - chain.push(name) - } - - cur = cur._debugOwner ?? cur.return - } - - return chain -} - -let debugRepaints: boolean | undefined - -export function isDebugRepaintsEnabled(): boolean { - if (debugRepaints === undefined) { - debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS) - } - - return debugRepaints -} - export const dispatcher = new Dispatcher() -// --- COMMIT INSTRUMENTATION (temp debugging) --- - -const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG -let _commits = 0 -let _lastLog = 0 -let _lastCommitAt = 0 -let _maxGapMs = 0 -let _createCount = 0 -let _prepareAt = 0 -// --- END --- - // --- SCROLL PROFILING (bench/scroll-e2e.sh reads via getLastYogaMs) --- // Set by onComputeLayout wrapper in ink.tsx; read by onRender for phases. let _lastYogaMs = 0 @@ -261,67 +193,17 @@ const reconciler = createReconciler< null >({ getRootHostContext: () => ({ isInsideText: false }), - prepareForCommit: () => { - if (COMMIT_LOG) { - _prepareAt = performance.now() - } - - return null - }, + prepareForCommit: () => null, preparePortalMount: () => null, clearContainer: () => false, resetAfterCommit(rootNode) { _lastCommitMs = _commitStart > 0 ? performance.now() - _commitStart : 0 _commitStart = 0 - if (COMMIT_LOG) { - const now = performance.now() - _commits++ - const gap = _lastCommitAt > 0 ? now - _lastCommitAt : 0 - - if (gap > _maxGapMs) { - _maxGapMs = gap - } - - _lastCommitAt = now - const reconcileMs = _prepareAt > 0 ? now - _prepareAt : 0 - - if (gap > 30 || reconcileMs > 20 || _createCount > 50) { - appendFileSync( - COMMIT_LOG, - `${now.toFixed(1)} gap=${gap.toFixed(1)}ms reconcile=${reconcileMs.toFixed(1)}ms creates=${_createCount}\n` - ) - } - - _createCount = 0 - - if (now - _lastLog > 1000) { - appendFileSync(COMMIT_LOG, `${now.toFixed(1)} commits=${_commits}/s maxGap=${_maxGapMs.toFixed(1)}ms\n`) - _commits = 0 - _maxGapMs = 0 - _lastLog = now - } - } - - const _t0 = COMMIT_LOG ? performance.now() : 0 - if (typeof rootNode.onComputeLayout === 'function') { rootNode.onComputeLayout() } - if (COMMIT_LOG) { - const layoutMs = performance.now() - _t0 - - if (layoutMs > 20) { - const c = getYogaCounters() - - appendFileSync( - COMMIT_LOG, - `${_t0.toFixed(1)} SLOW_YOGA ${layoutMs.toFixed(1)}ms visited=${c.visited} measured=${c.measured} hits=${c.cacheHits} live=${c.live}\n` - ) - } - } - if (process.env.NODE_ENV === 'test') { if (rootNode.childNodes.length === 0 && rootNode.hasRenderedContent) { return @@ -336,16 +218,7 @@ const reconciler = createReconciler< return } - const _tr = COMMIT_LOG ? performance.now() : 0 rootNode.onRender?.() - - if (COMMIT_LOG) { - const renderMs = performance.now() - _tr - - if (renderMs > 10) { - appendFileSync(COMMIT_LOG, `${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms\n`) - } - } }, getChildHostContext(parentHostContext: HostContext, type: ElementNames): HostContext { const previousIsInsideText = parentHostContext.isInsideText @@ -364,7 +237,7 @@ const reconciler = createReconciler< newProps: Props, _root: DOMElement, hostContext: HostContext, - internalHandle?: unknown + _internalHandle?: unknown ): DOMElement { if (hostContext.isInsideText && originalType === 'ink-box') { throw new Error(` can't be nested inside component`) @@ -374,18 +247,10 @@ const reconciler = createReconciler< const node = createNode(type) - if (COMMIT_LOG) { - _createCount++ - } - for (const [key, value] of Object.entries(newProps)) { applyProp(node, key, value) } - if (isDebugRepaintsEnabled()) { - node.debugOwnerChain = getOwnerChain(internalHandle) - } - return node }, createTextInstance(text: string, _root: DOMElement, hostContext: HostContext): TextNode { diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts index ccd8e49574..03a32b8239 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -12,7 +12,7 @@ import { clamp } from './layout/geometry.js' import type { Screen, StylePool } from './screen.js' -import { cellAt, cellAtIndex, CellWidth, setCellStyleId } from './screen.js' +import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js' type Point = { col: number; row: number } @@ -133,7 +133,6 @@ export function clearSelection(s: SelectionState): void { // Unicode-aware word character matcher: letters (any script), digits, // and the punctuation set iTerm2 treats as word-part by default. // Matching iTerm2's default means double-clicking a path like -// `/usr/bin/bash` or `~/.claude/config.json` selects the whole thing, // which is the muscle memory most macOS terminal users have. // iTerm2 default "characters considered part of a word": /-+\~_. const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.ts index 8ac7d62b69..8bdac62212 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.ts @@ -138,7 +138,7 @@ export function isSynchronizedOutputSupported(): boolean { // -- XTVERSION-detected terminal name (populated async at startup) -- // // TERM_PROGRAM is not forwarded over SSH by default, so env-based detection -// fails when claude runs remotely inside a VS Code integrated terminal. +// fails when the process runs remotely inside a VS Code integrated terminal. // XTVERSION (CSI > 0 q → DCS > | name ST) goes through the pty — the query // reaches the *client* terminal and the reply comes back through stdin. // App.tsx fires the query when raw mode enables; setXtversionName() is called diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 56517645d8..5065c37d64 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -20,7 +20,7 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' +import { buildToolTrailLine, compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -107,24 +107,41 @@ const toTranscriptMessages = (rows: unknown): Msg[] => { return [] } - return rows.flatMap(row => { - if (!row || typeof row !== 'object') { - return [] - } + const result: Msg[] = [] + let pendingTools: string[] = [] + + for (const row of rows) { + if (!row || typeof row !== 'object') continue const role = (row as any).role const text = (row as any).text - if ( - (role !== 'assistant' && role !== 'system' && role !== 'tool' && role !== 'user') || - typeof text !== 'string' || - !text.trim() - ) { - return [] + if (role === 'tool') { + const name = (row as any).name ?? 'tool' + const ctx = (row as any).context ?? '' + pendingTools.push(buildToolTrailLine(name, ctx)) + continue } - return [{ role, text }] - }) + if (typeof text !== 'string' || !text.trim()) continue + + if (role === 'assistant') { + const msg: Msg = { role, text } + if (pendingTools.length) { + msg.tools = pendingTools + pendingTools = [] + } + result.push(msg) + continue + } + + if (role === 'user' || role === 'system') { + pendingTools = [] + result.push({ role, text }) + } + } + + return result } // ── StatusRule ──────────────────────────────────────────────────────── @@ -1155,14 +1172,13 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.complete': { - const mark = p.error ? '✗' : '✓' - toolCompleteRibbonRef.current = null setTools(prev => { const done = prev.find(t => t.id === p.tool_id) - const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name + const name = done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' - const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` + const label = TOOL_VERBS[name] ?? name + const line = buildToolTrailLine(name, ctx, !!p.error) toolCompleteRibbonRef.current = { label, line } const remaining = prev.filter(t => t.id !== p.tool_id) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index c0299ccc12..88418d2805 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,4 +1,4 @@ -import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' +import { INTERPOLATION_RE, LONG_MSG, TOOL_VERBS } from '../constants.js' // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*m/g @@ -35,6 +35,13 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +/** Build a single tool trail line — used by both live tool.complete and resume replay. */ +export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => { + const label = TOOL_VERBS[name] ?? name + const mark = error ? '✗' : '✓' + return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}` +} + /** Tool completed / failed row in the inline trail (not CoT prose). */ export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')