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[] {