feat: small refactors

This commit is contained in:
Brooklyn Nicholson 2026-04-06 18:38:13 -05:00
parent e2b3b1c5e4
commit afd670a36f
12 changed files with 2780 additions and 68 deletions

View file

@ -45,6 +45,7 @@ Usage:
import argparse import argparse
import os import os
import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -562,7 +563,16 @@ def _launch_tui():
print(f" cd {tui_dir} && npm install") print(f" cd {tui_dir} && npm install")
sys.exit(1) sys.exit(1)
sys.exit(subprocess.call(["npm", "start"], cwd=str(tui_dir))) tsx = tui_dir / "node_modules" / ".bin" / "tsx"
if tsx.exists():
sys.exit(subprocess.call([str(tsx), "src/entry.tsx"], cwd=str(tui_dir)))
npm = shutil.which("npm")
if not npm:
print("npm not found in PATH. Source your nvm/node setup or set PATH.")
sys.exit(1)
sys.exit(subprocess.call([npm, "start"], cwd=str(tui_dir)))
def cmd_chat(args): def cmd_chat(args):
@ -5529,27 +5539,20 @@ Examples:
# Handle top-level --resume / --continue as shortcut to chat # Handle top-level --resume / --continue as shortcut to chat
if (args.resume or args.continue_last) and args.command is None: if (args.resume or args.continue_last) and args.command is None:
args.command = "chat" args.command = "chat"
args.query = None for attr, default in [("query", None), ("model", None), ("provider", None),
args.model = None ("toolsets", None), ("verbose", False), ("worktree", False)]:
args.provider = None if not hasattr(args, attr):
args.toolsets = None setattr(args, attr, default)
args.verbose = False
if not hasattr(args, "worktree"):
args.worktree = False
cmd_chat(args) cmd_chat(args)
return return
# Default to chat if no command specified # Default to chat if no command specified
if args.command is None: if args.command is None:
args.query = None for attr, default in [("query", None), ("model", None), ("provider", None),
args.model = None ("toolsets", None), ("verbose", False), ("resume", None),
args.provider = None ("continue_last", None), ("worktree", False)]:
args.toolsets = None if not hasattr(args, attr):
args.verbose = False setattr(args, attr, default)
args.resume = None
args.continue_last = None
if not hasattr(args, "worktree"):
args.worktree = False
cmd_chat(args) cmd_chat(args)
return return

2372
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,79 @@
import json
import threading
import time
from unittest.mock import patch
from tui_gateway import server
class _ChunkyStdout:
def __init__(self):
self.parts: list[str] = []
def write(self, text: str) -> int:
for ch in text:
self.parts.append(ch)
time.sleep(0.0001)
return len(text)
def flush(self) -> None:
return None
class _BrokenStdout:
def write(self, text: str) -> int:
raise BrokenPipeError
def flush(self) -> None:
return None
def test_write_json_serializes_concurrent_writes(monkeypatch):
out = _ChunkyStdout()
monkeypatch.setattr(server.sys, "stdout", out)
threads = [
threading.Thread(target=server.write_json, args=({"seq": i, "text": "x" * 24},))
for i in range(8)
]
for t in threads:
t.start()
for t in threads:
t.join()
lines = "".join(out.parts).splitlines()
assert len(lines) == 8
assert {json.loads(line)["seq"] for line in lines} == set(range(8))
def test_write_json_returns_false_on_broken_pipe(monkeypatch):
monkeypatch.setattr(server.sys, "stdout", _BrokenStdout())
assert server.write_json({"ok": True}) is False
def test_status_callback_emits_kind_and_text():
with patch("tui_gateway.server._emit") as emit:
cb = server._agent_cbs("sid")["status_callback"]
cb("context_pressure", "85% to compaction")
emit.assert_called_once_with(
"status.update",
"sid",
{"kind": "context_pressure", "text": "85% to compaction"},
)
def test_status_callback_accepts_single_message_argument():
with patch("tui_gateway.server._emit") as emit:
cb = server._agent_cbs("sid")["status_callback"]
cb("thinking...")
emit.assert_called_once_with(
"status.update",
"sid",
{"kind": "status", "text": "thinking..."},
)

View file

@ -2,25 +2,17 @@ import json
import signal import signal
import sys import sys
from tui_gateway.server import handle_request, resolve_skin from tui_gateway.server import handle_request, resolve_skin, write_json
signal.signal(signal.SIGPIPE, signal.SIG_DFL) signal.signal(signal.SIGPIPE, signal.SIG_DFL)
def _write(obj: dict):
try:
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
except BrokenPipeError:
sys.exit(0)
def main(): def main():
_write({ if not write_json({
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "event", "method": "event",
"params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}}, "params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}},
}) }):
sys.exit(0)
for raw in sys.stdin: for raw in sys.stdin:
line = raw.strip() line = raw.strip()
@ -30,12 +22,14 @@ def main():
try: try:
req = json.loads(line) req = json.loads(line)
except json.JSONDecodeError: except json.JSONDecodeError:
_write({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}) if not write_json({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}):
sys.exit(0)
continue continue
resp = handle_request(req) resp = handle_request(req)
if resp is not None: if resp is not None:
_write(resp) if not write_json(resp):
sys.exit(0)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -19,6 +19,7 @@ _methods: dict[str, callable] = {}
_pending: dict[str, threading.Event] = {} _pending: dict[str, threading.Event] = {}
_answers: dict[str, str] = {} _answers: dict[str, str] = {}
_db = None _db = None
_stdout_lock = threading.Lock()
# ── Plumbing ────────────────────────────────────────────────────────── # ── Plumbing ──────────────────────────────────────────────────────────
@ -31,12 +32,29 @@ def _get_db():
return _db return _db
def write_json(obj: dict) -> bool:
line = json.dumps(obj, ensure_ascii=False) + "\n"
try:
with _stdout_lock:
sys.stdout.write(line)
sys.stdout.flush()
return True
except BrokenPipeError:
return False
def _emit(event: str, sid: str, payload: dict | None = None): def _emit(event: str, sid: str, payload: dict | None = None):
params = {"type": event, "session_id": sid} params = {"type": event, "session_id": sid}
if payload: if payload:
params["payload"] = payload params["payload"] = payload
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "method": "event", "params": params}) + "\n") write_json({"jsonrpc": "2.0", "method": "event", "params": params})
sys.stdout.flush()
def _status_update(sid: str, kind: str, text: str | None = None):
body = (text if text is not None else kind).strip()
if not body:
return
_emit("status.update", sid, {"kind": kind if text is not None else "status", "text": body})
def _ok(rid, result: dict) -> dict: def _ok(rid, result: dict) -> dict:
@ -164,7 +182,7 @@ def _agent_cbs(sid: str) -> dict:
tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}), tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}),
thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}),
reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}),
status_callback=lambda text: _emit("status.update", sid, {"text": text}), status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)),
clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}),
) )

View file

@ -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 { Box, Text, useApp, useInput, useStdout } from 'ink'
import TextInput from 'ink-text-input' import TextInput from 'ink-text-input'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { MaskedPrompt } from './components/maskedPrompt.js'
import { MessageLine } from './components/messageLine.js' import { MessageLine } from './components/messageLine.js'
import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.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 { SessionPicker } from './components/sessionPicker.js'
import { Thinking } from './components/thinking.js' import { Thinking } from './components/thinking.js'
import { HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.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 historyDraftRef = useRef('')
const queueEditRef = useRef<number | null>(null) const queueEditRef = useRef<number | null>(null)
const lastEmptyAt = useRef(0) const lastEmptyAt = useRef(0)
const lastStatusNoteRef = useRef('')
const protocolWarnedRef = useRef(false)
const stderrWarnedRef = useRef(false)
const empty = !messages.length const empty = !messages.length
const blocked = !!(clarify || approval || sudo || secret || picker) 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 }, [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(() => { const viewport = useMemo(() => {
if (!messages.length) { if (!messages.length) {
@ -146,7 +164,8 @@ export function App({ gw }: { gw: GatewayClient }) {
for (let i = end - 1; i >= 0 && budget > 0; i--) { for (let i = end - 1; i >= 0 && budget > 0; i--) {
const msg = messages[i]! const msg = messages[i]!
const margin = msg.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 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) { if (budget >= 0) {
start = i start = i
@ -162,7 +181,7 @@ export function App({ gw }: { gw: GatewayClient }) {
} }
return { above: start, end, start } 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 }]), []) const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), [])
@ -181,6 +200,9 @@ export function App({ gw }: { gw: GatewayClient }) {
setMessages([]) setMessages([])
setUsage(ZERO) setUsage(ZERO)
setStatus('ready') setStatus('ready')
lastStatusNoteRef.current = ''
protocolWarnedRef.current = false
stderrWarnedRef.current = false
if (msg) { if (msg) {
sys(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') 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) => { const interpolate = (text: string, then: (result: string) => void) => {
setStatus('interpolating…') setStatus('interpolating…')
const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))]
@ -409,6 +459,10 @@ export function App({ gw }: { gw: GatewayClient }) {
setMessages([]) setMessages([])
} }
if (key.ctrl && ch === 'g') {
return openEditor()
}
if (key.ctrl && ch === 'v') { if (key.ctrl && ch === 'v') {
return paste() return paste()
} }
@ -471,6 +525,29 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'status.update': case 'status.update':
if (p?.text) { if (p?.text) {
setStatus(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 break
@ -785,6 +862,14 @@ export function App({ gw }: { gw: GatewayClient }) {
) )
return true 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': case 'resume':
setPicker(true) setPicker(true)
@ -1436,6 +1521,15 @@ export function App({ gw }: { gw: GatewayClient }) {
? theme.color.warn ? theme.color.warn
: theme.color.dim : 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 ( return (
<AltScreen> <AltScreen>
<Box flexDirection="column" flexGrow={1} padding={1}> <Box flexDirection="column" flexGrow={1} padding={1}>
@ -1573,6 +1667,9 @@ export function App({ gw }: { gw: GatewayClient }) {
setSid(r.session_id) setSid(r.session_id)
setMessages([]) setMessages([])
setUsage(ZERO) setUsage(ZERO)
lastStatusNoteRef.current = ''
protocolWarnedRef.current = false
stderrWarnedRef.current = false
sys(`resumed session (${r.message_count} messages)`) sys(`resumed session (${r.message_count} messages)`)
setStatus('ready') 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} /> <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> <Text color={theme.color.bronze}>{'─'.repeat(cols - 2)}</Text>
{!blocked && ( {!blocked && (

View file

@ -90,11 +90,17 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
} }
i++ i++
const isDiff = lang === 'diff'
nodes.push( nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}> <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) => ( {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} {l}
</Text> </Text>
))} ))}

View file

@ -3,6 +3,27 @@ import { Box, Text } from 'ink'
import { compactPreview } from '../lib/text.js' import { compactPreview } from '../lib/text.js'
import type { Theme } from '../theme.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({ export function QueuedMessages({
cols, cols,
queueEditIdx, queueEditIdx,
@ -18,23 +39,21 @@ export function QueuedMessages({
return null return null
} }
const qWindow = 3 const q = getQueueWindow(queued.length, queueEditIdx)
const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queued.length - qWindow))
const qEnd = Math.min(queued.length, qStart + qWindow)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color={t.color.dim} dimColor> <Text color={t.color.dim} dimColor>
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
</Text> </Text>
{qStart > 0 && ( {q.showLead && (
<Text color={t.color.dim} dimColor> <Text color={t.color.dim} dimColor>
{' '} {' '}
</Text> </Text>
)} )}
{queued.slice(qStart, qEnd).map((item, i) => { {queued.slice(q.start, q.end).map((item, i) => {
const idx = qStart + i const idx = q.start + i
const active = queueEditIdx === idx const active = queueEditIdx === idx
return ( return (
@ -43,9 +62,9 @@ export function QueuedMessages({
</Text> </Text>
) )
})} })}
{qEnd < queued.length && ( {q.showTail && (
<Text color={t.color.dim} dimColor> <Text color={t.color.dim} dimColor>
{' '}and {queued.length - qEnd} more {' '}and {queued.length - q.end} more
</Text> </Text>
)} )}
</Box> </Box>

View file

@ -27,6 +27,8 @@ export function Thinking({
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
const tail = (reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{tools.length ? ( {tools.length ? (
@ -35,17 +37,15 @@ export function Thinking({
{SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name} {SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}
</Text> </Text>
)) ))
) : tail ? (
<Text color={t.color.dim} dimColor wrap="truncate-end">
{SPINNER[frame]} 💭 {tail}
</Text>
) : ( ) : (
<Text color={t.color.dim}> <Text color={t.color.dim}>
{SPINNER[frame]} {face} {verb} {SPINNER[frame]} {face} {verb}
</Text> </Text>
)} )}
{(reasoning || thinking) && (
<Text color={t.color.dim} dimColor wrap="truncate-end">
{' 💭 '}
{(reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')}
</Text>
)}
</Box> </Box>
) )
} }

View file

@ -22,6 +22,7 @@ export const FACES = [
export const HOTKEYS: [string, string][] = [ export const HOTKEYS: [string, string][] = [
['Ctrl+C', 'interrupt / clear / exit'], ['Ctrl+C', 'interrupt / clear / exit'],
['Ctrl+D', 'exit'], ['Ctrl+D', 'exit'],
['Ctrl+G', 'open $EDITOR for prompt'],
['Ctrl+L', 'clear screen'], ['Ctrl+L', 'clear screen'],
['Ctrl+V', 'paste clipboard image (same as /paste)'], ['Ctrl+V', 'paste clipboard image (same as /paste)'],
['Tab', 'complete /commands (registry-aware)'], ['Tab', 'complete /commands (registry-aware)'],

View file

@ -3,6 +3,9 @@ import { EventEmitter } from 'node:events'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { createInterface } from 'node:readline' import { createInterface } from 'node:readline'
const MAX_GATEWAY_LOG_LINES = 200
const MAX_LOG_PREVIEW = 240
export interface GatewayEvent { export interface GatewayEvent {
type: string type: string
session_id?: string session_id?: string
@ -17,6 +20,7 @@ interface Pending {
export class GatewayClient extends EventEmitter { export class GatewayClient extends EventEmitter {
private proc: ChildProcess | null = null private proc: ChildProcess | null = null
private reqId = 0 private reqId = 0
private logs: string[] = []
private pending = new Map<string, Pending>() private pending = new Map<string, Pending>()
start() { 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'], { this.proc = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], {
cwd: root, cwd: root,
stdio: ['pipe', 'pipe', 'inherit'] stdio: ['pipe', 'pipe', 'pipe']
}) })
createInterface({ input: this.proc.stdout! }).on('line', raw => { createInterface({ input: this.proc.stdout! }).on('line', raw => {
try { try {
this.dispatch(JSON.parse(raw)) this.dispatch(JSON.parse(raw))
} catch { } 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>) { 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> { 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}` const id = `r${++this.reqId}`
this.proc!.stdin!.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject }) const timeout = setTimeout(() => {
setTimeout(() => {
if (this.pending.delete(id)) { if (this.pending.delete(id)) {
reject(new Error(`timeout: ${method}`)) reject(new Error(`timeout: ${method}`))
} }
}, 30_000) }, 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)))
}
}) })
} }

View file

@ -7,14 +7,72 @@ export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
export const hasAnsi = (s: string) => s.includes('\x1b[') 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) => { export const compactPreview = (s: string, max: number) => {
const one = s.replace(/\s+/g, ' ').trim() const one = s.replace(/\s+/g, ' ').trim()
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
} }
export const estimateRows = (text: string, w: number) => export const estimateRows = (text: string, w: number, compact = false) => {
text.split('\n').reduce((sum, line) => sum + Math.ceil((stripAnsi(line).length || 1) / w), 0) 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() export const flat = (r: Record<string, string[]>) => Object.values(r).flat()