2026-04-15 10:20:56 -05:00
|
|
|
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
2026-04-14 22:30:18 -05:00
|
|
|
import { useStore } from '@nanostores/react'
|
2026-04-15 14:14:01 -05:00
|
|
|
import { memo } from 'react'
|
2026-04-14 22:30:18 -05:00
|
|
|
|
2026-04-16 20:49:41 -05:00
|
|
|
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
|
2026-04-14 22:30:18 -05:00
|
|
|
import { $isBlocked } from '../app/overlayStore.js'
|
|
|
|
|
import { $uiState } from '../app/uiStore.js'
|
2026-04-16 12:18:56 -05:00
|
|
|
import { PLACEHOLDER } from '../content/placeholders.js'
|
2026-04-16 20:49:41 -05:00
|
|
|
import type { Theme } from '../theme.js'
|
|
|
|
|
import type { DetailsMode } from '../types.js'
|
2026-04-14 22:30:18 -05:00
|
|
|
|
2026-04-15 17:43:38 -05:00
|
|
|
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
2026-04-17 10:37:48 -05:00
|
|
|
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
2026-04-14 22:30:18 -05:00
|
|
|
import { Banner, Panel, SessionPanel } from './branding.js'
|
|
|
|
|
import { MessageLine } from './messageLine.js'
|
|
|
|
|
import { QueuedMessages } from './queuedMessages.js'
|
|
|
|
|
import { TextInput } from './textInput.js'
|
|
|
|
|
import { ToolTrail } from './thinking.js'
|
|
|
|
|
|
2026-04-16 20:49:41 -05:00
|
|
|
const StreamingAssistant = memo(function StreamingAssistant({
|
|
|
|
|
busy,
|
|
|
|
|
cols,
|
|
|
|
|
compact,
|
|
|
|
|
detailsMode,
|
|
|
|
|
progress,
|
|
|
|
|
t
|
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
2026-04-16 22:32:53 -05:00
|
|
|
}: StreamingAssistantProps) {
|
2026-04-16 21:09:50 -05:00
|
|
|
if (!progress.showProgressArea && !progress.showStreamingArea) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
2026-04-16 20:49:41 -05:00
|
|
|
|
|
|
|
|
return (
|
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
2026-04-16 22:32:53 -05:00
|
|
|
<>
|
2026-04-16 20:49:41 -05:00
|
|
|
{progress.showProgressArea && (
|
|
|
|
|
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
|
|
|
|
|
<ToolTrail
|
|
|
|
|
activity={progress.activity}
|
|
|
|
|
busy={busy}
|
|
|
|
|
detailsMode={detailsMode}
|
2026-04-17 10:37:48 -05:00
|
|
|
outcome={progress.outcome}
|
2026-04-16 20:49:41 -05:00
|
|
|
reasoning={progress.reasoning}
|
|
|
|
|
reasoningActive={progress.reasoningActive}
|
|
|
|
|
reasoningStreaming={progress.reasoningStreaming}
|
|
|
|
|
reasoningTokens={progress.reasoningTokens}
|
|
|
|
|
subagents={progress.subagents}
|
|
|
|
|
t={t}
|
|
|
|
|
tools={progress.tools}
|
|
|
|
|
toolTokens={progress.toolTokens}
|
|
|
|
|
trail={progress.turnTrail}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{progress.showStreamingArea && (
|
|
|
|
|
<MessageLine
|
|
|
|
|
cols={cols}
|
|
|
|
|
compact={compact}
|
|
|
|
|
detailsMode={detailsMode}
|
|
|
|
|
isStreaming
|
|
|
|
|
msg={{ role: 'assistant', text: progress.streaming }}
|
|
|
|
|
t={t}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
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
2026-04-16 22:32:53 -05:00
|
|
|
</>
|
2026-04-16 20:49:41 -05:00
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-15 14:14:01 -05:00
|
|
|
const TranscriptPane = memo(function TranscriptPane({
|
|
|
|
|
actions,
|
|
|
|
|
composer,
|
|
|
|
|
progress,
|
|
|
|
|
transcript
|
|
|
|
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
2026-04-14 22:30:18 -05:00
|
|
|
const ui = useStore($uiState)
|
2026-04-15 16:34:58 -05:00
|
|
|
|
2026-04-14 22:30:18 -05:00
|
|
|
return (
|
2026-04-15 14:14:01 -05:00
|
|
|
<>
|
|
|
|
|
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
|
|
|
|
<Box flexDirection="column" paddingX={1}>
|
|
|
|
|
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
|
|
|
|
|
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
2026-04-16 22:32:53 -05:00
|
|
|
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
|
2026-04-15 14:14:01 -05:00
|
|
|
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
2026-04-16 14:58:12 -05:00
|
|
|
{row.msg.kind === 'intro' ? (
|
2026-04-15 14:14:01 -05:00
|
|
|
<Box flexDirection="column" paddingTop={1}>
|
|
|
|
|
<Banner t={ui.theme} />
|
2026-04-15 16:34:58 -05:00
|
|
|
|
perf(tui): async session.create — sid live in ~250ms instead of ~1350ms
Previously `session.create` blocked for ~1.2s on `_make_agent` (mostly
`run_agent` transitive imports + AIAgent constructor). The UI waited
through that whole window before sid became known and the banner/panel
could render.
Now `session.create` returns immediately with `{session_id, info:
{model, cwd, tools:{}, skills:{}}}` and spawns a background thread that
does the real `_make_agent` + `_init_session`. When the agent is live,
the thread emits `session.info` with the full payload.
Python side:
- `_sessions[sid]` gets a placeholder dict with `agent=None` and a
`threading.Event()` named `agent_ready`
- `_wait_agent(session, rid, timeout=30)` blocks until the event is set
(no-op when already set or absent, e.g. for `session.resume`)
- `_sess()` now calls `_wait_agent` — so every handler routed through it
(prompt.submit, session.usage, session.compress, session.branch,
rollback.*, tools.configure, etc.) automatically holds until the agent
is live, but only during the ~1s startup window
- `terminal.resize` and `input.detect_drop` bypass the wait via direct
dict lookup — they don't touch the agent and would otherwise block
the first post-startup RPCs unnecessarily
TS side:
- `session.info` event handler now patches the intro message's `info`
in-place so the seeded banner upgrades to the full session panel when
the agent finishes initializing
- `appLayout` gates `SessionPanel` on `info.version` being present
(only set by `_session_info(agent)`, not by the partial payload from
`session.create`) — so the panel only appears when real data arrives
Net effect on cold start:
T=~400ms banner paints (seeded intro)
T=~245ms ui.sid set (session.create responds in ~1ms after ready)
T=~1400ms session panel fills in (real session.info event)
Pre-session keystrokes queue as before (already handled by the flush
effect); `prompt.submit` will wait on `agent_ready` on the Python side
when the flush tries to send before the agent is live.
2026-04-16 15:39:19 -05:00
|
|
|
{row.msg.info?.version && <SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />}
|
2026-04-14 22:30:18 -05:00
|
|
|
</Box>
|
2026-04-15 14:14:01 -05:00
|
|
|
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
|
|
|
|
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
|
|
|
|
|
) : (
|
2026-04-14 22:30:18 -05:00
|
|
|
<MessageLine
|
|
|
|
|
cols={composer.cols}
|
|
|
|
|
compact={ui.compact}
|
|
|
|
|
detailsMode={ui.detailsMode}
|
2026-04-15 14:14:01 -05:00
|
|
|
msg={row.msg}
|
2026-04-14 22:30:18 -05:00
|
|
|
t={ui.theme}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
2026-04-15 14:14:01 -05:00
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
|
|
|
|
|
|
2026-04-16 20:49:41 -05:00
|
|
|
<StreamingAssistant
|
|
|
|
|
busy={ui.busy}
|
|
|
|
|
cols={composer.cols}
|
|
|
|
|
compact={ui.compact}
|
|
|
|
|
detailsMode={ui.detailsMode}
|
|
|
|
|
progress={progress}
|
|
|
|
|
t={ui.theme}
|
|
|
|
|
/>
|
2026-04-14 22:30:18 -05:00
|
|
|
</Box>
|
2026-04-15 14:14:01 -05:00
|
|
|
</ScrollBox>
|
|
|
|
|
|
|
|
|
|
<NoSelect flexShrink={0} marginLeft={1}>
|
|
|
|
|
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
|
|
|
|
|
</NoSelect>
|
|
|
|
|
|
|
|
|
|
<StickyPromptTracker
|
|
|
|
|
messages={transcript.historyItems}
|
|
|
|
|
offsets={transcript.virtualHistory.offsets}
|
|
|
|
|
onChange={actions.setStickyPrompt}
|
|
|
|
|
scrollRef={transcript.scrollRef}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const ComposerPane = memo(function ComposerPane({
|
|
|
|
|
actions,
|
|
|
|
|
composer,
|
|
|
|
|
status
|
|
|
|
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
|
|
|
|
|
const ui = useStore($uiState)
|
|
|
|
|
const isBlocked = useStore($isBlocked)
|
2026-04-15 16:34:58 -05:00
|
|
|
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
|
|
|
|
const pw = sh ? 2 : 3
|
|
|
|
|
|
2026-04-15 14:14:01 -05:00
|
|
|
return (
|
|
|
|
|
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
|
|
|
|
<QueuedMessages
|
|
|
|
|
cols={composer.cols}
|
|
|
|
|
queued={composer.queuedDisplay}
|
|
|
|
|
queueEditIdx={composer.queueEditIdx}
|
|
|
|
|
t={ui.theme}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{ui.bgTasks.size > 0 && (
|
2026-04-16 12:18:56 -05:00
|
|
|
<Text color={ui.theme.color.dim}>
|
2026-04-15 14:14:01 -05:00
|
|
|
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{status.showStickyPrompt ? (
|
2026-04-16 12:18:56 -05:00
|
|
|
<Text color={ui.theme.color.dim} wrap="truncate-end">
|
|
|
|
|
<Text color={ui.theme.color.label}>↳ </Text>
|
2026-04-15 16:34:58 -05:00
|
|
|
|
2026-04-15 14:14:01 -05:00
|
|
|
{status.stickyPrompt}
|
|
|
|
|
</Text>
|
|
|
|
|
) : (
|
|
|
|
|
<Text> </Text>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Box flexDirection="column" position="relative">
|
|
|
|
|
{ui.statusBar && (
|
|
|
|
|
<StatusRule
|
|
|
|
|
bgCount={ui.bgTasks.size}
|
feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel
The status bar was showing stale lifecycle text ("running…") while the
face+verb stream flickered through the thinking panel as Python pushed
thinking.delta events. That's backwards — the face ticker is the
primary "I'm alive" signal, it belongs in the status bar; the thinking
panel is for substantive reasoning and tool activity.
Status bar now reads `ui.busy`: when true, renders a local `<FaceTicker>`
cycling FACES × VERBS on a 2.5s interval, unaffected by server events.
When false, the bar shows the actual status string (ready, starting
agent…, interrupted, etc.).
Side effect: `scheduleThinkingStatus` still patches `ui.status` with
Python's face text, but while busy the bar ignores that string and uses
the ticker instead. No server-side changes needed — Python keeps
emitting thinking.delta as a liveness heartbeat, the TUI just doesn't
let it fight the status bar.
2026-04-16 20:14:25 -05:00
|
|
|
busy={ui.busy}
|
2026-04-14 22:30:18 -05:00
|
|
|
cols={composer.cols}
|
2026-04-15 14:14:01 -05:00
|
|
|
cwdLabel={status.cwdLabel}
|
|
|
|
|
model={ui.info?.model?.split('/').pop() ?? ''}
|
|
|
|
|
sessionStartedAt={status.sessionStartedAt}
|
|
|
|
|
status={ui.status}
|
|
|
|
|
statusColor={status.statusColor}
|
2026-04-14 22:30:18 -05:00
|
|
|
t={ui.theme}
|
2026-04-15 14:14:01 -05:00
|
|
|
usage={ui.usage}
|
|
|
|
|
voiceLabel={status.voiceLabel}
|
2026-04-14 22:30:18 -05:00
|
|
|
/>
|
2026-04-15 14:14:01 -05:00
|
|
|
)}
|
|
|
|
|
|
2026-04-17 10:37:48 -05:00
|
|
|
<FloatingOverlays
|
2026-04-15 14:14:01 -05:00
|
|
|
cols={composer.cols}
|
|
|
|
|
compIdx={composer.compIdx}
|
|
|
|
|
completions={composer.completions}
|
|
|
|
|
onModelSelect={actions.onModelSelect}
|
|
|
|
|
onPickerSelect={actions.resumeById}
|
|
|
|
|
pagerPageSize={composer.pagerPageSize}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
2026-04-14 22:30:18 -05:00
|
|
|
|
2026-04-15 14:14:01 -05:00
|
|
|
{!isBlocked && (
|
|
|
|
|
<Box flexDirection="column" marginBottom={1}>
|
|
|
|
|
{composer.inputBuf.map((line, i) => (
|
|
|
|
|
<Box key={i}>
|
|
|
|
|
<Box width={3}>
|
2026-04-16 12:18:56 -05:00
|
|
|
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
2026-04-15 14:14:01 -05:00
|
|
|
</Box>
|
2026-04-14 22:30:18 -05:00
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
|
2026-04-15 14:14:01 -05:00
|
|
|
</Box>
|
|
|
|
|
))}
|
2026-04-14 22:30:18 -05:00
|
|
|
|
2026-04-15 17:43:38 -05:00
|
|
|
<Box position="relative">
|
2026-04-15 16:34:58 -05:00
|
|
|
<Box width={pw}>
|
|
|
|
|
{sh ? (
|
2026-04-16 12:18:56 -05:00
|
|
|
<Text color={ui.theme.color.shellDollar}>$ </Text>
|
2026-04-15 16:34:58 -05:00
|
|
|
) : (
|
feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel
The status bar was showing stale lifecycle text ("running…") while the
face+verb stream flickered through the thinking panel as Python pushed
thinking.delta events. That's backwards — the face ticker is the
primary "I'm alive" signal, it belongs in the status bar; the thinking
panel is for substantive reasoning and tool activity.
Status bar now reads `ui.busy`: when true, renders a local `<FaceTicker>`
cycling FACES × VERBS on a 2.5s interval, unaffected by server events.
When false, the bar shows the actual status string (ready, starting
agent…, interrupted, etc.).
Side effect: `scheduleThinkingStatus` still patches `ui.status` with
Python's face text, but while busy the bar ignores that string and uses
the ticker instead. No server-side changes needed — Python keeps
emitting thinking.delta as a liveness heartbeat, the TUI just doesn't
let it fight the status bar.
2026-04-16 20:14:25 -05:00
|
|
|
<Text bold color={ui.theme.color.prompt}>
|
2026-04-15 16:34:58 -05:00
|
|
|
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
2026-04-15 14:14:01 -05:00
|
|
|
</Box>
|
|
|
|
|
|
2026-04-15 17:43:38 -05:00
|
|
|
<Box flexGrow={1} position="relative">
|
|
|
|
|
<TextInput
|
|
|
|
|
columns={Math.max(20, composer.cols - pw)}
|
|
|
|
|
onChange={composer.updateInput}
|
|
|
|
|
onPaste={composer.handleTextPaste}
|
|
|
|
|
onSubmit={composer.submit}
|
|
|
|
|
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
|
|
|
|
value={composer.input}
|
|
|
|
|
/>
|
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
2026-04-16 22:32:53 -05:00
|
|
|
|
2026-04-15 17:43:38 -05:00
|
|
|
<Box position="absolute" right={0}>
|
|
|
|
|
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
2026-04-14 22:30:18 -05:00
|
|
|
</Box>
|
2026-04-15 14:14:01 -05:00
|
|
|
</Box>
|
|
|
|
|
)}
|
2026-04-14 22:30:18 -05:00
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}>⚕ {ui.status}</Text>}
|
2026-04-15 14:14:01 -05:00
|
|
|
</NoSelect>
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
export const AppLayout = memo(function AppLayout({
|
|
|
|
|
actions,
|
|
|
|
|
composer,
|
|
|
|
|
mouseTracking,
|
|
|
|
|
progress,
|
|
|
|
|
status,
|
|
|
|
|
transcript
|
|
|
|
|
}: AppLayoutProps) {
|
|
|
|
|
return (
|
|
|
|
|
<AlternateScreen mouseTracking={mouseTracking}>
|
|
|
|
|
<Box flexDirection="column" flexGrow={1}>
|
|
|
|
|
<Box flexDirection="row" flexGrow={1}>
|
|
|
|
|
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
|
|
|
|
</Box>
|
2026-04-14 22:30:18 -05:00
|
|
|
|
2026-04-17 10:37:48 -05:00
|
|
|
<PromptZone
|
|
|
|
|
cols={composer.cols}
|
|
|
|
|
onApprovalChoice={actions.answerApproval}
|
|
|
|
|
onClarifyAnswer={actions.answerClarify}
|
|
|
|
|
onSecretSubmit={actions.answerSecret}
|
|
|
|
|
onSudoSubmit={actions.answerSudo}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-04-15 14:14:01 -05:00
|
|
|
<ComposerPane actions={actions} composer={composer} status={status} />
|
2026-04-14 22:30:18 -05:00
|
|
|
</Box>
|
|
|
|
|
</AlternateScreen>
|
|
|
|
|
)
|
2026-04-15 14:14:01 -05:00
|
|
|
})
|
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
2026-04-16 22:32:53 -05:00
|
|
|
|
|
|
|
|
interface StreamingAssistantProps {
|
|
|
|
|
busy: boolean
|
|
|
|
|
cols: number
|
|
|
|
|
compact?: boolean
|
|
|
|
|
detailsMode: DetailsMode
|
|
|
|
|
progress: AppLayoutProgressProps
|
|
|
|
|
t: Theme
|
|
|
|
|
}
|