mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: small refactors
This commit is contained in:
parent
e2b3b1c5e4
commit
afd670a36f
12 changed files with 2780 additions and 68 deletions
|
|
@ -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
2372
package-lock.json
generated
File diff suppressed because it is too large
Load diff
79
tests/test_tui_gateway_server.py
Normal file
79
tests/test_tui_gateway_server.py
Normal 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..."},
|
||||||
|
)
|
||||||
|
|
@ -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__":
|
||||||
|
|
|
||||||
|
|
@ -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}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)'],
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue