diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index a950a14ab9..0109be3f44 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -82,6 +82,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!stdout) { return } + const sync = () => setCols(stdout.columns ?? 80) stdout.on('resize', sync) @@ -197,6 +198,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (input === compInputRef.current) { return } + compInputRef.current = input const isSlash = input.startsWith('/') @@ -225,6 +227,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (compInputRef.current !== input) { return } + setCompletions(r?.items ?? []) setCompIdx(0) setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) @@ -880,6 +883,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!sid) { return true } + rpc('session.undo', { session_id: sid }).then((r: any) => { if (r.removed > 0) { setMessages(prev => { @@ -913,6 +917,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (sid) { gw.request('session.undo', { session_id: sid }).catch(() => {}) } + setMessages(prev => { const q = [...prev] @@ -1174,6 +1179,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (r.info) { appendHistory(introMsg(r.info)) } + setUsage(ZERO) lastStatusNoteRef.current = '' protocolWarnedRef.current = false diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 45214accb0..e18b5523b3 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -75,7 +75,11 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {truncLine(strip(k) + ': ', vs)} ))} - {overflow > 0 && (and {overflow} {overflowLabel})} + {overflow > 0 && ( + + (and {overflow} {overflowLabel}) + + )} ) } @@ -90,13 +94,17 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {info.model.split('/').pop()} · Nous Research - {cwd} + + {cwd} + {sid && Session: {sid}} )} - {title} + + {title} + {section('Tools', info.tools, 8, 'more toolsets…')} {section('Skills', info.skills)} @@ -110,9 +118,17 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {typeof info.update_behind === 'number' && info.update_behind > 0 && ( ⚠ {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind - — run - {info.update_command || 'hermes update'} - to update + + {' '} + — run{' '} + + + {info.update_command || 'hermes update'} + + + {' '} + to update + )} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 65868c89d3..2abb5bf41b 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -23,7 +23,11 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[4]) { - parts.push({m[4]}) + parts.push( + + {m[4]} + + ) } else if (m[5]) { parts.push( @@ -31,7 +35,11 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[6]) { - parts.push({m[6]}) + parts.push( + + {m[6]} + + ) } else if (m[7]) { parts.push( @@ -58,7 +66,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const gap = () => { if (nodes.length && prevKind !== 'blank') { - nodes.push({' '}) + nodes.push( ) prevKind = 'blank' } } @@ -67,6 +75,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st if (prevKind && prevKind !== 'blank' && prevKind !== kind) { gap() } + prevKind = kind } @@ -104,9 +113,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st {lang && !isDiff && {'─ ' + lang}} {block.map((l, j) => ( diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index e3058eb073..1274a51f29 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -5,9 +5,20 @@ import { LONG_MSG, ROLE } from '../constants.js' import { hasAnsi, userDisplay } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg } from '../types.js' + import { Md } from './markdown.js' -export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: { cols: number; compact?: boolean; msg: Msg; t: Theme }) { +export const MessageLine = memo(function MessageLine({ + cols, + compact, + msg, + t +}: { + cols: number + compact?: boolean + msg: Msg + t: Theme +}) { const { body, glyph, prefix } = ROLE[msg.role](t) const contentWidth = Math.max(20, cols - 5) @@ -20,8 +31,9 @@ export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: } const content = (() => { - if (msg.role === 'assistant') + if (msg.role === 'assistant') { return hasAnsi(msg.text) ? {msg.text} : + } if (msg.role === 'user' && msg.text.length > LONG_MSG) { const [head, ...rest] = userDisplay(msg.text).split('[long message]') @@ -29,7 +41,9 @@ export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: return ( {head} - [long message] + + [long message] + {rest.join('')} ) @@ -40,16 +54,16 @@ export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: return ( - {(msg.role === 'user' || msg.role === 'assistant') && {' '}} + {(msg.role === 'user' || msg.role === 'assistant') && } - {glyph} + + {glyph}{' '} + - - {content} - + {content} ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 63a19750fe..2ac64efb37 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -3,15 +3,29 @@ import { useEffect, useRef, useState } from 'react' function wl(s: string, p: number) { let i = p - 1 - while (i > 0 && /\s/.test(s[i]!)) i-- - while (i > 0 && !/\s/.test(s[i - 1]!)) i-- + + while (i > 0 && /\s/.test(s[i]!)) { + i-- + } + + while (i > 0 && !/\s/.test(s[i - 1]!)) { + i-- + } + return Math.max(0, i) } function wr(s: string, p: number) { let i = p - while (i < s.length && !/\s/.test(s[i]!)) i++ - while (i < s.length && /\s/.test(s[i]!)) i++ + + while (i < s.length && !/\s/.test(s[i]!)) { + i++ + } + + while (i < s.length && /\s/.test(s[i]!)) { + i++ + } + return i } @@ -21,7 +35,7 @@ const INV_OFF = ESC + '[27m' const DIM = ESC + '[2m' const DIM_OFF = ESC + '[22m' const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ -const BRACKET_PASTE = /\x1b\[20[01]~/g +const BRACKET_PASTE = new RegExp(`${ESC}\\[20[01]~`, 'g') interface Props { value: string @@ -42,7 +56,11 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder vRef.current = value useEffect(() => { - if (selfChange.current) { selfChange.current = false } else { setCur(value.length) } + if (selfChange.current) { + selfChange.current = false + } else { + setCur(value.length) + } }, [value]) const flushPaste = () => { @@ -50,9 +68,13 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder const at = pastePos.current pasteBuf.current = '' pasteTimer.current = null - if (!pasted) return + + if (!pasted) { + return + } const v = vRef.current + if (pasted.split('\n').length >= 5 || pasted.length > 500) { const ph = onLargePaste?.(pasted) ?? pasted.replace(/\n/g, ' ') const nv = v.slice(0, at) + ph + v.slice(at) @@ -61,6 +83,7 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder setCur(at + ph.length) } else { const clean = pasted.replace(/\n/g, ' ') + if (clean.length && PRINTABLE.test(clean)) { const nv = v.slice(0, at) + clean + v.slice(at) selfChange.current = true @@ -72,57 +95,120 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder useInput( (inp, k) => { - if (k.upArrow || k.downArrow || (k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) + if ( + k.upArrow || + k.downArrow || + (k.ctrl && inp === 'c') || + k.tab || + (k.shift && k.tab) || + k.pageUp || + k.pageDown || + k.escape + ) { return - if (k.return) { onSubmit?.(value); return } + } - let c = cur, v = value + if (k.return) { + onSubmit?.(value) + + return + } + + let c = cur, + v = value const mod = k.ctrl || k.meta - if (k.home || (k.ctrl && inp === 'a')) c = 0 - else if (k.end || (k.ctrl && inp === 'e')) c = v.length - else if (k.leftArrow) c = mod ? wl(v, c) : Math.max(0, c - 1) - else if (k.rightArrow) c = mod ? wr(v, c) : Math.min(v.length, c + 1) - else if ((k.backspace || k.delete) && c > 0) { - if (mod) { const t = wl(v, c); v = v.slice(0, t) + v.slice(c); c = t } - else { v = v.slice(0, c - 1) + v.slice(c); c-- } - } - else if (k.ctrl && inp === 'w' && c > 0) { const t = wl(v, c); v = v.slice(0, t) + v.slice(c); c = t } - else if (k.ctrl && inp === 'u') { v = v.slice(c); c = 0 } - else if (k.ctrl && inp === 'k') v = v.slice(0, c) - else if (k.meta && inp === 'b') c = wl(v, c) - else if (k.meta && inp === 'f') c = wr(v, c) - else if (inp.length > 0) { + if (k.home || (k.ctrl && inp === 'a')) { + c = 0 + } else if (k.end || (k.ctrl && inp === 'e')) { + c = v.length + } else if (k.leftArrow) { + c = mod ? wl(v, c) : Math.max(0, c - 1) + } else if (k.rightArrow) { + c = mod ? wr(v, c) : Math.min(v.length, c + 1) + } else if ((k.backspace || k.delete) && c > 0) { + if (mod) { + const t = wl(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else { + v = v.slice(0, c - 1) + v.slice(c) + c-- + } + } else if (k.ctrl && inp === 'w' && c > 0) { + const t = wl(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else if (k.ctrl && inp === 'u') { + v = v.slice(c) + c = 0 + } else if (k.ctrl && inp === 'k') { + v = v.slice(0, c) + } else if (k.meta && inp === 'b') { + c = wl(v, c) + } else if (k.meta && inp === 'f') { + c = wr(v, c) + } else if (inp.length > 0) { const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') - if (!raw) return + + if (!raw) { + return + } const isMultiChar = raw.length > 1 || raw.includes('\n') if (isMultiChar) { - if (!pasteBuf.current) pastePos.current = c + if (!pasteBuf.current) { + pastePos.current = c + } pasteBuf.current += raw - if (pasteTimer.current) clearTimeout(pasteTimer.current) + + if (pasteTimer.current) { + clearTimeout(pasteTimer.current) + } pasteTimer.current = setTimeout(flushPaste, 50) + return } - if (PRINTABLE.test(raw)) { v = v.slice(0, c) + raw + v.slice(c); c += raw.length } - else return + if (PRINTABLE.test(raw)) { + v = v.slice(0, c) + raw + v.slice(c) + c += raw.length + } else { + return + } + } else { + return } - else return c = Math.max(0, Math.min(c, v.length)) setCur(c) - if (v !== value) { selfChange.current = true; onChange(v) } + + if (v !== value) { + selfChange.current = true + onChange(v) + } }, { isActive: focus } ) - if (!focus) return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} - if (!value && placeholder) return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} + if (!focus) { + return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} + } + + if (!value && placeholder) { + return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} + } let r = '' - for (let i = 0; i < value.length; i++) r += i === cur ? INV + value[i] + INV_OFF : value[i] - if (cur === value.length) r += INV + ' ' + INV_OFF + + for (let i = 0; i < value.length; i++) { + r += i === cur ? INV + value[i] + INV_OFF : value[i] + } + + if (cur === value.length) { + r += INV + ' ' + INV_OFF + } + return {r} } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 4d24e812ad..183401f8f4 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -11,6 +11,7 @@ function Spinner({ color }: { color: string }) { useEffect(() => { const id = setInterval(() => setI(p => (p + 1) % SPINNER.length), 80) + return () => clearInterval(id) }, []) @@ -18,15 +19,23 @@ function Spinner({ color }: { color: string }) { } export const Thinking = memo(function Thinking({ - reasoning, t, tools + reasoning, + t, + tools }: { - reasoning: string; t: Theme; tools: ActiveTool[] + reasoning: string + t: Theme + tools: ActiveTool[] }) { const [verb, setVerb] = useState(() => pick(VERBS)) const [face, setFace] = useState(() => pick(FACES)) useEffect(() => { - const id = setInterval(() => { setVerb(pick(VERBS)); setFace(pick(FACES)) }, 1100) + const id = setInterval(() => { + setVerb(pick(VERBS)) + setFace(pick(FACES)) + }, 1100) + return () => clearInterval(id) }, []) @@ -47,7 +56,11 @@ export const Thinking = memo(function Thinking({ )} - {tail && 💭 {tail}} + {tail && ( + + 💭 {tail} + + )} ) }) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index d1abdb78f9..83660c4cb3 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -35,7 +35,7 @@ export const HOTKEYS: [string, string][] = [ ['Home/End', 'start / end of line'], ['\\+Enter', 'multi-line continuation'], ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'], + ['{!cmd}', 'interpolate shell output inline'] ] export const INTERPOLATION_RE = /\{!(.+?)\}/g diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 9af62a73f7..7beb1516ac 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -9,10 +9,16 @@ const file = join(dir, '.hermes_history') let cache: string[] | null = null export function load(): string[] { - if (cache) return cache + if (cache) { + return cache + } try { - if (!existsSync(file)) { cache = []; return cache } + if (!existsSync(file)) { + cache = [] + + return cache + } const lines = readFileSync(file, 'utf8').split('\n') const entries: string[] = [] @@ -26,7 +32,10 @@ export function load(): string[] { current = [] } } - if (current.length) entries.push(current.join('\n')) + + if (current.length) { + entries.push(current.join('\n')) + } cache = entries.slice(-MAX) } catch { @@ -38,21 +47,37 @@ export function load(): string[] { export function append(line: string): void { const trimmed = line.trim() - if (!trimmed) return + + if (!trimmed) { + return + } const items = load() - if (items.at(-1) === trimmed) return + + if (items.at(-1) === trimmed) { + return + } items.push(trimmed) - if (items.length > MAX) items.splice(0, items.length - MAX) + + if (items.length > MAX) { + items.splice(0, items.length - MAX) + } try { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } const ts = new Date().toISOString().replace('T', ' ').replace('Z', '') - const encoded = trimmed.split('\n').map(l => '+' + l).join('\n') + const encoded = trimmed + .split('\n') + .map(l => '+' + l) + .join('\n') appendFileSync(file, `\n# ${ts}\n${encoded}\n`) - } catch { /* ignore */ } + } catch { + /* ignore */ + } } export function all(): string[] {