feat: small refactors
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { Box, Text, useApp, useInput, useStdout } from 'ink'
|
||||
import TextInput from 'ink-text-input'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -8,7 +13,7 @@ import { CommandPalette } from './components/commandPalette.js'
|
||||
import { MaskedPrompt } from './components/maskedPrompt.js'
|
||||
import { MessageLine } from './components/messageLine.js'
|
||||
import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js'
|
||||
import { QueuedMessages } from './components/queuedMessages.js'
|
||||
import { estimateQueuedRows, QueuedMessages } from './components/queuedMessages.js'
|
||||
import { SessionPicker } from './components/sessionPicker.js'
|
||||
import { Thinking } from './components/thinking.js'
|
||||
import { HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js'
|
||||
@@ -73,6 +78,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
const historyDraftRef = useRef('')
|
||||
const queueEditRef = useRef<number | null>(null)
|
||||
const lastEmptyAt = useRef(0)
|
||||
const lastStatusNoteRef = useRef('')
|
||||
const protocolWarnedRef = useRef(false)
|
||||
const stderrWarnedRef = useRef(false)
|
||||
|
||||
const empty = !messages.length
|
||||
const blocked = !!(clarify || approval || sudo || secret || picker)
|
||||
@@ -130,7 +138,17 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}
|
||||
}, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2)
|
||||
const paletteMatches = useMemo(() => (!blocked && input.startsWith('/') ? paletteForLine(input, catalog) : []), [
|
||||
blocked,
|
||||
catalog,
|
||||
input
|
||||
])
|
||||
|
||||
const queueRows = useMemo(() => estimateQueuedRows(queuedDisplay.length, queueEditIdx), [queueEditIdx, queuedDisplay.length])
|
||||
const thinkingRows = thinking ? Math.max(1, tools.length || 1) + (reasoning || thinkingText ? 1 : 0) : 0
|
||||
const paletteRows = paletteMatches.length ? paletteMatches.length + 1 : 0
|
||||
const footerRows = statusBar ? 1 : 0
|
||||
const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - thinkingRows - queueRows - paletteRows - footerRows - 2)
|
||||
|
||||
const viewport = useMemo(() => {
|
||||
if (!messages.length) {
|
||||
@@ -146,7 +164,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
for (let i = end - 1; i >= 0 && budget > 0; i--) {
|
||||
const msg = messages[i]!
|
||||
const margin = msg.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0
|
||||
budget -= margin + estimateRows(msg.role === 'user' ? userDisplay(msg.text) : msg.text, width)
|
||||
const text = msg.role === 'user' ? userDisplay(msg.text) : msg.text
|
||||
budget -= margin + estimateRows(text, width, compact && msg.role === 'assistant')
|
||||
|
||||
if (budget >= 0) {
|
||||
start = i
|
||||
@@ -162,7 +181,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}
|
||||
|
||||
return { above: start, end, start }
|
||||
}, [cols, messages, msgBudget, scrollOffset])
|
||||
}, [cols, compact, messages, msgBudget, scrollOffset])
|
||||
|
||||
const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), [])
|
||||
|
||||
@@ -181,6 +200,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
setMessages([])
|
||||
setUsage(ZERO)
|
||||
setStatus('ready')
|
||||
lastStatusNoteRef.current = ''
|
||||
protocolWarnedRef.current = false
|
||||
stderrWarnedRef.current = false
|
||||
|
||||
if (msg) {
|
||||
sys(msg)
|
||||
@@ -273,6 +295,34 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
sys(r.attached ? `📎 image #${r.count} attached` : r.message || 'no image in clipboard')
|
||||
)
|
||||
|
||||
const openEditor = () => {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-'))
|
||||
const file = join(dir, 'prompt.md')
|
||||
|
||||
writeFileSync(file, [...inputBuf, input].join('\n'))
|
||||
|
||||
process.stdout.write('\x1b[?1049l')
|
||||
const { status } = spawnSync(editor, [file], { stdio: 'inherit' })
|
||||
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H')
|
||||
|
||||
if (status === 0) {
|
||||
try {
|
||||
const text = readFileSync(file, 'utf8').trimEnd()
|
||||
|
||||
if (text) {
|
||||
setInput('')
|
||||
setInputBuf([])
|
||||
submit(text)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const interpolate = (text: string, then: (result: string) => void) => {
|
||||
setStatus('interpolating…')
|
||||
const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))]
|
||||
@@ -409,6 +459,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
if (key.ctrl && ch === 'g') {
|
||||
return openEditor()
|
||||
}
|
||||
|
||||
if (key.ctrl && ch === 'v') {
|
||||
return paste()
|
||||
}
|
||||
@@ -471,6 +525,29 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
case 'status.update':
|
||||
if (p?.text) {
|
||||
setStatus(p.text)
|
||||
|
||||
if (p.kind && p.kind !== 'status' && lastStatusNoteRef.current !== p.text) {
|
||||
lastStatusNoteRef.current = p.text
|
||||
sys(p.text)
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'gateway.stderr':
|
||||
if (!stderrWarnedRef.current) {
|
||||
stderrWarnedRef.current = true
|
||||
sys('gateway stderr captured · /logs to inspect')
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'gateway.protocol_error':
|
||||
setStatus('protocol warning')
|
||||
|
||||
if (!protocolWarnedRef.current) {
|
||||
protocolWarnedRef.current = true
|
||||
sys('protocol noise detected · /logs to inspect')
|
||||
}
|
||||
|
||||
break
|
||||
@@ -785,6 +862,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
)
|
||||
|
||||
return true
|
||||
case 'logs': {
|
||||
const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20))
|
||||
const out = gw.getLogTail(limit)
|
||||
|
||||
sys(out || 'no gateway logs')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case 'resume':
|
||||
setPicker(true)
|
||||
@@ -1436,6 +1521,15 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
? theme.color.warn
|
||||
: theme.color.dim
|
||||
|
||||
const footer = [
|
||||
sid ? `session ${sid}` : 'no session',
|
||||
info?.model ? info.model.split('/').pop() : '',
|
||||
queuedDisplay.length ? `queue ${queuedDisplay.length}` : '',
|
||||
usage.total > 0 ? `${fmtK(usage.total)} tok` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<AltScreen>
|
||||
<Box flexDirection="column" flexGrow={1} padding={1}>
|
||||
@@ -1573,6 +1667,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
setSid(r.session_id)
|
||||
setMessages([])
|
||||
setUsage(ZERO)
|
||||
lastStatusNoteRef.current = ''
|
||||
protocolWarnedRef.current = false
|
||||
stderrWarnedRef.current = false
|
||||
sys(`resumed session (${r.message_count} messages)`)
|
||||
setStatus('ready')
|
||||
})
|
||||
@@ -1585,10 +1682,17 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!blocked && input.startsWith('/') && <CommandPalette matches={paletteForLine(input, catalog)} t={theme} />}
|
||||
{!!paletteMatches.length && <CommandPalette matches={paletteMatches} t={theme} />}
|
||||
|
||||
<QueuedMessages cols={cols} queued={queuedDisplay} queueEditIdx={queueEditIdx} t={theme} />
|
||||
|
||||
{statusBar && (
|
||||
<Text color={theme.color.dim}>
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
{footer ? ` · ${footer}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text color={theme.color.bronze}>{'─'.repeat(cols - 2)}</Text>
|
||||
|
||||
{!blocked && (
|
||||
|
||||
@@ -90,11 +90,17 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
}
|
||||
|
||||
i++
|
||||
const isDiff = lang === 'diff'
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.cornsilk} key={j}>
|
||||
<Text
|
||||
color={isDiff && l.startsWith('+') ? '#a6e3a1' : isDiff && l.startsWith('-') ? '#f38ba8' : t.color.cornsilk}
|
||||
dimColor={isDiff && !l.startsWith('+') && !l.startsWith('-') && l.startsWith(' ')}
|
||||
key={j}
|
||||
>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,27 @@ import { Box, Text } from 'ink'
|
||||
import { compactPreview } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
export const QUEUE_WINDOW = 3
|
||||
|
||||
export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
|
||||
const start =
|
||||
queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, Math.max(0, queueLen - QUEUE_WINDOW)))
|
||||
|
||||
const end = Math.min(queueLen, start + QUEUE_WINDOW)
|
||||
|
||||
return { end, showLead: start > 0, showTail: end < queueLen, start }
|
||||
}
|
||||
|
||||
export function estimateQueuedRows(queueLen: number, queueEditIdx: number | null): number {
|
||||
if (!queueLen) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const win = getQueueWindow(queueLen, queueEditIdx)
|
||||
|
||||
return 1 + (win.showLead ? 1 : 0) + (win.end - win.start) + (win.showTail ? 1 : 0)
|
||||
}
|
||||
|
||||
export function QueuedMessages({
|
||||
cols,
|
||||
queueEditIdx,
|
||||
@@ -18,23 +39,21 @@ export function QueuedMessages({
|
||||
return null
|
||||
}
|
||||
|
||||
const qWindow = 3
|
||||
const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queued.length - qWindow))
|
||||
const qEnd = Math.min(queued.length, qStart + qWindow)
|
||||
const q = getQueueWindow(queued.length, queueEditIdx)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim} dimColor>
|
||||
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
|
||||
</Text>
|
||||
{qStart > 0 && (
|
||||
{q.showLead && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
{' '}
|
||||
…
|
||||
</Text>
|
||||
)}
|
||||
{queued.slice(qStart, qEnd).map((item, i) => {
|
||||
const idx = qStart + i
|
||||
{queued.slice(q.start, q.end).map((item, i) => {
|
||||
const idx = q.start + i
|
||||
const active = queueEditIdx === idx
|
||||
|
||||
return (
|
||||
@@ -43,9 +62,9 @@ export function QueuedMessages({
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
{qEnd < queued.length && (
|
||||
{q.showTail && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
{' '}…and {queued.length - qEnd} more
|
||||
{' '}…and {queued.length - q.end} more
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -27,6 +27,8 @@ export function Thinking({
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const tail = (reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{tools.length ? (
|
||||
@@ -35,17 +37,15 @@ export function Thinking({
|
||||
{SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}…
|
||||
</Text>
|
||||
))
|
||||
) : tail ? (
|
||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
||||
{SPINNER[frame]} 💭 {tail}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim}>
|
||||
{SPINNER[frame]} {face} {verb}…
|
||||
</Text>
|
||||
)}
|
||||
{(reasoning || thinking) && (
|
||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
||||
{' 💭 '}
|
||||
{(reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const FACES = [
|
||||
export const HOTKEYS: [string, string][] = [
|
||||
['Ctrl+C', 'interrupt / clear / exit'],
|
||||
['Ctrl+D', 'exit'],
|
||||
['Ctrl+G', 'open $EDITOR for prompt'],
|
||||
['Ctrl+L', 'clear screen'],
|
||||
['Ctrl+V', 'paste clipboard image (same as /paste)'],
|
||||
['Tab', 'complete /commands (registry-aware)'],
|
||||
|
||||
@@ -3,6 +3,9 @@ import { EventEmitter } from 'node:events'
|
||||
import { resolve } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const MAX_GATEWAY_LOG_LINES = 200
|
||||
const MAX_LOG_PREVIEW = 240
|
||||
|
||||
export interface GatewayEvent {
|
||||
type: string
|
||||
session_id?: string
|
||||
@@ -17,6 +20,7 @@ interface Pending {
|
||||
export class GatewayClient extends EventEmitter {
|
||||
private proc: ChildProcess | null = null
|
||||
private reqId = 0
|
||||
private logs: string[] = []
|
||||
private pending = new Map<string, Pending>()
|
||||
|
||||
start() {
|
||||
@@ -24,18 +28,40 @@ export class GatewayClient extends EventEmitter {
|
||||
|
||||
this.proc = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], {
|
||||
cwd: root,
|
||||
stdio: ['pipe', 'pipe', 'inherit']
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
createInterface({ input: this.proc.stdout! }).on('line', raw => {
|
||||
try {
|
||||
this.dispatch(JSON.parse(raw))
|
||||
} catch {
|
||||
/* malformed line */
|
||||
const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)'
|
||||
this.pushLog(`[protocol] malformed stdout: ${preview}`)
|
||||
this.emit('event', { type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent)
|
||||
}
|
||||
})
|
||||
|
||||
this.proc.on('exit', code => this.emit('exit', code))
|
||||
createInterface({ input: this.proc.stderr! }).on('line', raw => {
|
||||
const line = raw.trim()
|
||||
|
||||
if (!line) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pushLog(line)
|
||||
this.emit('event', { type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent)
|
||||
})
|
||||
|
||||
this.proc.on('error', err => {
|
||||
this.pushLog(`[spawn] ${err.message}`)
|
||||
this.rejectPending(new Error(`gateway error: ${err.message}`))
|
||||
this.emit('event', { type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent)
|
||||
})
|
||||
|
||||
this.proc.on('exit', code => {
|
||||
this.rejectPending(new Error(`gateway exited${code === null ? '' : ` (${code})`}`))
|
||||
this.emit('exit', code)
|
||||
})
|
||||
}
|
||||
|
||||
private dispatch(msg: Record<string, unknown>) {
|
||||
@@ -54,19 +80,57 @@ export class GatewayClient extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private pushLog(line: string) {
|
||||
this.logs.push(line)
|
||||
|
||||
if (this.logs.length > MAX_GATEWAY_LOG_LINES) {
|
||||
this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES)
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(err: Error) {
|
||||
for (const [id, pending] of this.pending) {
|
||||
this.pending.delete(id)
|
||||
pending.reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
getLogTail(limit = 20): string {
|
||||
return this.logs.slice(-Math.max(1, limit)).join('\n')
|
||||
}
|
||||
|
||||
request(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
if (!this.proc?.stdin) {
|
||||
return Promise.reject(new Error('gateway not running'))
|
||||
}
|
||||
|
||||
const id = `r${++this.reqId}`
|
||||
|
||||
this.proc!.stdin!.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
|
||||
setTimeout(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pending.delete(id)) {
|
||||
reject(new Error(`timeout: ${method}`))
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
this.pending.set(id, {
|
||||
reject: e => {
|
||||
clearTimeout(timeout)
|
||||
reject(e)
|
||||
},
|
||||
resolve: v => {
|
||||
clearTimeout(timeout)
|
||||
resolve(v)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
this.proc!.stdin!.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
|
||||
} catch (e) {
|
||||
clearTimeout(timeout)
|
||||
this.pending.delete(id)
|
||||
reject(e instanceof Error ? e : new Error(String(e)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,72 @@ export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
|
||||
|
||||
export const hasAnsi = (s: string) => s.includes('\x1b[')
|
||||
|
||||
const renderEstimateLine = (line: string) => {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed.startsWith('|')) {
|
||||
return trimmed
|
||||
.split('|')
|
||||
.filter(Boolean)
|
||||
.map(cell => cell.trim())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
return line
|
||||
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/^#{1,3}\s+/, '')
|
||||
.replace(/^\s*[-*]\s+/, '• ')
|
||||
.replace(/^\s*(\d+)\.\s+/, '$1. ')
|
||||
.replace(/^>\s?/, '│ ')
|
||||
}
|
||||
|
||||
export const compactPreview = (s: string, max: number) => {
|
||||
const one = s.replace(/\s+/g, ' ').trim()
|
||||
|
||||
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
|
||||
}
|
||||
|
||||
export const estimateRows = (text: string, w: number) =>
|
||||
text.split('\n').reduce((sum, line) => sum + Math.ceil((stripAnsi(line).length || 1) / w), 0)
|
||||
export const estimateRows = (text: string, w: number, compact = false) => {
|
||||
let inCode = false
|
||||
let rows = 0
|
||||
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = stripAnsi(raw)
|
||||
|
||||
if (line.startsWith('```')) {
|
||||
if (!inCode) {
|
||||
const lang = line.slice(3).trim()
|
||||
|
||||
if (lang) {
|
||||
rows += Math.ceil((`─ ${lang}`.length || 1) / w)
|
||||
}
|
||||
}
|
||||
|
||||
inCode = !inCode
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rendered = inCode ? line : renderEstimateLine(line)
|
||||
|
||||
if (compact && !rendered.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
rows += Math.ceil((rendered.length || 1) / w)
|
||||
}
|
||||
|
||||
return Math.max(1, rows)
|
||||
}
|
||||
|
||||
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user