diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 904e44ec2a..181b96b43f 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -78,24 +78,10 @@ describe('edgePreview', () => { describe('pasteTokenLabel', () => { it('builds readable long-paste labels with counts', () => { - expect( - pasteTokenLabel({ - charCount: 1000, - id: 7, - lineCount: 250, - text: 'Vampire Bondage ropes slipped from her neck, still stained with blood', - tokenCount: 250 - }) - ).toContain('[[paste:7 ') - expect( - pasteTokenLabel({ - charCount: 1000, - id: 7, - lineCount: 250, - text: 'Vampire Bondage ropes slipped from her neck, still stained with blood', - tokenCount: 250 - }) - ).toContain('[250 lines · 250 tok · 1K chars]') + const label = pasteTokenLabel('Vampire Bondage ropes slipped from her neck, still stained with blood', 250) + expect(label.startsWith('[[ ')).toBe(true) + expect(label).toContain('[250 lines]') + expect(label.endsWith(' ]]')).toBe(true) }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index b6cb03cb3b..c329057c7e 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -40,6 +40,7 @@ import { hasInterpolation, isToolTrailResultLine, isTransientTrailLine, + pasteTokenLabel, pick, sameToolTrailGroup, stripTrailingPasteNewlines, @@ -66,10 +67,12 @@ import type { const PLACEHOLDER = pick(PLACEHOLDERS) const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() +const LARGE_PASTE = { chars: 8000, lines: 80 } const MAX_HISTORY = 800 const REASONING_PULSE_MS = 700 const WHEEL_SCROLL_STEP = 3 const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()) +const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] @@ -90,6 +93,7 @@ const nextDetailsMode = (m: DetailsMode): DetailsMode => // ── Pure helpers ───────────────────────────────────────────────────── const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) +type PasteSnippet = { label: string; text: string } const shortCwd = (cwd: string, max = 28) => { const home = process.env.HOME @@ -339,6 +343,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [reasoningStreaming, setReasoningStreaming] = useState(false) const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') + const [pasteSnips, setPasteSnips] = useState([]) const [streaming, setStreaming] = useState('') const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) @@ -672,6 +677,7 @@ export function App({ gw }: { gw: GatewayClient }) { setInfo(null) setHistoryItems([]) setMessages([]) + setPasteSnips([]) setActivity([]) setBgTasks(new Set()) setUsage(ZERO) @@ -687,6 +693,7 @@ export function App({ gw }: { gw: GatewayClient }) { setHistoryItems(info ? [introMsg(info)] : []) setInfo(info) setUsage(info?.usage ? { ...ZERO, ...info.usage } : ZERO) + setPasteSnips([]) setActivity([]) setLastUserMsg('') turnToolsRef.current = [] @@ -852,9 +859,25 @@ export function App({ gw }: { gw: GatewayClient }) { return null } + const lineCount = cleanedText.split('\n').length + + if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { + return { + cursor: cursor + cleanedText.length, + value: value.slice(0, cursor) + cleanedText + value.slice(cursor) + } + } + + const label = pasteTokenLabel(cleanedText, lineCount) + const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' + const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' + const insert = `${lead}${label}${tail}` + + setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32)) + return { - cursor: cursor + cleanedText.length, - value: value.slice(0, cursor) + cleanedText + value.slice(cursor) + cursor: cursor + insert.length, + value: value.slice(0, cursor) + insert + value.slice(cursor) } }, [paste] @@ -863,6 +886,15 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Send ───────────────────────────────────────────────────────── const send = (text: string) => { + const expandPasteSnips = (value: string) => { + const byLabel = new Map() + for (const item of pasteSnips) { + const list = byLabel.get(item.label) + list ? list.push(item.text) : byLabel.set(item.label, [item.text]) + } + return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token) + } + const startSubmit = (displayText: string, submitText: string) => { if (statusTimerRef.current) { clearTimeout(statusTimerRef.current) @@ -893,14 +925,14 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity(`detected file: ${r.name}`) } - startSubmit(r.text || text, r.text || text) + startSubmit(r.text || text, expandPasteSnips(r.text || text)) return } - startSubmit(text, text) + startSubmit(text, expandPasteSnips(text)) }) - .catch(() => startSubmit(text, text)) + .catch(() => startSubmit(text, expandPasteSnips(text))) } const shellExec = (cmd: string) => { diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 9c67b1e372..ba1880ed3c 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -59,35 +59,18 @@ export const edgePreview = (s: string, head = 16, tail = 28) => { return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}` } -export const pasteTokenLabel = ({ - charCount, - id, - lineCount, - text, - tokenCount -}: { - charCount: number - id: number - lineCount: number - text: string - tokenCount: number -}) => { +export const pasteTokenLabel = (text: string, lineCount: number) => { const preview = edgePreview(text) - const counts = `[${fmtK(lineCount)} lines · ${fmtK(tokenCount)} tok · ${fmtK(charCount)} chars]` if (!preview) { - return `[[paste:${id} ${counts}]]` - } - - const one = text.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]') - - if (one.length === preview.length) { - return `[[paste:${id} ${preview} ${counts}]]` + return `[[ [${fmtK(lineCount)} lines] ]]` } const [head = preview, tail = ''] = preview.split('.. ', 2) - return `[[paste:${id} ${head.trimEnd()}.. ${counts} .. ${tail.trimStart()}]]` + return tail + ? `[[ ${head.trimEnd()}.. [${fmtK(lineCount)} lines] .. ${tail.trimStart()} ]]` + : `[[ ${preview} [${fmtK(lineCount)} lines] ]]` } export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => {