diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx index f5fb660bed..6bf9f513aa 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx @@ -53,7 +53,11 @@ export function AlternateScreen(t0: Props) { } writeRaw( - ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING) + ENTER_ALT_SCREEN + + ERASE_SCROLLBACK + + ERASE_SCREEN + + CURSOR_HOME + + (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING) ) ink?.setAltScreenActive(true, mouseTracking) diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 9ff1be4119..1a1ad4af49 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -323,27 +323,38 @@ const measureTextNode = function ( widthMode: LayoutMeasureMode ): { width: number; height: number } { const elem = node.nodeName !== '#text' ? (node as DOMElement) : node.parentNode + if (elem && elem.nodeName === 'ink-text') { let cache = elem._textMeasureCache + if (!cache) { cache = { gen: 0, entries: new Map() } elem._textMeasureCache = cache } + const key = `${width}|${widthMode}` const hit = cache.entries.get(key) + if (hit && hit._gen === cache.gen) { return hit.result } + const result = computeTextMeasure(node, width, widthMode) + // Enforce cap with FIFO eviction to avoid unbounded growth during // pathological frames where yoga probes many widths. if (cache.entries.size >= MEASURE_CACHE_CAP) { const firstKey = cache.entries.keys().next().value - cache.entries.delete(firstKey) + if (firstKey !== undefined) { + cache.entries.delete(firstKey) + } } + cache.entries.set(key, { _gen: cache.gen, result }) + return result } + return computeTextMeasure(node, width, widthMode) } @@ -475,6 +486,7 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { for (const child of node.childNodes) { clearYogaNodeReferences(child) } + node._textMeasureCache = undefined } diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts index 02ea9ebd2c..4860544479 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts @@ -9,18 +9,21 @@ describe('shouldEmitClipboardSequence', () => { }) it('keeps OSC enabled for remote or plain local terminals', () => { - expect(shouldEmitClipboardSequence({ SSH_CONNECTION: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe( - true - ) + expect( + shouldEmitClipboardSequence({ SSH_CONNECTION: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv) + ).toBe(true) expect(shouldEmitClipboardSequence({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true) }) it('honors explicit env override', () => { - expect(shouldEmitClipboardSequence({ HERMES_TUI_CLIPBOARD_OSC52: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe( - true - ) - expect(shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe( - false - ) + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_CLIPBOARD_OSC52: '1', + TMUX: '/tmp/tmux-1/default,1,0' + } as NodeJS.ProcessEnv) + ).toBe(true) + expect( + shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv) + ).toBe(false) }) }) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index f8d88a50f6..991c87a1c6 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -226,7 +226,10 @@ describe('createGatewayEventHandler', () => { const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```' - onEvent({ payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any) + onEvent({ + payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, + type: 'tool.complete' + } as any) onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any) expect(appended).toHaveLength(1) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 9a255f704c..3df9f2818c 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -126,9 +126,7 @@ describe('createSlashHandler', () => { const ctx = buildCtx() createSlashHandler(ctx)('/details tools blink') expect(getUiState().sections.tools).toBeUndefined() - expect(ctx.transcript.sys).toHaveBeenCalledWith( - 'usage: /details
[hidden|collapsed|expanded|reset]' - ) + expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /details
[hidden|collapsed|expanded|reset]') }) it('shows tool enable usage when names are missing', () => { diff --git a/ui-tui/src/__tests__/details.test.ts b/ui-tui/src/__tests__/details.test.ts index 15ef681dc2..0f567b2f72 100644 --- a/ui-tui/src/__tests__/details.test.ts +++ b/ui-tui/src/__tests__/details.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isSectionName, parseDetailsMode, resolveSections, sectionMode, SECTION_NAMES } from '../domain/details.js' +import { isSectionName, parseDetailsMode, resolveSections, SECTION_NAMES, sectionMode } from '../domain/details.js' describe('parseDetailsMode', () => { it('accepts the canonical modes case-insensitively', () => { diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 522e982958..5fc0ba9888 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,8 +1,8 @@ import { useStore } from '@nanostores/react' import { GatewayProvider } from './app/gatewayContext.js' -import { useMainApp } from './app/useMainApp.js' import { $uiState } from './app/uiStore.js' +import { useMainApp } from './app/useMainApp.js' import { AppLayout } from './components/appLayout.js' import type { GatewayClient } from './gatewayClient.js' diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index a252d8d453..518eb668a5 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,7 +1,7 @@ import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' -import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' +import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' import type { ConfigGetValueResponse, ConfigSetResponse, @@ -40,8 +40,10 @@ const flagFromArg = (arg: string, current: boolean): boolean | null => { const RESET_WORDS = new Set(['reset', 'clear', 'default']) const CYCLE_WORDS = new Set(['cycle', 'toggle']) + const DETAILS_USAGE = 'usage: /details [hidden|collapsed|expanded|cycle] or /details
[hidden|collapsed|expanded|reset]' + const DETAILS_SECTION_USAGE = 'usage: /details
[hidden|collapsed|expanded|reset]' export const coreCommands: SlashCommand[] = [ @@ -97,9 +99,7 @@ export const coreCommands: SlashCommand[] = [ } patchUiState({ mouseTracking: next }) - ctx.gateway - .rpc('config.set', { key: 'mouse', value: next ? 'on' : 'off' }) - .catch(() => {}) + ctx.gateway.rpc('config.set', { key: 'mouse', value: next ? 'on' : 'off' }).catch(() => {}) queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`)) } @@ -178,7 +178,9 @@ export const coreCommands: SlashCommand[] = [ gateway .rpc('config.get', { key: 'details_mode' }) .then(r => { - if (ctx.stale()) return + if (ctx.stale()) { + return + } const mode = parseDetailsMode(r?.value) ?? ui.detailsMode patchUiState({ detailsMode: mode }) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index f45cab241c..1041b4d4f5 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -300,6 +300,7 @@ class TurnController { const hasDiffSegment = segments.some(msg => msg.kind === 'diff') const detailsBelongBeforeDiff = hasDiffSegment && (tools.length > 0 || Boolean(savedReasoning)) + const finalMessages = detailsBelongBeforeDiff ? insertBeforeFirstDiff(segments, { kind: 'trail', diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 260b26ab5a..fc17a6948f 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -1,8 +1,8 @@ import { atom } from 'nanostores' +import { MOUSE_TRACKING } from '../config/env.js' import { ZERO } from '../domain/usage.js' import { DEFAULT_THEME } from '../theme.js' -import { MOUSE_TRACKING } from '../config/env.js' import type { UiState } from './interfaces.js' diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 47fe8a2166..294a44ca6f 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -159,16 +159,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { voice.setProcessing(false) } - gateway - .rpc('voice.record', { action }) - .catch((e: Error) => { - // Revert optimistic UI on failure. - if (starting) { - voice.setRecording(false) - } + gateway.rpc('voice.record', { action }).catch((e: Error) => { + // Revert optimistic UI on failure. + if (starting) { + voice.setRecording(false) + } - actions.sys(`voice error: ${e.message}`) - }) + actions.sys(`voice error: ${e.message}`) + }) } useInput((ch, key) => { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index d2e5494a95..0230e0b1fd 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -640,14 +640,14 @@ export function useMainApp(gw: GatewayClient) { const showProgressArea = anyPanelVisible ? Boolean( ui.busy || - turn.outcome || - turn.streamPendingTools.length || - turn.streamSegments.length || - turn.subagents.length || - turn.tools.length || - turn.turnTrail.length || - hasReasoning || - turn.activity.length + turn.outcome || + turn.streamPendingTools.length || + turn.streamSegments.length || + turn.subagents.length || + turn.tools.length || + turn.turnTrail.length || + hasReasoning || + turn.activity.length ) : turn.activity.some(item => item.tone !== 'info') diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 7b697eedce..001c89b91f 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -218,11 +218,7 @@ export function StatusRule({ {voiceLabel ? ( {' │ '} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 3fc40528a3..fc6f78e924 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,8 +1,8 @@ import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' -import { sectionMode } from '../domain/details.js' import { LONG_MSG } from '../config/limits.js' +import { sectionMode } from '../domain/details.js' import { userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js' @@ -72,8 +72,7 @@ export const MessageLine = memo(function MessageLine({ const { body, glyph, prefix } = ROLE[msg.role](t) const showDetails = - (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || - (thinkingMode !== 'hidden' && Boolean(thinking)) + (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking)) const content = (() => { if (msg.kind === 'slash') { diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index e2cfc47663..2d52102b51 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,5 +1,5 @@ import { Box, NoSelect, Text } from '@hermes/ink' -import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' +import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' @@ -919,13 +919,22 @@ export const ToolTrail = memo(function ToolTrail({ // hidden sections stay hidden so the override is honoured. const expandAll = () => { - if (visible.thinking !== 'hidden') setOpenThinking(true) - if (visible.tools !== 'hidden') setOpenTools(true) + if (visible.thinking !== 'hidden') { + setOpenThinking(true) + } + + if (visible.tools !== 'hidden') { + setOpenTools(true) + } + if (visible.subagents !== 'hidden') { setOpenSubagents(true) setDeepSubagents(true) } - if (visible.activity !== 'hidden') setOpenMeta(true) + + if (visible.activity !== 'hidden') { + setOpenMeta(true) + } } const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index 9e85da16f8..6913df4bc8 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -43,7 +43,5 @@ export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, * accept Cmd+B (the platform action modifier) so existing macOS muscle memory * keeps working. */ -export const isVoiceToggleKey = ( - key: { ctrl: boolean; meta: boolean; super?: boolean }, - ch: string -): boolean => (key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b' +export const isVoiceToggleKey = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string): boolean => + (key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b'