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

@ -10,6 +10,7 @@ import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
function FaceTicker({ color }: { color: string }) {
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
@ -76,10 +77,8 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
return
}
const options = ['#ff5fa2', '#ff4d6d', t.color.amber]
const picked = options[Math.floor(Math.random() * options.length)]!
setColor(picked)
const palette = [...HEART_COLORS, t.color.amber]
setColor(palette[Math.floor(Math.random() * palette.length)]!)
setActive(true)
const id = setTimeout(() => setActive(false), 650)
@ -102,19 +101,7 @@ export function StatusRule({
sessionStartedAt,
voiceLabel,
t
}: {
cwdLabel: string
cols: number
busy: boolean
status: string
statusColor: string
model: string
usage: Usage
bgCount: number
sessionStartedAt?: number | null
voiceLabel?: string
t: Theme
}) {
}: StatusRuleProps) {
const pct = usage.context_percent
const barColor = ctxBarColor(pct, t)
@ -124,7 +111,6 @@ export function StatusRule({
? `${fmtK(usage.total)} tok`
: ''
const pctLabel = pct != null ? `${pct}%` : ''
const bar = usage.context_max ? ctxBar(pct) : ''
const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
@ -139,7 +125,7 @@ export function StatusRule({
{bar ? (
<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>
) : null}
{sessionStartedAt ? (
@ -152,6 +138,7 @@ export function StatusRule({
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
</Text>
</Box>
<Text color={t.color.bronze}> </Text>
<Text color={t.color.label}>{cwdLabel}</Text>
</Box>
@ -174,17 +161,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
)
}
export function StickyPromptTracker({
messages,
offsets,
scrollRef,
onChange
}: {
messages: readonly Msg[]
offsets: ArrayLike<number>
scrollRef: RefObject<ScrollBoxHandle | null>
onChange: (text: string) => void
}) {
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => {
@ -210,13 +187,9 @@ export function StickyPromptTracker({
return null
}
export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<ScrollBoxHandle | null>; t: Theme }) {
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
// Quantize the scroll snapshot to the values the thumb actually renders
// with — thumbTop + thumbSize + viewport height. Streaming drives
// scrollHeight up by ~1 row at a time, but the quantized thumb usually
// doesn't move, so we skip thousands of render cycles mid-turn.
() => {
const s = scrollRef.current
@ -304,3 +277,29 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
</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,
progress,
t
}: {
busy: boolean
cols: number
compact?: boolean
detailsMode: DetailsMode
progress: AppLayoutProgressProps
t: Theme
}) {
}: StreamingAssistantProps) {
if (!progress.showProgressArea && !progress.showStreamingArea) {
return null
}
return (
<Box flexDirection="column">
<>
{progress.showProgressArea && (
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
<ToolTrail
@ -67,7 +60,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
t={t}
/>
)}
</Box>
</>
)
})
@ -79,15 +72,13 @@ const TranscriptPane = memo(function TranscriptPane({
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState)
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
return (
<>
<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}
{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)}>
{row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}>
@ -234,6 +225,7 @@ const ComposerPane = memo(function ComposerPane({
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
/>
<Box position="absolute" right={0}>
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
</Box>
@ -267,3 +259,12 @@ export const AppLayout = memo(function AppLayout({
</AlternateScreen>
)
})
interface StreamingAssistantProps {
busy: boolean
cols: number
compact?: boolean
detailsMode: DetailsMode
progress: AppLayoutProgressProps
t: Theme
}

View file

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

View file

@ -20,11 +20,10 @@ export function ArtLines({ lines }: { lines: [string, string][] }) {
export function Banner({ t }: { t: Theme }) {
const cols = useStdout().stdout?.columns ?? 80
const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
return (
<Box flexDirection="column" marginBottom={1}>
{cols >= logoW ? (
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
<ArtLines lines={logoLines} />
) : (
<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 heroLines = caduceus(t.color, t.bannerHero || undefined)
const heroW = artWidth(heroLines) || CADUCEUS_WIDTH
const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4))
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
const wide = cols >= 90 && leftW + 40 < cols
// Keep an explicit gutter so right border never gets overwritten by long lines.
const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
const lineBudget = Math.max(12, w - 2)
const cwd = info.cwd || process.cwd()
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}`
const truncLine = (pfx: string, items: string[]) => {
let line = ''
@ -78,12 +73,14 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
<Text bold color={t.color.amber}>
Available {title}
</Text>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.dim}>{strip(k)}: </Text>
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && (
<Text color={t.color.dim}>
(and {overflow} {overflowLabel})
@ -99,13 +96,16 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
<Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={heroLines} />
<Text />
<Text color={t.color.amber}>
{info.model.split('/').pop()}
<Text color={t.color.dim}> · Nous Research</Text>
</Text>
<Text color={t.color.dim} wrap="truncate-end">
{cwd}
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
@ -114,21 +114,27 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
)}
</Box>
)}
<Box flexDirection="column" width={w}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.gold}>
{title}
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
{section('Tools', info.tools, 8, 'more toolsets…')}
{section('Skills', info.skills)}
<Text />
<Text color={t.color.cornsilk}>
{flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills
{' · '}
<Text color={t.color.dim}>/help for commands</Text>
</Text>
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color="yellow">
! {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 (
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<Box justifyContent="center" marginBottom={1}>
@ -186,3 +192,15 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The
</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'
export function MaskedPrompt({
cols = 80,
icon,
label,
onSubmit,
sub,
t
}: {
cols?: number
icon: string
label: string
onSubmit: (v: string) => void
sub?: string
t: Theme
}) {
export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) {
const [value, setValue] = useState('')
return (
@ -27,6 +13,7 @@ export function MaskedPrompt({
<Text bold color={t.color.warn}>
{icon} {label}
</Text>
{sub && <Text color={t.color.dim}> {sub}</Text>}
<Box>
@ -36,3 +23,12 @@ export function MaskedPrompt({
</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,
msg,
t
}: {
cols: number
compact?: boolean
detailsMode?: DetailsMode
isStreaming?: boolean
msg: Msg
t: Theme
}) {
}: MessageLineProps) {
if (msg.kind === 'trail' && msg.tools?.length) {
return detailsMode === 'hidden' ? null : (
<Box flexDirection="column" marginTop={1}>
@ -110,3 +103,12 @@ export const MessageLine = memo(function MessageLine({
</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))
export function ModelPicker({
gw,
onCancel,
onSelect,
sessionId,
t
}: {
gw: GatewayClient
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null
t: Theme
}) {
const visibleItems = (items: string[], sel: number) => {
const off = pageOffset(items.length, sel)
return { items: items.slice(off, off + VISIBLE), off }
}
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('')
const [err, setErr] = useState('')
@ -66,12 +60,6 @@ export function ModelPicker({
const provider = providers[providerIdx]
const models = provider?.models ?? []
const visibleItems = (items: string[], sel: number) => {
const off = pageOffset(items.length, sel)
return { items: items.slice(off, off + VISIBLE), off }
}
useInput((ch, key) => {
if (key.escape) {
if (stage === 'model') {
@ -182,9 +170,11 @@ export function ModelPicker({
<Text bold color={t.color.amber}>
Select Provider
</Text>
<Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text>
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
{off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{items.map((row, i) => {
const idx = off + i
@ -195,6 +185,7 @@ export function ModelPicker({
</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}>/ select · Enter choose · 1-9,0 quick · Esc cancel</Text>
@ -209,10 +200,12 @@ export function ModelPicker({
<Text bold color={t.color.amber}>
Select Model
</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}
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
{off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{items.map((row, i) => {
const idx = off + i
@ -223,6 +216,7 @@ export function ModelPicker({
</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}>
@ -231,3 +225,11 @@ export function ModelPicker({
</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'
export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) {
const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(3)
const opts = ['once', 'session', 'always', 'deny'] as const
const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
useInput((ch, key) => {
if (key.upArrow && sel > 0) {
@ -21,7 +22,7 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
}
if (key.return) {
onChoice(opts[sel]!)
onChoice(OPTS[sel]!)
}
if (ch === 'o') {
@ -46,34 +47,25 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
<Text bold color={t.color.warn}>
! DANGEROUS COMMAND: {req.description}
</Text>
<Text color={t.color.dim}> {req.command}</Text>
<Text />
{opts.map((o, i) => (
{OPTS.map((o, i) => (
<Text key={o}>
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
[{o[0]}] {labels[o]}
[{o[0]}] {LABELS[o]}
</Text>
</Text>
))}
<Text color={t.color.dim}>/ select · Enter confirm · o/s/a/d quick pick</Text>
</Box>
)
}
export function ClarifyPrompt({
cols = 80,
onAnswer,
onCancel,
req,
t
}: {
cols?: number
onAnswer: (s: string) => void
onCancel: () => void
req: ClarifyReq
t: Theme
}) {
export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) {
const [sel, setSel] = useState(0)
const [custom, setCustom] = useState('')
const [typing, setTyping] = useState(false)
@ -117,8 +109,6 @@ export function ClarifyPrompt({
})
if (typing || !choices.length) {
const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel'
return (
<Box flexDirection="column">
{heading}
@ -128,7 +118,7 @@ export function ClarifyPrompt({
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
</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>
)
}
@ -150,3 +140,17 @@ export function ClarifyPrompt({
</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 }
}
export function QueuedMessages({
cols,
queueEditIdx,
queued,
t
}: {
cols: number
queueEditIdx: number | null
queued: string[]
t: Theme
}) {
export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) {
if (!queued.length) {
return null
}
@ -36,12 +26,14 @@ export function QueuedMessages({
<Text color={t.color.dim} dimColor>
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
</Text>
{q.showLead && (
<Text color={t.color.dim} dimColor>
{' '}
</Text>
)}
{queued.slice(q.start, q.end).map((item, i) => {
const idx = q.start + i
const active = queueEditIdx === idx
@ -52,6 +44,7 @@ export function QueuedMessages({
</Text>
)
})}
{q.showTail && (
<Text color={t.color.dim} dimColor>
{' '}and {queued.length - q.end} more
@ -60,3 +53,10 @@ export function QueuedMessages({
</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 type { Theme } from '../theme.js'
function age(ts: number): string {
const VISIBLE = 15
const age = (ts: number) => {
const d = (Date.now() / 1000 - ts) / 86400
if (d < 1) {
@ -20,19 +22,7 @@ function age(ts: number): string {
return `${Math.floor(d)}d ago`
}
const VISIBLE = 15
export function SessionPicker({
gw,
onCancel,
onSelect,
t
}: {
gw: GatewayClient
onCancel: () => void
onSelect: (id: string) => void
t: Theme
}) {
export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) {
const [items, setItems] = useState<SessionListItem[]>([])
const [err, setErr] = useState('')
const [sel, setSel] = useState(0)
@ -107,36 +97,48 @@ export function SessionPicker({
}
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
const visible = items.slice(off, off + VISIBLE)
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
Resume Session
</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
return (
<Box key={s.id}>
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Box width={30}>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
{String(i + 1).padStart(2)}. [{s.id}]
</Text>
</Box>
<Box width={30}>
<Text color={t.color.dim}>
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
</Text>
</Box>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>{s.title || s.preview || '(untitled)'}</Text>
</Box>
)
})}
{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>
</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 { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
// ── ANSI escapes ─────────────────────────────────────────────────────
const ESC = '\x1b'
const INV = `${ESC}[7m`
const INV_OFF = `${ESC}[27m`
@ -25,8 +23,6 @@ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
const invert = (s: string) => INV + s + INV_OFF
const dim = (s: string) => DIM + s + DIM_OFF
// ── Grapheme segmenter (lazy singleton) ──────────────────────────────
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
const STOP_CACHE_MAX = 32
@ -106,8 +102,6 @@ function nextPos(s: string, p: number) {
return s.length
}
// ── Word movement ────────────────────────────────────────────────────
function wordLeft(s: string, p: number) {
let i = snapPos(s, p) - 1
@ -136,8 +130,6 @@ function wordRight(s: string, p: number) {
return i
}
// ── Cursor layout (line/column from offset + terminal width) ─────────
function cursorLayout(value: string, cursor: number, cols: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
const w = Math.max(1, cols - 1)
@ -226,8 +218,6 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
return lastOffset
}
// ── Render value with inverse-video cursor ───────────────────────────
function renderWithCursor(value: string, cursor: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
@ -250,8 +240,6 @@ function renderWithCursor(value: string, cursor: number) {
return done ? out : out + invert(' ')
}
// ── Forward-delete detection hook ────────────────────────────────────
function useFwdDelete(active: boolean) {
const ref = useRef(false)
const { inputEmitter: ee } = useStdin()
@ -275,29 +263,6 @@ function useFwdDelete(active: boolean) {
return ref
}
// ── Types ────────────────────────────────────────────────────────────
export interface PasteEvent {
bracketed?: boolean
cursor: number
hotkey?: boolean
text: string
value: string
}
interface Props {
columns?: number
value: string
onChange: (v: string) => void
onSubmit?: (v: string) => void
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
mask?: string
placeholder?: string
focus?: boolean
}
// ── Component ────────────────────────────────────────────────────────
export function TextInput({
columns = 80,
value,
@ -307,7 +272,7 @@ export function TextInput({
mask,
placeholder = '',
focus = true
}: Props) {
}: TextInputProps) {
const [cur, setCur] = useState(value.length)
const fwdDel = useFwdDelete(focus)
const termFocus = useTerminalFocus()
@ -331,8 +296,6 @@ export function TextInput({
const raw = self.current ? vRef.current : value
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
// ── Cursor declaration ───────────────────────────────────────────
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
const boxRef = useDeclaredCursor({
@ -353,18 +316,6 @@ export function TextInput({
return renderWithCursor(display, cur)
}, [cur, display, focus, placeholder])
const clickCursor = (e: { localRow?: number; localCol?: number }) => {
if (!focus) {
return
}
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
setCur(next)
curRef.current = next
}
// ── Sync external value changes ──────────────────────────────────
useEffect(() => {
if (self.current) {
self.current = false
@ -386,8 +337,6 @@ export function TextInput({
[]
)
// ── Buffer ops (synchronous, ref-based) ──────────────────────────
const commit = (next: string, nextCur: number, track = true) => {
const prev = vRef.current
const c = snapPos(next, nextCur)
@ -450,18 +399,14 @@ export function TextInput({
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
// ── Input handler ────────────────────────────────────────────────
useInput(
(inp: string, k: Key, event: InputEvent) => {
const raw = event.keypress.raw
const metaPaste = raw === '\x1bv' || raw === '\x1bV'
const eventRaw = event.keypress.raw
if (metaPaste) {
if (eventRaw === '\x1bv' || eventRaw === '\x1bV') {
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
}
// Delegated to App
if (
k.upArrow ||
k.downArrow ||
@ -487,7 +432,6 @@ export function TextInput({
let v = vRef.current
const mod = k.ctrl || k.meta
// Undo / redo
if (k.ctrl && inp === 'z') {
return swap(undo, redo)
}
@ -496,7 +440,6 @@ export function TextInput({
return swap(redo, undo)
}
// Navigation
if (k.home || (k.ctrl && inp === 'a')) {
c = 0
} else if (k.end || (k.ctrl && inp === 'e')) {
@ -509,10 +452,7 @@ export function TextInput({
c = wordLeft(v, c)
} else if (k.meta && inp === 'f') {
c = wordRight(v, c)
}
// Deletion
else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
} else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
if (mod) {
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
@ -538,31 +478,28 @@ export function TextInput({
c = 0
} else if (k.ctrl && inp === 'k') {
v = v.slice(0, c)
}
// Text insertion / paste buffering
else if (inp.length > 0) {
} else if (inp.length > 0) {
const bracketed = inp.includes('[200~')
const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) {
if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) {
return
}
if (!raw) {
if (!text) {
return
}
if (raw === '\n') {
if (text === '\n') {
return commit(ins(v, c, '\n'), c + 1)
}
if (raw.length > 1 || raw.includes('\n')) {
if (text.length > 1 || text.includes('\n')) {
if (!pasteBuf.current) {
pastePos.current = c
}
pasteBuf.current += raw
pasteBuf.current += text
if (pasteTimer.current) {
clearTimeout(pasteTimer.current)
@ -573,9 +510,9 @@ export function TextInput({
return
}
if (PRINTABLE.test(raw)) {
v = v.slice(0, c) + raw + v.slice(c)
c += raw.length
if (PRINTABLE.test(text)) {
v = v.slice(0, c) + text + v.slice(c)
c += text.length
} else {
return
}
@ -588,11 +525,39 @@ export function TextInput({
{ isActive: focus }
)
// ── Render ───────────────────────────────────────────────────────
return (
<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>
</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 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 interface FgProps {
@ -18,28 +28,3 @@ export interface FgProps {
underline?: boolean
wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim'
}
/**
* Theme-aware text. `literal` wins; otherwise `c` is a palette key.
*
* <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>
)
}