refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC

Full codebase pass using the /clean doctrine (KISS/DRY, no one-off
helpers, no variables-used-once, pure functional where natural,
inlined obvious one-liners, killed dead exports, narrowed types,
spaced JSX). All contracts preserved — no RPC method, event name,
or exported type shape changed.

app/ — 15 files, -134 LOC
- inlined 4 one-off helpers (titleCase, isLong, statusToneFrom,
  focusOutside predicate)
- stores to arrow-const style (buildUiState, buildTurnState,
  buildOverlayState plus get/patch/reset triplets)
- functional slash/registry byName map (flatMap over for-loops)
- dropped dead param `live` in cancelOverlayFromCtrlC
- DRY'd duplicate shift() call in scrollWithSelection
- consolidated sections.push calls in /help

components/ — 12 files, -40 LOC
- extracted inline prop types to interfaces at file bottom (13×)
- inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint)
- promoted HEART_COLORS + OPTS/LABELS to module scope
- JSX sibling spacing across 9 files
- un-shadowed `raw` in textInput
- components/thinking.tsx + components/markdown.tsx untouched
  (structurally load-bearing / edge-case-heavy)

config content domain protocol/ — 8 files, -77 LOC
- tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand,
  hasInterpolation — dropped stateful lastIndex dance)
- dead export ParsedSlashCommand removed
- MODES narrowed to `as const`, `.find(m => m === s)` replaces
  `.includes() ? (as cast) : null`
- fortunes.ts hash via reduce
- fmtDuration ternary chain
- inlined aboveViewport predicate in viewport.ts

hooks/ + lib/ — 9 files, -38 LOC
- ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module
  scope (no more eslint-disable no-control-regex)
- compactPreview/edgePreview/thinkingPreview → ternary arrows
- useCompletion: hoisted pathReplace, moved stale-ref guard earlier
- useInputHistory: dropped useCallback wrapper (append is stable)
- useVirtualHistory: replaced 4× any with unknown + narrow
  MeasuredNode interface + one cast site

root TS — 3 files, -63 LOC
- banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex,
  artWidth via reduce
- gatewayClient.ts: resolvePython candidate list collapse, inlined
  one-branch guards in dispatch/pushLog/drain/request
- types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq
  members

eslint config
- disabled react-hooks/exhaustive-deps on packages/hermes-ink/**
  (compiled by react/compiler, deps live in $[N] memo arrays that
  eslint can't introspect) and removed the now-orphan in-file
  disable directive in ScrollBox.tsx

fixes (not from the cleaner pass)
- useComposerState: unlinkSync(file) + try/catch → rmSync(file,
  { force: true }) — kills the no-empty lint error and is more
  idiomatic
- useConfigSync: added setBellOnComplete + setVoiceEnabled to the
  two useEffect dep arrays (they're stable React setState setters;
  adding is safe and silences exhaustive-deps)

verification
- npx eslint src/ packages/ → 0 errors, 0 warnings
- npm run type-check → clean
- npm test → 50/50
- npm run build → 394.8kb ink-bundle.js, 11ms esbuild
- pytest tests/tui_gateway/ tests/test_tui_gateway_server.py
  tests/hermes_cli/test_tui_resume_flow.py
  tests/hermes_cli/test_tui_npm_install.py → 57/57
This commit is contained in:
Brooklyn Nicholson 2026-04-16 22:32:53 -05:00
parent c730ab8ad7
commit 39231f29c6
49 changed files with 527 additions and 744 deletions

View file

@ -88,7 +88,8 @@ export default [
'@typescript-eslint/consistent-type-imports': 'off', '@typescript-eslint/consistent-type-imports': 'off',
'no-constant-condition': 'off', 'no-constant-condition': 'off',
'no-empty': 'off', 'no-empty': 'off',
'no-redeclare': 'off' 'no-redeclare': 'off',
'react-hooks/exhaustive-deps': 'off'
} }
}, },
{ {

View file

@ -235,7 +235,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
// notify/scrollMutated are inline (no useCallback) but only close over // notify/scrollMutated are inline (no useCallback) but only close over
// refs + imports — stable. Empty deps avoids rebuilding the handle on // refs + imports — stable. Empty deps avoids rebuilding the handle on
// every render (which re-registers the ref = churn). // every render (which re-registers the ref = churn).
// eslint-disable-next-line react-hooks/exhaustive-deps
[] []
) )

View file

@ -35,9 +35,6 @@ const dropBgTask = (taskId: string) =>
return { ...state, bgTasks: next } return { ...state, bgTasks: next }
}) })
const statusToneFrom = (kind: string): 'error' | 'info' | 'warn' =>
kind === 'error' ? 'error' : kind === 'warn' || kind === 'approval' ? 'warn' : 'info'
const pushUnique = const pushUnique =
(max: number) => (max: number) =>
<T>(xs: T[], x: T): T[] => <T>(xs: T[], x: T): T[] =>
@ -213,7 +210,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (turnController.lastStatusNote !== p.text) { if (turnController.lastStatusNote !== p.text) {
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) restoreStatusAfter(4000)

View file

@ -7,10 +7,6 @@ import { findSlashCommand } from './slash/registry.js'
import type { SlashRunCtx } from './slash/types.js' import type { SlashRunCtx } from './slash/types.js'
import { getUiState } from './uiStore.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 { export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
const { gw } = ctx.gateway const { gw } = ctx.gateway
const { catalog } = ctx.local 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 body = r?.output || `/${parsed.name}: no output`
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body 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(() => { .catch(() => {
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid }) gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })

View file

@ -2,8 +2,7 @@ import { atom, computed } from 'nanostores'
import type { OverlayState } from './interfaces.js' import type { OverlayState } from './interfaces.js'
function buildOverlayState(): OverlayState { const buildOverlayState = (): OverlayState => ({
return {
approval: null, approval: null,
clarify: null, clarify: null,
modelPicker: false, modelPicker: false,
@ -11,31 +10,17 @@ function buildOverlayState(): OverlayState {
picker: false, picker: false,
secret: null, secret: null,
sudo: null sudo: null
} })
}
export const $overlayState = atom<OverlayState>(buildOverlayState()) export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed($overlayState, state => export const $isBlocked = computed($overlayState, ({ approval, clarify, modelPicker, pager, picker, secret, sudo }) =>
Boolean( Boolean(approval || clarify || modelPicker || pager || picker || secret || sudo)
state.approval || state.clarify || state.modelPicker || state.pager || state.picker || state.secret || state.sudo
)
) )
export function getOverlayState() { export const getOverlayState = () => $overlayState.get()
return $overlayState.get()
}
export function patchOverlayState(next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) { export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
if (typeof next === 'function') { $overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
$overlayState.set(next($overlayState.get()))
return export const resetOverlayState = () => $overlayState.set(buildOverlayState())
}
$overlayState.set({ ...$overlayState.get(), ...next })
}
export function resetOverlayState() {
$overlayState.set(buildOverlayState())
}

View file

@ -9,12 +9,12 @@ import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js' import type { SlashCommand } from '../types.js'
const flagFromArg = (arg: string, current: boolean): boolean | null => { const flagFromArg = (arg: string, current: boolean): boolean | null => {
const mode = arg.trim().toLowerCase()
if (!arg) { if (!arg) {
return !current return !current
} }
const mode = arg.trim().toLowerCase()
if (mode === 'on') { if (mode === 'on') {
return true 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({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` })
} }
sections.push({ sections.push(
{
rows: [ rows: [
['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'],
['/fortune [random|daily]', 'show a random or daily local fortune'] ['/fortune [random|daily]', 'show a random or daily local fortune']
], ],
title: 'TUI' title: 'TUI'
}) },
sections.push({ rows: HOTKEYS, title: 'Hotkeys' }) { rows: HOTKEYS, title: 'Hotkeys' }
)
ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections) ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections)
} }

View file

@ -9,7 +9,7 @@ export const opsCommands: SlashCommand[] = [
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
if (subcommand !== 'disable' && subcommand !== 'enable') { if (subcommand !== 'disable' && subcommand !== 'enable') {
return // py prints lists / show / usage return
} }
if (!names.length) { if (!names.length) {

View file

@ -109,7 +109,7 @@ export const sessionCommands: SlashCommand[] = [
name: 'personality', name: 'personality',
run: (arg, ctx) => { run: (arg, ctx) => {
if (!arg) { if (!arg) {
return // py handles listing return
} }
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then( ctx.gateway.rpc<ConfigSetResponse>('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)', help: 'switch theme skin (fires skin.changed)',
name: 'skin', name: 'skin',

View file

@ -5,14 +5,8 @@ import type { SlashCommand } from './types.js'
export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands] export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands]
const byName = new Map<string, SlashCommand>() const byName = new Map<string, SlashCommand>(
SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const))
)
for (const cmd of SLASH_COMMANDS) { export const findSlashCommand = (name: string) => byName.get(name.toLowerCase())
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())

View file

@ -2,6 +2,28 @@ import { atom } from 'nanostores'
import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' 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<TurnState>(buildTurnState())
export const getTurnState = () => $turnState.get()
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
export const resetTurnState = () => $turnState.set(buildTurnState())
export interface TurnState { export interface TurnState {
activity: ActivityItem[] activity: ActivityItem[]
reasoning: string reasoning: string
@ -14,34 +36,3 @@ export interface TurnState {
tools: ActiveTool[] tools: ActiveTool[]
turnTrail: string[] turnTrail: string[]
} }
function buildTurnState(): TurnState {
return {
activity: [],
reasoning: '',
reasoningActive: false,
reasoningStreaming: false,
reasoningTokens: 0,
streaming: '',
subagents: [],
toolTokens: 0,
tools: [],
turnTrail: []
}
}
export const $turnState = atom<TurnState>(buildTurnState())
export const getTurnState = () => $turnState.get()
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) => {
if (typeof next === 'function') {
$turnState.set(next($turnState.get()))
return
}
$turnState.set({ ...$turnState.get(), ...next })
}
export const resetTurnState = () => $turnState.set(buildTurnState())

View file

@ -5,8 +5,7 @@ import { DEFAULT_THEME } from '../theme.js'
import type { UiState } from './interfaces.js' import type { UiState } from './interfaces.js'
function buildUiState(): UiState { const buildUiState = (): UiState => ({
return {
bgTasks: new Set(), bgTasks: new Set(),
busy: false, busy: false,
compact: false, compact: false,
@ -17,25 +16,13 @@ function buildUiState(): UiState {
statusBar: true, statusBar: true,
theme: DEFAULT_THEME, theme: DEFAULT_THEME,
usage: ZERO usage: ZERO
} })
}
export const $uiState = atom<UiState>(buildUiState()) export const $uiState = atom<UiState>(buildUiState())
export function getUiState() { export const getUiState = () => $uiState.get()
return $uiState.get()
}
export function patchUiState(next: Partial<UiState> | ((state: UiState) => UiState)) { export const patchUiState = (next: Partial<UiState> | ((state: UiState) => UiState)) =>
if (typeof next === 'function') { $uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next })
$uiState.set(next($uiState.get()))
return export const resetUiState = () => $uiState.set(buildUiState())
}
$uiState.set({ ...$uiState.get(), ...next })
}
export function resetUiState() {
$uiState.set(buildUiState())
}

View file

@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process' 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 { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
@ -97,11 +97,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
} }
} }
try { rmSync(file, { force: true })
unlinkSync(file)
} catch {
/* noop */
}
}, [input, inputBuf, submitRef]) }, [input, inputBuf, submitRef])
const actions = useMemo( const actions = useMemo(

View file

@ -15,23 +15,16 @@ import { patchUiState } from './uiStore.js'
const MTIME_POLL_MS = 5000 const MTIME_POLL_MS = 5000
const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { 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({ patchUiState({
compact: !!display.tui_compact, compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(display), detailsMode: resolveDetailsMode(d),
statusBar: display.tui_statusbar !== false 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) { export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
const mtimeRef = useRef(0) const mtimeRef = useRef(0)
@ -45,8 +38,7 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
mtimeRef.current = Number(r?.mtime ?? 0) mtimeRef.current = Number(r?.mtime ?? 0)
}) })
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
// eslint-disable-next-line react-hooks/exhaustive-deps }, [rpc, setBellOnComplete, setVoiceEnabled, sid])
}, [rpc, sid])
useEffect(() => { useEffect(() => {
if (!sid) { if (!sid) {
@ -79,6 +71,12 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
}, MTIME_POLL_MS) }, MTIME_POLL_MS)
return () => clearInterval(id) return () => clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps }, [rpc, setBellOnComplete, sid])
}, [rpc, sid]) }
export interface UseConfigSyncOptions {
rpc: GatewayRpc
setBellOnComplete: (v: boolean) => void
setVoiceEnabled: (v: boolean) => void
sid: null | string
} }

View file

@ -29,14 +29,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
} }
} }
const cancelOverlayFromCtrlC = (live: ReturnType<typeof getUiState>) => { const cancelOverlayFromCtrlC = () => {
if (overlay.clarify) { if (overlay.clarify) {
return actions.answerClarify('') return actions.answerClarify('')
} }
if (overlay.approval) { if (overlay.approval) {
return gateway return gateway
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: live.sid }) .rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
.then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied'))) .then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied')))
} }
@ -172,7 +172,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
} }
if (isCtrl(key, ch, 'c')) { if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC(live) cancelOverlayFromCtrlC()
} else if (key.escape && overlay.picker) { } else if (key.escape && overlay.picker) {
patchOverlayState({ picker: false }) patchOverlayState({ picker: false })
} }

View file

@ -47,10 +47,9 @@ export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) {
} }
slots.current.set(tool.id, { count: slot.count + 1, lastAt: now }) slots.current.set(tool.id, { count: slot.count + 1, lastAt: now })
turnController.pushActivity(
const sec = Math.round((now - tool.startedAt) / 1000) `${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)`
)
turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${sec}s)`)
} }
} }

View file

@ -170,18 +170,16 @@ export function useMainApp(gw: GatewayClient) {
} }
const sel = selection.getState() as null | SelectionSnap 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?.anchor ||
!sel.focus || !sel.focus ||
sel.anchor.row < top || sel.anchor.row < top ||
sel.anchor.row > bottom || sel.anchor.row > bottom ||
(!sel.isDragging && (sel.focus.row < top || sel.focus.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) return s.scrollBy(delta)
} }
@ -197,12 +195,11 @@ export function useMainApp(gw: GatewayClient) {
if (actual > 0) { if (actual > 0) {
selection.captureScrolledRows(top, top + actual - 1, 'above') selection.captureScrolledRows(top, top + actual - 1, 'above')
shift(-actual, top, bottom)
} else { } else {
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below') selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
shift(-actual, top, bottom)
} }
shift(-actual, top, bottom)
s.scrollBy(delta) s.scrollBy(delta)
}, },
[selection] [selection]

View file

@ -29,19 +29,6 @@ const expandSnips = (snips: PasteSnippet[]) => {
const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => 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) 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) { export function useSubmission(opts: UseSubmissionOptions) {
const { const {
appendMessage, appendMessage,
@ -183,7 +170,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
return return
} }
// Slash + shell run regardless of session state (each handles its own sid needs).
if (looksLikeSlashCommand(full)) { if (looksLikeSlashCommand(full)) {
appendMessage({ kind: 'slash', role: 'system', text: full }) appendMessage({ kind: 'slash', role: 'system', text: full })
composerActions.pushHistory(full) composerActions.pushHistory(full)
@ -201,7 +187,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
const live = getUiState() const live = getUiState()
// No session yet — queue the text and let the ready-flush effect send it.
if (!live.sid) { if (!live.sid) {
composerActions.pushHistory(full) composerActions.pushHistory(full)
composerActions.enqueue(full) composerActions.enqueue(full)
@ -246,7 +231,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
send(full) send(full)
}, },
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef, sys] [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef]
) )
const submit = useCallback( const submit = useCallback(
@ -256,7 +241,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
if (row?.text) { if (row?.text) {
const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text
const next = value.slice(0, composerState.compReplace) + text const next = value.slice(0, composerState.compReplace) + text
if (next !== value) { if (next !== value) {
@ -304,3 +288,16 @@ export function useSubmission(opts: UseSubmissionOptions) {
return { dispatchSubmission, send, sendQueued, shellExec, submit } 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
}

View file

@ -1,9 +1,5 @@
import type { ThemeColors } from './theme.js' 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 const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g
export function parseRichMarkup(markup: string): Line[] { export function parseRichMarkup(markup: string): Line[] {
@ -18,28 +14,29 @@ export function parseRichMarkup(markup: string): Line[] {
continue continue
} }
let lastIndex = 0 const matches = [...trimmed.matchAll(RICH_RE)]
let matched = false
let m: RegExpExecArray | null
RICH_RE.lastIndex = 0 if (!matches.length) {
lines.push(['', trimmed])
while ((m = RICH_RE.exec(trimmed)) !== null) { continue
matched = true }
const before = trimmed.slice(lastIndex, m.index)
let cursor = 0
for (const m of matches) {
const before = trimmed.slice(cursor, m.index)
if (before) { if (before) {
lines.push(['', before]) lines.push(['', before])
} }
lines.push([m[1]!, m[2]!]) lines.push([m[1]!, m[2]!])
lastIndex = m.index + m[0].length cursor = m.index! + m[0].length
} }
if (!matched) { if (cursor < trimmed.length) {
lines.push(['', trimmed]) lines.push(['', trimmed.slice(cursor)])
} else if (lastIndex < trimmed.length) {
lines.push(['', trimmed.slice(lastIndex)])
} }
} }
@ -76,10 +73,10 @@ const CADUCEUS_ART = [
const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const 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 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 colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => {
const palette = [c.gold, c.amber, c.bronze, c.dim] 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 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[] => export const caduceus = (c: ThemeColors, customHero?: string): Line[] =>
customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c) customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
export function artWidth(lines: Line[]): number { export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, t.length), 0)
let max = 0
for (const [, text] of lines) { type Line = [string, string]
if (text.length > max) {
max = text.length
}
}
return max
}

View file

@ -10,6 +10,7 @@ import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js' import type { Msg, Usage } from '../types.js'
const FACE_TICK_MS = 2500 const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
function FaceTicker({ color }: { color: string }) { function FaceTicker({ color }: { color: string }) {
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000)) const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
@ -76,10 +77,8 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
return return
} }
const options = ['#ff5fa2', '#ff4d6d', t.color.amber] const palette = [...HEART_COLORS, t.color.amber]
const picked = options[Math.floor(Math.random() * options.length)]! setColor(palette[Math.floor(Math.random() * palette.length)]!)
setColor(picked)
setActive(true) setActive(true)
const id = setTimeout(() => setActive(false), 650) const id = setTimeout(() => setActive(false), 650)
@ -102,19 +101,7 @@ export function StatusRule({
sessionStartedAt, sessionStartedAt,
voiceLabel, voiceLabel,
t t
}: { }: StatusRuleProps) {
cwdLabel: string
cols: number
busy: boolean
status: string
statusColor: string
model: string
usage: Usage
bgCount: number
sessionStartedAt?: number | null
voiceLabel?: string
t: Theme
}) {
const pct = usage.context_percent const pct = usage.context_percent
const barColor = ctxBarColor(pct, t) const barColor = ctxBarColor(pct, t)
@ -124,7 +111,6 @@ export function StatusRule({
? `${fmtK(usage.total)} tok` ? `${fmtK(usage.total)} tok`
: '' : ''
const pctLabel = pct != null ? `${pct}%` : ''
const bar = usage.context_max ? ctxBar(pct) : '' const bar = usage.context_max ? ctxBar(pct) : ''
const leftWidth = Math.max(12, cols - cwdLabel.length - 3) const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
@ -139,7 +125,7 @@ export function StatusRule({
{bar ? ( {bar ? (
<Text color={t.color.dim}> <Text color={t.color.dim}>
{' │ '} {' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text> <Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
</Text> </Text>
) : null} ) : null}
{sessionStartedAt ? ( {sessionStartedAt ? (
@ -152,6 +138,7 @@ export function StatusRule({
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null} {bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
</Text> </Text>
</Box> </Box>
<Text color={t.color.bronze}> </Text> <Text color={t.color.bronze}> </Text>
<Text color={t.color.label}>{cwdLabel}</Text> <Text color={t.color.label}>{cwdLabel}</Text>
</Box> </Box>
@ -174,17 +161,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
) )
} }
export function StickyPromptTracker({ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
messages,
offsets,
scrollRef,
onChange
}: {
messages: readonly Msg[]
offsets: ArrayLike<number>
scrollRef: RefObject<ScrollBoxHandle | null>
onChange: (text: string) => void
}) {
useSyncExternalStore( useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => { () => {
@ -210,13 +187,9 @@ export function StickyPromptTracker({
return null return null
} }
export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<ScrollBoxHandle | null>; t: Theme }) { export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
useSyncExternalStore( useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), 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 const s = scrollRef.current
@ -304,3 +277,29 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
</Box> </Box>
) )
} }
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<number>
onChange: (text: string) => void
scrollRef: RefObject<ScrollBoxHandle | null>
}
interface TranscriptScrollbarProps {
scrollRef: RefObject<ScrollBoxHandle | null>
t: Theme
}

View file

@ -24,20 +24,13 @@ const StreamingAssistant = memo(function StreamingAssistant({
detailsMode, detailsMode,
progress, progress,
t t
}: { }: StreamingAssistantProps) {
busy: boolean
cols: number
compact?: boolean
detailsMode: DetailsMode
progress: AppLayoutProgressProps
t: Theme
}) {
if (!progress.showProgressArea && !progress.showStreamingArea) { if (!progress.showProgressArea && !progress.showStreamingArea) {
return null return null
} }
return ( return (
<Box flexDirection="column"> <>
{progress.showProgressArea && ( {progress.showProgressArea && (
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}> <Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
<ToolTrail <ToolTrail
@ -67,7 +60,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
t={t} t={t}
/> />
)} )}
</Box> </>
) )
}) })
@ -79,15 +72,13 @@ const TranscriptPane = memo(function TranscriptPane({
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) { }: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState) const ui = useStore($uiState)
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
return ( return (
<> <>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll> <ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
<Box flexDirection="column" paddingX={1}> <Box flexDirection="column" paddingX={1}>
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null} {transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
{visibleHistory.map(row => ( {transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}> <Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.kind === 'intro' ? ( {row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}> <Box flexDirection="column" paddingTop={1}>
@ -234,6 +225,7 @@ const ComposerPane = memo(function ComposerPane({
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input} value={composer.input}
/> />
<Box position="absolute" right={0}> <Box position="absolute" right={0}>
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} /> <GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
</Box> </Box>
@ -267,3 +259,12 @@ export const AppLayout = memo(function AppLayout({
</AlternateScreen> </AlternateScreen>
) )
}) })
interface StreamingAssistantProps {
busy: boolean
cols: number
compact?: boolean
detailsMode: DetailsMode
progress: AppLayoutProgressProps
t: Theme
}

View file

@ -28,8 +28,7 @@ export function AppOverlays({
const overlay = useStore($overlayState) const overlay = useStore($overlayState)
const ui = useStore($uiState) const ui = useStore($uiState)
if ( const hasAny =
!(
overlay.approval || overlay.approval ||
overlay.clarify || overlay.clarify ||
overlay.modelPicker || overlay.modelPicker ||
@ -38,8 +37,8 @@ export function AppOverlays({
overlay.secret || overlay.secret ||
overlay.sudo || overlay.sudo ||
completions.length completions.length
)
) { if (!hasAny) {
return null return null
} }

View file

@ -20,11 +20,10 @@ export function ArtLines({ lines }: { lines: [string, string][] }) {
export function Banner({ t }: { t: Theme }) { export function Banner({ t }: { t: Theme }) {
const cols = useStdout().stdout?.columns ?? 80 const cols = useStdout().stdout?.columns ?? 80
const logoLines = logo(t.color, t.bannerLogo || undefined) const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
{cols >= logoW ? ( {cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
<ArtLines lines={logoLines} /> <ArtLines lines={logoLines} />
) : ( ) : (
<Text bold color={t.color.gold}> <Text bold color={t.color.gold}>
@ -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 cols = useStdout().stdout?.columns ?? 100
const heroLines = caduceus(t.color, t.bannerHero || undefined) const heroLines = caduceus(t.color, t.bannerHero || undefined)
const heroW = artWidth(heroLines) || CADUCEUS_WIDTH const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4))
const wide = cols >= 90 && leftW + 40 < cols 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 w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
const lineBudget = Math.max(12, w - 2) 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 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[]) => { const truncLine = (pfx: string, items: string[]) => {
let line = '' let line = ''
@ -78,12 +73,14 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
<Text bold color={t.color.amber}> <Text bold color={t.color.amber}>
Available {title} Available {title}
</Text> </Text>
{shown.map(([k, vs]) => ( {shown.map(([k, vs]) => (
<Text key={k} wrap="truncate"> <Text key={k} wrap="truncate">
<Text color={t.color.dim}>{strip(k)}: </Text> <Text color={t.color.dim}>{strip(k)}: </Text>
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text> <Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text> </Text>
))} ))}
{overflow > 0 && ( {overflow > 0 && (
<Text color={t.color.dim}> <Text color={t.color.dim}>
(and {overflow} {overflowLabel}) (and {overflow} {overflowLabel})
@ -99,13 +96,16 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
<Box flexDirection="column" marginRight={2} width={leftW}> <Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={heroLines} /> <ArtLines lines={heroLines} />
<Text /> <Text />
<Text color={t.color.amber}> <Text color={t.color.amber}>
{info.model.split('/').pop()} {info.model.split('/').pop()}
<Text color={t.color.dim}> · Nous Research</Text> <Text color={t.color.dim}> · Nous Research</Text>
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.dim} wrap="truncate-end">
{cwd} {info.cwd || process.cwd()}
</Text> </Text>
{sid && ( {sid && (
<Text> <Text>
<Text color={t.color.sessionLabel}>Session: </Text> <Text color={t.color.sessionLabel}>Session: </Text>
@ -114,21 +114,27 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
)} )}
</Box> </Box>
)} )}
<Box flexDirection="column" width={w}> <Box flexDirection="column" width={w}>
<Box justifyContent="center" marginBottom={1}> <Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.gold}> <Text bold color={t.color.gold}>
{title} {t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text> </Text>
</Box> </Box>
{section('Tools', info.tools, 8, 'more toolsets…')} {section('Tools', info.tools, 8, 'more toolsets…')}
{section('Skills', info.skills)} {section('Skills', info.skills)}
<Text /> <Text />
<Text color={t.color.cornsilk}> <Text color={t.color.cornsilk}>
{flat(info.tools).length} tools{' · '} {flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills {flat(info.skills).length} skills
{' · '} {' · '}
<Text color={t.color.dim}>/help for commands</Text> <Text color={t.color.dim}>/help for commands</Text>
</Text> </Text>
{typeof info.update_behind === 'number' && info.update_behind > 0 && ( {typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color="yellow"> <Text bold color="yellow">
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind ! {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 ( return (
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}> <Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<Box justifyContent="center" marginBottom={1}> <Box justifyContent="center" marginBottom={1}>
@ -186,3 +192,15 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The
</Box> </Box>
) )
} }
interface PanelProps {
sections: PanelSection[]
t: Theme
title: string
}
interface SessionPanelProps {
info: SessionInfo
sid?: string | null
t: Theme
}

View file

@ -5,21 +5,7 @@ import type { Theme } from '../theme.js'
import { TextInput } from './textInput.js' import { TextInput } from './textInput.js'
export function MaskedPrompt({ export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) {
cols = 80,
icon,
label,
onSubmit,
sub,
t
}: {
cols?: number
icon: string
label: string
onSubmit: (v: string) => void
sub?: string
t: Theme
}) {
const [value, setValue] = useState('') const [value, setValue] = useState('')
return ( return (
@ -27,6 +13,7 @@ export function MaskedPrompt({
<Text bold color={t.color.warn}> <Text bold color={t.color.warn}>
{icon} {label} {icon} {label}
</Text> </Text>
{sub && <Text color={t.color.dim}> {sub}</Text>} {sub && <Text color={t.color.dim}> {sub}</Text>}
<Box> <Box>
@ -36,3 +23,12 @@ export function MaskedPrompt({
</Box> </Box>
) )
} }
interface MaskedPromptProps {
cols?: number
icon: string
label: string
onSubmit: (v: string) => void
sub?: string
t: Theme
}

View file

@ -18,14 +18,7 @@ export const MessageLine = memo(function MessageLine({
isStreaming = false, isStreaming = false,
msg, msg,
t t
}: { }: MessageLineProps) {
cols: number
compact?: boolean
detailsMode?: DetailsMode
isStreaming?: boolean
msg: Msg
t: Theme
}) {
if (msg.kind === 'trail' && msg.tools?.length) { if (msg.kind === 'trail' && msg.tools?.length) {
return detailsMode === 'hidden' ? null : ( return detailsMode === 'hidden' ? null : (
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
@ -110,3 +103,12 @@ export const MessageLine = memo(function MessageLine({
</Box> </Box>
) )
}) })
interface MessageLineProps {
cols: number
compact?: boolean
detailsMode?: DetailsMode
isStreaming?: boolean
msg: Msg
t: Theme
}

View file

@ -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)) const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
export function ModelPicker({ const visibleItems = (items: string[], sel: number) => {
gw, const off = pageOffset(items.length, sel)
onCancel,
onSelect, return { items: items.slice(off, off + VISIBLE), off }
sessionId, }
t
}: { export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
gw: GatewayClient
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null
t: Theme
}) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([]) const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('') const [currentModel, setCurrentModel] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
@ -66,12 +60,6 @@ export function ModelPicker({
const provider = providers[providerIdx] const provider = providers[providerIdx]
const models = provider?.models ?? [] 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) => { useInput((ch, key) => {
if (key.escape) { if (key.escape) {
if (stage === 'model') { if (stage === 'model') {
@ -182,9 +170,11 @@ export function ModelPicker({
<Text bold color={t.color.amber}> <Text bold color={t.color.amber}>
Select Provider Select Provider
</Text> </Text>
<Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text> <Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text>
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null} {provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
{off > 0 && <Text color={t.color.dim}> {off} more</Text>} {off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{items.map((row, i) => { {items.map((row, i) => {
const idx = off + i const idx = off + i
@ -195,6 +185,7 @@ export function ModelPicker({
</Text> </Text>
) )
})} })}
{off + VISIBLE < rows.length && <Text color={t.color.dim}> {rows.length - off - VISIBLE} more</Text>} {off + VISIBLE < rows.length && <Text color={t.color.dim}> {rows.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text> <Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
<Text color={t.color.dim}>/ select · Enter choose · 1-9,0 quick · Esc cancel</Text> <Text color={t.color.dim}>/ select · Enter choose · 1-9,0 quick · Esc cancel</Text>
@ -209,10 +200,12 @@ export function ModelPicker({
<Text bold color={t.color.amber}> <Text bold color={t.color.amber}>
Select Model Select Model
</Text> </Text>
<Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text> <Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text>
{!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null} {!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null} {provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
{off > 0 && <Text color={t.color.dim}> {off} more</Text>} {off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{items.map((row, i) => { {items.map((row, i) => {
const idx = off + i const idx = off + i
@ -223,6 +216,7 @@ export function ModelPicker({
</Text> </Text>
) )
})} })}
{off + VISIBLE < models.length && <Text color={t.color.dim}> {models.length - off - VISIBLE} more</Text>} {off + VISIBLE < models.length && <Text color={t.color.dim}> {models.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text> <Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
<Text color={t.color.dim}> <Text color={t.color.dim}>
@ -231,3 +225,11 @@ export function ModelPicker({
</Box> </Box>
) )
} }
interface ModelPickerProps {
gw: GatewayClient
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null
t: Theme
}

View file

@ -6,10 +6,11 @@ import type { ApprovalReq, ClarifyReq } from '../types.js'
import { TextInput } from './textInput.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 [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) => { useInput((ch, key) => {
if (key.upArrow && sel > 0) { if (key.upArrow && sel > 0) {
@ -21,7 +22,7 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
} }
if (key.return) { if (key.return) {
onChoice(opts[sel]!) onChoice(OPTS[sel]!)
} }
if (ch === 'o') { if (ch === 'o') {
@ -46,34 +47,25 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
<Text bold color={t.color.warn}> <Text bold color={t.color.warn}>
! DANGEROUS COMMAND: {req.description} ! DANGEROUS COMMAND: {req.description}
</Text> </Text>
<Text color={t.color.dim}> {req.command}</Text> <Text color={t.color.dim}> {req.command}</Text>
<Text /> <Text />
{opts.map((o, i) => (
{OPTS.map((o, i) => (
<Text key={o}> <Text key={o}>
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text> <Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}> <Text color={sel === i ? t.color.cornsilk : t.color.dim}>
[{o[0]}] {labels[o]} [{o[0]}] {LABELS[o]}
</Text> </Text>
</Text> </Text>
))} ))}
<Text color={t.color.dim}>/ select · Enter confirm · o/s/a/d quick pick</Text> <Text color={t.color.dim}>/ select · Enter confirm · o/s/a/d quick pick</Text>
</Box> </Box>
) )
} }
export function ClarifyPrompt({ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) {
cols = 80,
onAnswer,
onCancel,
req,
t
}: {
cols?: number
onAnswer: (s: string) => void
onCancel: () => void
req: ClarifyReq
t: Theme
}) {
const [sel, setSel] = useState(0) const [sel, setSel] = useState(0)
const [custom, setCustom] = useState('') const [custom, setCustom] = useState('')
const [typing, setTyping] = useState(false) const [typing, setTyping] = useState(false)
@ -117,8 +109,6 @@ export function ClarifyPrompt({
}) })
if (typing || !choices.length) { if (typing || !choices.length) {
const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel'
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{heading} {heading}
@ -128,7 +118,7 @@ export function ClarifyPrompt({
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} /> <TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
</Box> </Box>
<Text color={t.color.dim}>{hint}</Text> <Text color={t.color.dim}>Enter send · Esc {choices.length ? 'back' : 'cancel'} · Ctrl+C cancel</Text>
</Box> </Box>
) )
} }
@ -150,3 +140,17 @@ export function ClarifyPrompt({
</Box> </Box>
) )
} }
interface ApprovalPromptProps {
onChoice: (s: string) => void
req: ApprovalReq
t: Theme
}
interface ClarifyPromptProps {
cols?: number
onAnswer: (s: string) => void
onCancel: () => void
req: ClarifyReq
t: Theme
}

View file

@ -14,17 +14,7 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
return { end, showLead: start > 0, showTail: end < queueLen, start } return { end, showLead: start > 0, showTail: end < queueLen, start }
} }
export function QueuedMessages({ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) {
cols,
queueEditIdx,
queued,
t
}: {
cols: number
queueEditIdx: number | null
queued: string[]
t: Theme
}) {
if (!queued.length) { if (!queued.length) {
return null return null
} }
@ -36,12 +26,14 @@ export function QueuedMessages({
<Text color={t.color.dim} dimColor> <Text color={t.color.dim} dimColor>
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
</Text> </Text>
{q.showLead && ( {q.showLead && (
<Text color={t.color.dim} dimColor> <Text color={t.color.dim} dimColor>
{' '} {' '}
</Text> </Text>
)} )}
{queued.slice(q.start, q.end).map((item, i) => { {queued.slice(q.start, q.end).map((item, i) => {
const idx = q.start + i const idx = q.start + i
const active = queueEditIdx === idx const active = queueEditIdx === idx
@ -52,6 +44,7 @@ export function QueuedMessages({
</Text> </Text>
) )
})} })}
{q.showTail && ( {q.showTail && (
<Text color={t.color.dim} dimColor> <Text color={t.color.dim} dimColor>
{' '}and {queued.length - q.end} more {' '}and {queued.length - q.end} more
@ -60,3 +53,10 @@ export function QueuedMessages({
</Box> </Box>
) )
} }
interface QueuedMessagesProps {
cols: number
queueEditIdx: number | null
queued: string[]
t: Theme
}

View file

@ -6,7 +6,9 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.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 const d = (Date.now() / 1000 - ts) / 86400
if (d < 1) { if (d < 1) {
@ -20,19 +22,7 @@ function age(ts: number): string {
return `${Math.floor(d)}d ago` return `${Math.floor(d)}d ago`
} }
const VISIBLE = 15 export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) {
export function SessionPicker({
gw,
onCancel,
onSelect,
t
}: {
gw: GatewayClient
onCancel: () => void
onSelect: (id: string) => void
t: Theme
}) {
const [items, setItems] = useState<SessionListItem[]>([]) const [items, setItems] = useState<SessionListItem[]>([])
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [sel, setSel] = useState(0) 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 off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
const visible = items.slice(off, off + VISIBLE)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text bold color={t.color.amber}> <Text bold color={t.color.amber}>
Resume Session Resume Session
</Text> </Text>
{off > 0 && <Text color={t.color.dim}> {off} more</Text>} {off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{visible.map((s, vi) => {
{items.slice(off, off + VISIBLE).map((s, vi) => {
const i = off + vi const i = off + vi
return ( return (
<Box key={s.id}> <Box key={s.id}>
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text> <Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Box width={30}> <Box width={30}>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}> <Text color={sel === i ? t.color.cornsilk : t.color.dim}>
{String(i + 1).padStart(2)}. [{s.id}] {String(i + 1).padStart(2)}. [{s.id}]
</Text> </Text>
</Box> </Box>
<Box width={30}> <Box width={30}>
<Text color={t.color.dim}> <Text color={t.color.dim}>
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
</Text> </Text>
</Box> </Box>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>{s.title || s.preview || '(untitled)'}</Text> <Text color={sel === i ? t.color.cornsilk : t.color.dim}>{s.title || s.preview || '(untitled)'}</Text>
</Box> </Box>
) )
})} })}
{off + VISIBLE < items.length && <Text color={t.color.dim}> {items.length - off - VISIBLE} more</Text>} {off + VISIBLE < items.length && <Text color={t.color.dim}> {items.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>/ select · Enter resume · 1-9 quick · Esc cancel</Text> <Text color={t.color.dim}>/ select · Enter resume · 1-9 quick · Esc cancel</Text>
</Box> </Box>
) )
} }
interface SessionPickerProps {
gw: GatewayClient
onCancel: () => void
onSelect: (id: string) => void
t: Theme
}

View file

@ -11,8 +11,6 @@ type InkExt = typeof Ink & {
const ink = Ink as unknown as InkExt const ink = Ink as unknown as InkExt
const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
// ── ANSI escapes ─────────────────────────────────────────────────────
const ESC = '\x1b' const ESC = '\x1b'
const INV = `${ESC}[7m` const INV = `${ESC}[7m`
const INV_OFF = `${ESC}[27m` 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 invert = (s: string) => INV + s + INV_OFF
const dim = (s: string) => DIM + s + DIM_OFF const dim = (s: string) => DIM + s + DIM_OFF
// ── Grapheme segmenter (lazy singleton) ──────────────────────────────
let _seg: Intl.Segmenter | null = null let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
const STOP_CACHE_MAX = 32 const STOP_CACHE_MAX = 32
@ -106,8 +102,6 @@ function nextPos(s: string, p: number) {
return s.length return s.length
} }
// ── Word movement ────────────────────────────────────────────────────
function wordLeft(s: string, p: number) { function wordLeft(s: string, p: number) {
let i = snapPos(s, p) - 1 let i = snapPos(s, p) - 1
@ -136,8 +130,6 @@ function wordRight(s: string, p: number) {
return i return i
} }
// ── Cursor layout (line/column from offset + terminal width) ─────────
function cursorLayout(value: string, cursor: number, cols: number) { function cursorLayout(value: string, cursor: number, cols: number) {
const pos = Math.max(0, Math.min(cursor, value.length)) const pos = Math.max(0, Math.min(cursor, value.length))
const w = Math.max(1, cols - 1) const w = Math.max(1, cols - 1)
@ -226,8 +218,6 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
return lastOffset return lastOffset
} }
// ── Render value with inverse-video cursor ───────────────────────────
function renderWithCursor(value: string, cursor: number) { function renderWithCursor(value: string, cursor: number) {
const pos = Math.max(0, Math.min(cursor, value.length)) 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(' ') return done ? out : out + invert(' ')
} }
// ── Forward-delete detection hook ────────────────────────────────────
function useFwdDelete(active: boolean) { function useFwdDelete(active: boolean) {
const ref = useRef(false) const ref = useRef(false)
const { inputEmitter: ee } = useStdin() const { inputEmitter: ee } = useStdin()
@ -275,29 +263,6 @@ function useFwdDelete(active: boolean) {
return ref 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({ export function TextInput({
columns = 80, columns = 80,
value, value,
@ -307,7 +272,7 @@ export function TextInput({
mask, mask,
placeholder = '', placeholder = '',
focus = true focus = true
}: Props) { }: TextInputProps) {
const [cur, setCur] = useState(value.length) const [cur, setCur] = useState(value.length)
const fwdDel = useFwdDelete(focus) const fwdDel = useFwdDelete(focus)
const termFocus = useTerminalFocus() const termFocus = useTerminalFocus()
@ -331,8 +296,6 @@ export function TextInput({
const raw = self.current ? vRef.current : value const raw = self.current ? vRef.current : value
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
// ── Cursor declaration ───────────────────────────────────────────
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display]) const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
const boxRef = useDeclaredCursor({ const boxRef = useDeclaredCursor({
@ -353,18 +316,6 @@ export function TextInput({
return renderWithCursor(display, cur) return renderWithCursor(display, cur)
}, [cur, display, focus, placeholder]) }, [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(() => { useEffect(() => {
if (self.current) { if (self.current) {
self.current = false self.current = false
@ -386,8 +337,6 @@ export function TextInput({
[] []
) )
// ── Buffer ops (synchronous, ref-based) ──────────────────────────
const commit = (next: string, nextCur: number, track = true) => { const commit = (next: string, nextCur: number, track = true) => {
const prev = vRef.current const prev = vRef.current
const c = snapPos(next, nextCur) 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) const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
// ── Input handler ────────────────────────────────────────────────
useInput( useInput(
(inp: string, k: Key, event: InputEvent) => { (inp: string, k: Key, event: InputEvent) => {
const raw = event.keypress.raw const eventRaw = event.keypress.raw
const metaPaste = raw === '\x1bv' || raw === '\x1bV'
if (metaPaste) { if (eventRaw === '\x1bv' || eventRaw === '\x1bV') {
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
} }
// Delegated to App
if ( if (
k.upArrow || k.upArrow ||
k.downArrow || k.downArrow ||
@ -487,7 +432,6 @@ export function TextInput({
let v = vRef.current let v = vRef.current
const mod = k.ctrl || k.meta const mod = k.ctrl || k.meta
// Undo / redo
if (k.ctrl && inp === 'z') { if (k.ctrl && inp === 'z') {
return swap(undo, redo) return swap(undo, redo)
} }
@ -496,7 +440,6 @@ export function TextInput({
return swap(redo, undo) return swap(redo, undo)
} }
// Navigation
if (k.home || (k.ctrl && inp === 'a')) { if (k.home || (k.ctrl && inp === 'a')) {
c = 0 c = 0
} else if (k.end || (k.ctrl && inp === 'e')) { } else if (k.end || (k.ctrl && inp === 'e')) {
@ -509,10 +452,7 @@ export function TextInput({
c = wordLeft(v, c) c = wordLeft(v, c)
} else if (k.meta && inp === 'f') { } else if (k.meta && inp === 'f') {
c = wordRight(v, c) c = wordRight(v, c)
} } else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
// Deletion
else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
if (mod) { if (mod) {
const t = wordLeft(v, c) const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c) v = v.slice(0, t) + v.slice(c)
@ -538,31 +478,28 @@ export function TextInput({
c = 0 c = 0
} else if (k.ctrl && inp === 'k') { } else if (k.ctrl && inp === 'k') {
v = v.slice(0, c) v = v.slice(0, c)
} } else if (inp.length > 0) {
// Text insertion / paste buffering
else if (inp.length > 0) {
const bracketed = inp.includes('[200~') 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 return
} }
if (!raw) { if (!text) {
return return
} }
if (raw === '\n') { if (text === '\n') {
return commit(ins(v, c, '\n'), c + 1) 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) { if (!pasteBuf.current) {
pastePos.current = c pastePos.current = c
} }
pasteBuf.current += raw pasteBuf.current += text
if (pasteTimer.current) { if (pasteTimer.current) {
clearTimeout(pasteTimer.current) clearTimeout(pasteTimer.current)
@ -573,9 +510,9 @@ export function TextInput({
return return
} }
if (PRINTABLE.test(raw)) { if (PRINTABLE.test(text)) {
v = v.slice(0, c) + raw + v.slice(c) v = v.slice(0, c) + text + v.slice(c)
c += raw.length c += text.length
} else { } else {
return return
} }
@ -588,11 +525,39 @@ export function TextInput({
{ isActive: focus } { isActive: focus }
) )
// ── Render ───────────────────────────────────────────────────────
return ( return (
<Box onClick={clickCursor} ref={boxRef}> <Box
onClick={(e: { localRow?: number; localCol?: number }) => {
if (!focus) {
return
}
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
setCur(next)
curRef.current = next
}}
ref={boxRef}
>
<Text wrap="wrap">{rendered}</Text> <Text wrap="wrap">{rendered}</Text>
</Box> </Box>
) )
} }
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
}

View file

@ -5,6 +5,16 @@ import type { ReactNode } from 'react'
import { $uiState } from '../app/uiStore.js' import { $uiState } from '../app/uiStore.js'
import type { ThemeColors } from '../theme.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 (
<Text color={literal ?? (c && theme.color[c])} dimColor={dim} {...{ bold, italic, strikethrough, underline, wrap }}>
{children}
</Text>
)
}
export type ThemeColor = keyof ThemeColors export type ThemeColor = keyof ThemeColors
export interface FgProps { export interface FgProps {
@ -18,28 +28,3 @@ export interface FgProps {
underline?: boolean underline?: boolean
wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim' wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim'
} }
/**
* Theme-aware text. `literal` wins; otherwise `c` is a palette key.
*
* <Fg c="amber">hi</Fg> // amber
* <Fg c="dim" dim></Fg> // dim cornsilk
* <Fg literal="#ff00ff">x</Fg> // raw hex
*/
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
const { theme } = useStore($uiState)
return (
<Text
bold={bold}
color={literal ?? (c && theme.color[c])}
dimColor={dim}
italic={italic}
strikethrough={strikethrough}
underline={underline}
wrap={wrap}
>
{children}
</Text>
)
}

View file

@ -1,5 +1,2 @@
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim())
export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test(
(process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()
)

View file

@ -11,30 +11,20 @@ const FORTUNES = [
'your instincts are correctly suspicious of that one branch' 'your instincts are correctly suspicious of that one branch'
] ]
const LEGENDARY_FORTUNES = [ const LEGENDARY = [
'legendary drop: one-line fix, first try', 'legendary drop: one-line fix, first try',
'legendary drop: every flaky test passes cleanly', 'legendary drop: every flaky test passes cleanly',
'legendary drop: your diff teaches by itself' 'legendary drop: your diff teaches by itself'
] ]
const hash = (input: string) => { const hash = (s: string) => [...s].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) >>> 0
let out = 2166136261
for (let i = 0; i < input.length; i++) { const fromScore = (n: number) => {
out ^= input.charCodeAt(i) const rare = n % 20 === 0
out = Math.imul(out, 16777619) const bag = rare ? LEGENDARY : FORTUNES
}
return out >>> 0 return `${rare ? '🌟' : '🔮'} ${bag[n % bag.length]}`
}
const fromScore = (score: number) => {
const rare = score % 20 === 0
const bag = rare ? LEGENDARY_FORTUNES : FORTUNES
return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}`
} }
export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff)) export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff))
export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`)) export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`))

View file

@ -1,6 +1,6 @@
import type { DetailsMode } from '../types.js' import type { DetailsMode } from '../types.js'
const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] const MODES = ['hidden', 'collapsed', 'expanded'] as const
const THINKING_FALLBACK: Record<string, DetailsMode> = { const THINKING_FALLBACK: Record<string, DetailsMode> = {
collapsed: 'collapsed', collapsed: 'collapsed',
@ -11,12 +11,10 @@ const THINKING_FALLBACK: Record<string, DetailsMode> = {
export const parseDetailsMode = (v: unknown): DetailsMode | null => { export const parseDetailsMode = (v: unknown): DetailsMode | null => {
const s = typeof v === 'string' ? v.trim().toLowerCase() : '' 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 = ( export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode =>
d: { details_mode?: unknown; thinking_mode?: unknown } | null | undefined
): DetailsMode =>
parseDetailsMode(d?.details_mode) ?? parseDetailsMode(d?.details_mode) ??
THINKING_FALLBACK[ THINKING_FALLBACK[
String(d?.thinking_mode ?? '') String(d?.thinking_mode ?? '')
@ -25,5 +23,4 @@ export const resolveDetailsMode = (
] ?? ] ??
'collapsed' 'collapsed'
export const nextDetailsMode = (m: DetailsMode): DetailsMode => export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]!
DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!

View file

@ -2,30 +2,17 @@ import { LONG_MSG } from '../config/limits.js'
import { buildToolTrailLine, fmtK } from '../lib/text.js' import { buildToolTrailLine, fmtK } from '../lib/text.js'
import type { Msg, SessionInfo } from '../types.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 introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' })
export const imageTokenMeta = (info: ImageMeta | null | undefined) => export const imageTokenMeta = (info?: ImageMeta | null) => {
[ const { width, height, token_estimate: t } = info ?? {}
info?.width && info.height ? `${info.width}x${info.height}` : '',
typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' return [width && height ? `${width}x${height}` : '', (t ?? 0) > 0 ? `~${fmtK(t!)} tok` : '']
]
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
}
export const userDisplay = (text: string): string => { export const userDisplay = (text: string) => {
if (text.length <= LONG_MSG) { if (text.length <= LONG_MSG) {
return text return text
} }
@ -42,8 +29,8 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
return [] return []
} }
const result: Msg[] = [] const out: Msg[] = []
let pendingTools: string[] = [] let pending: string[] = []
for (const row of rows) { for (const row of rows) {
if (!row || typeof row !== 'object') { if (!row || typeof row !== 'object') {
@ -53,7 +40,7 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
const { context, name, role, text } = row as TranscriptRow const { context, name, role, text } = row as TranscriptRow
if (role === 'tool') { if (role === 'tool') {
pendingTools.push(buildToolTrailLine(name ?? 'tool', context ?? '')) pending.push(buildToolTrailLine(name ?? 'tool', context ?? ''))
continue continue
} }
@ -63,40 +50,35 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
} }
if (role === 'assistant') { if (role === 'assistant') {
const msg: Msg = { role, text } out.push({ role, text, ...(pending.length && { tools: pending }) })
pending = []
if (pendingTools.length) { } else if (role === 'user' || role === 'system') {
msg.tools = pendingTools out.push({ role, text })
pendingTools = [] pending = []
}
result.push(msg)
continue
}
if (role === 'user' || role === 'system') {
pendingTools = []
result.push({ role, text })
} }
} }
return result return out
} }
export function fmtDuration(ms: number) { export const fmtDuration = (ms: number) => {
const total = Math.max(0, Math.floor(ms / 1000)) const t = Math.max(0, Math.floor(ms / 1000))
const hours = Math.floor(total / 3600) const h = Math.floor(t / 3600)
const mins = Math.floor((total % 3600) / 60) const m = Math.floor((t % 3600) / 60)
const secs = total % 60 const s = t % 60
if (hours > 0) { return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`
return `${hours}h ${mins}m` }
}
interface ImageMeta {
if (mins > 0) { height?: number
return `${mins}m ${secs}s` token_estimate?: number
} width?: number
}
return `${secs}s`
interface TranscriptRow {
context?: string
name?: string
role?: string
text?: string
} }

View file

@ -1,5 +1,6 @@
export const shortCwd = (cwd: string, max = 28) => { 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))}` return p.length <= max ? p : `${p.slice(-(max - 1))}`
} }

View file

@ -1,25 +1,7 @@
export interface ParsedSlashCommand { export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text)
arg: string
cmd: string export const parseSlashCommand = (cmd: string) => {
name: string const [name = '', ...rest] = cmd.slice(1).split(/\s+/)
}
return { arg: rest.join(' '), cmd, name: name.toLowerCase() }
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()
}
} }

View file

@ -26,17 +26,13 @@ export const stickyPromptFromViewport = (
} }
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) 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--) { for (let i = first; i >= 0; i--) {
if (messages[i]?.role !== 'user') { if (messages[i]?.role !== 'user') {
continue 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 '' return ''

View file

@ -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 REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)
const resolvePython = (root: string) => { const resolvePython = (root: string) => {
const configured = process.env.HERMES_PYTHON?.trim() const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()
if (configured) { if (configured) {
return configured return configured
} }
const envPython = process.env.PYTHON?.trim()
if (envPython) {
return envPython
}
const venv = process.env.VIRTUAL_ENV?.trim() const venv = process.env.VIRTUAL_ENV?.trim()
const candidates = [ const hit = [
venv ? resolve(venv, 'bin/python') : '', venv && resolve(venv, 'bin/python'),
venv ? resolve(venv, 'Scripts/python.exe') : '', venv && resolve(venv, 'Scripts/python.exe'),
resolve(root, '.venv/bin/python'), resolve(root, '.venv/bin/python'),
resolve(root, '.venv/bin/python3'), resolve(root, '.venv/bin/python3'),
resolve(root, 'venv/bin/python'), resolve(root, 'venv/bin/python'),
resolve(root, 'venv/bin/python3') resolve(root, 'venv/bin/python3')
].filter(Boolean) ].find(p => p && existsSync(p))
const hit = candidates.find(path => existsSync(path)) return hit || (process.platform === 'win32' ? 'python' : 'python3')
if (hit) {
return hit
}
return process.platform === 'win32' ? 'python' : 'python3'
} }
const asGatewayEvent = (value: unknown): GatewayEvent | null => { const asGatewayEvent = (value: unknown): GatewayEvent | null =>
if (!value || typeof value !== 'object' || Array.isArray(value)) { value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string'
return null ? (value as GatewayEvent)
} : null
return typeof (value as { type?: unknown }).type === 'string' ? (value as GatewayEvent) : null
}
interface Pending { interface Pending {
resolve: (v: unknown) => void
reject: (e: Error) => void reject: (e: Error) => void
resolve: (v: unknown) => void
} }
export class GatewayClient extends EventEmitter { export class GatewayClient extends EventEmitter {
@ -81,9 +66,7 @@ export class GatewayClient extends EventEmitter {
} }
if (this.subscribed) { if (this.subscribed) {
this.emit('event', ev) return void this.emit('event', ev)
return
} }
this.bufferedEvents.push(ev) this.bufferedEvents.push(ev)
@ -94,8 +77,9 @@ export class GatewayClient extends EventEmitter {
const python = resolvePython(root) const python = resolvePython(root)
const cwd = process.env.HERMES_CWD || root const cwd = process.env.HERMES_CWD || root
const env = { ...process.env } const env = { ...process.env }
const pyPath = (env.PYTHONPATH ?? '').trim() const pyPath = env.PYTHONPATH?.trim()
env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root
this.ready = false this.ready = false
this.bufferedEvents = [] this.bufferedEvents = []
this.pendingExit = undefined this.pendingExit = undefined
@ -121,11 +105,7 @@ export class GatewayClient extends EventEmitter {
this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } }) this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } })
}, STARTUP_TIMEOUT_MS) }, STARTUP_TIMEOUT_MS)
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe']
})
this.stdoutRl = createInterface({ input: this.proc.stdout! }) this.stdoutRl = createInterface({ input: this.proc.stdout! })
this.stdoutRl.on('line', raw => { this.stdoutRl.on('line', raw => {
@ -133,8 +113,9 @@ export class GatewayClient extends EventEmitter {
this.dispatch(JSON.parse(raw)) this.dispatch(JSON.parse(raw))
} catch { } catch {
const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)' const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)'
this.pushLog(`[protocol] malformed stdout: ${preview}`) 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.pushLog(line)
this.publish({ type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent) this.publish({ type: 'gateway.stderr', payload: { line } })
}) })
this.proc.on('error', err => { this.proc.on('error', err => {
this.pushLog(`[spawn] ${err.message}`) this.pushLog(`[spawn] ${err.message}`)
this.rejectPending(new Error(`gateway error: ${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 => { this.proc.on('exit', code => {
@ -181,6 +162,7 @@ export class GatewayClient extends EventEmitter {
if (msg.error) { if (msg.error) {
const err = msg.error as { message?: unknown } | null | undefined const err = msg.error as { message?: unknown } | null | undefined
p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed')) p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed'))
} else { } else {
p.resolve(msg.result) p.resolve(msg.result)
@ -199,30 +181,29 @@ export class GatewayClient extends EventEmitter {
} }
private pushLog(line: string) { private pushLog(line: string) {
this.logs.push(line) if (this.logs.push(line) > MAX_GATEWAY_LOG_LINES) {
if (this.logs.length > MAX_GATEWAY_LOG_LINES) {
this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES) this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES)
} }
} }
private rejectPending(err: Error) { private rejectPending(err: Error) {
for (const [id, pending] of this.pending) { for (const p of this.pending.values()) {
this.pending.delete(id) p.reject(err)
pending.reject(err)
} }
this.pending.clear()
} }
drain() { drain() {
this.subscribed = true 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) this.emit('event', ev)
} }
if (this.pendingExit !== undefined) { if (this.pendingExit !== undefined) {
const code = this.pendingExit const code = this.pendingExit
this.pendingExit = undefined this.pendingExit = undefined
this.emit('exit', code) this.emit('exit', code)
} }

View file

@ -34,7 +34,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
ref.current = input ref.current = input
const isSlash = input.startsWith('/') 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) { if (!isSlash && !pathWord) {
clear() clear()
@ -42,6 +42,8 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
return return
} }
const pathReplace = input.length - (pathWord?.length ?? 0)
const t = setTimeout(() => { const t = setTimeout(() => {
if (ref.current !== input) { if (ref.current !== input) {
return return
@ -53,15 +55,15 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
req req
.then(raw => { .then(raw => {
const r = asRpcResult<CompletionResponse>(raw)
if (ref.current !== input) { if (ref.current !== input) {
return return
} }
const r = asRpcResult<CompletionResponse>(raw)
setCompletions(r?.items ?? []) setCompletions(r?.items ?? [])
setCompIdx(0) setCompIdx(0)
setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) setCompReplace(isSlash ? (r?.replace_from ?? 1) : pathReplace)
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
if (ref.current !== input) { if (ref.current !== input) {
@ -76,7 +78,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
} }
]) ])
setCompIdx(0) setCompIdx(0)
setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) setCompReplace(isSlash ? 1 : pathReplace)
}) })
}, 60) }, 60)

View file

@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react' import { useRef, useState } from 'react'
import * as inputHistory from '../lib/history.js' import * as inputHistory from '../lib/history.js'
@ -7,9 +7,5 @@ export function useInputHistory() {
const [historyIdx, setHistoryIdx] = useState<number | null>(null) const [historyIdx, setHistoryIdx] = useState<number | null>(null)
const historyDraftRef = useRef('') const historyDraftRef = useRef('')
const pushHistory = useCallback((text: string) => { return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory: inputHistory.append }
inputHistory.append(text)
}, [])
return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory }
} }

View file

@ -6,9 +6,7 @@ export function useQueue() {
const queueEditRef = useRef<number | null>(null) const queueEditRef = useRef<number | null>(null)
const [queueEditIdx, setQueueEditIdx] = useState<number | null>(null) const [queueEditIdx, setQueueEditIdx] = useState<number | null>(null)
const syncQueue = useCallback(() => { const syncQueue = useCallback(() => setQueuedDisplay([...queueRef.current]), [])
setQueuedDisplay([...queueRef.current])
}, [])
const setQueueEdit = useCallback((idx: number | null) => { const setQueueEdit = useCallback((idx: number | null) => {
queueEditRef.current = idx queueEditRef.current = idx
@ -39,12 +37,12 @@ export function useQueue() {
) )
return { return {
queueRef,
queueEditRef,
queuedDisplay,
queueEditIdx,
enqueue,
dequeue, dequeue,
enqueue,
queueEditIdx,
queueEditRef,
queueRef,
queuedDisplay,
replaceQ, replaceQ,
setQueueEdit, setQueueEdit,
syncQueue syncQueue

View file

@ -33,9 +33,9 @@ export function useVirtualHistory(
items: readonly { key: string }[], items: readonly { key: string }[],
{ estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {} { estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {}
) { ) {
const nodes = useRef(new Map<string, any>()) const nodes = useRef(new Map<string, unknown>())
const heights = useRef(new Map<string, number>()) const heights = useRef(new Map<string, number>())
const refs = useRef(new Map<string, (el: any) => void>()) const refs = useRef(new Map<string, (el: unknown) => void>())
const [ver, setVer] = useState(0) const [ver, setVer] = useState(0)
useSyncExternalStore( useSyncExternalStore(
@ -108,7 +108,7 @@ export function useVirtualHistory(
let fn = refs.current.get(key) let fn = refs.current.get(key)
if (!fn) { 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) refs.current.set(key, fn)
} }
@ -125,7 +125,7 @@ export function useVirtualHistory(
continue 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) { if (h > 0 && heights.current.get(k) !== h) {
heights.current.set(k, h) heights.current.set(k, h)
@ -139,11 +139,15 @@ export function useVirtualHistory(
}, [end, items, start]) }, [end, items, start])
return { return {
start,
end,
offsets,
topSpacer: offsets[start] ?? 0,
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)), bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
measureRef end,
measureRef,
offsets,
start,
topSpacer: offsets[start] ?? 0
} }
} }
interface MeasuredNode {
yogaNode?: { getComputedHeight?: () => number } | null
}

View file

@ -3,12 +3,12 @@ import { homedir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
const MAX = 1000 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') const file = join(dir, '.hermes_history')
let cache: string[] | null = null let cache: string[] | null = null
export function load(): string[] { export function load() {
if (cache) { if (cache) {
return cache return cache
} }
@ -20,11 +20,10 @@ export function load(): string[] {
return cache return cache
} }
const lines = readFileSync(file, 'utf8').split('\n')
const entries: string[] = [] const entries: string[] = []
let current: string[] = [] let current: string[] = []
for (const line of lines) { for (const line of readFileSync(file, 'utf8').split('\n')) {
if (line.startsWith('+')) { if (line.startsWith('+')) {
current.push(line.slice(1)) current.push(line.slice(1))
} else if (current.length) { } else if (current.length) {
@ -45,7 +44,7 @@ export function load(): string[] {
return cache return cache
} }
export function append(line: string): void { export function append(line: string) {
const trimmed = line.trim() const trimmed = line.trim()
if (!trimmed) { if (!trimmed) {
@ -73,11 +72,11 @@ export function append(line: string): void {
const encoded = trimmed const encoded = trimmed
.split('\n') .split('\n')
.map(l => '+' + l) .map(l => `+${l}`)
.join('\n') .join('\n')
appendFileSync(file, `\n# ${ts}\n${encoded}\n`) appendFileSync(file, `\n# ${ts}\n${encoded}\n`)
} catch { } catch {
/* ignore */ void 0
} }
} }

View file

@ -1,5 +1,4 @@
import type { Msg, Role } from '../types.js' import type { Msg, Role } from '../types.js'
export function upsert(prev: Msg[], role: Role, text: string): Msg[] { export const upsert = (prev: Msg[], role: Role, text: string): Msg[] =>
return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }]
}

View file

@ -1,3 +1,2 @@
export function writeOsc52Clipboard(s: string): void { export const writeOsc52Clipboard = (s: string) =>
process.stdout.write('\x1b]52;c;' + Buffer.from(s, 'utf8').toString('base64') + '\x07') process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`)
}

View file

@ -2,13 +2,8 @@ import type { CommandDispatchResponse } from '../gatewayTypes.js'
export type RpcResult = Record<string, any> export type RpcResult = Record<string, any>
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null => { export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null =>
if (!value || typeof value !== 'object' || Array.isArray(value)) { !value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T)
return null
}
return value as T
}
export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => { export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => {
const o = asRpcResult(value) const o = asRpcResult(value)
@ -28,24 +23,11 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul
} }
if (t === 'skill' && typeof o.name === 'string') { if (t === 'skill' && typeof o.name === 'string') {
return { return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined }
type: 'skill',
name: o.name,
message: typeof o.message === 'string' ? o.message : undefined
}
} }
return null return null
} }
export const rpcErrorMessage = (err: unknown) => { export const rpcErrorMessage = (err: unknown) =>
if (err instanceof Error && err.message) { err instanceof Error && err.message ? err.message : typeof err === 'string' && err.trim() ? err : 'request failed'
return err.message
}
if (typeof err === 'string' && err.trim()) {
return err
}
return 'request failed'
}

View file

@ -1,12 +1,13 @@
import { THINKING_COT_MAX } from '../config/limits.js' import { THINKING_COT_MAX } from '../config/limits.js'
import type { ThinkingMode } from '../types.js' import type { ThinkingMode } from '../types.js'
// eslint-disable-next-line no-control-regex const ESC = String.fromCharCode(27)
const ANSI_RE = /\x1b\[[0-9;]*m/g 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 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 renderEstimateLine = (line: string) => {
const trimmed = line.trim() const trimmed = line.trim()
@ -38,7 +39,7 @@ const renderEstimateLine = (line: string) => {
} }
export const compactPreview = (s: string, max: number) => { 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 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 estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
export const edgePreview = (s: string, head = 16, tail = 28) => { 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 !one
return '' ? ''
} : one.length <= head + tail + 4
? one
if (one.length <= head + tail + 4) { : `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
return one
}
return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
} }
export const pasteTokenLabel = (text: string, lineCount: number) => { 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) => { export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
const raw = reasoning.trim() const raw = reasoning.trim()
if (!raw || mode === 'collapsed') { return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
return ''
}
if (mode === 'full') {
return raw
}
return compactPreview(raw.replace(/\s+/g, ' '), max)
} }
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text) export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
@ -97,18 +86,18 @@ export const toolTrailLabel = (name: string) =>
.join(' ') || name .join(' ') || name
export const formatToolCall = (name: string, context = '') => { export const formatToolCall = (name: string, context = '') => {
const label = toolTrailLabel(name)
const preview = compactPreview(context, 64) 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) const detail = compactPreview(note ?? '', 72)
return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}` 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 isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
export const parseToolTrailResultLine = (line: string) => { export const parseToolTrailResultLine = (line: string) => {
@ -133,10 +122,8 @@ export const parseToolTrailResultLine = (line: string) => {
return { call: body, detail: '', mark } 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…' 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) => export const sameToolTrailGroup = (label: string, entry: string) =>
entry === `${label}` || entry === `${label}` ||
entry === `${label}` || entry === `${label}` ||
@ -144,7 +131,6 @@ export const sameToolTrailGroup = (label: string, entry: string) =>
entry.startsWith(`${label} ::`) || entry.startsWith(`${label} ::`) ||
entry.startsWith(`${label}:`) entry.startsWith(`${label}:`)
/** Index of the last non-result trail line, or -1. */
export const lastCotTrailIndex = (trail: readonly string[]) => { export const lastCotTrailIndex = (trail: readonly string[]) => {
for (let i = trail.length - 1; i >= 0; i--) { for (let i = trail.length - 1; i >= 0; i--) {
if (!isToolTrailResultLine(trail[i]!)) { if (!isToolTrailResultLine(trail[i]!)) {
@ -168,10 +154,7 @@ export const estimateRows = (text: string, w: number, compact = false) => {
const lang = maybeFence[2]!.trim() const lang = maybeFence[2]!.trim()
if (!fence) { if (!fence) {
fence = { fence = { char: marker[0] as '`' | '~', len: marker.length }
char: marker[0] as '`' | '~',
len: marker.length
}
if (lang) { if (lang) {
rows += Math.ceil((`${lang}`.length || 1) / w) 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<string, string[]>) => Object.values(r).flat() export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' })
maximumFractionDigits: 1,
notation: 'compact'
})
export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase()) export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase())
export const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]! export const pick = <T>(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) /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text)

View file

@ -1,7 +1,3 @@
export const INTERPOLATION_RE = /\{!(.+?)\}/g export const INTERPOLATION_RE = /\{!(.+?)\}/g
export const hasInterpolation = (s: string) => { export const hasInterpolation = (s: string) => /\{!.+?\}/.test(s)
INTERPOLATION_RE.lastIndex = 0
return INTERPOLATION_RE.test(s)
}

View file

@ -1,7 +1,7 @@
export interface ActiveTool { export interface ActiveTool {
context?: string
id: string id: string
name: string name: string
context?: string
startedAt?: number startedAt?: number
} }
@ -36,11 +36,11 @@ export interface ClarifyReq {
} }
export interface Msg { export interface Msg {
info?: SessionInfo
kind?: 'intro' | 'panel' | 'slash' | 'trail'
panelData?: PanelData
role: Role role: Role
text: string text: string
kind?: 'intro' | 'panel' | 'slash' | 'trail'
info?: SessionInfo
panelData?: PanelData
thinking?: string thinking?: string
thinkingTokens?: number thinkingTokens?: number
toolTokens?: number toolTokens?: number
@ -76,6 +76,7 @@ export interface Usage {
export interface SudoReq { export interface SudoReq {
requestId: string requestId: string
} }
export interface SecretReq { export interface SecretReq {
envVar: string envVar: string
prompt: string prompt: string