diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a6e3907c33..0b4be63ded 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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") diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py new file mode 100644 index 0000000000..5d37418643 --- /dev/null +++ b/tui_gateway/slash_worker.py @@ -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() diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index ab41da1227..02b3f69210 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,28 +1,23 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' -import { Box, Text, useApp, useInput, useStdout } from 'ink' -import TextInput from 'ink-text-input' +import { Box, Static, Text, useApp, useInput, useStdout } from 'ink' +import { TextInput } from './components/textInput.js' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { AltScreen } from './altScreen.js' import { Banner, SessionPanel } from './components/branding.js' -import { CommandPalette } from './components/commandPalette.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' -import { estimateQueuedRows, QueuedMessages } from './components/queuedMessages.js' +import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' import { Thinking } from './components/thinking.js' -import { HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' +import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import * as inputHistory from './lib/history.js' -import { upsert } from './lib/messages.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { paletteForLine, tabAdvance } from './lib/slash.js' -import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js' +import { fmtK, hasInterpolation, pick } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -37,16 +32,49 @@ import type { } from './types.js' const PLACEHOLDER = pick(PLACEHOLDERS) +const PASTE_REF_RE = /\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]/g + +const introMsg = (info: SessionInfo): Msg => ({ + role: 'system', + text: '', + kind: 'intro', + info +}) + +function extractTabWord(input: string): string | null { + const m = input.match(/((?:\.\.?\/|~\/|\/|@)[^\s]*)$/) + return m?.[1] ?? null +} + +function StatusRule({ cols, color, dimColor, statusColor, parts }: { + cols: number; color: string; dimColor: string; statusColor: string; parts: (string | false | undefined | null)[] +}) { + const label = parts.filter(Boolean).join(' · ') + const fill = Math.max(0, cols - label.length - 5) + + return ( + + {'─ '}{parts[0]}{label.slice(String(parts[0] || '').length)}{' ' + '─'.repeat(fill)} + + ) +} export function App({ gw }: { gw: GatewayClient }) { const { exit } = useApp() const { stdout } = useStdout() - const cols = stdout?.columns ?? 80 - const rows = stdout?.rows ?? 24 + const [cols, setCols] = useState(stdout?.columns ?? 80) + + useEffect(() => { + if (!stdout) return + const sync = () => setCols(stdout.columns ?? 80) + stdout.on('resize', sync) + return () => { stdout.off('resize', sync) } + }, [stdout]) const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) const [messages, setMessages] = useState([]) + const [historyItems, setHistoryItems] = useState([]) const [status, setStatus] = useState('summoning hermes…') const [sid, setSid] = useState(null) const [theme, setTheme] = useState(DEFAULT_THEME) @@ -67,12 +95,12 @@ export function App({ gw }: { gw: GatewayClient }) { const [lastUserMsg, setLastUserMsg] = useState('') const [queueEditIdx, setQueueEditIdx] = useState(null) const [historyIdx, setHistoryIdx] = useState(null) - const [scrollOffset, setScrollOffset] = useState(0) + const [streaming, setStreaming] = useState('') const [queuedDisplay, setQueuedDisplay] = useState([]) const [catalog, setCatalog] = useState(null) const buf = useRef('') - const stickyRef = useRef(true) + const interruptedRef = useRef(false) const queueRef = useRef([]) const historyRef = useRef(inputHistory.load()) const historyDraftRef = useRef('') @@ -81,6 +109,7 @@ export function App({ gw }: { gw: GatewayClient }) { const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) const stderrWarnedRef = useRef(false) + const pasteCounterRef = useRef(0) const empty = !messages.length const blocked = !!(clarify || approval || sudo || secret || picker) @@ -119,12 +148,6 @@ export function App({ gw }: { gw: GatewayClient }) { } } - useEffect(() => { - if (stickyRef.current) { - setScrollOffset(0) - } - }, [messages.length]) - useEffect(() => { if (!sid || !stdout) { return @@ -138,55 +161,52 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps - const paletteMatches = useMemo( - () => (!blocked && input.startsWith('/') ? paletteForLine(input, catalog) : []), - [blocked, catalog, input] - ) + const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) + const [compIdx, setCompIdx] = useState(0) + const [compReplace, setCompReplace] = useState(0) + const compInputRef = useRef('') - const queueRows = useMemo( - () => estimateQueuedRows(queuedDisplay.length, queueEditIdx), - [queueEditIdx, queuedDisplay.length] - ) + useEffect(() => { + if (blocked) { if (completions.length) { setCompletions([]); setCompIdx(0) }; return } + if (input === compInputRef.current) return + compInputRef.current = input - 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 isSlash = input.startsWith('/') + const pathWord = !isSlash ? extractTabWord(input) : null - const viewport = useMemo(() => { - if (!messages.length) { - return { above: 0, end: 0, start: 0 } + if (!isSlash && !pathWord) { + if (completions.length) { setCompletions([]); setCompIdx(0) } + return } - const end = Math.max(0, messages.length - scrollOffset) - const width = Math.max(20, cols - 5) + const t = setTimeout(() => { + if (compInputRef.current !== input) return - let budget = msgBudget - let start = end + const req = isSlash + ? gw.request('complete.slash', { text: input }) + : gw.request('complete.path', { word: pathWord }) - for (let i = end - 1; i >= 0 && budget > 0; i--) { - const msg = messages[i]! - const margin = msg.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 - const text = msg.role === 'user' ? userDisplay(msg.text) : msg.text - budget -= margin + estimateRows(text, width, compact && msg.role === 'assistant') + req.then((r: any) => { + if (compInputRef.current !== input) return + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + }).catch(() => {}) + }, 60) - if (budget >= 0) { - start = i - } - } + return () => clearTimeout(t) + }, [input, blocked, gw]) // eslint-disable-line react-hooks/exhaustive-deps - if (start === end && end > 0) { - start = end - 1 - } + const appendMessage = useCallback((msg: Msg) => { + setMessages(prev => [...prev, msg]) + setHistoryItems(prev => [...prev, msg]) + }, []) - if (start > 0 && messages[start - 1]?.role === 'user') { - start-- - } + const appendHistory = useCallback((msg: Msg) => { + setHistoryItems(prev => [...prev, msg]) + }, []) - return { above: start, end, start } - }, [cols, compact, messages, msgBudget, scrollOffset]) - - const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), []) + const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) const colsRef = useRef(cols) colsRef.current = cols @@ -214,11 +234,18 @@ export function App({ gw }: { gw: GatewayClient }) { protocolWarnedRef.current = false stderrWarnedRef.current = false + if (r.info) { + setInfo(r.info) + appendHistory(introMsg(r.info)) + } else { + setInfo(null) + } + if (msg) { sys(msg) } }), - [rpc, sys] + [appendHistory, rpc, sys] ) const idle = () => { @@ -231,6 +258,8 @@ export function App({ gw }: { gw: GatewayClient }) { setSecret(null) setReasoning('') setThinkingText('') + setStreaming('') + buf.current = '' } const die = () => { @@ -246,36 +275,28 @@ export function App({ gw }: { gw: GatewayClient }) { historyDraftRef.current = '' } - const scrollBot = () => { - setScrollOffset(0) - stickyRef.current = true - } - - const scrollUp = (n: number) => { - setScrollOffset(prev => Math.min(Math.max(0, messages.length - 1), prev + n)) - stickyRef.current = false - } - - const scrollDown = (n: number) => { - setScrollOffset(prev => { - const v = Math.max(0, prev - n) - - if (!v) { - stickyRef.current = true - } - - return v + const expandPastes = (text: string) => + text.replace(PASTE_REF_RE, (m, path) => { + try { return readFileSync(path, 'utf8') } catch { return m } }) + + const collapsePaste = (text: string) => { + pasteCounterRef.current += 1 + const lineCount = text.split('\n').length + const pasteDir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'pastes') + mkdirSync(pasteDir, { recursive: true }) + const pasteFile = join(pasteDir, `paste_${pasteCounterRef.current}_${new Date().toTimeString().slice(0, 8).replace(/:/g, '')}.txt`) + writeFileSync(pasteFile, text, 'utf8') + return `[Pasted text #${pasteCounterRef.current}: ${lineCount} lines → ${pasteFile}]` } const send = (text: string) => { setLastUserMsg(text) - setMessages(prev => [...prev, { role: 'user', text }]) - scrollBot() - setStatus('thinking…') + appendMessage({ role: 'user', text }) setBusy(true) buf.current = '' - gw.request('prompt.submit', { session_id: sid, text }).catch((e: Error) => { + interruptedRef.current = false + gw.request('prompt.submit', { session_id: sid, text: expandPastes(text) }).catch((e: Error) => { sys(`error: ${e.message}`) setStatus('ready') setBusy(false) @@ -283,7 +304,7 @@ export function App({ gw }: { gw: GatewayClient }) { } const shellExec = (cmd: string) => { - setMessages(prev => [...prev, { role: 'user', text: `!${cmd}` }]) + appendMessage({ role: 'user', text: `!${cmd}` }) setBusy(true) setStatus('running…') gw.request('shell.exec', { command: cmd }) @@ -377,25 +398,14 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (!inputBuf.length && key.tab && input.startsWith('/')) { - const next = tabAdvance(input, catalog) - - if (next) { - setInput(next) - } - + if (completions.length && input && (key.upArrow || key.downArrow)) { + setCompIdx(i => key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length) return } - if (key.pageUp) { - scrollUp(5) - - return - } - - if (key.pageDown) { - scrollDown(5) - + if (!inputBuf.length && key.tab && completions.length) { + const pick = completions[compIdx] + if (pick) setInput(input.slice(0, compReplace) + pick.text) return } @@ -447,7 +457,11 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.ctrl && ch === 'c') { if (busy && sid) { + interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + if (buf.current.trim()) { + appendMessage({ role: 'assistant' as const, text: buf.current.trimStart() }) + } idle() setStatus('interrupted') sys('interrupted by user') @@ -465,18 +479,11 @@ export function App({ gw }: { gw: GatewayClient }) { die() } - if (key.ctrl && ch === 'l') { - setMessages([]) - } if (key.ctrl && ch === 'g') { return openEditor() } - if (key.ctrl && ch === 'v') { - return paste() - } - if (key.escape) { clearIn() } @@ -513,7 +520,6 @@ export function App({ gw }: { gw: GatewayClient }) { case 'session.info': setInfo(p as SessionInfo) - break case 'thinking.delta': @@ -528,7 +534,6 @@ export function App({ gw }: { gw: GatewayClient }) { setBusy(true) setReasoning('') setThinkingText('') - setStatus('thinking…') break @@ -545,11 +550,6 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'gateway.stderr': - if (!stderrWarnedRef.current) { - stderrWarnedRef.current = true - sys('gateway stderr captured · /logs to inspect') - } - break case 'gateway.protocol_error': @@ -570,33 +570,34 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.generating': - if (p?.name) { - setStatus(`preparing ${p.name}…`) - } - break case 'tool.progress': if (p?.preview) { - setMessages(prev => - prev.at(-1)?.role === 'tool' - ? [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] - : [...prev, { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] - ) + setTools(prev => { + const idx = prev.findIndex(t => t.name === p.name) + if (idx >= 0) return [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)] + return prev + }) } break - case 'tool.start': - setTools(prev => [...prev, { id: p.tool_id, name: p.name }]) - setStatus(`running ${p.name}…`) - setMessages(prev => [...prev, { role: 'tool', text: `${TOOL_VERBS[p.name] ?? p.name}…` }]) - + case 'tool.start': { + const ctx = (p.context as string) || '' + setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: ctx }]) + appendMessage({ role: 'tool', text: `${TOOL_VERBS[p.name] ?? p.name}${ctx ? ' ' + ctx : ''}` }) break + } case 'tool.complete': - setTools(prev => prev.filter(t => t.id !== p.tool_id)) - + setTools(prev => { + const done = prev.find(t => t.id === p.tool_id) + const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name + const ctx = done?.context || '' + appendMessage({ role: 'tool', text: `${label}${ctx ? ' ' + ctx : ''} ✓` }) + return prev.filter(t => t.id !== p.tool_id) + }) break case 'clarify.request': @@ -634,20 +635,16 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.delta': - if (!p?.text) { - break - } + if (!p?.text || interruptedRef.current) break buf.current += p.rendered ?? p.text - setThinking(false) - setTools([]) - setReasoning('') - setMessages(prev => upsert(prev, 'assistant', buf.current.trimStart())) + setStreaming(buf.current.trimStart()) break case 'message.complete': { idle() - setMessages(prev => upsert(prev, 'assistant', (p?.rendered ?? p?.text ?? buf.current).trimStart())) + setStreaming('') + appendMessage({ role: 'assistant' as const, text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) buf.current = '' setStatus('ready') @@ -667,8 +664,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (next) { setLastUserMsg(next) - setMessages(prev => [...prev, { role: 'user' as const, text: next }]) - setStatus('thinking…') + appendMessage({ role: 'user' as const, text: next }) setBusy(true) buf.current = '' gw.request('prompt.submit', { session_id: ev.session_id, text: next }).catch((e: Error) => { @@ -690,7 +686,7 @@ export function App({ gw }: { gw: GatewayClient }) { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [gw, sys, newSession] + [appendMessage, gw, sys, newSession] ) useEffect(() => { @@ -715,7 +711,6 @@ export function App({ gw }: { gw: GatewayClient }) { const rows = catalog?.pairs ?? [] const cap = 52 const lines = rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`) - sys( [ ' Commands:', @@ -728,717 +723,108 @@ export function App({ gw }: { gw: GatewayClient }) { .filter(Boolean) .join('\n') ) - return true } + case 'quit': + case 'exit': + case 'q': + die() + return true + case 'clear': setStatus('forging session…') newSession() - - return true - - case 'quit': // falls through - - case 'exit': - die() - return true case 'new': setStatus('forging session…') newSession('new session started') - - return true - - case 'branch': // falls through - - case 'fork': - if (!sid) { - return true - } - - rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - setSid(r.session_id) - setUsage(ZERO) - sys(`branched → ${r.title}`) - setStatus('ready') - }) - - return true - - case 'undo': - if (!sid) { - return true - } - - rpc('session.undo', { session_id: sid }).then((r: any) => { - if (r.removed > 0) { - setMessages(prev => { - const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - if (q.at(-1)?.role === 'user') { - q.pop() - } - - return q - }) - sys(`undid ${r.removed} messages`) - } else { - sys('nothing to undo') - } - }) - - return true - - case 'retry': - if (!lastUserMsg) { - sys('nothing to retry') - - return true - } - - if (sid) { - gw.request('session.undo', { session_id: sid }).catch(() => {}) - } - - setMessages(prev => { - const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - return q - }) - send(lastUserMsg) - return true case 'compact': setCompact(c => (arg ? true : !c)) sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) - return true - case 'compress': - if (!sid) { - return true - } - - rpc('session.compress', { session_id: sid }).then((r: any) => { - sys('context compressed') - - if (r.usage) { - setUsage(r.usage) - } - }) - + case 'resume': + if (!sid) { setPicker(true); return true } + setPicker(true) return true - case 'cost': // falls through - - case 'usage': - sys( - `in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}` - ) - - return true case 'copy': { const all = messages.filter(m => m.role === 'assistant') const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.length - 1] - - if (!target) { - sys('nothing to copy') - - return true - } - + if (!target) { sys('nothing to copy'); return true } writeOsc52Clipboard(target.text) sys('copied to clipboard') - return true } - case 'context': { - const pct = Math.min(100, Math.round((usage.total / MAX_CTX) * 100)) - const bar = Math.round((pct / 100) * 30) - const icon = pct < 50 ? '✓' : pct < 80 ? '⚠' : '✗' - sys( - `context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(bar)}${'░'.repeat(30 - bar)}] ${icon}` - ) - - return true - } - - case 'config': - sys( - `model: ${info?.model ?? '?'} session: ${sid ?? 'none'} compact: ${compact}\ntools: ${flat(info?.tools ?? {}).length} skills: ${flat(info?.skills ?? {}).length}` - ) - - return true - - case 'status': - sys( - `session: ${sid ?? 'none'} status: ${status} tokens: ${fmtK(usage.input)}↑ ${fmtK(usage.output)}↓ (${usage.calls} calls)` - ) - - return true - case 'logs': { - const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20)) - const out = gw.getLogTail(limit) - - sys(out || 'no gateway logs') - - return true - } - - case 'resume': - setPicker(true) - - return true - - case 'history': - if (!sid) { - setPicker(true) - - return true - } - - rpc('session.history', { session_id: sid }).then((r: any) => - sys(`session ${sid}: ${r.count} messages in context`) - ) - - return true - - case 'title': - if (!sid) { - return true - } - - if (!arg) { - rpc('session.title', { session_id: sid }).then((r: any) => - sys(`title: ${r.title || '(none)'} session: ${r.session_key}`) - ) - - return true - } - - rpc('session.title', { session_id: sid, title: arg }).then(() => sys(`title → ${arg}`)) - - return true - - case 'tools': - if (!info?.tools || !Object.keys(info.tools).length) { - sys('no tools loaded') - - return true - } - - sys( - Object.entries(info.tools) - .map(([k, vs]) => `${k} (${vs.length}): ${vs.join(', ')}`) - .join('\n') - ) - - return true - - case 'skills': - if (!arg || arg === 'list') { - if (!info?.skills || !Object.keys(info.skills).length) { - sys('no skills loaded') - - return true - } - - sys( - Object.entries(info.skills) - .map(([k, vs]) => `${k}: ${vs.join(', ')}`) - .join('\n') - ) - - return true - } - - if (arg.startsWith('search ')) { - rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }).then((r: any) => { - if (!r.results?.length) { - sys('no results') - - return - } - - sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) - }) - - return true - } - - if (arg.startsWith('install ')) { - rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }).then((r: any) => - sys(r.installed ? `installed ${r.name}` : 'install failed') - ) - - return true - } - - if (arg === 'browse' || arg.startsWith('browse ')) { - rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }).then((r: any) => { - if (!r.results?.length) { - sys('no skills available') - - return - } - - sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) - }) - - return true - } - - if (arg.startsWith('inspect ')) { - rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }).then((r: any) => - sys(JSON.stringify(r.info, null, 2)) - ) - - return true - } - - sys('usage: /skills [list|search |install |browse|inspect ]') - - return true - - case 'verbose': - rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose → ${r.value}`)) - - return true - - case 'yolo': - rpc('config.set', { key: 'yolo', value: '' }).then((r: any) => - sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`) - ) - - return true - - case 'reasoning': - if (!arg) { - sys('usage: /reasoning ') - - return true - } - - rpc('config.set', { key: 'reasoning', value: arg }).then((r: any) => sys(`reasoning → ${r.value}`)) - - return true - - case 'stop': - rpc('process.stop').then((r: any) => sys(`killed ${r.killed} process(es)`)) - - return true - - case 'profile': - gw.request('config.get', { key: 'profile' }) - .then((r: any) => sys(`profile: ${r.display}`)) - .catch(() => sys(`profile: ${process.env.HERMES_HOME ?? '~/.hermes'}`)) - - return true - - case 'save': - if (!sid) { - return true - } - - rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved to ${r.file}`)) - - return true - - case 'provider': - rpc('config.get', { key: 'provider' }).then((r: any) => { - const lines = [`model: ${r.model} provider: ${r.provider}`] - - if (r.providers?.length) { - lines.push(`available: ${r.providers.join(', ')}`) - } - - sys(lines.join('\n')) - }) - - return true - - case 'prompt': - if (!arg) { - rpc('config.get', { key: 'prompt' }).then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`)) - - return true - } - - rpc('config.set', { key: 'prompt', value: arg }).then((r: any) => - sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared') - ) - - return true - - case 'personality': - if (!arg) { - sys('usage: /personality (concise, creative, analytical, friendly, none)') - - return true - } - - rpc('config.set', { key: 'personality', value: arg }).then((r: any) => - sys(`personality → ${r.value || 'default'}`) - ) - - return true - - case 'plan': - send(arg ? `/plan ${arg}` : 'Create a detailed plan for the current task.') - - return true - - case 'background': - - case 'bg': - if (!arg) { - sys('usage: /background ') - - return true - } - - rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => - sys(`background task ${r.task_id} started`) - ) - - return true - - case 'btw': - if (!arg) { - sys('usage: /btw ') - - return true - } - - rpc('prompt.btw', { session_id: sid, text: arg }).then(() => sys('btw running…')) - - return true - - case 'queue': // falls through - - case 'q': - if (!arg) { - sys(`${queueRef.current.length} queued message(s)`) - - return true - } - - enqueue(arg) - sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) - - return true - - case 'rollback': - if (!sid) { - return true - } - - if (!arg) { - rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r.enabled) { - sys('checkpoints not enabled — use hermes --checkpoints') - - return - } - - if (!r.checkpoints?.length) { - sys('no checkpoints') - - return - } - - sys( - r.checkpoints - .map((c: any, i: number) => ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})`) - .join('\n') - ) - }) - - return true - } - - if (arg.startsWith('diff ')) { - const ref = arg.slice(5).trim() - rpc('rollback.list', { session_id: sid }).then((r: any) => { - const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref - - if (!hash) { - sys(`checkpoint ${ref} not found`) - - return - } - - rpc('rollback.diff', { session_id: sid, hash }).then((d: any) => - sys(d.rendered || d.stat || d.diff || 'no changes') - ) - }) - - return true - } - - { - const parts = arg.trim().split(/\s+/) - const ref = parts[0]! - const file = parts[1] - rpc('rollback.list', { session_id: sid }).then((r: any) => { - const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref - - if (!hash) { - sys(`checkpoint ${ref} not found`) - - return - } - - rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }).then((d: any) => - sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`) - ) - }) - } - - return true - - case 'insights': - rpc('insights.get', { days: arg ? parseInt(arg) : 30 }).then((r: any) => - sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) - ) - - return true - - case 'toolsets': - if (!info?.tools) { - sys('no toolsets loaded') - - return true - } - - sys( - Object.entries(info.tools) - .map(([k, vs]) => `${k}: ${vs.length} tools`) - .join('\n') - ) - - return true - case 'paste': paste() - return true - case 'reload-mcp': - - case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP servers reloaded')) - - return true - - case 'browser': - if (!arg || arg === 'status') { - rpc('browser.manage', { action: 'status' }).then((r: any) => - sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected') - ) - } else if (arg === 'connect' || arg.startsWith('connect ')) { - const url = arg.split(/\s+/)[1] - rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }).then((r: any) => - sys(`browser connected: ${r.url}`) - ) - } else if (arg === 'disconnect') { - rpc('browser.manage', { action: 'disconnect' }).then(() => sys('browser disconnected')) - } else { - sys('usage: /browser [connect|disconnect|status]') - } - - return true - - case 'platforms': - - case 'gateway': - sys('gateway status is not available in TUI mode') - - return true - - case 'statusbar': - - case 'sb': - setStatusBar(v => !v) - sys(`status bar ${statusBar ? 'off' : 'on'}`) - - return true - - case 'voice': - if (!arg || arg === 'status') { - rpc('voice.toggle', { action: 'status' }).then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) - } else if (arg === 'on' || arg === 'off') { - rpc('voice.toggle', { action: arg }).then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`)) - } else if (arg === 'record') { - rpc('voice.record', { action: 'start' }).then(() => sys('recording… (use /voice stop to transcribe)')) - } else if (arg === 'stop') { - rpc('voice.record', { action: 'stop' }).then((r: any) => { - if (r.text) { - send(r.text) - } else { - sys('no speech detected') - } - }) - } else if (arg === 'tts') { - const last = messages.filter(m => m.role === 'assistant').at(-1) - - if (last) { - rpc('voice.tts', { text: last.text }).then(() => sys('speaking…')) - } else { - sys('no response to speak') - } - } else { - sys('usage: /voice [on|off|status|record|stop|tts]') - } - - return true - - case 'plugins': - rpc('plugins.list').then((r: any) => { - if (!r.plugins?.length) { - sys('no plugins installed') - - return - } - - sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n')) - }) - - return true - - case 'cron': - if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }).then((r: any) => { - const jobs = r.jobs || r.schedules || [] - - if (!jobs.length) { - sys('no cron jobs') - - return - } - - sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n')) - }) - } else { - const parts = arg.split(/\s+/) - const sub = parts[0]! - - if (sub === 'add' || sub === 'create') { - const name = parts[1] || '' - const schedule = parts[2] || '' - const prompt = parts.slice(3).join(' ') - rpc('cron.manage', { action: 'add', name, schedule, prompt }).then((r: any) => - sys(r.message || r.status || 'created') - ) - } else { - rpc('cron.manage', { action: sub, name: parts[1] || '' }).then((r: any) => - sys(r.message || r.status || JSON.stringify(r)) - ) - } - } - - return true - - case 'update': - case 'hermes': { - const argv = name === 'update' ? ['update'] : arg.split(/\s+/).filter(Boolean) - - if (!argv.length) { - sys('usage: /hermes (e.g. sessions list, chat -q "hi")') - - return true - } - - if (name === 'update') { - setBusy(true) - setStatus('updating…') - } - - rpc('cli.exec', { argv, timeout: name === 'update' ? 600 : 240 }) - .then((r: any) => { - if (r.blocked) { - return sys(r.hint ?? 'blocked') - } - - sys(r.output ?? '(no output)') - - if (r.code !== 0) { - sys(`exit ${r.code}`) - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - .finally(() => { - if (name === 'update') { - setStatus('ready') - setBusy(false) - } - }) - + case 'logs': { + const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20)) + sys(gw.getLogTail(limit) || 'no gateway logs') return true } - case 'model': - if (!arg) { - sys('usage: /model ') - - return true - } - - rpc('config.set', { key: 'model', value: arg }).then(() => sys(`model → ${arg}`)) - + case 'statusbar': + case 'sb': + setStatusBar(v => !v) + sys(`status bar ${statusBar ? 'off' : 'on'}`) return true - case 'skin': - if (!arg) { - sys('usage: /skin ') + case 'queue': + if (!arg) { sys(`${queueRef.current.length} queued message(s)`); return true } + enqueue(arg) + sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + return true - return true - } - - rpc('config.set', { key: 'skin', value: arg }).then(() => sys(`skin → ${arg} (restart to apply)`)) + case 'undo': + if (!sid) return true + rpc('session.undo', { session_id: sid }).then((r: any) => { + if (r.removed > 0) { + setMessages(prev => { + const q = [...prev] + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() + if (q.at(-1)?.role === 'user') q.pop() + return q + }) + sys(`undid ${r.removed} messages`) + } else sys('nothing to undo') + }) + return true + case 'retry': + if (!lastUserMsg) { sys('nothing to retry'); return true } + if (sid) gw.request('session.undo', { session_id: sid }).catch(() => {}) + setMessages(prev => { + const q = [...prev] + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() + return q + }) + send(lastUserMsg) return true default: - gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) + rpc('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => { - if (r.type === 'exec') { - sys(r.output || '(no output)') - } else if (r.type === 'alias') { - slash(`/${r.target}${arg ? ' ' + arg : ''}`) - } else if (r.type === 'plugin') { - sys(r.output || '(no output)') - } else if (r.type === 'skill') { - sys(`⚡ loading skill: ${r.name}`) - send(r.message) - } + if (r?.output) sys(r.output) + else sys(`/${name}: no output`) }) .catch(() => { - gw.request('command.resolve', { name: name ?? '' }) - .then((r: any) => { - if (!r.canonical || r.canonical === name) { - sys(`unknown command: /${name}`) - } else if (r.category === 'cli-only') { - sys(`/${name} is CLI-only — run it in a terminal`) - } else { - send(`/${r.canonical}${arg ? ' ' + arg : ''}`) - } + gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) + .then((d: any) => { + if (d.type === 'exec') sys(d.output || '(no output)') + else if (d.type === 'alias') slash(`/${d.target}${arg ? ' ' + arg : ''}`) + else if (d.type === 'plugin') sys(d.output || '(no output)') + else if (d.type === 'skill') { sys(`⚡ loading skill: ${d.name}`); send(d.message) } }) .catch(() => sys(`unknown command: /${name}`)) }) - return true } }, @@ -1495,8 +881,23 @@ export function App({ gw }: { gw: GatewayClient }) { if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { replaceQ(editIdx, full) + const picked = queueRef.current.splice(editIdx, 1)[0] + syncQueue() setQueueEdit(null) + if (picked && busy && sid) { + queueRef.current.unshift(picked) + syncQueue() + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + setStatus('interrupting…') + return + } + + if (picked && sid) { + send(picked) + return + } + return } @@ -1551,84 +952,39 @@ export function App({ gw }: { gw: GatewayClient }) { : theme.color.dim return ( - - - {empty ? ( - <> - - {info && } - {!sid ? ( - ⚕ {status} - ) : ( - - type / for commands - {' · '} - ! for shell - {' · '} - Ctrl+C to interrupt - - )} - - ) : ( - - - {theme.brand.icon}{' '} - - - {theme.brand.name} - - - {info?.model ? ` · ${info.model.split('/').pop()}` : ''} - {' · '} - {status} - {busy && ' · Ctrl+C to stop'} - - {usage.total > 0 && ( - - {' · '} - {fmtK(usage.input)}↑ {fmtK(usage.output)}↓ ({usage.calls} calls) - - )} + + + {(m, i) => ( + + {m.kind === 'intro' && m.info + ? ( + + + + + ) + : ( + + )} + + )} + + + + {streaming && ( + + )} - - {viewport.above > 0 && ( - - ↑ {viewport.above} above · PgUp/PgDn to scroll - - )} - - {messages.slice(viewport.start, viewport.end).map((m, i) => { - const ri = viewport.start + i - - return ( - 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0} - > - - - ) - })} - - {scrollOffset > 0 && ( - - ↓ {scrollOffset} below · PgDn or Enter to return - - )} - - {thinking && } - + {(thinking || tools.length > 0) && !streaming && } {clarify && ( { gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) - setMessages(prev => [...prev, { role: 'user', text: answer }]) + appendMessage({ role: 'user', text: answer }) setClarify(null) - setStatus('thinking…') }} req={clarify} t={theme} @@ -1686,6 +1042,8 @@ export function App({ gw }: { gw: GatewayClient }) { .then((r: any) => { setSid(r.session_id) setMessages([]) + setInfo(r.info ?? null) + if (r.info) appendHistory(introMsg(r.info)) setUsage(ZERO) lastStatusNoteRef.current = '' protocolWarnedRef.current = false @@ -1702,51 +1060,45 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} - {!!paletteMatches.length && } - - {statusBar && ( - - {status} - {' · '} - {[ - sid && `session ${sid}`, - info?.model?.split('/').pop(), - queuedDisplay.length && `queue ${queuedDisplay.length}`, - usage.total > 0 && `${fmtK(usage.total)} tok` - ] - .filter(Boolean) - .join(' · ')} - - )} + {' '} - {'─'.repeat(cols - 2)} + 0 && `${fmtK(usage.total)} tok`]} /> {!blocked && ( - - {inputBuf.length ? '… ' : `${theme.brand.prompt} `} - + {inputBuf.length ? '… ' : `${theme.brand.prompt} `} + )} + + {!!completions.length && ( + + {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { + const active = Math.max(0, compIdx - 8) + i === compIdx + return ( + + {item.display} + {item.meta ? {item.meta} : null} + + ) + })} + + )} + + {!empty && !sid && ⚕ {status}} - + ) } diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index ba46d0147d..45214accb0 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -29,20 +29,19 @@ export function Banner({ t }: { t: Theme }) { {t.brand.icon} NOUS HERMES )} - - - {t.brand.icon} Nous Research - · Messenger of the Digital Gods - + {t.brand.icon} Nous Research · Messenger of the Digital Gods ) } -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, max = 8) => { + const section = (title: string, data: Record, 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 }) { {truncLine(strip(k) + ': ', vs)} ))} - {overflow > 0 && (and {overflow} more…)} + {overflow > 0 && (and {overflow} {overflowLabel})} ) } @@ -84,17 +83,22 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { return ( {wide && ( - + - Nous Research + + {info.model.split('/').pop()} + · Nous Research + + {cwd} + {sid && Session: {sid}} )} - - {t.brand.icon} {t.brand.name} - - {section('Tools', info.tools)} + + {title} + + {section('Tools', info.tools, 8, 'more toolsets…')} {section('Skills', info.skills)} @@ -103,10 +107,14 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { {' · '} /help for commands - - {info.model.split('/').pop()} - {' · '}Ctrl+C to interrupt - + {typeof info.update_behind === 'number' && info.update_behind > 0 && ( + + ⚠ {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind + — run + {info.update_command || 'hermes update'} + to update + + )} ) diff --git a/ui-tui/src/components/commandPalette.tsx b/ui-tui/src/components/commandPalette.tsx index 810dc01007..2dad8c04d2 100644 --- a/ui-tui/src/components/commandPalette.tsx +++ b/ui-tui/src/components/commandPalette.tsx @@ -8,13 +8,12 @@ export function CommandPalette({ matches, t }: { matches: [string, string][]; t: } return ( - + {matches.map(([cmd, desc], i) => ( {cmd} - {desc ? — {desc} : null} ))} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index d7b3df17f2..26810a8dd6 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -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({' '}) + prevKind = 'blank' + } + } + + const start = (kind: Exclude) => { + 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( {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( @@ -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( {numbered[1]}. @@ -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() + i++ } diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 5b9f4659a5..6e59f10609 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -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 ( + + {' '}{msg.text} + + ) + } const content = (() => { - if (msg.role === 'assistant') { - if (hasAnsi(msg.text)) { - return {msg.text} - } - - return - } + if (msg.role === 'assistant') + return hasAnsi(msg.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 ( {head} - - [long message] - + [long message] {rest.join('')} ) } - return {msg.text} + return {msg.text} })() return ( - - - - {glyph}{' '} - + + {(msg.role === 'user' || msg.role === 'assistant') && {' '}} + + + + {glyph} + + + + {content} + - {content} ) }) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx new file mode 100644 index 0000000000..63a19750fe --- /dev/null +++ b/ui-tui/src/components/textInput.tsx @@ -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 | 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 {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} + if (!value && placeholder) return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} + + 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 {r} +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 71e3ebf4f3..e5f13992d1 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -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 {SPINNER[ref.current]} + return {SPINNER[i]} } 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 => ( - - ⚡ {TOOL_VERBS[tool.name] ?? tool.name}… - - ))} - - ) - } - - if (tail) { - return ( - - 💭 {tail} - - ) - } + const tail = reasoning.slice(-160).replace(/\n/g, ' ') return ( - - {face} {verb}… - + <> + {tools.map(tool => ( + + {TOOL_VERBS[tool.name] ?? tool.name} + {tool.context ? ` ${tool.context}` : ''} + + ))} + + {!tools.length && ( + + {face} {verb}… + + )} + + {tail && 💭 {tail}} + ) }) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 2211a483eb..64666014e3 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -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 { 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 }) } diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 6300cef3a7..9af62a73f7 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -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[] { diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4e3bfce2d9..8a34f3cb75 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -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 tools: Record + update_behind?: number | null + update_command?: string + version?: string } export interface Usage {