fix(tui): -c resume, ctrl z, pasting updates, exit summary, session fix

This commit is contained in:
Brooklyn Nicholson
2026-04-09 00:36:53 -05:00
parent b66550ed08
commit 54bd25ff4a
11 changed files with 426 additions and 146 deletions

View File

@@ -1,98 +1,20 @@
import { describe, expect, it } from 'vitest'
import {
compactPreview,
estimateRows,
fmtK,
hasAnsi,
hasInterpolation,
pick,
stripAnsi,
userDisplay
} from '../lib/text.js'
import { sameToolTrailGroup } from '../lib/text.js'
describe('stripAnsi / hasAnsi', () => {
it('strips ANSI codes', () => {
expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red')
describe('sameToolTrailGroup', () => {
it('matches bare check lines', () => {
expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✓')).toBe(true)
expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✗')).toBe(true)
})
it('passes plain text through', () => {
expect(stripAnsi('hello')).toBe('hello')
it('matches contextual lines', () => {
expect(sameToolTrailGroup('🔍 searching', '🔍 searching: * ✓')).toBe(true)
expect(sameToolTrailGroup('🔍 searching', '🔍 searching: foo ✓')).toBe(true)
})
it('detects ANSI', () => {
expect(hasAnsi('\x1b[1mbold\x1b[0m')).toBe(true)
expect(hasAnsi('plain')).toBe(false)
})
})
describe('compactPreview', () => {
it('truncates with ellipsis', () => {
expect(compactPreview('a'.repeat(100), 20)).toHaveLength(20)
expect(compactPreview('a'.repeat(100), 20).at(-1)).toBe('…')
})
it('returns short strings as-is', () => {
expect(compactPreview('hello', 20)).toBe('hello')
})
it('collapses whitespace', () => {
expect(compactPreview(' a b ', 20)).toBe('a b')
})
it('returns empty for whitespace-only', () => {
expect(compactPreview(' ', 20)).toBe('')
})
})
describe('estimateRows', () => {
it('single line', () => expect(estimateRows('hello', 80)).toBe(1))
it('wraps long lines', () => expect(estimateRows('a'.repeat(160), 80)).toBe(2))
it('counts newlines', () => expect(estimateRows('a\nb\nc', 80)).toBe(3))
it('skips table separators', () => {
expect(estimateRows('| a | b |\n|---|---|\n| 1 | 2 |', 80)).toBe(2)
})
it('handles code blocks', () => {
expect(estimateRows('```python\nprint("hi")\n```', 80)).toBeGreaterThanOrEqual(2)
})
it('compact mode skips empty lines', () => {
expect(estimateRows('a\n\nb', 80, true)).toBe(2)
expect(estimateRows('a\n\nb', 80, false)).toBe(3)
})
})
describe('fmtK', () => {
it('formats thousands', () => expect(fmtK(1500)).toBe('1.5k'))
it('keeps small numbers', () => expect(fmtK(42)).toBe('42'))
it('boundary', () => {
expect(fmtK(1000)).toBe('1.0k')
expect(fmtK(999)).toBe('999')
})
})
describe('hasInterpolation', () => {
it('detects {!cmd}', () => expect(hasInterpolation('echo {!date}')).toBe(true))
it('rejects plain text', () => expect(hasInterpolation('plain')).toBe(false))
})
describe('pick', () => {
it('returns element from array', () => {
expect([1, 2, 3]).toContain(pick([1, 2, 3]))
})
})
describe('userDisplay', () => {
it('returns short messages as-is', () => expect(userDisplay('hello')).toBe('hello'))
it('truncates long messages', () => {
expect(userDisplay('word '.repeat(100))).toContain('[long message]')
it('rejects other tools', () => {
expect(sameToolTrailGroup('🔍 searching', '📖 reading ✓')).toBe(false)
expect(sameToolTrailGroup('🔍 searching', '🔍 searching extra ✓')).toBe(false)
})
})

View File

@@ -21,7 +21,7 @@ import { useCompletion } from './hooks/useCompletion.js'
import { useInputHistory } from './hooks/useInputHistory.js'
import { useQueue } from './hooks/useQueue.js'
import { writeOsc52Clipboard } from './lib/osc52.js'
import { compactPreview, fmtK, hasInterpolation, pick } from './lib/text.js'
import { compactPreview, fmtK, hasInterpolation, pick, sameToolTrailGroup } from './lib/text.js'
import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
import type {
ActiveTool,
@@ -42,8 +42,8 @@ import type {
const PLACEHOLDER = pick(PLACEHOLDERS)
const PASTE_TOKEN_RE = /\[\[paste:(\d+)\]\]/g
const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
const SMALL_PASTE = { chars: 400, lines: 4 }
const LARGE_PASTE = { chars: 8000, lines: 80 }
const EXCERPT = { chars: 1200, lines: 14 }
@@ -102,6 +102,31 @@ const stripTokens = (text: string, re: RegExp) =>
.replace(/\s{2,}/g, ' ')
.trim()
const toTranscriptMessages = (rows: unknown): Msg[] => {
if (!Array.isArray(rows)) {
return []
}
return rows.flatMap(row => {
if (!row || typeof row !== 'object') {
return []
}
const role = (row as any).role
const text = (row as any).text
if (
(role !== 'assistant' && role !== 'system' && role !== 'tool' && role !== 'user') ||
typeof text !== 'string' ||
!text.trim()
) {
return []
}
return [{ role, text }]
})
}
// ── StatusRule ────────────────────────────────────────────────────────
function ctxBarColor(pct: number | undefined, t: Theme) {
@@ -250,6 +275,7 @@ export function App({ gw }: { gw: GatewayClient }) {
// ── Refs ─────────────────────────────────────────────────────────
const activityIdRef = useRef(0)
const toolCompleteRibbonRef = useRef<{ label: string; line: string } | null>(null)
const buf = useRef('')
const inflightPasteIdsRef = useRef<number[]>([])
const interruptedRef = useRef(false)
@@ -301,21 +327,19 @@ export function App({ gw }: { gw: GatewayClient }) {
setHistoryItems(prev => [...prev, msg])
}, [])
const appendHistory = useCallback((msg: Msg) => {
setHistoryItems(prev => [...prev, msg])
}, [])
const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage])
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info') => {
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
setActivity(prev => {
if (prev.at(-1)?.text === text && prev.at(-1)?.tone === tone) {
return prev
const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev
if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) {
return base
}
activityIdRef.current++
return [...prev, { id: activityIdRef.current, text, tone }].slice(-8)
return [...base, { id: activityIdRef.current, text, tone }].slice(-8)
})
}, [])
@@ -387,7 +411,7 @@ export function App({ gw }: { gw: GatewayClient }) {
setUsage(prev => ({ ...prev, ...r.info.usage }))
}
appendHistory(introMsg(r.info))
setHistoryItems([introMsg(r.info)])
} else {
setInfo(null)
}
@@ -396,7 +420,7 @@ export function App({ gw }: { gw: GatewayClient }) {
sys(msg)
}
}),
[appendHistory, rpc, sys]
[rpc, sys]
)
// ── Paste pipeline ───────────────────────────────────────────────
@@ -466,10 +490,17 @@ export function App({ gw }: { gw: GatewayClient }) {
const handleTextPaste = useCallback(
({ cursor, text, value }: { cursor: number; text: string; value: string }) => {
const lineCount = text.split('\n').length
// Inline normal paste payloads exactly as typed. Only very large
// payloads are tokenized into attached snippets.
if (text.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) {
return { cursor: cursor + text.length, value: value.slice(0, cursor) + text + value.slice(cursor) }
}
pasteCounterRef.current++
const id = pasteCounterRef.current
const lineCount = text.split('\n').length
const mode: PasteMode = lineCount > SMALL_PASTE.lines || text.length > SMALL_PASTE.chars ? 'attach' : 'excerpt'
const mode: PasteMode = 'attach'
const token = pasteToken(id)
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
@@ -887,8 +918,31 @@ export function App({ gw }: { gw: GatewayClient }) {
})
.catch(() => {})
setStatus('forging session…')
newSession()
if (STARTUP_RESUME_ID) {
setStatus('resuming…')
gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then((r: any) => {
resetSession()
setSid(r.session_id)
setInfo(r.info ?? null)
const resumed = toTranscriptMessages(r.messages)
if (r.info?.usage) {
setUsage(prev => ({ ...prev, ...r.info.usage }))
}
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
setStatus('ready')
})
.catch(() => {
setStatus('forging session…')
newSession('resume failed, started a new session')
})
} else {
setStatus('forging session…')
newSession()
}
break
@@ -965,18 +1019,26 @@ export function App({ gw }: { gw: GatewayClient }) {
break
case 'tool.complete': {
const mark = p.error ? '✗' : '✓'
const tone = p.error ? 'error' : 'info'
toolCompleteRibbonRef.current = null
setTools(prev => {
const done = prev.find(t => t.id === p.tool_id)
const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name
const ctx = (p.error as string) || done?.context || ''
const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}`
pushActivity(line, p.error ? 'error' : 'info')
turnToolsRef.current = [...turnToolsRef.current, line].slice(-8)
toolCompleteRibbonRef.current = { label, line }
turnToolsRef.current = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8)
return prev.filter(t => t.id !== p.tool_id)
})
if (toolCompleteRibbonRef.current) {
const { line, label } = toolCompleteRibbonRef.current
pushActivity(line, tone, label)
}
break
}
@@ -1733,21 +1795,19 @@ export function App({ gw }: { gw: GatewayClient }) {
onSelect={id => {
setPicker(false)
setStatus('resuming…')
gw.request('session.resume', { cols, session_id: id })
gw.request('session.resume', { cols: colsRef.current, session_id: id })
.then((r: any) => {
resetSession()
setSid(r.session_id)
setInfo(r.info ?? null)
const resumed = toTranscriptMessages(r.messages)
if (r.info?.usage) {
setUsage(prev => ({ ...prev, ...r.info.usage }))
}
if (r.info) {
appendHistory(introMsg(r.info))
}
sys(`resumed session (${r.message_count} messages)`)
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
setStatus('ready')
})
.catch((e: Error) => {

View File

@@ -70,7 +70,7 @@ export const MessageLine = memo(function MessageLine({
</Box>
{!!msg.tools?.length && (
<Box flexDirection="column">
<Box flexDirection="column" marginBottom={1} marginTop={1}>
{msg.tools.map((tool, i) => (
<Text
color={tool.endsWith(' ✗') ? t.color.error : t.color.dim}

View File

@@ -48,11 +48,15 @@ interface Props {
export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) {
const [cur, setCur] = useState(value.length)
const curRef = useRef(cur)
const vRef = useRef(value)
const selfChange = useRef(false)
const pasteBuf = useRef('')
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pastePos = useRef(0)
const undo = useRef<Array<{ cursor: number; value: string }>>([])
const redo = useRef<Array<{ cursor: number; value: string }>>([])
curRef.current = cur
vRef.current = value
useEffect(() => {
@@ -60,16 +64,34 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
selfChange.current = false
} else {
setCur(value.length)
curRef.current = value.length
undo.current = []
redo.current = []
}
}, [value])
const commit = (v: string, c: number) => {
c = Math.max(0, Math.min(c, v.length))
setCur(c)
const commit = (nextValue: string, nextCursor: number, track = true) => {
const currentValue = vRef.current
const currentCursor = curRef.current
const c = Math.max(0, Math.min(nextCursor, nextValue.length))
if (v !== value) {
if (track && nextValue !== currentValue) {
undo.current.push({ cursor: currentCursor, value: currentValue })
if (undo.current.length > 200) {
undo.current.shift()
}
redo.current = []
}
setCur(c)
curRef.current = c
vRef.current = nextValue
if (nextValue !== currentValue) {
selfChange.current = true
onChange(v)
onChange(nextValue)
}
}
@@ -83,21 +105,17 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
return
}
const v = vRef.current
const handled = onPaste?.({ cursor: at, text: pasted, value: v })
const currentValue = vRef.current
const handled = onPaste?.({ cursor: at, text: pasted, value: currentValue })
if (handled) {
selfChange.current = true
onChange(handled.value)
setCur(handled.cursor)
commit(handled.value, handled.cursor)
return
}
if (pasted.length && PRINTABLE.test(pasted)) {
selfChange.current = true
onChange(v.slice(0, at) + pasted + v.slice(at))
setCur(at + pasted.length)
commit(currentValue.slice(0, at) + pasted + currentValue.slice(at), at + pasted.length)
}
}
@@ -130,6 +148,32 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
let v = value
const mod = k.ctrl || k.meta
if (k.ctrl && inp === 'z') {
const prev = undo.current.pop()
if (!prev) {
return
}
redo.current.push({ cursor: curRef.current, value: vRef.current })
commit(prev.value, prev.cursor, false)
return
}
if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) {
const next = redo.current.pop()
if (!next) {
return
}
undo.current.push({ cursor: curRef.current, value: vRef.current })
commit(next.value, next.cursor, false)
return
}
if (k.home || (k.ctrl && inp === 'a')) {
c = 0
} else if (k.end || (k.ctrl && inp === 'e')) {

View File

@@ -28,6 +28,7 @@ export const HOTKEYS: [string, string][] = [
['Tab', 'apply completion'],
['↑/↓', 'completions / queue edit / history'],
['Ctrl+A/E', 'home / end of line'],
['Ctrl+Z / Ctrl+Y', 'undo / redo input edits'],
['Ctrl+W', 'delete word'],
['Ctrl+U/K', 'delete to start / end'],
['Ctrl+←/→', 'jump word'],

View File

@@ -35,6 +35,10 @@ export const compactPreview = (s: string, max: number) => {
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
}
/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */
export const sameToolTrailGroup = (label: string, entry: string) =>
entry === `${label}` || entry === `${label}` || entry.startsWith(`${label}:`)
export const estimateRows = (text: string, w: number, compact = false) => {
let inCode = false
let rows = 0