feat: ensure feature parity once again

This commit is contained in:
Brooklyn Nicholson 2026-04-11 14:02:36 -05:00
parent bf6af95ff5
commit e2ea8934d4
6 changed files with 922 additions and 112 deletions

View file

@ -189,6 +189,23 @@ function ctxBar(pct: number | undefined, w = 10) {
return '█'.repeat(filled) + '░'.repeat(w - filled)
}
function fmtDuration(ms: number) {
const total = Math.max(0, Math.floor(ms / 1000))
const hours = Math.floor(total / 3600)
const mins = Math.floor((total % 3600) / 60)
const secs = total % 60
if (hours > 0) {
return `${hours}h ${mins}m`
}
if (mins > 0) {
return `${mins}m ${secs}s`
}
return `${secs}s`
}
function StatusRule({
cols,
status,
@ -196,6 +213,8 @@ function StatusRule({
model,
usage,
bgCount,
durationLabel,
voiceLabel,
t
}: {
cols: number
@ -204,6 +223,8 @@ function StatusRule({
model: string
usage: Usage
bgCount: number
durationLabel?: string
voiceLabel?: string
t: Theme
}) {
const pct = usage.context_percent
@ -218,9 +239,16 @@ function StatusRule({
const pctLabel = pct != null ? `${pct}%` : ''
const bar = usage.context_max ? ctxBar(pct) : ''
const segs = [status, model, ctxLabel, bar ? `[${bar}]` : '', pctLabel, bgCount > 0 ? `${bgCount} bg` : ''].filter(
Boolean
)
const segs = [
status,
model,
ctxLabel,
bar ? `[${bar}]` : '',
pctLabel,
durationLabel || '',
voiceLabel || '',
bgCount > 0 ? `${bgCount} bg` : ''
].filter(Boolean)
const inner = segs.join(' │ ')
const pad = Math.max(0, cols - inner.length - 5)
@ -237,6 +265,8 @@ function StatusRule({
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
</Text>
) : null}
{durationLabel ? <Text color={t.color.dim}> {durationLabel}</Text> : null}
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
{' ' + '─'.repeat(pad)}
</Text>
@ -314,6 +344,12 @@ export function App({ gw }: { gw: GatewayClient }) {
const [bgTasks, setBgTasks] = useState<Set<string>>(new Set())
const [catalog, setCatalog] = useState<SlashCatalog | null>(null)
const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null)
const [voiceEnabled, setVoiceEnabled] = useState(false)
const [voiceRecording, setVoiceRecording] = useState(false)
const [voiceProcessing, setVoiceProcessing] = useState(false)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [bellOnComplete, setBellOnComplete] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
// ── Refs ─────────────────────────────────────────────────────────
@ -333,6 +369,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const busyRef = useRef(busy)
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
const configMtimeRef = useRef(0)
colsRef.current = cols
busyRef.current = busy
reasoningRef.current = reasoning
@ -367,6 +404,12 @@ export function App({ gw }: { gw: GatewayClient }) {
}
}, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const id = setInterval(() => setClockNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
// ── Core actions ─────────────────────────────────────────────────
const appendMessage = useCallback((msg: Msg) => {
@ -423,6 +466,44 @@ export function App({ gw }: { gw: GatewayClient }) {
[gw, sys]
)
useEffect(() => {
if (!sid) {
return
}
rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled))
rpc('config.get', { key: 'mtime' }).then((r: any) => {
configMtimeRef.current = Number(r?.mtime ?? 0)
})
rpc('config.get', { key: 'full' }).then((r: any) => {
setBellOnComplete(!!r?.config?.display?.bell_on_complete)
})
}, [rpc, sid])
useEffect(() => {
if (!sid) {
return
}
const id = setInterval(() => {
rpc('config.get', { key: 'mtime' }).then((r: any) => {
const next = Number(r?.mtime ?? 0)
if (configMtimeRef.current && next && next !== configMtimeRef.current) {
configMtimeRef.current = next
rpc('reload.mcp', { session_id: sid }).then(() => pushActivity('MCP reloaded after config change'))
rpc('config.get', { key: 'full' }).then((cfg: any) => {
setBellOnComplete(!!cfg?.config?.display?.bell_on_complete)
})
} else if (!configMtimeRef.current && next) {
configMtimeRef.current = next
}
})
}, 5000)
return () => clearInterval(id)
}, [pushActivity, rpc, sid])
const idle = () => {
setThinking(false)
setTools([])
@ -454,6 +535,8 @@ export function App({ gw }: { gw: GatewayClient }) {
const resetSession = () => {
idle()
setReasoning('')
setVoiceRecording(false)
setVoiceProcessing(false)
setSid(null as any) // will be set by caller
setHistoryItems([])
setMessages([])
@ -477,6 +560,7 @@ export function App({ gw }: { gw: GatewayClient }) {
resetSession()
setSid(r.session_id)
setSessionStartedAt(Date.now())
setStatus('ready')
if (r.info) {
@ -506,6 +590,7 @@ export function App({ gw }: { gw: GatewayClient }) {
.then((r: any) => {
resetSession()
setSid(r.session_id)
setSessionStartedAt(Date.now())
setInfo(r.info ?? null)
const resumed = toTranscriptMessages(r.messages)
@ -667,25 +752,45 @@ export function App({ gw }: { gw: GatewayClient }) {
pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn')
}
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
statusTimerRef.current = null
const startSubmit = (displayText: string, submitText: string) => {
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
statusTimerRef.current = null
}
inflightPasteIdsRef.current = payload.usedIds
setLastUserMsg(text)
appendMessage({ role: 'user', text: displayText })
setBusy(true)
setStatus('running…')
buf.current = ''
interruptedRef.current = false
gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => {
inflightPasteIdsRef.current = []
sys(`error: ${e.message}`)
setStatus('ready')
setBusy(false)
})
}
inflightPasteIdsRef.current = payload.usedIds
setLastUserMsg(text)
appendMessage({ role: 'user', text })
setBusy(true)
setStatus('running…')
buf.current = ''
interruptedRef.current = false
gw.request('input.detect_drop', { session_id: sid, text: payload.text })
.then((r: any) => {
if (r?.matched) {
if (r.is_image) {
pushActivity(`attached image: ${r.name}`)
} else {
pushActivity(`detected file: ${r.name}`)
}
gw.request('prompt.submit', { session_id: sid, text: payload.text }).catch((e: Error) => {
inflightPasteIdsRef.current = []
sys(`error: ${e.message}`)
setStatus('ready')
setBusy(false)
})
startSubmit(r.text || text, r.text || payload.text)
return
}
startSubmit(text, payload.text)
})
.catch(() => startSubmit(text, payload.text))
}
const shellExec = (cmd: string) => {
@ -1027,6 +1132,37 @@ export function App({ gw }: { gw: GatewayClient }) {
return
}
if (ctrl(key, ch, 'b')) {
if (voiceRecording) {
setVoiceRecording(false)
setVoiceProcessing(true)
rpc('voice.record', { action: 'stop' })
.then((r: any) => {
const transcript = String(r?.text || '').trim()
if (transcript) {
setInput(prev => (prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript))
} else {
sys('voice: no speech detected')
}
})
.catch((e: Error) => sys(`voice error: ${e.message}`))
.finally(() => {
setVoiceProcessing(false)
setStatus('ready')
})
} else {
rpc('voice.record', { action: 'start' })
.then(() => {
setVoiceRecording(true)
setStatus('recording…')
})
.catch((e: Error) => sys(`voice error: ${e.message}`))
}
return
}
if (ctrl(key, ch, 'g')) {
return openEditor()
}
@ -1184,7 +1320,10 @@ export function App({ gw }: { gw: GatewayClient }) {
break
case 'tool.start':
setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '' }])
setTools(prev => [
...prev,
{ id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() }
])
break
case 'tool.complete': {
@ -1211,6 +1350,10 @@ export function App({ gw }: { gw: GatewayClient }) {
return remaining
})
if (p?.inline_diff) {
sys(p.inline_diff as string)
}
break
}
@ -1262,7 +1405,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'message.delta':
if (p?.text && !interruptedRef.current) {
buf.current += p.rendered ?? p.text
buf.current = p.rendered ?? buf.current + p.text
setStreaming(buf.current.trimStart())
}
@ -1289,6 +1432,10 @@ export function App({ gw }: { gw: GatewayClient }) {
thinking: savedReasoning || undefined,
tools: savedTools.length ? savedTools : undefined
})
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')
}
}
turnToolsRef.current = []
@ -1624,14 +1771,31 @@ export function App({ gw }: { gw: GatewayClient }) {
if (!arg) {
rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`))
} else {
rpc('config.set', { key: 'model', value: arg.replace('--global', '').trim() }).then((r: any) => {
sys(`model → ${r.value}`)
setInfo(prev => (prev ? { ...prev, model: r.value } : prev))
})
rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then(
(r: any) => {
sys(`model → ${r.value}`)
setInfo(prev => (prev ? { ...prev, model: r.value } : prev))
}
)
}
return true
case 'image':
rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => {
if (!r) {
return
}
sys(`attached image: ${r.name}`)
if (r?.remainder) {
setInput(r.remainder)
}
})
return true
case 'provider':
gw.request('slash.exec', { command: 'provider', session_id: sid })
.then((r: any) => page(r?.output || '(no output)'))
@ -1649,17 +1813,23 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
case 'yolo':
rpc('config.set', { key: 'yolo' }).then((r: any) => sys(`yolo ${r.value === '1' ? 'on' : 'off'}`))
rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) =>
sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)
)
return true
case 'reasoning':
rpc('config.set', { key: 'reasoning', value: arg || 'medium' }).then((r: any) => sys(`reasoning: ${r.value}`))
rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) =>
sys(`reasoning: ${r.value}`)
)
return true
case 'verbose':
rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose: ${r.value}`))
rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) =>
sys(`verbose: ${r.value}`)
)
return true
@ -1694,6 +1864,7 @@ export function App({ gw }: { gw: GatewayClient }) {
rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => {
if (r?.session_id) {
setSid(r.session_id)
setSessionStartedAt(Date.now())
setHistoryItems([])
setMessages([])
sys(`branched → ${r.title}`)
@ -1773,9 +1944,14 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
case 'voice':
rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) =>
rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => {
if (!r) {
return
}
setVoiceEnabled(!!r?.enabled)
sys(`voice${arg === 'on' || arg === 'off' ? '' : ':'} ${r.enabled ? 'on' : 'off'}`)
)
})
return true
@ -1794,13 +1970,19 @@ export function App({ gw }: { gw: GatewayClient }) {
return sys('no checkpoints')
}
sys(r.checkpoints.map((c: any, i: number) => ` ${i} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n'))
sys(r.checkpoints.map((c: any, i: number) => ` ${i + 1} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n'))
})
} else {
const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub
rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { session_id: sid, hash }).then((r: any) =>
sys(r.rendered || r.diff || r.message || 'done')
)
const filePath =
sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim()
rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', {
session_id: sid,
hash,
...(sub === 'diff' || !filePath ? {} : { file_path: filePath })
}).then((r: any) => sys(r.rendered || r.diff || r.message || 'done'))
}
return true
@ -2003,6 +2185,9 @@ export function App({ gw }: { gw: GatewayClient }) {
? theme.color.warn
: theme.color.dim
const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : ''
const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
// ── Render ───────────────────────────────────────────────────────
return (
@ -2024,7 +2209,6 @@ export function App({ gw }: { gw: GatewayClient }) {
<ToolTrail
activity={busy ? activity : []}
animateCot={busy && !streaming}
padAfter={!!streaming}
t={theme}
tools={tools}
trail={turnTrail}
@ -2126,11 +2310,13 @@ export function App({ gw }: { gw: GatewayClient }) {
<StatusRule
bgCount={bgTasks.size}
cols={cols}
durationLabel={durationLabel}
model={info?.model?.split('/').pop() ?? ''}
status={status}
statusColor={statusColor}
t={theme}
usage={usage}
voiceLabel={voiceLabel}
/>
)}

View file

@ -39,8 +39,12 @@ export const MessageLine = memo(function MessageLine({
return <Text color={t.color.dim}>{msg.text}</Text>
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Text wrap="wrap">{msg.text}</Text>
}
if (msg.role === 'assistant') {
return hasAnsi(msg.text) ? <Text wrap="wrap">{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
return <Md compact={compact} t={t} text={msg.text} />
}
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
@ -63,7 +67,11 @@ export const MessageLine = memo(function MessageLine({
})()
return (
<Box flexDirection="column" marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0}>
<Box
flexDirection="column"
marginBottom={msg.role === 'user' ? 1 : 0}
marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0}
>
{msg.thinking && (
<Text color={t.color.dim} dimColor wrap="truncate-end">
💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)}

View file

@ -25,6 +25,12 @@ const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : i
const TreeFork = ({ last }: { last: boolean }) => <Text dimColor>{last ? '└─ ' : '├─ '}</Text>
const fmtElapsed = (ms: number) => {
const sec = Math.max(0, ms) / 1000
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
}
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
const [spin] = useState(() => {
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
@ -48,16 +54,26 @@ export const ToolTrail = memo(function ToolTrail({
tools = [],
trail = [],
activity = [],
animateCot = false,
padAfter = false
animateCot = false
}: {
t: Theme
tools?: ActiveTool[]
trail?: string[]
activity?: ActivityItem[]
animateCot?: boolean
padAfter?: boolean
}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!tools.length) {
return
}
const id = setInterval(() => setNow(Date.now()), 200)
return () => clearInterval(id)
}, [tools.length])
if (!trail.length && !tools.length && !activity.length) {
return null
}
@ -70,7 +86,6 @@ export const ToolTrail = memo(function ToolTrail({
<>
{trail.map((line, i) => {
const lastInBlock = i === rowCount - 1
const suffix = padAfter && lastInBlock ? '\n' : ''
if (isToolTrailResultLine(line)) {
return (
@ -81,7 +96,6 @@ export const ToolTrail = memo(function ToolTrail({
>
<TreeFork last={lastInBlock} />
{line}
{suffix}
</Text>
)
}
@ -91,7 +105,6 @@ export const ToolTrail = memo(function ToolTrail({
<Text color={t.color.dim} key={`c-${i}`}>
<TreeFork last={lastInBlock} />
<Spinner color={t.color.amber} variant="think" /> {line}
{suffix}
</Text>
)
}
@ -100,34 +113,30 @@ export const ToolTrail = memo(function ToolTrail({
<Text color={t.color.dim} dimColor key={`c-${i}`}>
<TreeFork last={lastInBlock} />
{line}
{suffix}
</Text>
)
})}
{tools.map((tool, j) => {
const lastInBlock = trail.length + j === rowCount - 1
const suffix = padAfter && lastInBlock ? '\n' : ''
return (
<Text color={t.color.dim} key={tool.id}>
<TreeFork last={lastInBlock} />
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
{tool.context ? `: ${tool.context}` : ''}
{suffix}
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
</Text>
)
})}
{act.map((item, k) => {
const lastInBlock = trail.length + tools.length + k === rowCount - 1
const suffix = padAfter && lastInBlock ? '\n' : ''
return (
<Text color={tone(item, t)} dimColor={item.tone === 'info'} key={`a-${item.id}`}>
<TreeFork last={lastInBlock} />
{activityGlyph(item)} {item.text}
{suffix}
</Text>
)
})}

View file

@ -2,6 +2,7 @@ export interface ActiveTool {
id: string
name: string
context?: string
startedAt?: number
}
export interface ActivityItem {