diff --git a/ui-tui/eslint.config.mjs b/ui-tui/eslint.config.mjs index 14a5d108d..1b20c3244 100644 --- a/ui-tui/eslint.config.mjs +++ b/ui-tui/eslint.config.mjs @@ -88,7 +88,8 @@ export default [ '@typescript-eslint/consistent-type-imports': 'off', 'no-constant-condition': 'off', 'no-empty': 'off', - 'no-redeclare': 'off' + 'no-redeclare': 'off', + 'react-hooks/exhaustive-deps': 'off' } }, { diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index bed421234..c4cafcc4d 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -235,7 +235,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren< // notify/scrollMutated are inline (no useCallback) but only close over // refs + imports — stable. Empty deps avoids rebuilding the handle on // every render (which re-registers the ref = churn). - // eslint-disable-next-line react-hooks/exhaustive-deps + [] ) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index ff7345e2b..46d34a70a 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -35,9 +35,6 @@ const dropBgTask = (taskId: string) => return { ...state, bgTasks: next } }) -const statusToneFrom = (kind: string): 'error' | 'info' | 'warn' => - kind === 'error' ? 'error' : kind === 'warn' || kind === 'approval' ? 'warn' : 'info' - const pushUnique = (max: number) => (xs: T[], x: T): T[] => @@ -213,7 +210,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (turnController.lastStatusNote !== p.text) { turnController.lastStatusNote = p.text - turnController.pushActivity(p.text, statusToneFrom(p.kind)) + turnController.pushActivity( + p.text, + p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' + ) } restoreStatusAfter(4000) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index de77075db..87475341a 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -7,10 +7,6 @@ import { findSlashCommand } from './slash/registry.js' import type { SlashRunCtx } from './slash/types.js' import { getUiState } from './uiStore.js' -const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1) - -const isLong = (text: string) => text.length > 180 || text.split('\n').filter(Boolean).length > 2 - export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { const { gw } = ctx.gateway const { catalog } = ctx.local @@ -79,8 +75,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b const body = r?.output || `/${parsed.name}: no output` const text = r?.warning ? `warning: ${r.warning}\n${body}` : body + const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2 - isLong(text) ? page(text, titleCase(parsed.name)) : sys(text) + long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text) }) .catch(() => { gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid }) diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index de4adad62..4b24f0daa 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -2,40 +2,25 @@ import { atom, computed } from 'nanostores' import type { OverlayState } from './interfaces.js' -function buildOverlayState(): OverlayState { - return { - approval: null, - clarify: null, - modelPicker: false, - pager: null, - picker: false, - secret: null, - sudo: null - } -} +const buildOverlayState = (): OverlayState => ({ + approval: null, + clarify: null, + modelPicker: false, + pager: null, + picker: false, + secret: null, + sudo: null +}) export const $overlayState = atom(buildOverlayState()) -export const $isBlocked = computed($overlayState, state => - Boolean( - state.approval || state.clarify || state.modelPicker || state.pager || state.picker || state.secret || state.sudo - ) +export const $isBlocked = computed($overlayState, ({ approval, clarify, modelPicker, pager, picker, secret, sudo }) => + Boolean(approval || clarify || modelPicker || pager || picker || secret || sudo) ) -export function getOverlayState() { - return $overlayState.get() -} +export const getOverlayState = () => $overlayState.get() -export function patchOverlayState(next: Partial | ((state: OverlayState) => OverlayState)) { - if (typeof next === 'function') { - $overlayState.set(next($overlayState.get())) +export const patchOverlayState = (next: Partial | ((state: OverlayState) => OverlayState)) => + $overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next }) - return - } - - $overlayState.set({ ...$overlayState.get(), ...next }) -} - -export function resetOverlayState() { - $overlayState.set(buildOverlayState()) -} +export const resetOverlayState = () => $overlayState.set(buildOverlayState()) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 11c110773..e0832c7a6 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -9,12 +9,12 @@ import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' const flagFromArg = (arg: string, current: boolean): boolean | null => { - const mode = arg.trim().toLowerCase() - if (!arg) { return !current } + const mode = arg.trim().toLowerCase() + if (mode === 'on') { return true } @@ -46,14 +46,16 @@ export const coreCommands: SlashCommand[] = [ sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` }) } - sections.push({ - rows: [ - ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], - ['/fortune [random|daily]', 'show a random or daily local fortune'] - ], - title: 'TUI' - }) - sections.push({ rows: HOTKEYS, title: 'Hotkeys' }) + sections.push( + { + rows: [ + ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/fortune [random|daily]', 'show a random or daily local fortune'] + ], + title: 'TUI' + }, + { rows: HOTKEYS, title: 'Hotkeys' } + ) ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections) } diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index c1f6c6d83..979e1f470 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -9,7 +9,7 @@ export const opsCommands: SlashCommand[] = [ const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) if (subcommand !== 'disable' && subcommand !== 'enable') { - return // py prints lists / show / usage + return } if (!names.length) { diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 02a625604..354d3c197 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -109,7 +109,7 @@ export const sessionCommands: SlashCommand[] = [ name: 'personality', run: (arg, ctx) => { if (!arg) { - return // py handles listing + return } ctx.gateway.rpc('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then( @@ -200,11 +200,6 @@ export const sessionCommands: SlashCommand[] = [ } }, - // The four shims below call `config.set` directly because Python's `slash.exec` - // worker is a separate subprocess — it writes config but does NOT fire the - // live side-effects (`skin.changed` event, agent.reasoning_config, - // agent.verbose_logging, per-session yolo flip). Direct RPC does. - { help: 'switch theme skin (fires skin.changed)', name: 'skin', diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index 3c7d1ee1d..6a59d0638 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -5,14 +5,8 @@ import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands] -const byName = new Map() +const byName = new Map( + SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const)) +) -for (const cmd of SLASH_COMMANDS) { - byName.set(cmd.name, cmd) - - for (const alias of cmd.aliases ?? []) { - byName.set(alias, cmd) - } -} - -export const findSlashCommand = (name: string): SlashCommand | undefined => byName.get(name.toLowerCase()) +export const findSlashCommand = (name: string) => byName.get(name.toLowerCase()) diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index f4166ea8d..d84633c94 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -2,6 +2,28 @@ import { atom } from 'nanostores' import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' +const buildTurnState = (): TurnState => ({ + activity: [], + reasoning: '', + reasoningActive: false, + reasoningStreaming: false, + reasoningTokens: 0, + streaming: '', + subagents: [], + toolTokens: 0, + tools: [], + turnTrail: [] +}) + +export const $turnState = atom(buildTurnState()) + +export const getTurnState = () => $turnState.get() + +export const patchTurnState = (next: Partial | ((state: TurnState) => TurnState)) => + $turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next }) + +export const resetTurnState = () => $turnState.set(buildTurnState()) + export interface TurnState { activity: ActivityItem[] reasoning: string @@ -14,34 +36,3 @@ export interface TurnState { tools: ActiveTool[] turnTrail: string[] } - -function buildTurnState(): TurnState { - return { - activity: [], - reasoning: '', - reasoningActive: false, - reasoningStreaming: false, - reasoningTokens: 0, - streaming: '', - subagents: [], - toolTokens: 0, - tools: [], - turnTrail: [] - } -} - -export const $turnState = atom(buildTurnState()) - -export const getTurnState = () => $turnState.get() - -export const patchTurnState = (next: Partial | ((state: TurnState) => TurnState)) => { - if (typeof next === 'function') { - $turnState.set(next($turnState.get())) - - return - } - - $turnState.set({ ...$turnState.get(), ...next }) -} - -export const resetTurnState = () => $turnState.set(buildTurnState()) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 868f2ba5e..b7f5c20f4 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -5,37 +5,24 @@ import { DEFAULT_THEME } from '../theme.js' import type { UiState } from './interfaces.js' -function buildUiState(): UiState { - return { - bgTasks: new Set(), - busy: false, - compact: false, - detailsMode: 'collapsed', - info: null, - sid: null, - status: 'summoning hermes…', - statusBar: true, - theme: DEFAULT_THEME, - usage: ZERO - } -} +const buildUiState = (): UiState => ({ + bgTasks: new Set(), + busy: false, + compact: false, + detailsMode: 'collapsed', + info: null, + sid: null, + status: 'summoning hermes…', + statusBar: true, + theme: DEFAULT_THEME, + usage: ZERO +}) export const $uiState = atom(buildUiState()) -export function getUiState() { - return $uiState.get() -} +export const getUiState = () => $uiState.get() -export function patchUiState(next: Partial | ((state: UiState) => UiState)) { - if (typeof next === 'function') { - $uiState.set(next($uiState.get())) +export const patchUiState = (next: Partial | ((state: UiState) => UiState)) => + $uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next }) - return - } - - $uiState.set({ ...$uiState.get(), ...next }) -} - -export function resetUiState() { - $uiState.set(buildUiState()) -} +export const resetUiState = () => $uiState.set(buildUiState()) diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index a4ccb1f01..14a40412c 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -97,11 +97,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose } } - try { - unlinkSync(file) - } catch { - /* noop */ - } + rmSync(file, { force: true }) }, [input, inputBuf, submitRef]) const actions = useMemo( diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 6a6edb4df..3b40e7246 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -15,23 +15,16 @@ import { patchUiState } from './uiStore.js' const MTIME_POLL_MS = 5000 const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { - const display = cfg?.config?.display ?? {} + const d = cfg?.config?.display ?? {} - setBell(!!display.bell_on_complete) + setBell(!!d.bell_on_complete) patchUiState({ - compact: !!display.tui_compact, - detailsMode: resolveDetailsMode(display), - statusBar: display.tui_statusbar !== false + compact: !!d.tui_compact, + detailsMode: resolveDetailsMode(d), + statusBar: d.tui_statusbar !== false }) } -export interface UseConfigSyncOptions { - rpc: GatewayRpc - setBellOnComplete: (v: boolean) => void - setVoiceEnabled: (v: boolean) => void - sid: null | string -} - export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) { const mtimeRef = useRef(0) @@ -45,8 +38,7 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: mtimeRef.current = Number(r?.mtime ?? 0) }) rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rpc, sid]) + }, [rpc, setBellOnComplete, setVoiceEnabled, sid]) useEffect(() => { if (!sid) { @@ -79,6 +71,12 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: }, MTIME_POLL_MS) return () => clearInterval(id) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rpc, sid]) + }, [rpc, setBellOnComplete, sid]) +} + +export interface UseConfigSyncOptions { + rpc: GatewayRpc + setBellOnComplete: (v: boolean) => void + setVoiceEnabled: (v: boolean) => void + sid: null | string } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 535934150..71ecab6ac 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -29,14 +29,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } } - const cancelOverlayFromCtrlC = (live: ReturnType) => { + const cancelOverlayFromCtrlC = () => { if (overlay.clarify) { return actions.answerClarify('') } if (overlay.approval) { return gateway - .rpc('approval.respond', { choice: 'deny', session_id: live.sid }) + .rpc('approval.respond', { choice: 'deny', session_id: getUiState().sid }) .then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied'))) } @@ -172,7 +172,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } if (isCtrl(key, ch, 'c')) { - cancelOverlayFromCtrlC(live) + cancelOverlayFromCtrlC() } else if (key.escape && overlay.picker) { patchOverlayState({ picker: false }) } diff --git a/ui-tui/src/app/useLongRunToolCharms.ts b/ui-tui/src/app/useLongRunToolCharms.ts index 60a871c19..a65898db2 100644 --- a/ui-tui/src/app/useLongRunToolCharms.ts +++ b/ui-tui/src/app/useLongRunToolCharms.ts @@ -47,10 +47,9 @@ export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) { } slots.current.set(tool.id, { count: slot.count + 1, lastAt: now }) - - const sec = Math.round((now - tool.startedAt) / 1000) - - turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${sec}s)`) + turnController.pushActivity( + `${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)` + ) } } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index daa61bccf..cfc471e0a 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -170,18 +170,16 @@ export function useMainApp(gw: GatewayClient) { } const sel = selection.getState() as null | SelectionSnap + const top = s.getViewportTop() + const bottom = top + s.getViewportHeight() - 1 - const focusOutside = (top: number, bottom: number) => + if ( !sel?.anchor || !sel.focus || sel.anchor.row < top || sel.anchor.row > bottom || (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) - - const top = s.getViewportTop() - const bottom = top + s.getViewportHeight() - 1 - - if (focusOutside(top, bottom)) { + ) { return s.scrollBy(delta) } @@ -197,12 +195,11 @@ export function useMainApp(gw: GatewayClient) { if (actual > 0) { selection.captureScrolledRows(top, top + actual - 1, 'above') - shift(-actual, top, bottom) } else { selection.captureScrolledRows(bottom + actual + 1, bottom, 'below') - shift(-actual, top, bottom) } + shift(-actual, top, bottom) s.scrollBy(delta) }, [selection] diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index fee2dd727..f8a40f5a0 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -29,19 +29,6 @@ const expandSnips = (snips: PasteSnippet[]) => { const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text) -export interface UseSubmissionOptions { - appendMessage: (msg: Msg) => void - composerActions: ComposerActions - composerRefs: ComposerRefs - composerState: ComposerState - gw: GatewayClient - maybeGoodVibes: (text: string) => void - setLastUserMsg: (value: string) => void - slashRef: MutableRefObject<(cmd: string) => boolean> - submitRef: MutableRefObject<(value: string) => void> - sys: (text: string) => void -} - export function useSubmission(opts: UseSubmissionOptions) { const { appendMessage, @@ -183,7 +170,6 @@ export function useSubmission(opts: UseSubmissionOptions) { return } - // Slash + shell run regardless of session state (each handles its own sid needs). if (looksLikeSlashCommand(full)) { appendMessage({ kind: 'slash', role: 'system', text: full }) composerActions.pushHistory(full) @@ -201,7 +187,6 @@ export function useSubmission(opts: UseSubmissionOptions) { const live = getUiState() - // No session yet — queue the text and let the ready-flush effect send it. if (!live.sid) { composerActions.pushHistory(full) composerActions.enqueue(full) @@ -246,7 +231,7 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef, sys] + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] ) const submit = useCallback( @@ -256,7 +241,6 @@ export function useSubmission(opts: UseSubmissionOptions) { if (row?.text) { const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text - const next = value.slice(0, composerState.compReplace) + text if (next !== value) { @@ -304,3 +288,16 @@ export function useSubmission(opts: UseSubmissionOptions) { return { dispatchSubmission, send, sendQueued, shellExec, submit } } + +export interface UseSubmissionOptions { + appendMessage: (msg: Msg) => void + composerActions: ComposerActions + composerRefs: ComposerRefs + composerState: ComposerState + gw: GatewayClient + maybeGoodVibes: (text: string) => void + setLastUserMsg: (value: string) => void + slashRef: MutableRefObject<(cmd: string) => boolean> + submitRef: MutableRefObject<(value: string) => void> + sys: (text: string) => void +} diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts index 6324cafe1..d048b7dac 100644 --- a/ui-tui/src/banner.ts +++ b/ui-tui/src/banner.ts @@ -1,9 +1,5 @@ import type { ThemeColors } from './theme.js' -type Line = [string, string] - -// ── Rich markup parser ────────────────────────────────────────────── -// Parses Python Rich markup like "[bold #A3261F]text[/]" into Line[]. const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g export function parseRichMarkup(markup: string): Line[] { @@ -18,28 +14,29 @@ export function parseRichMarkup(markup: string): Line[] { continue } - let lastIndex = 0 - let matched = false - let m: RegExpExecArray | null + const matches = [...trimmed.matchAll(RICH_RE)] - RICH_RE.lastIndex = 0 + if (!matches.length) { + lines.push(['', trimmed]) - while ((m = RICH_RE.exec(trimmed)) !== null) { - matched = true - const before = trimmed.slice(lastIndex, m.index) + continue + } + + let cursor = 0 + + for (const m of matches) { + const before = trimmed.slice(cursor, m.index) if (before) { lines.push(['', before]) } lines.push([m[1]!, m[2]!]) - lastIndex = m.index + m[0].length + cursor = m.index! + m[0].length } - if (!matched) { - lines.push(['', trimmed]) - } else if (lastIndex < trimmed.length) { - lines.push(['', trimmed.slice(lastIndex)]) + if (cursor < trimmed.length) { + lines.push(['', trimmed.slice(cursor)]) } } @@ -76,10 +73,10 @@ const CADUCEUS_ART = [ const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const -function colorize(art: string[], gradient: readonly number[], c: ThemeColors): Line[] { - const palette = [c.gold, c.amber, c.bronze, c.dim] +const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => { + const p = [c.gold, c.amber, c.bronze, c.dim] - return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text]) + return art.map((text, i) => [p[gradient[i]!] ?? c.dim, text]) } export const LOGO_WIDTH = 98 @@ -91,14 +88,6 @@ export const logo = (c: ThemeColors, customLogo?: string): Line[] => export const caduceus = (c: ThemeColors, customHero?: string): Line[] => customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c) -export function artWidth(lines: Line[]): number { - let max = 0 +export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, t.length), 0) - for (const [, text] of lines) { - if (text.length > max) { - max = text.length - } - } - - return max -} +type Line = [string, string] diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index f381057c0..ed6f914c9 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -10,6 +10,7 @@ import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' const FACE_TICK_MS = 2500 +const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] function FaceTicker({ color }: { color: string }) { const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000)) @@ -76,10 +77,8 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { return } - const options = ['#ff5fa2', '#ff4d6d', t.color.amber] - const picked = options[Math.floor(Math.random() * options.length)]! - - setColor(picked) + const palette = [...HEART_COLORS, t.color.amber] + setColor(palette[Math.floor(Math.random() * palette.length)]!) setActive(true) const id = setTimeout(() => setActive(false), 650) @@ -102,19 +101,7 @@ export function StatusRule({ sessionStartedAt, voiceLabel, t -}: { - cwdLabel: string - cols: number - busy: boolean - status: string - statusColor: string - model: string - usage: Usage - bgCount: number - sessionStartedAt?: number | null - voiceLabel?: string - t: Theme -}) { +}: StatusRuleProps) { const pct = usage.context_percent const barColor = ctxBarColor(pct, t) @@ -124,7 +111,6 @@ export function StatusRule({ ? `${fmtK(usage.total)} tok` : '' - const pctLabel = pct != null ? `${pct}%` : '' const bar = usage.context_max ? ctxBar(pct) : '' const leftWidth = Math.max(12, cols - cwdLabel.length - 3) @@ -139,7 +125,7 @@ export function StatusRule({ {bar ? ( {' │ '} - [{bar}] {pctLabel} + [{bar}] {pct != null ? `${pct}%` : ''} ) : null} {sessionStartedAt ? ( @@ -152,6 +138,7 @@ export function StatusRule({ {bgCount > 0 ? │ {bgCount} bg : null} + {cwdLabel} @@ -174,17 +161,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri ) } -export function StickyPromptTracker({ - messages, - offsets, - scrollRef, - onChange -}: { - messages: readonly Msg[] - offsets: ArrayLike - scrollRef: RefObject - onChange: (text: string) => void -}) { +export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) { useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), () => { @@ -210,13 +187,9 @@ export function StickyPromptTracker({ return null } -export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) { +export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) { useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), - // Quantize the scroll snapshot to the values the thumb actually renders - // with — thumbTop + thumbSize + viewport height. Streaming drives - // scrollHeight up by ~1 row at a time, but the quantized thumb usually - // doesn't move, so we skip thousands of render cycles mid-turn. () => { const s = scrollRef.current @@ -304,3 +277,29 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject ) } + +interface StatusRuleProps { + bgCount: number + busy: boolean + cols: number + cwdLabel: string + model: string + sessionStartedAt?: number | null + status: string + statusColor: string + t: Theme + usage: Usage + voiceLabel?: string +} + +interface StickyPromptTrackerProps { + messages: readonly Msg[] + offsets: ArrayLike + onChange: (text: string) => void + scrollRef: RefObject +} + +interface TranscriptScrollbarProps { + scrollRef: RefObject + t: Theme +} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 7d02503bd..ca0edfea3 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -24,20 +24,13 @@ const StreamingAssistant = memo(function StreamingAssistant({ detailsMode, progress, t -}: { - busy: boolean - cols: number - compact?: boolean - detailsMode: DetailsMode - progress: AppLayoutProgressProps - t: Theme -}) { +}: StreamingAssistantProps) { if (!progress.showProgressArea && !progress.showStreamingArea) { return null } return ( - + <> {progress.showProgressArea && ( )} - + ) }) @@ -79,15 +72,13 @@ const TranscriptPane = memo(function TranscriptPane({ }: Pick) { const ui = useStore($uiState) - const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end) - return ( <> {transcript.virtualHistory.topSpacer > 0 ? : null} - {visibleHistory.map(row => ( + {transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => ( {row.msg.kind === 'intro' ? ( @@ -234,6 +225,7 @@ const ComposerPane = memo(function ComposerPane({ placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} value={composer.input} /> + @@ -267,3 +259,12 @@ export const AppLayout = memo(function AppLayout({ ) }) + +interface StreamingAssistantProps { + busy: boolean + cols: number + compact?: boolean + detailsMode: DetailsMode + progress: AppLayoutProgressProps + t: Theme +} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 5cdddd504..509a990cd 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -28,18 +28,17 @@ export function AppOverlays({ const overlay = useStore($overlayState) const ui = useStore($uiState) - if ( - !( - overlay.approval || - overlay.clarify || - overlay.modelPicker || - overlay.pager || - overlay.picker || - overlay.secret || - overlay.sudo || - completions.length - ) - ) { + const hasAny = + overlay.approval || + overlay.clarify || + overlay.modelPicker || + overlay.pager || + overlay.picker || + overlay.secret || + overlay.sudo || + completions.length + + if (!hasAny) { return null } diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 859ff9bee..fc019ac86 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -20,11 +20,10 @@ export function ArtLines({ lines }: { lines: [string, string][] }) { export function Banner({ t }: { t: Theme }) { const cols = useStdout().stdout?.columns ?? 80 const logoLines = logo(t.color, t.bannerLogo || undefined) - const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH return ( - {cols >= logoW ? ( + {cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? ( ) : ( @@ -37,18 +36,14 @@ export function Banner({ t }: { t: Theme }) { ) } -export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) { +export function SessionPanel({ info, sid, t }: SessionPanelProps) { const cols = useStdout().stdout?.columns ?? 100 const heroLines = caduceus(t.color, t.bannerHero || undefined) - const heroW = artWidth(heroLines) || CADUCEUS_WIDTH - const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4)) + const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4)) const wide = cols >= 90 && leftW + 40 < cols - // Keep an explicit gutter so right border never gets overwritten by long lines. const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12) const lineBudget = Math.max(12, w - 2) - const cwd = info.cwd || process.cwd() const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) - const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}` const truncLine = (pfx: string, items: string[]) => { let line = '' @@ -78,12 +73,14 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string Available {title} + {shown.map(([k, vs]) => ( {strip(k)}: {truncLine(strip(k) + ': ', vs)} ))} + {overflow > 0 && ( (and {overflow} {overflowLabel}) @@ -99,13 +96,16 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string + {info.model.split('/').pop()} · Nous Research + - {cwd} + {info.cwd || process.cwd()} + {sid && ( Session: @@ -114,21 +114,27 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string )} )} + - {title} + {t.brand.name} + {info.version ? ` v${info.version}` : ''} + {info.release_date ? ` (${info.release_date})` : ''} + {section('Tools', info.tools, 8, 'more toolsets…')} {section('Skills', info.skills)} + {flat(info.tools).length} tools{' · '} {flat(info.skills).length} skills {' · '} /help for commands + {typeof info.update_behind === 'number' && info.update_behind > 0 && ( ! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind @@ -150,7 +156,7 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string ) } -export function Panel({ sections, t, title }: { sections: PanelSection[]; t: Theme; title: string }) { +export function Panel({ sections, t, title }: PanelProps) { return ( @@ -186,3 +192,15 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The ) } + +interface PanelProps { + sections: PanelSection[] + t: Theme + title: string +} + +interface SessionPanelProps { + info: SessionInfo + sid?: string | null + t: Theme +} diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index f159cc681..3739326bc 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -5,21 +5,7 @@ import type { Theme } from '../theme.js' import { TextInput } from './textInput.js' -export function MaskedPrompt({ - cols = 80, - icon, - label, - onSubmit, - sub, - t -}: { - cols?: number - icon: string - label: string - onSubmit: (v: string) => void - sub?: string - t: Theme -}) { +export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) { const [value, setValue] = useState('') return ( @@ -27,6 +13,7 @@ export function MaskedPrompt({ {icon} {label} + {sub && {sub}} @@ -36,3 +23,12 @@ export function MaskedPrompt({ ) } + +interface MaskedPromptProps { + cols?: number + icon: string + label: string + onSubmit: (v: string) => void + sub?: string + t: Theme +} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 541971a01..59db604e4 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -18,14 +18,7 @@ export const MessageLine = memo(function MessageLine({ isStreaming = false, msg, t -}: { - cols: number - compact?: boolean - detailsMode?: DetailsMode - isStreaming?: boolean - msg: Msg - t: Theme -}) { +}: MessageLineProps) { if (msg.kind === 'trail' && msg.tools?.length) { return detailsMode === 'hidden' ? null : ( @@ -110,3 +103,12 @@ export const MessageLine = memo(function MessageLine({ ) }) + +interface MessageLineProps { + cols: number + compact?: boolean + detailsMode?: DetailsMode + isStreaming?: boolean + msg: Msg + t: Theme +} diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index b2891661a..10a00cdf1 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -10,19 +10,13 @@ const VISIBLE = 12 const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) -export function ModelPicker({ - gw, - onCancel, - onSelect, - sessionId, - t -}: { - gw: GatewayClient - onCancel: () => void - onSelect: (value: string) => void - sessionId: string | null - t: Theme -}) { +const visibleItems = (items: string[], sel: number) => { + const off = pageOffset(items.length, sel) + + return { items: items.slice(off, off + VISIBLE), off } +} + +export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { const [providers, setProviders] = useState([]) const [currentModel, setCurrentModel] = useState('') const [err, setErr] = useState('') @@ -66,12 +60,6 @@ export function ModelPicker({ const provider = providers[providerIdx] const models = provider?.models ?? [] - const visibleItems = (items: string[], sel: number) => { - const off = pageOffset(items.length, sel) - - return { items: items.slice(off, off + VISIBLE), off } - } - useInput((ch, key) => { if (key.escape) { if (stage === 'model') { @@ -182,9 +170,11 @@ export function ModelPicker({ Select Provider + Current model: {currentModel || '(unknown)'} {provider?.warning ? warning: {provider.warning} : null} {off > 0 && ↑ {off} more} + {items.map((row, i) => { const idx = off + i @@ -195,6 +185,7 @@ export function ModelPicker({ ) })} + {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} persist: {persistGlobal ? 'global' : 'session'} · g toggle ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel @@ -209,10 +200,12 @@ export function ModelPicker({ Select Model + {provider?.name || '(unknown provider)'} {!models.length ? no models listed for this provider : null} {provider?.warning ? warning: {provider.warning} : null} {off > 0 && ↑ {off} more} + {items.map((row, i) => { const idx = off + i @@ -223,6 +216,7 @@ export function ModelPicker({ ) })} + {off + VISIBLE < models.length && ↓ {models.length - off - VISIBLE} more} persist: {persistGlobal ? 'global' : 'session'} · g toggle @@ -231,3 +225,11 @@ export function ModelPicker({ ) } + +interface ModelPickerProps { + gw: GatewayClient + onCancel: () => void + onSelect: (value: string) => void + sessionId: string | null + t: Theme +} diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 3dd8a9d75..e53c5be44 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -6,10 +6,11 @@ import type { ApprovalReq, ClarifyReq } from '../types.js' import { TextInput } from './textInput.js' -export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) { +const OPTS = ['once', 'session', 'always', 'deny'] as const +const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const + +export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { const [sel, setSel] = useState(3) - const opts = ['once', 'session', 'always', 'deny'] as const - const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const useInput((ch, key) => { if (key.upArrow && sel > 0) { @@ -21,7 +22,7 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => } if (key.return) { - onChoice(opts[sel]!) + onChoice(OPTS[sel]!) } if (ch === 'o') { @@ -46,34 +47,25 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => ! DANGEROUS COMMAND: {req.description} + {req.command} - {opts.map((o, i) => ( + + {OPTS.map((o, i) => ( {sel === i ? '▸ ' : ' '} - [{o[0]}] {labels[o]} + [{o[0]}] {LABELS[o]} ))} + ↑/↓ select · Enter confirm · o/s/a/d quick pick ) } -export function ClarifyPrompt({ - cols = 80, - onAnswer, - onCancel, - req, - t -}: { - cols?: number - onAnswer: (s: string) => void - onCancel: () => void - req: ClarifyReq - t: Theme -}) { +export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) { const [sel, setSel] = useState(0) const [custom, setCustom] = useState('') const [typing, setTyping] = useState(false) @@ -117,8 +109,6 @@ export function ClarifyPrompt({ }) if (typing || !choices.length) { - const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel' - return ( {heading} @@ -128,7 +118,7 @@ export function ClarifyPrompt({ - {hint} + Enter send · Esc {choices.length ? 'back' : 'cancel'} · Ctrl+C cancel ) } @@ -150,3 +140,17 @@ export function ClarifyPrompt({ ) } + +interface ApprovalPromptProps { + onChoice: (s: string) => void + req: ApprovalReq + t: Theme +} + +interface ClarifyPromptProps { + cols?: number + onAnswer: (s: string) => void + onCancel: () => void + req: ClarifyReq + t: Theme +} diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index 7688e6148..ab9c42c55 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -14,17 +14,7 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) { return { end, showLead: start > 0, showTail: end < queueLen, start } } -export function QueuedMessages({ - cols, - queueEditIdx, - queued, - t -}: { - cols: number - queueEditIdx: number | null - queued: string[] - t: Theme -}) { +export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) { if (!queued.length) { return null } @@ -36,12 +26,14 @@ export function QueuedMessages({ queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} + {q.showLead && ( {' '} … )} + {queued.slice(q.start, q.end).map((item, i) => { const idx = q.start + i const active = queueEditIdx === idx @@ -52,6 +44,7 @@ export function QueuedMessages({ ) })} + {q.showTail && ( {' '}…and {queued.length - q.end} more @@ -60,3 +53,10 @@ export function QueuedMessages({ ) } + +interface QueuedMessagesProps { + cols: number + queueEditIdx: number | null + queued: string[] + t: Theme +} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index 5aeb23878..905fa707e 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -6,7 +6,9 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' -function age(ts: number): string { +const VISIBLE = 15 + +const age = (ts: number) => { const d = (Date.now() / 1000 - ts) / 86400 if (d < 1) { @@ -20,19 +22,7 @@ function age(ts: number): string { return `${Math.floor(d)}d ago` } -const VISIBLE = 15 - -export function SessionPicker({ - gw, - onCancel, - onSelect, - t -}: { - gw: GatewayClient - onCancel: () => void - onSelect: (id: string) => void - t: Theme -}) { +export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) { const [items, setItems] = useState([]) const [err, setErr] = useState('') const [sel, setSel] = useState(0) @@ -107,36 +97,48 @@ export function SessionPicker({ } const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE)) - const visible = items.slice(off, off + VISIBLE) return ( Resume Session + {off > 0 && ↑ {off} more} - {visible.map((s, vi) => { + + {items.slice(off, off + VISIBLE).map((s, vi) => { const i = off + vi return ( {sel === i ? '▸ ' : ' '} + {String(i + 1).padStart(2)}. [{s.id}] + ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) + {s.title || s.preview || '(untitled)'} ) })} + {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} ↑/↓ select · Enter resume · 1-9 quick · Esc cancel ) } + +interface SessionPickerProps { + gw: GatewayClient + onCancel: () => void + onSelect: (id: string) => void + t: Theme +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index fbbd37ccb..482537376 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -11,8 +11,6 @@ type InkExt = typeof Ink & { const ink = Ink as unknown as InkExt const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink -// ── ANSI escapes ───────────────────────────────────────────────────── - const ESC = '\x1b' const INV = `${ESC}[7m` const INV_OFF = `${ESC}[27m` @@ -25,8 +23,6 @@ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') const invert = (s: string) => INV + s + INV_OFF const dim = (s: string) => DIM + s + DIM_OFF -// ── Grapheme segmenter (lazy singleton) ────────────────────────────── - let _seg: Intl.Segmenter | null = null const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) const STOP_CACHE_MAX = 32 @@ -106,8 +102,6 @@ function nextPos(s: string, p: number) { return s.length } -// ── Word movement ──────────────────────────────────────────────────── - function wordLeft(s: string, p: number) { let i = snapPos(s, p) - 1 @@ -136,8 +130,6 @@ function wordRight(s: string, p: number) { return i } -// ── Cursor layout (line/column from offset + terminal width) ───────── - function cursorLayout(value: string, cursor: number, cols: number) { const pos = Math.max(0, Math.min(cursor, value.length)) const w = Math.max(1, cols - 1) @@ -226,8 +218,6 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe return lastOffset } -// ── Render value with inverse-video cursor ─────────────────────────── - function renderWithCursor(value: string, cursor: number) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -250,8 +240,6 @@ function renderWithCursor(value: string, cursor: number) { return done ? out : out + invert(' ') } -// ── Forward-delete detection hook ──────────────────────────────────── - function useFwdDelete(active: boolean) { const ref = useRef(false) const { inputEmitter: ee } = useStdin() @@ -275,29 +263,6 @@ function useFwdDelete(active: boolean) { return ref } -// ── Types ──────────────────────────────────────────────────────────── - -export interface PasteEvent { - bracketed?: boolean - cursor: number - hotkey?: boolean - text: string - value: string -} - -interface Props { - columns?: number - value: string - onChange: (v: string) => void - onSubmit?: (v: string) => void - onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null - mask?: string - placeholder?: string - focus?: boolean -} - -// ── Component ──────────────────────────────────────────────────────── - export function TextInput({ columns = 80, value, @@ -307,7 +272,7 @@ export function TextInput({ mask, placeholder = '', focus = true -}: Props) { +}: TextInputProps) { const [cur, setCur] = useState(value.length) const fwdDel = useFwdDelete(focus) const termFocus = useTerminalFocus() @@ -331,8 +296,6 @@ export function TextInput({ const raw = self.current ? vRef.current : value const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw - // ── Cursor declaration ─────────────────────────────────────────── - const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display]) const boxRef = useDeclaredCursor({ @@ -353,18 +316,6 @@ export function TextInput({ return renderWithCursor(display, cur) }, [cur, display, focus, placeholder]) - const clickCursor = (e: { localRow?: number; localCol?: number }) => { - if (!focus) { - return - } - - const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) - setCur(next) - curRef.current = next - } - - // ── Sync external value changes ────────────────────────────────── - useEffect(() => { if (self.current) { self.current = false @@ -386,8 +337,6 @@ export function TextInput({ [] ) - // ── Buffer ops (synchronous, ref-based) ────────────────────────── - const commit = (next: string, nextCur: number, track = true) => { const prev = vRef.current const c = snapPos(next, nextCur) @@ -450,18 +399,14 @@ export function TextInput({ const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c) - // ── Input handler ──────────────────────────────────────────────── - useInput( (inp: string, k: Key, event: InputEvent) => { - const raw = event.keypress.raw - const metaPaste = raw === '\x1bv' || raw === '\x1bV' + const eventRaw = event.keypress.raw - if (metaPaste) { + if (eventRaw === '\x1bv' || eventRaw === '\x1bV') { return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } - // Delegated to App if ( k.upArrow || k.downArrow || @@ -487,7 +432,6 @@ export function TextInput({ let v = vRef.current const mod = k.ctrl || k.meta - // Undo / redo if (k.ctrl && inp === 'z') { return swap(undo, redo) } @@ -496,7 +440,6 @@ export function TextInput({ return swap(redo, undo) } - // Navigation if (k.home || (k.ctrl && inp === 'a')) { c = 0 } else if (k.end || (k.ctrl && inp === 'e')) { @@ -509,10 +452,7 @@ export function TextInput({ c = wordLeft(v, c) } else if (k.meta && inp === 'f') { c = wordRight(v, c) - } - - // Deletion - else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { + } else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { if (mod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -538,31 +478,28 @@ export function TextInput({ c = 0 } else if (k.ctrl && inp === 'k') { v = v.slice(0, c) - } - - // Text insertion / paste buffering - else if (inp.length > 0) { + } else if (inp.length > 0) { const bracketed = inp.includes('[200~') - const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') - if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) { + if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) { return } - if (!raw) { + if (!text) { return } - if (raw === '\n') { + if (text === '\n') { return commit(ins(v, c, '\n'), c + 1) } - if (raw.length > 1 || raw.includes('\n')) { + if (text.length > 1 || text.includes('\n')) { if (!pasteBuf.current) { pastePos.current = c } - pasteBuf.current += raw + pasteBuf.current += text if (pasteTimer.current) { clearTimeout(pasteTimer.current) @@ -573,9 +510,9 @@ export function TextInput({ return } - if (PRINTABLE.test(raw)) { - v = v.slice(0, c) + raw + v.slice(c) - c += raw.length + if (PRINTABLE.test(text)) { + v = v.slice(0, c) + text + v.slice(c) + c += text.length } else { return } @@ -588,11 +525,39 @@ export function TextInput({ { isActive: focus } ) - // ── Render ─────────────────────────────────────────────────────── - return ( - + { + if (!focus) { + return + } + + const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + setCur(next) + curRef.current = next + }} + ref={boxRef} + > {rendered} ) } + +export interface PasteEvent { + bracketed?: boolean + cursor: number + hotkey?: boolean + text: string + value: string +} + +interface TextInputProps { + columns?: number + focus?: boolean + mask?: string + onChange: (v: string) => void + onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null + onSubmit?: (v: string) => void + placeholder?: string + value: string +} diff --git a/ui-tui/src/components/themed.tsx b/ui-tui/src/components/themed.tsx index b007d78aa..25fb43b44 100644 --- a/ui-tui/src/components/themed.tsx +++ b/ui-tui/src/components/themed.tsx @@ -5,6 +5,16 @@ import type { ReactNode } from 'react' import { $uiState } from '../app/uiStore.js' import type { ThemeColors } from '../theme.js' +export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) { + const { theme } = useStore($uiState) + + return ( + + {children} + + ) +} + export type ThemeColor = keyof ThemeColors export interface FgProps { @@ -18,28 +28,3 @@ export interface FgProps { underline?: boolean wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim' } - -/** - * Theme-aware text. `literal` wins; otherwise `c` is a palette key. - * - * hi // amber - * // dim cornsilk - * x // raw hex - */ -export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) { - const { theme } = useStore($uiState) - - return ( - - {children} - - ) -} diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 91da2121d..3a476d6bc 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -1,5 +1,2 @@ export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() - -export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test( - (process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase() -) +export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim()) diff --git a/ui-tui/src/content/fortunes.ts b/ui-tui/src/content/fortunes.ts index cd88dc478..87943f9f4 100644 --- a/ui-tui/src/content/fortunes.ts +++ b/ui-tui/src/content/fortunes.ts @@ -11,30 +11,20 @@ const FORTUNES = [ 'your instincts are correctly suspicious of that one branch' ] -const LEGENDARY_FORTUNES = [ +const LEGENDARY = [ 'legendary drop: one-line fix, first try', 'legendary drop: every flaky test passes cleanly', 'legendary drop: your diff teaches by itself' ] -const hash = (input: string) => { - let out = 2166136261 +const hash = (s: string) => [...s].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) >>> 0 - for (let i = 0; i < input.length; i++) { - out ^= input.charCodeAt(i) - out = Math.imul(out, 16777619) - } +const fromScore = (n: number) => { + const rare = n % 20 === 0 + const bag = rare ? LEGENDARY : FORTUNES - return out >>> 0 -} - -const fromScore = (score: number) => { - const rare = score % 20 === 0 - const bag = rare ? LEGENDARY_FORTUNES : FORTUNES - - return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` + return `${rare ? '🌟' : '🔮'} ${bag[n % bag.length]}` } export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff)) - export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`)) diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts index 84c2cd80e..fa01092f5 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -1,6 +1,6 @@ import type { DetailsMode } from '../types.js' -const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] +const MODES = ['hidden', 'collapsed', 'expanded'] as const const THINKING_FALLBACK: Record = { collapsed: 'collapsed', @@ -11,12 +11,10 @@ const THINKING_FALLBACK: Record = { export const parseDetailsMode = (v: unknown): DetailsMode | null => { const s = typeof v === 'string' ? v.trim().toLowerCase() : '' - return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null + return MODES.find(m => m === s) ?? null } -export const resolveDetailsMode = ( - d: { details_mode?: unknown; thinking_mode?: unknown } | null | undefined -): DetailsMode => +export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode => parseDetailsMode(d?.details_mode) ?? THINKING_FALLBACK[ String(d?.thinking_mode ?? '') @@ -25,5 +23,4 @@ export const resolveDetailsMode = ( ] ?? 'collapsed' -export const nextDetailsMode = (m: DetailsMode): DetailsMode => - DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! +export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]! diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 2b7f4a513..34b072f01 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -2,30 +2,17 @@ import { LONG_MSG } from '../config/limits.js' import { buildToolTrailLine, fmtK } from '../lib/text.js' import type { Msg, SessionInfo } from '../types.js' -interface ImageMeta { - height?: number - token_estimate?: number - width?: number -} - -interface TranscriptRow { - context?: string - name?: string - role?: string - text?: string -} - export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' }) -export const imageTokenMeta = (info: ImageMeta | null | undefined) => - [ - info?.width && info.height ? `${info.width}x${info.height}` : '', - typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' - ] +export const imageTokenMeta = (info?: ImageMeta | null) => { + const { width, height, token_estimate: t } = info ?? {} + + return [width && height ? `${width}x${height}` : '', (t ?? 0) > 0 ? `~${fmtK(t!)} tok` : ''] .filter(Boolean) .join(' · ') +} -export const userDisplay = (text: string): string => { +export const userDisplay = (text: string) => { if (text.length <= LONG_MSG) { return text } @@ -42,8 +29,8 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { return [] } - const result: Msg[] = [] - let pendingTools: string[] = [] + const out: Msg[] = [] + let pending: string[] = [] for (const row of rows) { if (!row || typeof row !== 'object') { @@ -53,7 +40,7 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { const { context, name, role, text } = row as TranscriptRow if (role === 'tool') { - pendingTools.push(buildToolTrailLine(name ?? 'tool', context ?? '')) + pending.push(buildToolTrailLine(name ?? 'tool', context ?? '')) continue } @@ -63,40 +50,35 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { } 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 }) + out.push({ role, text, ...(pending.length && { tools: pending }) }) + pending = [] + } else if (role === 'user' || role === 'system') { + out.push({ role, text }) + pending = [] } } - return result + return out } -export function fmtDuration(ms: number) { - const total = Math.max(0, Math.floor(ms / 1000)) - const hours = Math.floor(total / 3600) - const mins = Math.floor((total % 3600) / 60) - const secs = total % 60 +export const fmtDuration = (ms: number) => { + const t = Math.max(0, Math.floor(ms / 1000)) + const h = Math.floor(t / 3600) + const m = Math.floor((t % 3600) / 60) + const s = t % 60 - if (hours > 0) { - return `${hours}h ${mins}m` - } - - if (mins > 0) { - return `${mins}m ${secs}s` - } - - return `${secs}s` + return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s` +} + +interface ImageMeta { + height?: number + token_estimate?: number + width?: number +} + +interface TranscriptRow { + context?: string + name?: string + role?: string + text?: string } diff --git a/ui-tui/src/domain/paths.ts b/ui-tui/src/domain/paths.ts index 120a71d79..78daff170 100644 --- a/ui-tui/src/domain/paths.ts +++ b/ui-tui/src/domain/paths.ts @@ -1,5 +1,6 @@ export const shortCwd = (cwd: string, max = 28) => { - const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd + const h = process.env.HOME + const p = h && cwd.startsWith(h) ? `~${cwd.slice(h.length)}` : cwd return p.length <= max ? p : `…${p.slice(-(max - 1))}` } diff --git a/ui-tui/src/domain/slash.ts b/ui-tui/src/domain/slash.ts index fd5b327d7..1fc8082ba 100644 --- a/ui-tui/src/domain/slash.ts +++ b/ui-tui/src/domain/slash.ts @@ -1,25 +1,7 @@ -export interface ParsedSlashCommand { - arg: string - cmd: string - name: string -} - -export const looksLikeSlashCommand = (text: string) => { - if (!text.startsWith('/')) { - return false - } - - const first = text.split(/\s+/, 1)[0] || '' - - return !first.slice(1).includes('/') -} - -export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { - const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) - - return { - arg: rest.join(' '), - cmd, - name: rawName.toLowerCase() - } +export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text) + +export const parseSlashCommand = (cmd: string) => { + const [name = '', ...rest] = cmd.slice(1).split(/\s+/) + + return { arg: rest.join(' '), cmd, name: name.toLowerCase() } } diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 3dccc3177..788f94269 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -26,17 +26,13 @@ export const stickyPromptFromViewport = ( } const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) - const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top - // Walk backward from the first visible row. The nearest user message wins: - // if it's still on screen, no sticky is needed; if it's already scrolled - // above the top, its text becomes the floating breadcrumb. for (let i = first; i >= 0; i--) { if (messages[i]?.role !== 'user') { continue } - return aboveViewport(i) ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : '' + return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : '' } return '' diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index ad1fed31a..3d5f89eb8 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -12,49 +12,34 @@ const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTU const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000) const resolvePython = (root: string) => { - const configured = process.env.HERMES_PYTHON?.trim() + const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim() if (configured) { return configured } - const envPython = process.env.PYTHON?.trim() - - if (envPython) { - return envPython - } - const venv = process.env.VIRTUAL_ENV?.trim() - const candidates = [ - venv ? resolve(venv, 'bin/python') : '', - venv ? resolve(venv, 'Scripts/python.exe') : '', + const hit = [ + venv && resolve(venv, 'bin/python'), + venv && resolve(venv, 'Scripts/python.exe'), resolve(root, '.venv/bin/python'), resolve(root, '.venv/bin/python3'), resolve(root, 'venv/bin/python'), resolve(root, 'venv/bin/python3') - ].filter(Boolean) + ].find(p => p && existsSync(p)) - const hit = candidates.find(path => existsSync(path)) - - if (hit) { - return hit - } - - return process.platform === 'win32' ? 'python' : 'python3' + return hit || (process.platform === 'win32' ? 'python' : 'python3') } -const asGatewayEvent = (value: unknown): GatewayEvent | null => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - - return typeof (value as { type?: unknown }).type === 'string' ? (value as GatewayEvent) : null -} +const asGatewayEvent = (value: unknown): GatewayEvent | null => + value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string' + ? (value as GatewayEvent) + : null interface Pending { - resolve: (v: unknown) => void reject: (e: Error) => void + resolve: (v: unknown) => void } export class GatewayClient extends EventEmitter { @@ -81,9 +66,7 @@ export class GatewayClient extends EventEmitter { } if (this.subscribed) { - this.emit('event', ev) - - return + return void this.emit('event', ev) } this.bufferedEvents.push(ev) @@ -94,8 +77,9 @@ export class GatewayClient extends EventEmitter { const python = resolvePython(root) const cwd = process.env.HERMES_CWD || root const env = { ...process.env } - const pyPath = (env.PYTHONPATH ?? '').trim() + const pyPath = env.PYTHONPATH?.trim() env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root + this.ready = false this.bufferedEvents = [] this.pendingExit = undefined @@ -121,11 +105,7 @@ export class GatewayClient extends EventEmitter { this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } }) }, STARTUP_TIMEOUT_MS) - this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { - cwd, - env, - stdio: ['pipe', 'pipe', 'pipe'] - }) + this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }) this.stdoutRl = createInterface({ input: this.proc.stdout! }) this.stdoutRl.on('line', raw => { @@ -133,8 +113,9 @@ export class GatewayClient extends EventEmitter { this.dispatch(JSON.parse(raw)) } catch { const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)' + this.pushLog(`[protocol] malformed stdout: ${preview}`) - this.publish({ type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent) + this.publish({ type: 'gateway.protocol_error', payload: { preview } }) } }) @@ -147,13 +128,13 @@ export class GatewayClient extends EventEmitter { } this.pushLog(line) - this.publish({ type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent) + this.publish({ type: 'gateway.stderr', payload: { line } }) }) this.proc.on('error', err => { this.pushLog(`[spawn] ${err.message}`) this.rejectPending(new Error(`gateway error: ${err.message}`)) - this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent) + this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } }) }) this.proc.on('exit', code => { @@ -181,6 +162,7 @@ export class GatewayClient extends EventEmitter { if (msg.error) { const err = msg.error as { message?: unknown } | null | undefined + p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed')) } else { p.resolve(msg.result) @@ -199,30 +181,29 @@ export class GatewayClient extends EventEmitter { } private pushLog(line: string) { - this.logs.push(line) - - if (this.logs.length > MAX_GATEWAY_LOG_LINES) { + if (this.logs.push(line) > MAX_GATEWAY_LOG_LINES) { this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES) } } private rejectPending(err: Error) { - for (const [id, pending] of this.pending) { - this.pending.delete(id) - pending.reject(err) + for (const p of this.pending.values()) { + p.reject(err) } + + this.pending.clear() } drain() { this.subscribed = true - const pending = this.bufferedEvents.splice(0) - for (const ev of pending) { + for (const ev of this.bufferedEvents.splice(0)) { this.emit('event', ev) } if (this.pendingExit !== undefined) { const code = this.pendingExit + this.pendingExit = undefined this.emit('exit', code) } diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index c6ba28c80..5b0c2659e 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -34,7 +34,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient ref.current = input const isSlash = input.startsWith('/') - const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null + const pathWord = isSlash ? null : (input.match(TAB_PATH_RE)?.[1] ?? null) if (!isSlash && !pathWord) { clear() @@ -42,6 +42,8 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient return } + const pathReplace = input.length - (pathWord?.length ?? 0) + const t = setTimeout(() => { if (ref.current !== input) { return @@ -53,15 +55,15 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient req .then(raw => { - const r = asRpcResult(raw) - if (ref.current !== input) { return } + const r = asRpcResult(raw) + setCompletions(r?.items ?? []) setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : pathReplace) }) .catch((e: unknown) => { if (ref.current !== input) { @@ -76,7 +78,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient } ]) setCompIdx(0) - setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) + setCompReplace(isSlash ? 1 : pathReplace) }) }, 60) diff --git a/ui-tui/src/hooks/useInputHistory.ts b/ui-tui/src/hooks/useInputHistory.ts index 369a9f50f..8192b86c8 100644 --- a/ui-tui/src/hooks/useInputHistory.ts +++ b/ui-tui/src/hooks/useInputHistory.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useRef, useState } from 'react' import * as inputHistory from '../lib/history.js' @@ -7,9 +7,5 @@ export function useInputHistory() { const [historyIdx, setHistoryIdx] = useState(null) const historyDraftRef = useRef('') - const pushHistory = useCallback((text: string) => { - inputHistory.append(text) - }, []) - - return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } + return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory: inputHistory.append } } diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts index 21bdd51c9..7546d64e7 100644 --- a/ui-tui/src/hooks/useQueue.ts +++ b/ui-tui/src/hooks/useQueue.ts @@ -6,9 +6,7 @@ export function useQueue() { const queueEditRef = useRef(null) const [queueEditIdx, setQueueEditIdx] = useState(null) - const syncQueue = useCallback(() => { - setQueuedDisplay([...queueRef.current]) - }, []) + const syncQueue = useCallback(() => setQueuedDisplay([...queueRef.current]), []) const setQueueEdit = useCallback((idx: number | null) => { queueEditRef.current = idx @@ -39,12 +37,12 @@ export function useQueue() { ) return { - queueRef, - queueEditRef, - queuedDisplay, - queueEditIdx, - enqueue, dequeue, + enqueue, + queueEditIdx, + queueEditRef, + queueRef, + queuedDisplay, replaceQ, setQueueEdit, syncQueue diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index de6e71e2e..b92c07c46 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -33,9 +33,9 @@ export function useVirtualHistory( items: readonly { key: string }[], { estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {} ) { - const nodes = useRef(new Map()) + const nodes = useRef(new Map()) const heights = useRef(new Map()) - const refs = useRef(new Map void>()) + const refs = useRef(new Map void>()) const [ver, setVer] = useState(0) useSyncExternalStore( @@ -108,7 +108,7 @@ export function useVirtualHistory( let fn = refs.current.get(key) if (!fn) { - fn = (el: any) => (el ? nodes.current.set(key, el) : nodes.current.delete(key)) + fn = (el: unknown) => (el ? nodes.current.set(key, el) : nodes.current.delete(key)) refs.current.set(key, fn) } @@ -125,7 +125,7 @@ export function useVirtualHistory( continue } - const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0) + const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0) if (h > 0 && heights.current.get(k) !== h) { heights.current.set(k, h) @@ -139,11 +139,15 @@ export function useVirtualHistory( }, [end, items, start]) return { - start, - end, - offsets, - topSpacer: offsets[start] ?? 0, bottomSpacer: Math.max(0, total - (offsets[end] ?? total)), - measureRef + end, + measureRef, + offsets, + start, + topSpacer: offsets[start] ?? 0 } } + +interface MeasuredNode { + yogaNode?: { getComputedHeight?: () => number } | null +} diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 87adb2eb5..9affbb808 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -3,12 +3,12 @@ import { homedir } from 'node:os' import { join } from 'node:path' const MAX = 1000 -const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes')) +const dir = process.env.HERMES_HOME ?? join(homedir(), '.hermes') const file = join(dir, '.hermes_history') let cache: string[] | null = null -export function load(): string[] { +export function load() { if (cache) { return cache } @@ -20,11 +20,10 @@ export function load(): string[] { return cache } - const lines = readFileSync(file, 'utf8').split('\n') const entries: string[] = [] let current: string[] = [] - for (const line of lines) { + for (const line of readFileSync(file, 'utf8').split('\n')) { if (line.startsWith('+')) { current.push(line.slice(1)) } else if (current.length) { @@ -45,7 +44,7 @@ export function load(): string[] { return cache } -export function append(line: string): void { +export function append(line: string) { const trimmed = line.trim() if (!trimmed) { @@ -73,11 +72,11 @@ export function append(line: string): void { const encoded = trimmed .split('\n') - .map(l => '+' + l) + .map(l => `+${l}`) .join('\n') appendFileSync(file, `\n# ${ts}\n${encoded}\n`) } catch { - /* ignore */ + void 0 } } diff --git a/ui-tui/src/lib/messages.ts b/ui-tui/src/lib/messages.ts index fc265abd4..a459ec5a8 100644 --- a/ui-tui/src/lib/messages.ts +++ b/ui-tui/src/lib/messages.ts @@ -1,5 +1,4 @@ import type { Msg, Role } from '../types.js' -export function upsert(prev: Msg[], role: Role, text: string): Msg[] { - return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] -} +export const upsert = (prev: Msg[], role: Role, text: string): Msg[] => + prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] diff --git a/ui-tui/src/lib/osc52.ts b/ui-tui/src/lib/osc52.ts index 01688aca6..d99082992 100644 --- a/ui-tui/src/lib/osc52.ts +++ b/ui-tui/src/lib/osc52.ts @@ -1,3 +1,2 @@ -export function writeOsc52Clipboard(s: string): void { - process.stdout.write('\x1b]52;c;' + Buffer.from(s, 'utf8').toString('base64') + '\x07') -} +export const writeOsc52Clipboard = (s: string) => + process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`) diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index c2360dd0c..1697d142b 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -2,13 +2,8 @@ import type { CommandDispatchResponse } from '../gatewayTypes.js' export type RpcResult = Record -export const asRpcResult = (value: unknown): T | null => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - - return value as T -} +export const asRpcResult = (value: unknown): T | null => + !value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T) export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => { const o = asRpcResult(value) @@ -28,24 +23,11 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul } if (t === 'skill' && typeof o.name === 'string') { - return { - type: 'skill', - name: o.name, - message: typeof o.message === 'string' ? o.message : undefined - } + return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined } } return null } -export const rpcErrorMessage = (err: unknown) => { - if (err instanceof Error && err.message) { - return err.message - } - - if (typeof err === 'string' && err.trim()) { - return err - } - - return 'request failed' -} +export const rpcErrorMessage = (err: unknown) => + err instanceof Error && err.message ? err.message : typeof err === 'string' && err.trim() ? err : 'request failed' diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index c6b991a5e..fb10d7d2d 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,12 +1,13 @@ import { THINKING_COT_MAX } from '../config/limits.js' import type { ThinkingMode } from '../types.js' -// eslint-disable-next-line no-control-regex -const ANSI_RE = /\x1b\[[0-9;]*m/g +const ESC = String.fromCharCode(27) +const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g') +const WS_RE = /\s+/g export const stripAnsi = (s: string) => s.replace(ANSI_RE, '') -export const hasAnsi = (s: string) => s.includes('\x1b[') || s.includes('\x1b]') +export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`) const renderEstimateLine = (line: string) => { const trimmed = line.trim() @@ -38,7 +39,7 @@ const renderEstimateLine = (line: string) => { } export const compactPreview = (s: string, max: number) => { - const one = s.replace(/\s+/g, ' ').trim() + const one = s.replace(WS_RE, ' ').trim() return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } @@ -46,17 +47,13 @@ export const compactPreview = (s: string, max: number) => { export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2) export const edgePreview = (s: string, head = 16, tail = 28) => { - const one = s.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]') + const one = s.replace(WS_RE, ' ').trim().replace(/\]\]/g, '] ]') - if (!one) { - return '' - } - - if (one.length <= head + tail + 4) { - return one - } - - return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}` + return !one + ? '' + : one.length <= head + tail + 4 + ? one + : `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}` } export const pasteTokenLabel = (text: string, lineCount: number) => { @@ -76,15 +73,7 @@ export const pasteTokenLabel = (text: string, lineCount: number) => { export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => { const raw = reasoning.trim() - if (!raw || mode === 'collapsed') { - return '' - } - - if (mode === 'full') { - return raw - } - - return compactPreview(raw.replace(/\s+/g, ' '), max) + return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max) } export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text) @@ -97,18 +86,18 @@ export const toolTrailLabel = (name: string) => .join(' ') || name export const formatToolCall = (name: string, context = '') => { + const label = toolTrailLabel(name) const preview = compactPreview(context, 64) - return preview ? `${toolTrailLabel(name)}("${preview}")` : toolTrailLabel(name) + return preview ? `${label}("${preview}")` : label } -export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string): string => { +export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string) => { const detail = compactPreview(note ?? '', 72) return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}` } -/** Tool completed / failed row in the inline trail (not CoT prose). */ export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') export const parseToolTrailResultLine = (line: string) => { @@ -133,10 +122,8 @@ export const parseToolTrailResultLine = (line: string) => { return { call: body, detail: '', mark } } -/** Ephemeral status lines that should vanish once the next phase starts. */ export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…' -/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ export const sameToolTrailGroup = (label: string, entry: string) => entry === `${label} ✓` || entry === `${label} ✗` || @@ -144,7 +131,6 @@ export const sameToolTrailGroup = (label: string, entry: string) => entry.startsWith(`${label} ::`) || entry.startsWith(`${label}:`) -/** Index of the last non-result trail line, or -1. */ export const lastCotTrailIndex = (trail: readonly string[]) => { for (let i = trail.length - 1; i >= 0; i--) { if (!isToolTrailResultLine(trail[i]!)) { @@ -168,10 +154,7 @@ export const estimateRows = (text: string, w: number, compact = false) => { const lang = maybeFence[2]!.trim() if (!fence) { - fence = { - char: marker[0] as '`' | '~', - len: marker.length - } + fence = { char: marker[0] as '`' | '~', len: marker.length } if (lang) { rows += Math.ceil((`─ ${lang}`.length || 1) / w) @@ -204,14 +187,11 @@ export const estimateRows = (text: string, w: number, compact = false) => { export const flat = (r: Record) => Object.values(r).flat() -const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { - maximumFractionDigits: 1, - notation: 'compact' -}) +const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' }) export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase()) export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! -export const isPasteBackedText = (text: string): boolean => +export const isPasteBackedText = (text: string) => /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text) diff --git a/ui-tui/src/protocol/interpolation.ts b/ui-tui/src/protocol/interpolation.ts index b83d16c5c..804cf1cf0 100644 --- a/ui-tui/src/protocol/interpolation.ts +++ b/ui-tui/src/protocol/interpolation.ts @@ -1,7 +1,3 @@ export const INTERPOLATION_RE = /\{!(.+?)\}/g -export const hasInterpolation = (s: string) => { - INTERPOLATION_RE.lastIndex = 0 - - return INTERPOLATION_RE.test(s) -} +export const hasInterpolation = (s: string) => /\{!.+?\}/.test(s) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 317d33c97..ab7d7efab 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -1,7 +1,7 @@ export interface ActiveTool { + context?: string id: string name: string - context?: string startedAt?: number } @@ -36,11 +36,11 @@ export interface ClarifyReq { } export interface Msg { + info?: SessionInfo + kind?: 'intro' | 'panel' | 'slash' | 'trail' + panelData?: PanelData role: Role text: string - kind?: 'intro' | 'panel' | 'slash' | 'trail' - info?: SessionInfo - panelData?: PanelData thinking?: string thinkingTokens?: number toolTokens?: number @@ -76,6 +76,7 @@ export interface Usage { export interface SudoReq { requestId: string } + export interface SecretReq { envVar: string prompt: string