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 {