mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
commit
7803d21bcc
10 changed files with 78 additions and 239 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<unknown>()
|
||||
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(`<Box> can't be nested inside <Text> 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(' ✗')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue