feat: just more cleaning

This commit is contained in:
Brooklyn Nicholson 2026-04-15 14:14:01 -05:00
parent 46cef4b7fa
commit 4b4b4d47bc
24 changed files with 2852 additions and 829 deletions

View file

@ -1,7 +1,7 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { stickyPromptFromViewport } from '../app/helpers.js'
import { fmtDuration, stickyPromptFromViewport } from '../app/helpers.js'
import { fmtK } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
@ -33,6 +33,19 @@ function ctxBar(pct: number | undefined, w = 10) {
return '█'.repeat(filled) + '░'.repeat(w - filled)
}
function SessionDuration({ startedAt }: { startedAt: number }) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [startedAt])
return fmtDuration(now - startedAt)
}
export function StatusRule({
cwdLabel,
cols,
@ -41,7 +54,7 @@ export function StatusRule({
model,
usage,
bgCount,
durationLabel,
sessionStartedAt,
voiceLabel,
t
}: {
@ -52,7 +65,7 @@ export function StatusRule({
model: string
usage: Usage
bgCount: number
durationLabel?: string
sessionStartedAt?: number | null
voiceLabel?: string
t: Theme
}) {
@ -83,7 +96,12 @@ export function StatusRule({
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
</Text>
) : null}
{durationLabel ? <Text color={t.color.dim}> {durationLabel}</Text> : null}
{sessionStartedAt ? (
<Text color={t.color.dim}>
{' │ '}
<SessionDuration startedAt={sessionStartedAt} />
</Text>
) : null}
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
</Text>
@ -177,6 +195,8 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
const travel = Math.max(1, vp - thumb)
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const thumbColor = grab !== null ? t.color.gold : hover ? t.color.amber : t.color.bronze
const trackColor = hover ? t.color.bronze : t.color.dim
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
@ -203,25 +223,27 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
onMouseUp={() => setGrab(null)}
width={1}
>
{Array.from({ length: vp }, (_, i) => {
const active = i >= thumbTop && i < thumbTop + thumb
const color = active
? grab !== null
? t.color.gold
: hover
? t.color.amber
: t.color.bronze
: hover
? t.color.bronze
: t.color.dim
return (
<Text color={color} dimColor={!active && !hover} key={i}>
{scrollable ? (active ? '┃' : '│') : ' '}
</Text>
)
})}
{!scrollable ? (
<Text color={trackColor} dimColor>
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dimColor={!hover}>
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
</Text>
) : null}
{thumb > 0 ? (
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
) : null}
{vp - thumbTop - thumb > 0 ? (
<Text color={trackColor} dimColor={!hover}>
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
</Text>
) : null}
</>
)}
</Box>
)
}

View file

@ -1,5 +1,6 @@
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { memo } from 'react'
import { PLACEHOLDER } from '../app/constants.js'
import type { AppLayoutProps } from '../app/interfaces.js'
@ -14,172 +15,203 @@ import { QueuedMessages } from './queuedMessages.js'
import { TextInput } from './textInput.js'
import { ToolTrail } from './thinking.js'
export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) {
const TranscriptPane = memo(function TranscriptPane({
actions,
composer,
progress,
transcript
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
return (
<AlternateScreen mouseTracking={mouseTracking}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}>
<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}
<>
<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 => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.kind === 'intro' && row.msg.info ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
<SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />
</Box>
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
) : (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
msg={row.msg}
t={ui.theme}
/>
)}
{visibleHistory.map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.kind === 'intro' && row.msg.info ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
<SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />
</Box>
))}
{transcript.virtualHistory.bottomSpacer > 0 ? (
<Box height={transcript.virtualHistory.bottomSpacer} />
) : null}
{progress.showProgressArea && (
<ToolTrail
activity={progress.activity}
busy={ui.busy && !progress.streaming}
detailsMode={ui.detailsMode}
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
reasoningTokens={progress.reasoningTokens}
t={ui.theme}
tools={progress.tools}
toolTokens={progress.toolTokens}
trail={progress.turnTrail}
/>
)}
{progress.showStreamingArea && (
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
) : (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
isStreaming
msg={{ role: 'assistant', text: progress.streaming }}
msg={row.msg}
t={ui.theme}
/>
)}
</Box>
</ScrollBox>
))}
<NoSelect flexShrink={0} marginLeft={1}>
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
</NoSelect>
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
<StickyPromptTracker
messages={transcript.historyItems}
offsets={transcript.virtualHistory.offsets}
onChange={actions.setStickyPrompt}
scrollRef={transcript.scrollRef}
/>
</Box>
<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 && (
<Text color={ui.theme.color.dim as any}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
{progress.showProgressArea && (
<ToolTrail
activity={progress.activity}
busy={ui.busy && !progress.streaming}
detailsMode={ui.detailsMode}
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
reasoningTokens={progress.reasoningTokens}
subagents={progress.subagents}
t={ui.theme}
tools={progress.tools}
toolTokens={progress.toolTokens}
trail={progress.turnTrail}
/>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
<Text color={ui.theme.color.label as any}> </Text>
{status.stickyPrompt}
</Text>
) : (
<Text> </Text>
)}
<Box flexDirection="column" position="relative">
{ui.statusBar && (
<StatusRule
bgCount={ui.bgTasks.size}
cols={composer.cols}
cwdLabel={status.cwdLabel}
durationLabel={status.durationLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)}
<AppOverlays
{progress.showStreamingArea && (
<MessageLine
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
pagerPageSize={composer.pagerPageSize}
compact={ui.compact}
detailsMode={ui.detailsMode}
isStreaming
msg={{ role: 'assistant', text: progress.streaming }}
t={ui.theme}
/>
)}
</Box>
</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)
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 && (
<Text color={ui.theme.color.dim as any}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
<Text color={ui.theme.color.label as any}> </Text>
{status.stickyPrompt}
</Text>
) : (
<Text> </Text>
)}
<Box flexDirection="column" position="relative">
{ui.statusBar && (
<StatusRule
bgCount={ui.bgTasks.size}
cols={composer.cols}
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
sessionStartedAt={status.sessionStartedAt}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)}
<AppOverlays
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
pagerPageSize={composer.pagerPageSize}
/>
</Box>
{!isBlocked && (
<Box flexDirection="column" marginBottom={1}>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
</Box>
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
</Box>
))}
<Box>
<Box width={3}>
<Text bold color={ui.theme.color.gold as any}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
</Text>
</Box>
<TextInput
columns={Math.max(20, composer.cols - 3)}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
/>
</Box>
</Box>
)}
{!isBlocked && (
<Box flexDirection="column" marginBottom={1}>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
</Box>
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}> {ui.status}</Text>}
</NoSelect>
)
})
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
</Box>
))}
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>
<Box>
<Box width={3}>
<Text bold color={ui.theme.color.gold as any}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
</Text>
</Box>
<TextInput
columns={Math.max(20, composer.cols - 3)}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
/>
</Box>
</Box>
)}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}> {ui.status}</Text>}
</NoSelect>
<ComposerPane actions={actions} composer={composer} status={status} />
</Box>
</AlternateScreen>
)
}
})

View file

@ -1,5 +1,5 @@
import { Box, Text } from '@hermes/ink'
import type { ReactNode } from 'react'
import { memo, type ReactNode, useMemo } from 'react'
import type { Theme } from '../theme.js'
@ -212,367 +212,379 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
}
export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) {
const lines = text.split('\n')
const nodes: ReactNode[] = []
let i = 0
interface MdProps {
compact?: boolean
t: Theme
text: string
}
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null
function MdImpl({ compact, t, text }: MdProps) {
const nodes = useMemo(() => {
const lines = text.split('\n')
const nodes: ReactNode[] = []
let i = 0
const gap = () => {
if (nodes.length && prevKind !== 'blank') {
nodes.push(<Text key={`gap-${nodes.length}`}> </Text>)
prevKind = 'blank'
}
}
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
gap()
const gap = () => {
if (nodes.length && prevKind !== 'blank') {
nodes.push(<Text key={`gap-${nodes.length}`}> </Text>)
prevKind = 'blank'
}
}
prevKind = kind
}
while (i < lines.length) {
const line = lines[i]!
const key = nodes.length
if (compact && !line.trim()) {
i++
continue
}
if (!line.trim()) {
gap()
i++
continue
}
const fence = parseFence(line)
if (fence) {
const block: string[] = []
const lang = fence.lang
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
block.push(lines[i]!)
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
gap()
}
if (i < lines.length) {
prevKind = kind
}
while (i < lines.length) {
const line = lines[i]!
const key = nodes.length
if (compact && !line.trim()) {
i++
}
if (isMarkdownFence(lang)) {
start('paragraph')
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
continue
}
start('code')
if (!line.trim()) {
gap()
i++
const isDiff = lang === 'diff'
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
{block.map((l, j) => {
const add = isDiff && l.startsWith('+')
const del = isDiff && l.startsWith('-')
const hunk = isDiff && l.startsWith('@@')
return (
<Text
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
key={j}
>
{l}
</Text>
)
})}
</Box>
)
continue
}
if (line.trim().startsWith('$$')) {
start('code')
const block: string[] = []
for (i++; i < lines.length; i++) {
if (lines[i]!.trim().startsWith('$$')) {
i++
break
}
block.push(lines[i]!)
continue
}
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
<Text color={t.color.dim}> math</Text>
{block.map((l, j) => (
<Text color={t.color.amber} key={j}>
{l}
</Text>
))}
</Box>
)
const fence = parseFence(line)
continue
}
if (fence) {
const block: string[] = []
const lang = fence.lang
const heading = line.match(HEADING_RE)
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
block.push(lines[i]!)
}
if (heading) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
{heading[2]}
</Text>
)
i++
if (i < lines.length) {
i++
}
continue
}
if (isMarkdownFence(lang)) {
start('paragraph')
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
if (i + 1 < lines.length && line.trim()) {
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
continue
}
if (setext) {
start('code')
const isDiff = lang === 'diff'
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
{block.map((l, j) => {
const add = isDiff && l.startsWith('+')
const del = isDiff && l.startsWith('-')
const hunk = isDiff && l.startsWith('@@')
return (
<Text
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
key={j}
>
{l}
</Text>
)
})}
</Box>
)
continue
}
if (line.trim().startsWith('$$')) {
start('code')
const block: string[] = []
for (i++; i < lines.length; i++) {
if (lines[i]!.trim().startsWith('$$')) {
i++
break
}
block.push(lines[i]!)
}
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
<Text color={t.color.dim}> math</Text>
{block.map((l, j) => (
<Text color={t.color.amber} key={j}>
{l}
</Text>
))}
</Box>
)
continue
}
const heading = line.match(HEADING_RE)
if (heading) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
{line.trim()}
{heading[2]}
</Text>
)
i += 2
i++
continue
}
}
if (HR_RE.test(line)) {
start('rule')
nodes.push(
<Text color={t.color.dim} key={key}>
{'─'.repeat(36)}
</Text>
)
i++
if (i + 1 < lines.length && line.trim()) {
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
continue
}
const footnote = line.match(FOOTNOTE_RE)
if (footnote) {
start('list')
nodes.push(
<Text color={t.color.dim} key={key}>
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
</Text>
)
i++
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
nodes.push(
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
<Text color={t.color.dim}>
<MdInline t={t} text={lines[i]!.trim()} />
if (setext) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
{line.trim()}
</Text>
)
i += 2
continue
}
}
if (HR_RE.test(line)) {
start('rule')
nodes.push(
<Text color={t.color.dim} key={key}>
{'─'.repeat(36)}
</Text>
)
i++
continue
}
const footnote = line.match(FOOTNOTE_RE)
if (footnote) {
start('list')
nodes.push(
<Text color={t.color.dim} key={key}>
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
</Text>
)
i++
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
nodes.push(
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
<Text color={t.color.dim}>
<MdInline t={t} text={lines[i]!.trim()} />
</Text>
</Box>
)
i++
}
continue
}
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
start('list')
nodes.push(
<Text bold key={key}>
{line.trim()}
</Text>
)
i++
while (i < lines.length) {
const def = lines[i]!.match(DEF_RE)
if (!def) {
break
}
nodes.push(
<Text key={`${key}-def-${i}`}>
<Text color={t.color.dim}> · </Text>
<MdInline t={t} text={def[1]!} />
</Text>
)
i++
}
continue
}
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
if (bullet) {
start('list')
const depth = indentDepth(bullet[1]!)
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
const body = task ? task[2]! : bullet[2]!
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
{' '.repeat(depth * 2)}
{marker}{' '}
</Text>
<MdInline t={t} text={body} />
</Text>
)
i++
continue
}
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
if (numbered) {
start('list')
const depth = indentDepth(numbered[1]!)
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
{' '.repeat(depth * 2)}
{numbered[2]}.{' '}
</Text>
<MdInline t={t} text={numbered[3]!} />
</Text>
)
i++
continue
}
if (/^\s*(?:>\s*)+/.test(line)) {
start('quote')
const quoteLines: Array<{ depth: number; text: string }> = []
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
const raw = lines[i]!
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
quoteLines.push({
depth: (prefix.match(/>/g) ?? []).length,
text: raw.slice(prefix.length)
})
i++
}
nodes.push(
<Box flexDirection="column" key={key}>
{quoteLines.map((ql, qi) => (
<Text color={t.color.dim} key={qi}>
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
{'│ '}
<MdInline t={t} text={ql.text} />
</Text>
))}
</Box>
)
i++
continue
}
continue
}
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
start('table')
const tableRows: string[][] = []
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
start('list')
nodes.push(
<Text bold key={key}>
{line.trim()}
</Text>
)
i++
tableRows.push(splitTableRow(line))
i += 2
while (i < lines.length) {
const def = lines[i]!.match(DEF_RE)
if (!def) {
break
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
tableRows.push(splitTableRow(lines[i]!))
i++
}
nodes.push(renderTable(key, tableRows, t))
continue
}
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
i++
continue
}
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
if (summary) {
start('paragraph')
nodes.push(
<Text key={`${key}-def-${i}`}>
<Text color={t.color.dim}> · </Text>
<MdInline t={t} text={def[1]!} />
<Text color={t.color.dim} key={key}>
{summary[1]}
</Text>
)
i++
continue
}
continue
}
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
if (bullet) {
start('list')
const depth = indentDepth(bullet[1]!)
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
const body = task ? task[2]! : bullet[2]!
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
{' '.repeat(depth * 2)}
{marker}{' '}
if (/^<\/?[^>]+>$/.test(line.trim())) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
{line.trim()}
</Text>
<MdInline t={t} text={body} />
</Text>
)
i++
continue
}
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
if (numbered) {
start('list')
const depth = indentDepth(numbered[1]!)
nodes.push(
<Text key={key}>
<Text color={t.color.dim}>
{' '.repeat(depth * 2)}
{numbered[2]}.{' '}
</Text>
<MdInline t={t} text={numbered[3]!} />
</Text>
)
i++
continue
}
if (/^\s*(?:>\s*)+/.test(line)) {
start('quote')
const quoteLines: Array<{ depth: number; text: string }> = []
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
const raw = lines[i]!
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
quoteLines.push({
depth: (prefix.match(/>/g) ?? []).length,
text: raw.slice(prefix.length)
})
)
i++
continue
}
nodes.push(
<Box flexDirection="column" key={key}>
{quoteLines.map((ql, qi) => (
<Text color={t.color.dim} key={qi}>
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
{'│ '}
<MdInline t={t} text={ql.text} />
</Text>
))}
</Box>
)
if (line.includes('|') && line.trim().startsWith('|')) {
start('table')
const tableRows: string[][] = []
continue
}
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
const row = lines[i]!.trim()
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
start('table')
const tableRows: string[][] = []
if (!/^[|\s:-]+$/.test(row)) {
tableRows.push(splitTableRow(row))
}
tableRows.push(splitTableRow(line))
i += 2
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
tableRows.push(splitTableRow(lines[i]!))
i++
}
nodes.push(renderTable(key, tableRows, t))
continue
}
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
i++
continue
}
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
if (summary) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
{summary[1]}
</Text>
)
i++
continue
}
if (/^<\/?[^>]+>$/.test(line.trim())) {
start('paragraph')
nodes.push(
<Text color={t.color.dim} key={key}>
{line.trim()}
</Text>
)
i++
continue
}
if (line.includes('|') && line.trim().startsWith('|')) {
start('table')
const tableRows: string[][] = []
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
const row = lines[i]!.trim()
if (!/^[|\s:-]+$/.test(row)) {
tableRows.push(splitTableRow(row))
i++
}
i++
if (tableRows.length) {
nodes.push(renderTable(key, tableRows, t))
}
continue
}
if (tableRows.length) {
nodes.push(renderTable(key, tableRows, t))
}
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
continue
i++
}
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
i++
}
return nodes
}, [compact, t, text])
return <Box flexDirection="column">{nodes}</Box>
}
export const Md = memo(MdImpl)

View file

@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
interface ProviderItem {
is_current?: boolean
models?: string[]
name: string
slug: string
total_models?: number
warning?: string
}
const VISIBLE = 12
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
@ -31,7 +23,7 @@ export function ModelPicker({
sessionId: string | null
t: Theme
}) {
const [providers, setProviders] = useState<ProviderItem[]>([])
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('')
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
@ -41,9 +33,9 @@ export function ModelPicker({
const [stage, setStage] = useState<'model' | 'provider'>('provider')
useEffect(() => {
gw.request('model.options', sessionId ? { session_id: sessionId } : {})
.then((raw: any) => {
const r = asRpcResult(raw)
gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
.then(raw => {
const r = asRpcResult<ModelOptionsResponse>(raw)
if (!r) {
setErr('invalid response: model.options')
@ -52,7 +44,7 @@ export function ModelPicker({
return
}
const next = (r.providers ?? []) as ProviderItem[]
const next = r.providers ?? []
setProviders(next)
setCurrentModel(String(r.model ?? ''))
setProviderIdx(

View file

@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
interface SessionItem {
id: string
title: string
preview: string
started_at: number
message_count: number
source?: string
}
function age(ts: number): string {
const d = (Date.now() / 1000 - ts) / 86400
@ -41,15 +33,15 @@ export function SessionPicker({
onSelect: (id: string) => void
t: Theme
}) {
const [items, setItems] = useState<SessionItem[]>([])
const [items, setItems] = useState<SessionListItem[]>([])
const [err, setErr] = useState('')
const [sel, setSel] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
gw.request('session.list', { limit: 20 })
.then((raw: any) => {
const r = asRpcResult(raw)
gw.request<SessionListResponse>('session.list', { limit: 20 })
.then(raw => {
const r = asRpcResult<SessionListResponse>(raw)
if (!r) {
setErr('invalid response: session.list')
@ -58,7 +50,7 @@ export function SessionPicker({
return
}
setItems((r?.sessions ?? []) as SessionItem[])
setItems(r.sessions ?? [])
setErr('')
setLoading(false)
})

View file

@ -0,0 +1,15 @@
import { Box, NoSelect } from '@hermes/ink'
import type { Theme } from '../theme.js'
import type { WidgetSpec } from '../widgets.js'
import { WidgetHost } from '../widgets.js'
export function SidebarRail({ t, widgets, width }: { t: Theme; widgets: WidgetSpec[]; width: number }) {
return (
<NoSelect flexDirection="column" flexShrink={0} width={width}>
<Box borderColor={t.color.bronze as any} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<WidgetHost region="sidebar" widgets={widgets} />
</Box>
</NoSelect>
)
}

View file

@ -29,8 +29,16 @@ const dim = (s: string) => DIM + s + DIM_OFF
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
const STOP_CACHE_MAX = 32
const stopCache = new Map<string, number[]>()
function graphemeStops(s: string) {
const hit = stopCache.get(s)
if (hit) {
return hit
}
const stops = [0]
for (const { index } of seg().segment(s)) {
@ -43,6 +51,16 @@ function graphemeStops(s: string) {
stops.push(s.length)
}
stopCache.set(s, stops)
if (stopCache.size > STOP_CACHE_MAX) {
const oldest = stopCache.keys().next().value
if (oldest !== undefined) {
stopCache.delete(oldest)
}
}
return stops
}

View file

@ -3,6 +3,7 @@ import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import {
compactPreview,
estimateTokensRough,
fmtK,
formatToolCall,
@ -13,7 +14,7 @@ import {
toolTrailLabel
} from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js'
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js'
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
@ -128,6 +129,122 @@ function Chevron({
)
}
function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) {
const [open, setOpen] = useState(expanded)
const [openThinking, setOpenThinking] = useState(expanded)
const [openTools, setOpenTools] = useState(expanded)
const [openNotes, setOpenNotes] = useState(expanded)
useEffect(() => {
if (!expanded) {
return
}
setOpen(true)
setOpenThinking(true)
setOpenTools(true)
setOpenNotes(true)
}, [expanded])
const statusTone: 'dim' | 'error' | 'warn' =
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : ''
const title = `${prefix}${item.goal || `Subagent ${item.index + 1}`}`
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
const suffix =
item.status === 'running'
? 'running'
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
const thinkingText = item.thinking.join('\n')
const hasThinking = Boolean(thinkingText)
const hasTools = item.tools.length > 0
const noteRows = [...(summary ? [summary] : []), ...item.notes]
const hasNotes = noteRows.length > 0
const active = expanded || open
return (
<Box flexDirection="column" paddingLeft={1}>
<Chevron onClick={() => setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} />
{active && (
<Box flexDirection="column" paddingLeft={2}>
{hasThinking && (
<>
<Chevron
count={item.thinking.length}
onClick={() => setOpenThinking(v => !v)}
open={expanded || openThinking}
t={t}
title="Thinking"
/>
{(expanded || openThinking) && (
<Thinking
active={item.status === 'running'}
mode="full"
reasoning={thinkingText}
streaming={item.status === 'running'}
t={t}
/>
)}
</>
)}
{hasTools && (
<>
<Chevron
count={item.tools.length}
onClick={() => setOpenTools(v => !v)}
open={expanded || openTools}
t={t}
title="Tool calls"
/>
{(expanded || openTools) && (
<Box flexDirection="column">
{item.tools.map((line, index) => (
<Text color={t.color.cornsilk} key={`${item.id}-tool-${index}`} wrap="wrap-trim">
<Text color={t.color.amber}> </Text>
{line}
</Text>
))}
</Box>
)}
</>
)}
{hasNotes && (
<>
<Chevron
count={noteRows.length}
onClick={() => setOpenNotes(v => !v)}
open={expanded || openNotes}
t={t}
title="Progress"
tone={statusTone}
/>
{(expanded || openNotes) && (
<Box flexDirection="column">
{noteRows.map((line, index) => (
<Text
color={statusTone === 'error' ? t.color.error : t.color.dim}
dimColor
key={`${item.id}-note-${index}`}
>
<Text dimColor>{index === noteRows.length - 1 ? '└ ' : '├ '}</Text>
{line}
</Text>
))}
</Box>
)}
</>
)}
</Box>
)}
</Box>
)
}
// ── Thinking ─────────────────────────────────────────────────────────
export const Thinking = memo(function Thinking({
@ -143,7 +260,7 @@ export const Thinking = memo(function Thinking({
streaming?: boolean
t: Theme
}) {
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
return (
@ -198,6 +315,7 @@ export const ToolTrail = memo(function ToolTrail({
reasoning = '',
reasoningTokens,
reasoningStreaming = false,
subagents = [],
t,
tools = [],
toolTokens,
@ -210,6 +328,7 @@ export const ToolTrail = memo(function ToolTrail({
reasoning?: string
reasoningTokens?: number
reasoningStreaming?: boolean
subagents?: SubagentProgress[]
t: Theme
tools?: ActiveTool[]
toolTokens?: number
@ -219,6 +338,7 @@ export const ToolTrail = memo(function ToolTrail({
const [now, setNow] = useState(() => Date.now())
const [openThinking, setOpenThinking] = useState(false)
const [openTools, setOpenTools] = useState(false)
const [openSubagents, setOpenSubagents] = useState(false)
const [openMeta, setOpenMeta] = useState(false)
useEffect(() => {
@ -235,19 +355,21 @@ export const ToolTrail = memo(function ToolTrail({
if (detailsMode === 'expanded') {
setOpenThinking(true)
setOpenTools(true)
setOpenSubagents(true)
setOpenMeta(true)
}
if (detailsMode === 'hidden') {
setOpenThinking(false)
setOpenTools(false)
setOpenSubagents(false)
setOpenMeta(false)
}
}, [detailsMode])
const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX)
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) {
if (!busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive) {
return null
}
@ -334,6 +456,7 @@ export const ToolTrail = memo(function ToolTrail({
// ── Derived ────────────────────────────────────────────────────
const hasTools = groups.length > 0
const hasSubagents = subagents.length > 0
const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
const thinkingLive = reasoningActive || reasoningStreaming
@ -395,6 +518,10 @@ export const ToolTrail = memo(function ToolTrail({
))
: null
const subagentBlock = hasSubagents
? subagents.map(item => <SubagentAccordion expanded={detailsMode === 'expanded'} item={item} key={item.id} t={t} />)
: null
const metaBlock = hasMeta
? meta.map((row, i) => (
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
@ -418,6 +545,7 @@ export const ToolTrail = memo(function ToolTrail({
<Box flexDirection="column">
{thinkingBlock}
{toolBlock}
{subagentBlock}
{metaBlock}
{totalBlock}
</Box>
@ -468,6 +596,19 @@ export const ToolTrail = memo(function ToolTrail({
</>
)}
{hasSubagents && (
<>
<Chevron
count={subagents.length}
onClick={() => setOpenSubagents(v => !v)}
open={openSubagents}
t={t}
title="Subagents"
/>
{openSubagents && subagentBlock}
</>
)}
{hasMeta && (
<>
<Chevron