tui updates for rendering pipeline

This commit is contained in:
Brooklyn Nicholson 2026-04-07 20:10:33 -05:00
parent dcb97f7465
commit 29f2610e4b
12 changed files with 896 additions and 1030 deletions

View file

@ -29,20 +29,19 @@ export function Banner({ t }: { t: Theme }) {
{t.brand.icon} NOUS HERMES
</Text>
)}
<Text />
<Text>
<Text color={t.color.amber}>{t.brand.icon} Nous Research</Text>
<Text color={t.color.dim}> · Messenger of the Digital Gods</Text>
</Text>
<Text color={t.color.dim}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
</Box>
)
}
export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) {
export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) {
const cols = useStdout().stdout?.columns ?? 100
const wide = cols >= 90
const w = wide ? cols - 46 : cols - 10
const leftW = wide ? 34 : 0
const w = wide ? cols - leftW - 12 : cols - 10
const cwd = info.cwd || process.cwd()
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}`
const truncLine = (pfx: string, items: string[]) => {
let line = ''
@ -60,7 +59,7 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) {
return line
}
const section = (title: string, data: Record<string, string[]>, max = 8) => {
const section = (title: string, data: Record<string, string[]>, max = 8, overflowLabel = 'more…') => {
const entries = Object.entries(data).sort()
const shown = entries.slice(0, max)
const overflow = entries.length - max
@ -76,7 +75,7 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) {
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && <Text color={t.color.dim}>(and {overflow} more)</Text>}
{overflow > 0 && <Text color={t.color.dim}>(and {overflow} {overflowLabel})</Text>}
</Box>
)
}
@ -84,17 +83,22 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) {
return (
<Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
{wide && (
<Box flexDirection="column" marginRight={2} width={34}>
<Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={caduceus(t.color)} />
<Text />
<Text color={t.color.dim}>Nous Research</Text>
<Text color={t.color.amber}>
{info.model.split('/').pop()}
<Text color={t.color.dim}> · Nous Research</Text>
</Text>
<Text color={t.color.dim} wrap="truncate-end">{cwd}</Text>
{sid && <Text color={t.color.dim}>Session: {sid}</Text>}
</Box>
)}
<Box flexDirection="column" width={w}>
<Text bold color={t.color.gold}>
{t.brand.icon} {t.brand.name}
</Text>
{section('Tools', info.tools)}
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.gold}>{title}</Text>
</Box>
{section('Tools', info.tools, 8, 'more toolsets…')}
{section('Skills', info.skills)}
<Text />
<Text color={t.color.cornsilk}>
@ -103,10 +107,14 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) {
{' · '}
<Text color={t.color.dim}>/help for commands</Text>
</Text>
<Text color={t.color.dim}>
{info.model.split('/').pop()}
{' · '}Ctrl+C to interrupt
</Text>
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color="yellow">
{info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
<Text bold={false} color="yellow" dimColor> run </Text>
<Text bold color="yellow">{info.update_command || 'hermes update'}</Text>
<Text bold={false} color="yellow" dimColor> to update</Text>
</Text>
)}
</Box>
</Box>
)

View file

@ -8,13 +8,12 @@ export function CommandPalette({ matches, t }: { matches: [string, string][]; t:
}
return (
<Box flexDirection="column" marginBottom={1}>
<Box borderColor={t.color.bronze} borderStyle="single" flexDirection="column" paddingX={1}>
{matches.map(([cmd, desc], i) => (
<Text key={`${i}-${cmd}`}>
<Text bold color={t.color.amber}>
{cmd}
</Text>
{desc ? <Text color={t.color.dim}> {desc}</Text> : null}
</Text>
))}

View file

@ -70,6 +70,21 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
const lines = text.split('\n')
const nodes: ReactNode[] = []
let i = 0
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'table' | null = null
const gap = () => {
if (nodes.length && prevKind !== 'blank') {
nodes.push(<Text key={`gap-${nodes.length}`}>{' '}</Text>)
prevKind = 'blank'
}
}
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
gap()
}
prevKind = kind
}
while (i < lines.length) {
const line = lines[i]!
@ -81,7 +96,15 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
continue
}
if (!line.trim()) {
gap()
i++
continue
}
if (line.startsWith('```')) {
start('code')
const lang = line.slice(3).trim()
const block: string[] = []
@ -115,6 +138,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
const heading = line.match(/^#{1,3}\s+(.*)/)
if (heading) {
start('heading')
nodes.push(
<Text bold color={t.color.amber} key={key}>
{heading[1]}
@ -128,6 +152,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
const bullet = line.match(/^\s*[-*]\s(.*)/)
if (bullet) {
start('list')
nodes.push(
<Text key={key}>
<Text color={t.color.dim}> </Text>
@ -142,6 +167,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
const numbered = line.match(/^\s*(\d+)\.\s(.*)/)
if (numbered) {
start('list')
nodes.push(
<Text key={key}>
<Text color={t.color.dim}> {numbered[1]}. </Text>
@ -154,6 +180,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
}
if (line.match(/^>\s?/)) {
start('quote')
const quoteLines: string[] = []
while (i < lines.length && lines[i]!.match(/^>\s?/)) {
@ -176,6 +203,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
}
if (line.includes('|') && line.trim().startsWith('|')) {
start('table')
const tableRows: string[][] = []
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
@ -210,7 +238,9 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
continue
}
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
i++
}

View file

@ -5,47 +5,52 @@ 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({ compact, msg, t }: { 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)
if (msg.role === 'tool') {
return (
<Text color={t.color.dim} wrap="wrap">
{' '}{msg.text}
</Text>
)
}
const content = (() => {
if (msg.role === 'assistant') {
if (hasAnsi(msg.text)) {
return <Text>{msg.text}</Text>
}
return <Md compact={compact} t={t} text={msg.text} />
}
if (msg.role === 'assistant')
return hasAnsi(msg.text) ? <Text wrap="wrap">{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
if (msg.role === 'user' && msg.text.length > LONG_MSG) {
const displayed = userDisplay(msg.text)
const [head, ...rest] = displayed.split('[long message]')
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
return (
<Text color={body}>
{head}
<Text color={t.color.dim} dimColor>
[long message]
</Text>
<Text color={t.color.dim} dimColor>[long message]</Text>
{rest.join('')}
</Text>
)
}
return <Text color={body}>{msg.text}</Text>
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
})()
return (
<Box>
<Box width={3}>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
<Box flexDirection="column">
{(msg.role === 'user' || msg.role === 'assistant') && <Text>{' '}</Text>}
<Box>
<Box flexShrink={0} width={3}>
<Text bold={msg.role === 'user'} color={prefix}>{glyph} </Text>
</Box>
<Box width={contentWidth}>
{content}
</Box>
</Box>
{content}
</Box>
)
})

View file

@ -0,0 +1,128 @@
import { Text, useInput } from 'ink'
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--
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++
return i
}
const ESC = String.fromCharCode(0x1b)
const INV = ESC + '[7m'
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
interface Props {
value: string
onChange: (v: string) => void
onSubmit?: (v: string) => void
onLargePaste?: (text: string) => string
placeholder?: string
focus?: boolean
}
export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder = '', focus = true }: Props) {
const [cur, setCur] = useState(value.length)
const vRef = useRef(value)
const selfChange = useRef(false)
const pasteBuf = useRef('')
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pastePos = useRef(0)
vRef.current = value
useEffect(() => {
if (selfChange.current) { selfChange.current = false } else { setCur(value.length) }
}, [value])
const flushPaste = () => {
const pasted = pasteBuf.current
const at = pastePos.current
pasteBuf.current = ''
pasteTimer.current = null
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)
selfChange.current = true
onChange(nv)
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
onChange(nv)
setCur(at + clean.length)
}
}
}
useInput(
(inp, k) => {
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
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) {
const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (!raw) return
const isMultiChar = raw.length > 1 || raw.includes('\n')
if (isMultiChar) {
if (!pasteBuf.current) pastePos.current = c
pasteBuf.current += raw
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
}
else return
c = Math.max(0, Math.min(c, v.length))
setCur(c)
if (v !== value) { selfChange.current = true; onChange(v) }
},
{ isActive: focus }
)
if (!focus) return <Text>{value || (placeholder ? DIM + placeholder + DIM_OFF : '')}</Text>
if (!value && placeholder) return <Text>{INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF}</Text>
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
return <Text>{r}</Text>
}

View file

@ -1,64 +1,53 @@
import { Text } from 'ink'
import { memo, useEffect, useRef, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js'
import { pick } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool } from '../types.js'
function SpinnerChar({ color }: { color: string }) {
const ref = useRef(0)
function Spinner({ color }: { color: string }) {
const [i, setI] = useState(0)
useEffect(() => {
const id = setInterval(() => {
ref.current = (ref.current + 1) % SPINNER.length
}, 80)
const id = setInterval(() => setI(p => (p + 1) % SPINNER.length), 80)
return () => clearInterval(id)
}, [])
return <Text color={color}>{SPINNER[ref.current]}</Text>
return <Text color={color}>{SPINNER[i]}</Text>
}
export const Thinking = memo(function Thinking({
reasoning,
t,
thinking,
tools
reasoning, t, tools
}: {
reasoning: string
t: Theme
thinking?: string
tools: ActiveTool[]
reasoning: string; t: Theme; tools: ActiveTool[]
}) {
const [verb] = useState(() => pick(VERBS))
const [face] = useState(() => pick(FACES))
const [verb, setVerb] = useState(() => pick(VERBS))
const [face, setFace] = useState(() => pick(FACES))
const tail = (reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')
useEffect(() => {
const id = setInterval(() => { setVerb(pick(VERBS)); setFace(pick(FACES)) }, 1100)
return () => clearInterval(id)
}, [])
if (tools.length) {
return (
<>
{tools.map(tool => (
<Text color={t.color.dim} key={tool.id}>
{TOOL_VERBS[tool.name] ?? tool.name}
</Text>
))}
</>
)
}
if (tail) {
return (
<Text color={t.color.dim} dimColor wrap="truncate-end">
💭 {tail}
</Text>
)
}
const tail = reasoning.slice(-160).replace(/\n/g, ' ')
return (
<Text color={t.color.dim}>
<SpinnerChar color={t.color.dim} /> {face} {verb}
</Text>
<>
{tools.map(tool => (
<Text color={t.color.dim} key={tool.id}>
<Spinner color={t.color.amber} /> {TOOL_VERBS[tool.name] ?? tool.name}
{tool.context ? ` ${tool.context}` : ''}
</Text>
))}
{!tools.length && (
<Text color={t.color.dim}>
<Spinner color={t.color.dim} /> {face} {verb}
</Text>
)}
{tail && <Text color={t.color.dim} dimColor wrap="truncate-end">💭 {tail}</Text>}
</>
)
})