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

@ -1,3 +1,4 @@
import atexit
import json
import os
import subprocess
@ -12,6 +13,12 @@ from hermes_cli.env_loader import load_hermes_dotenv
_hermes_home = get_hermes_home()
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")
try:
from hermes_cli.banner import prefetch_update_check
prefetch_update_check()
except Exception:
pass
from tui_gateway.render import make_stream_renderer, render_diff, render_message
_sessions: dict[str, dict] = {}
@ -21,6 +28,74 @@ _answers: dict[str, str] = {}
_db = None
_stdout_lock = threading.Lock()
# Reserve real stdout for JSON-RPC only; redirect Python's stdout to stderr
# so stray print() from libraries/tools becomes harmless gateway.stderr instead
# of corrupting the JSON protocol.
_real_stdout = sys.stdout
sys.stdout = sys.stderr
class _SlashWorker:
"""Persistent HermesCLI subprocess for slash commands."""
def __init__(self, session_key: str, model: str):
self._lock = threading.Lock()
self._seq = 0
self.stderr_tail: list[str] = []
argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key]
if model:
argv += ["--model", model]
self.proc = subprocess.Popen(
argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, cwd=os.getcwd(), env=os.environ.copy(),
)
threading.Thread(target=self._drain_stderr, daemon=True).start()
def _drain_stderr(self):
for line in (self.proc.stderr or []):
if text := line.rstrip("\n"):
self.stderr_tail = (self.stderr_tail + [text])[-80:]
def run(self, command: str) -> str:
if self.proc.poll() is not None:
raise RuntimeError("slash worker exited")
with self._lock:
self._seq += 1
rid = self._seq
self.proc.stdin.write(json.dumps({"id": rid, "command": command}) + "\n")
self.proc.stdin.flush()
for line in self.proc.stdout:
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("id") != rid:
continue
if not msg.get("ok"):
raise RuntimeError(msg.get("error", "slash worker failed"))
return str(msg.get("output", "")).rstrip()
raise RuntimeError(f"slash worker closed pipe{': ' + chr(10).join(self.stderr_tail[-8:]) if self.stderr_tail else ''}")
def close(self):
try:
if self.proc.poll() is None:
self.proc.terminate()
self.proc.wait(timeout=1)
except Exception:
try: self.proc.kill()
except Exception: pass
atexit.register(lambda: [
s.get("slash_worker") and s["slash_worker"].close()
for s in _sessions.values()
])
# ── Plumbing ──────────────────────────────────────────────────────────
@ -36,8 +111,8 @@ 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()
_real_stdout.write(line)
_real_stdout.flush()
return True
except BrokenPipeError:
return False
@ -158,7 +233,22 @@ def _get_usage(agent) -> dict:
def _session_info(agent) -> dict:
info: dict = {"model": getattr(agent, "model", ""), "tools": {}, "skills": {}}
info: dict = {
"model": getattr(agent, "model", ""),
"tools": {},
"skills": {},
"cwd": os.getcwd(),
"version": "",
"release_date": "",
"update_behind": None,
"update_command": "",
}
try:
from hermes_cli import __version__, __release_date__
info["version"] = __version__
info["release_date"] = __release_date__
except Exception:
pass
try:
from model_tools import get_toolset_for_tool
for t in getattr(agent, "tools", []) or []:
@ -171,12 +261,27 @@ def _session_info(agent) -> dict:
info["skills"] = get_available_skills()
except Exception:
pass
try:
from hermes_cli.banner import get_update_result
from hermes_cli.config import recommended_update_command
info["update_behind"] = get_update_result(timeout=0.5)
info["update_command"] = recommended_update_command()
except Exception:
pass
return info
def _tool_ctx(name: str, args: dict) -> str:
try:
from agent.display import build_tool_preview
return build_tool_preview(name, args, max_len=80) or ""
except Exception:
return ""
def _agent_cbs(sid: str) -> dict:
return dict(
tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name}),
tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name, "context": _tool_ctx(name, args)}),
tool_complete_callback=lambda tc_id, name, args, result: _emit("tool.complete", sid, {"tool_id": tc_id, "name": name}),
tool_progress_callback=lambda name, preview, args: _emit("tool.progress", sid, {"name": name, "preview": preview}),
tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}),
@ -222,7 +327,13 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
"attached_images": [],
"image_counter": 0,
"cols": cols,
"slash_worker": None,
}
try:
_sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model()))
except Exception:
# Defer hard-failure to slash.exec; chat still works without slash worker.
_sessions[sid]["slash_worker"] = None
try:
from tools.approval import register_gateway_notify, load_permanent_allowlist
register_gateway_notify(key, lambda data: _emit("approval.request", sid, data))
@ -283,7 +394,7 @@ def _(rid, params: dict) -> dict:
_init_session(sid, key, agent, [], cols=int(params.get("cols", 80)))
except Exception as e:
return _err(rid, 5000, f"agent init failed: {e}")
return _ok(rid, {"session_id": sid})
return _ok(rid, {"session_id": sid, "info": _session_info(agent)})
@method("session.list")
@ -324,7 +435,7 @@ def _(rid, params: dict) -> dict:
_init_session(sid, target, agent, history, cols=int(params.get("cols", 80)))
except Exception as e:
return _err(rid, 5000, f"resume failed: {e}")
return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history)})
return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history), "info": _session_info(agent)})
@method("session.title")
@ -858,6 +969,177 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
# ── Methods: paste ────────────────────────────────────────────────────
_paste_counter = 0
@method("paste.collapse")
def _(rid, params: dict) -> dict:
global _paste_counter
text = params.get("text", "")
if not text:
return _err(rid, 4004, "empty paste")
_paste_counter += 1
line_count = text.count('\n') + 1
paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
from datetime import datetime
paste_file = paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt"
paste_file.write_text(text, encoding="utf-8")
placeholder = f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]"
return _ok(rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count})
# ── Methods: complete ─────────────────────────────────────────────────
@method("complete.path")
def _(rid, params: dict) -> dict:
word = params.get("word", "")
if not word:
return _ok(rid, {"items": []})
items: list[dict] = []
try:
is_context = word.startswith("@")
query = word[1:] if is_context else word
if is_context and not query:
items = [
{"text": "@diff", "display": "@diff", "meta": "git diff"},
{"text": "@staged", "display": "@staged", "meta": "staged diff"},
{"text": "@file:", "display": "@file:", "meta": "attach file"},
{"text": "@folder:", "display": "@folder:", "meta": "attach folder"},
{"text": "@url:", "display": "@url:", "meta": "fetch url"},
{"text": "@git:", "display": "@git:", "meta": "git log"},
]
return _ok(rid, {"items": items})
if is_context and query.startswith(("file:", "folder:")):
prefix_tag = query.split(":", 1)[0]
path_part = query.split(":", 1)[1] or "."
else:
prefix_tag = ""
path_part = query if not is_context else query
expanded = os.path.expanduser(path_part)
if expanded.endswith("/"):
search_dir, match = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "."
match = os.path.basename(expanded)
match_lower = match.lower()
for entry in sorted(os.listdir(search_dir))[:200]:
if match and not entry.lower().startswith(match_lower):
continue
if is_context and not prefix_tag and entry.startswith("."):
continue
full = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full)
rel = os.path.relpath(full)
suffix = "/" if is_dir else ""
if is_context and prefix_tag:
text = f"@{prefix_tag}:{rel}{suffix}"
elif is_context:
kind = "folder" if is_dir else "file"
text = f"@{kind}:{rel}{suffix}"
elif word.startswith("~"):
text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix
else:
text = rel + suffix
items.append({"text": text, "display": entry + suffix, "meta": "dir" if is_dir else ""})
if len(items) >= 30:
break
except Exception:
pass
return _ok(rid, {"items": items})
@method("complete.slash")
def _(rid, params: dict) -> dict:
text = params.get("text", "")
if not text.startswith("/"):
return _ok(rid, {"items": []})
try:
from hermes_cli.commands import SlashCommandCompleter
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import to_plain_text
completer = SlashCommandCompleter()
doc = Document(text, len(text))
items = [
{"text": c.text, "display": c.display or c.text,
"meta": to_plain_text(c.display_meta) if c.display_meta else ""}
for c in completer.get_completions(doc, None)
][:30]
return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1})
except Exception:
return _ok(rid, {"items": []})
# ── Methods: slash.exec ──────────────────────────────────────────────
def _mirror_slash_side_effects(session: dict, command: str):
"""Apply side effects that must also hit the gateway's live agent."""
parts = command.lstrip("/").split(None, 1)
if not parts:
return
name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent")
try:
if name == "model" and arg and agent:
from hermes_cli.model_switch import switch_model
switch_model(agent, arg)
elif name == "compress" and agent:
(getattr(agent, "compress_context", None) or getattr(agent, "context_compressor", agent).compress)()
elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"):
agent.reload_mcp_tools()
elif name == "stop":
from tools.process_registry import ProcessRegistry
ProcessRegistry().kill_all()
except Exception:
pass
@method("slash.exec")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
cmd = params.get("command", "").strip()
if not cmd:
return _err(rid, 4004, "empty command")
worker = session.get("slash_worker")
if not worker:
try:
worker = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model()))
session["slash_worker"] = worker
except Exception as e:
return _err(rid, 5030, f"slash worker start failed: {e}")
try:
output = worker.run(cmd)
_mirror_slash_side_effects(session, cmd)
return _ok(rid, {"output": output or "(no output)"})
except Exception as e:
try:
worker.close()
except Exception:
pass
session["slash_worker"] = None
return _err(rid, 5030, str(e))
# ── Methods: voice ───────────────────────────────────────────────────
@method("voice.toggle")

View file

@ -0,0 +1,69 @@
"""Persistent slash-command worker — one HermesCLI per TUI session.
Protocol: reads JSON lines from stdin {id, command}, writes {id, ok, output|error} to stdout.
"""
import argparse
import contextlib
import io
import json
import os
import sys
import cli as cli_mod
from cli import HermesCLI
def _run(cli: HermesCLI, command: str) -> str:
cmd = (command or "").strip()
if not cmd:
return ""
if not cmd.startswith("/"):
cmd = f"/{cmd}"
buf = io.StringIO()
old = getattr(cli_mod, "_cprint", None)
if old is not None:
cli_mod._cprint = lambda text: print(text)
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
cli.process_command(cmd)
finally:
if old is not None:
cli_mod._cprint = old
return buf.getvalue().rstrip()
def main():
p = argparse.ArgumentParser(add_help=False)
p.add_argument("--session-key", required=True)
p.add_argument("--model", default="")
args = p.parse_args()
os.environ["HERMES_SESSION_KEY"] = args.session_key
os.environ["HERMES_INTERACTIVE"] = "1"
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False)
for raw in sys.stdin:
line = raw.strip()
if not line:
continue
rid = None
try:
req = json.loads(line)
rid = req.get("id")
out = _run(cli, req.get("command", ""))
sys.stdout.write(json.dumps({"id": rid, "ok": True, "output": out}) + "\n")
sys.stdout.flush()
except Exception as e:
sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

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>}
</>
)
})

View file

@ -29,6 +29,10 @@ export const HOTKEYS: [string, string][] = [
['↑/↓', 'queue edit (if queued) / input history'],
['PgUp/PgDn', 'scroll messages'],
['Esc', 'clear input'],
['Ctrl+A/E', 'home / end of line'],
['Ctrl+W', 'delete word'],
['Ctrl+←/→', 'jump word'],
['Home/End', 'start / end of line'],
['\\+Enter', 'multi-line continuation'],
['!cmd', 'run shell command'],
['{!cmd}', 'interpolate shell output inline'],
@ -53,7 +57,7 @@ export const PLACEHOLDERS = [
export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; prefix: string }> = {
assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }),
system: t => ({ body: t.color.error, glyph: '!', prefix: t.color.error }),
system: t => ({ body: '', glyph: '·', prefix: t.color.dim }),
tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }),
user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label })
}

View file

@ -4,29 +4,31 @@ import { join } from 'node:path'
const MAX = 1000
const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'))
const file = join(dir, 'tui_history')
const file = join(dir, '.hermes_history')
let cache: string[] | null = null
function encode(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/\n/g, '\\n')
}
function decode(s: string): string {
return s.replace(/\\n/g, '\n').replace(/\\\\/g, '\\')
}
export function load(): string[] {
if (cache) {
return cache
}
if (cache) return cache
try {
if (existsSync(file)) {
cache = readFileSync(file, 'utf8').split('\n').filter(Boolean).map(decode).slice(-MAX)
} else {
cache = []
if (!existsSync(file)) { cache = []; return cache }
const lines = readFileSync(file, 'utf8').split('\n')
const entries: string[] = []
let current: string[] = []
for (const line of lines) {
if (line.startsWith('+')) {
current.push(line.slice(1))
} else if (current.length) {
entries.push(current.join('\n'))
current = []
}
}
if (current.length) entries.push(current.join('\n'))
cache = entries.slice(-MAX)
} catch {
cache = []
}
@ -36,32 +38,21 @@ export function load(): string[] {
export function append(line: string): void {
const trimmed = line.trim()
if (!trimmed) {
return
}
if (!trimmed) return
const items = load()
if (items.at(-1) === trimmed) {
return
}
if (items.at(-1) === trimmed) return
items.push(trimmed)
if (items.length > MAX) {
items.splice(0, items.length - MAX)
}
if (items.length > MAX) items.splice(0, items.length - MAX)
try {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
appendFileSync(file, encode(trimmed) + '\n')
} catch {
/* ignore */
}
const ts = new Date().toISOString().replace('T', ' ').replace('Z', '')
const encoded = trimmed.split('\n').map(l => '+' + l).join('\n')
appendFileSync(file, `\n# ${ts}\n${encoded}\n`)
} catch { /* ignore */ }
}
export function all(): string[] {

View file

@ -1,6 +1,7 @@
export interface ActiveTool {
id: string
name: string
context?: string
}
export interface ApprovalReq {
@ -17,14 +18,22 @@ export interface ClarifyReq {
export interface Msg {
role: Role
text: string
kind?: 'intro' | 'tool-active'
info?: SessionInfo
toolId?: string
}
export type Role = 'assistant' | 'system' | 'tool' | 'user'
export interface SessionInfo {
cwd?: string
model: string
release_date?: string
skills: Record<string, string[]>
tools: Record<string, string[]>
update_behind?: number | null
update_command?: string
version?: string
}
export interface Usage {