diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index b51962113..137a5de08 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1,6 +1,9 @@ import json +import sys import threading import time +import types +from pathlib import Path from unittest.mock import patch from tui_gateway import server @@ -30,7 +33,7 @@ class _BrokenStdout: def test_write_json_serializes_concurrent_writes(monkeypatch): out = _ChunkyStdout() - monkeypatch.setattr(server.sys, "stdout", out) + monkeypatch.setattr(server, "_real_stdout", out) threads = [ threading.Thread(target=server.write_json, args=({"seq": i, "text": "x" * 24},)) @@ -50,7 +53,7 @@ def test_write_json_serializes_concurrent_writes(monkeypatch): def test_write_json_returns_false_on_broken_pipe(monkeypatch): - monkeypatch.setattr(server.sys, "stdout", _BrokenStdout()) + monkeypatch.setattr(server, "_real_stdout", _BrokenStdout()) assert server.write_json({"ok": True}) is False @@ -77,3 +80,233 @@ def test_status_callback_accepts_single_message_argument(): "sid", {"kind": "status", "text": "thinking..."}, ) + + +def _session(agent=None, **extra): + return { + "agent": agent if agent is not None else types.SimpleNamespace(), + "session_key": "session-key", + "history": [], + "history_lock": threading.Lock(), + "history_version": 0, + "running": False, + "attached_images": [], + "image_counter": 0, + "cols": 80, + "slash_worker": None, + "show_reasoning": False, + "tool_progress_mode": "all", + **extra, + } + + +def test_config_set_yolo_toggles_session_scope(): + from tools.approval import clear_session, is_session_yolo_enabled + + server._sessions["sid"] = _session() + try: + resp_on = server.handle_request({"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}}) + assert resp_on["result"]["value"] == "1" + assert is_session_yolo_enabled("session-key") is True + + resp_off = server.handle_request({"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}}) + assert resp_off["result"]["value"] == "0" + assert is_session_yolo_enabled("session-key") is False + finally: + clear_session("session-key") + server._sessions.clear() + + +def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): + monkeypatch.setattr(server, "_hermes_home", tmp_path) + agent = types.SimpleNamespace(reasoning_config=None) + server._sessions["sid"] = _session(agent=agent) + + resp_effort = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "low"}} + ) + assert resp_effort["result"]["value"] == "low" + assert agent.reasoning_config == {"enabled": True, "effort": "low"} + + resp_show = server.handle_request( + {"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "show"}} + ) + assert resp_show["result"]["value"] == "show" + assert server._sessions["sid"]["show_reasoning"] is True + + +def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch): + monkeypatch.setattr(server, "_hermes_home", tmp_path) + agent = types.SimpleNamespace(verbose_logging=False) + server._sessions["sid"] = _session(agent=agent) + + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}} + ) + + assert resp["result"]["value"] == "verbose" + assert server._sessions["sid"]["tool_progress_mode"] == "verbose" + assert agent.verbose_logging is True + + +def test_config_set_model_uses_live_switch_path(monkeypatch): + server._sessions["sid"] = _session() + seen = {} + + def _fake_apply(sid, session, raw): + seen["args"] = (sid, session["session_key"], raw) + return "new/model" + + monkeypatch.setattr(server, "_apply_model_switch", _fake_apply) + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "new/model"}} + ) + + assert resp["result"]["value"] == "new/model" + assert seen["args"] == ("sid", "session-key", "new/model") + + +def test_session_compress_uses_compress_helper(monkeypatch): + agent = types.SimpleNamespace() + server._sessions["sid"] = _session(agent=agent) + + monkeypatch.setattr(server, "_compress_session_history", lambda session: (2, {"total": 42})) + monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) + + with patch("tui_gateway.server._emit") as emit: + resp = server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}) + + assert resp["result"]["removed"] == 2 + assert resp["result"]["usage"]["total"] == 42 + emit.assert_called_once_with("session.info", "sid", {"model": "x"}) + + +def test_prompt_submit_sets_approval_session_key(monkeypatch): + from tools.approval import get_current_session_key + + captured = {} + + class _Agent: + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + captured["session_key"] = get_current_session_key(default="") + return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]} + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + self._target() + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + + resp = server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "ping"}}) + + assert resp["result"]["status"] == "streaming" + assert captured["session_key"] == "session-key" + + +def test_prompt_submit_expands_context_refs(monkeypatch): + captured = {} + + class _Agent: + model = "test/model" + base_url = "" + api_key = "" + + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + captured["prompt"] = prompt + return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]} + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + self._target() + + fake_ctx = types.ModuleType("agent.context_references") + fake_ctx.preprocess_context_references = lambda message, **kwargs: types.SimpleNamespace( + blocked=False, message="expanded prompt", warnings=[], references=[], injected_tokens=0 + ) + fake_meta = types.ModuleType("agent.model_metadata") + fake_meta.get_model_context_length = lambda *args, **kwargs: 100000 + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + monkeypatch.setitem(sys.modules, "agent.context_references", fake_ctx) + monkeypatch.setitem(sys.modules, "agent.model_metadata", fake_meta) + + server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "@diff"}}) + + assert captured["prompt"] == "expanded prompt" + + +def test_image_attach_appends_local_image(monkeypatch): + fake_cli = types.ModuleType("cli") + fake_cli._IMAGE_EXTENSIONS = {".png"} + fake_cli._split_path_input = lambda raw: (raw, "") + fake_cli._resolve_attachment_path = lambda raw: Path("/tmp/cat.png") + + server._sessions["sid"] = _session() + monkeypatch.setitem(sys.modules, "cli", fake_cli) + + resp = server.handle_request({"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": "/tmp/cat.png"}}) + + assert resp["result"]["attached"] is True + assert resp["result"]["name"] == "cat.png" + assert len(server._sessions["sid"]["attached_images"]) == 1 + + +def test_input_detect_drop_attaches_image(monkeypatch): + fake_cli = types.ModuleType("cli") + fake_cli._detect_file_drop = lambda raw: { + "path": Path("/tmp/cat.png"), + "is_image": True, + "remainder": "", + } + + server._sessions["sid"] = _session() + monkeypatch.setitem(sys.modules, "cli", fake_cli) + + resp = server.handle_request( + {"id": "1", "method": "input.detect_drop", "params": {"session_id": "sid", "text": "/tmp/cat.png"}} + ) + + assert resp["result"]["matched"] is True + assert resp["result"]["is_image"] is True + assert resp["result"]["text"] == "[User attached image: cat.png]" + + +def test_rollback_restore_resolves_number_and_file_path(): + calls = {} + + class _Mgr: + enabled = True + + def list_checkpoints(self, cwd): + return [{"hash": "aaa111"}, {"hash": "bbb222"}] + + def restore(self, cwd, target, file_path=None): + calls["args"] = (cwd, target, file_path) + return {"success": True, "message": "done"} + + server._sessions["sid"] = _session(agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[]) + resp = server.handle_request( + { + "id": "1", + "method": "rollback.restore", + "params": {"session_id": "sid", "hash": "2", "file_path": "src/app.tsx"}, + } + ) + + assert resp["result"]["success"] is True + assert calls["args"][1] == "bbb222" + assert calls["args"][2] == "src/app.tsx" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5f50ab630..ab60f3a0b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -229,6 +229,122 @@ def _resolve_model() -> str: return "anthropic/claude-sonnet-4" +def _write_config_key(key_path: str, value): + cfg = _load_cfg() + current = cfg + keys = key_path.split(".") + for key in keys[:-1]: + if key not in current or not isinstance(current.get(key), dict): + current[key] = {} + current = current[key] + current[keys[-1]] = value + _save_cfg(cfg) + + +def _load_reasoning_config() -> dict | None: + from hermes_constants import parse_reasoning_effort + + effort = str(_load_cfg().get("agent", {}).get("reasoning_effort", "") or "").strip() + return parse_reasoning_effort(effort) + + +def _load_service_tier() -> str | None: + raw = str(_load_cfg().get("agent", {}).get("service_tier", "") or "").strip().lower() + if not raw or raw in {"normal", "default", "standard", "off", "none"}: + return None + if raw in {"fast", "priority", "on"}: + return "priority" + return None + + +def _load_show_reasoning() -> bool: + return bool(_load_cfg().get("display", {}).get("show_reasoning", False)) + + +def _load_tool_progress_mode() -> str: + raw = _load_cfg().get("display", {}).get("tool_progress", "all") + if raw is False: + return "off" + if raw is True: + return "all" + mode = str(raw or "all").strip().lower() + return mode if mode in {"off", "new", "all", "verbose"} else "all" + + +def _session_show_reasoning(sid: str) -> bool: + return bool(_sessions.get(sid, {}).get("show_reasoning", False)) + + +def _session_tool_progress_mode(sid: str) -> str: + return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all") + + +def _tool_progress_enabled(sid: str) -> bool: + return _session_tool_progress_mode(sid) != "off" + + +def _restart_slash_worker(session: dict): + worker = session.get("slash_worker") + if worker: + try: + worker.close() + except Exception: + pass + try: + session["slash_worker"] = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model())) + except Exception: + session["slash_worker"] = None + + +def _apply_model_switch(sid: str, session: dict, raw_input: str) -> str: + agent = session.get("agent") + if not agent: + os.environ["HERMES_MODEL"] = raw_input + return raw_input + + from hermes_cli.model_switch import switch_model + + result = switch_model( + raw_input=raw_input, + current_provider=getattr(agent, "provider", "") or "", + current_model=getattr(agent, "model", "") or "", + current_base_url=getattr(agent, "base_url", "") or "", + current_api_key=getattr(agent, "api_key", "") or "", + ) + if not result.success: + raise ValueError(result.error_message or "model switch failed") + + agent.switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + os.environ["HERMES_MODEL"] = result.new_model + _restart_slash_worker(session) + _emit("session.info", sid, _session_info(agent)) + return result.new_model + + +def _compress_session_history(session: dict) -> tuple[int, dict]: + from agent.model_metadata import estimate_messages_tokens_rough + + agent = session["agent"] + history = list(session.get("history", [])) + if len(history) < 4: + return 0, _get_usage(agent) + approx_tokens = estimate_messages_tokens_rough(history) + compressed, _ = agent._compress_context( + history, + getattr(agent, "_cached_system_prompt", "") or "", + approx_tokens=approx_tokens, + ) + session["history"] = compressed + session["history_version"] = int(session.get("history_version", 0)) + 1 + return len(history) - len(compressed), _get_usage(agent) + + def _get_usage(agent) -> dict: g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) usage = { @@ -320,14 +436,48 @@ def _tool_ctx(name: str, args: dict) -> str: return "" +def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): + session = _sessions.get(sid) + if session is not None: + try: + from agent.display import capture_local_edit_snapshot + + snapshot = capture_local_edit_snapshot(name, args) + if snapshot is not None: + session.setdefault("edit_snapshots", {})[tool_call_id] = snapshot + except Exception: + pass + if _tool_progress_enabled(sid): + _emit("tool.start", sid, {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}) + + +def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str): + payload = {"tool_id": tool_call_id, "name": name} + session = _sessions.get(sid) + snapshot = None + if session is not None: + snapshot = session.setdefault("edit_snapshots", {}).pop(tool_call_id, None) + try: + from agent.display import render_edit_diff_with_delta + + rendered: list[str] = [] + if render_edit_diff_with_delta(name, result, function_args=args, snapshot=snapshot, print_fn=rendered.append): + payload["inline_diff"] = "\n".join(rendered) + except Exception: + pass + if _tool_progress_enabled(sid) or payload.get("inline_diff"): + _emit("tool.complete", sid, payload) + + 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, "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}), + tool_start_callback=lambda tc_id, name, args: _on_tool_start(sid, tc_id, name, args), + tool_complete_callback=lambda tc_id, name, args, result: _on_tool_complete(sid, tc_id, name, args, result), + tool_progress_callback=lambda name, preview, args: _tool_progress_enabled(sid) + and _emit("tool.progress", sid, {"name": name, "preview": preview}), + tool_gen_callback=lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), - reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), + reasoning_callback=lambda text: _session_show_reasoning(sid) and _emit("reasoning.delta", sid, {"text": text}), status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)), clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), ) @@ -357,7 +507,12 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): cfg = _load_cfg() system_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" return AIAgent( - model=_resolve_model(), quiet_mode=True, platform="tui", + model=_resolve_model(), + quiet_mode=True, + verbose_logging=_load_tool_progress_mode() == "verbose", + reasoning_config=_load_reasoning_config(), + service_tier=_load_service_tier(), + platform="tui", session_id=session_id or key, session_db=_get_db(), ephemeral_system_prompt=system_prompt or None, **_agent_cbs(sid), @@ -369,10 +524,16 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "agent": agent, "session_key": key, "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + "running": False, "attached_images": [], "image_counter": 0, "cols": cols, "slash_worker": None, + "show_reasoning": _load_show_reasoning(), + "tool_progress_mode": _load_tool_progress_mode(), + "edit_snapshots": {}, } try: _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) @@ -397,6 +558,17 @@ def _with_checkpoints(session, fn): return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd())) +def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str: + try: + checkpoints = mgr.list_checkpoints(cwd) + idx = int(ref) - 1 + except ValueError: + return ref + if 0 <= idx < len(checkpoints): + return checkpoints[idx].get("hash", ref) + raise ValueError(f"Invalid checkpoint number. Use 1-{len(checkpoints)}.") + + def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str: """Pre-analyze attached images via vision and prepend descriptions to user text.""" import asyncio, json as _json @@ -561,11 +733,17 @@ def _(rid, params: dict) -> dict: session, err = _sess(params, rid) if err: return err - history, removed = session.get("history", []), 0 - while history and history[-1].get("role") in ("assistant", "tool"): - history.pop(); removed += 1 - if history and history[-1].get("role") == "user": - history.pop(); removed += 1 + removed = 0 + with session["history_lock"]: + history = session.get("history", []) + while history and history[-1].get("role") in ("assistant", "tool"): + history.pop() + removed += 1 + if history and history[-1].get("role") == "user": + history.pop() + removed += 1 + if removed: + session["history_version"] = int(session.get("history_version", 0)) + 1 return _ok(rid, {"removed": removed}) @@ -574,11 +752,11 @@ def _(rid, params: dict) -> dict: session, err = _sess(params, rid) if err: return err - agent = session["agent"] try: - if hasattr(agent, "compress_context"): - agent.compress_context() - return _ok(rid, {"status": "compressed", "usage": _get_usage(agent)}) + with session["history_lock"]: + removed, usage = _compress_session_history(session) + _emit("session.info", params.get("session_id", ""), _session_info(session["agent"])) + return _ok(rid, {"status": "compressed", "removed": removed, "usage": usage}) except Exception as e: return _err(rid, 5005, str(e)) @@ -606,7 +784,8 @@ def _(rid, params: dict) -> dict: return err db = _get_db() old_key = session["session_key"] - history = session.get("history", []) + with session["history_lock"]: + history = [dict(msg) for msg in session.get("history", [])] if not history: return _err(rid, 4008, "nothing to branch — send a message first") new_key = _new_session_key() @@ -666,15 +845,47 @@ def _(rid, params: dict) -> dict: session = _sessions.get(sid) if not session: return _err(rid, 4001, "session not found") - agent, history = session["agent"], session["history"] + with session["history_lock"]: + if session.get("running"): + return _err(rid, 4009, "session busy") + session["running"] = True + history = list(session["history"]) + history_version = int(session.get("history_version", 0)) + images = list(session.get("attached_images", [])) + session["attached_images"] = [] + agent = session["agent"] _emit("message.start", sid) def run(): + approval_token = None try: + from tools.approval import reset_current_session_key, set_current_session_key + approval_token = set_current_session_key(session["session_key"]) cols = session.get("cols", 80) streamer = make_stream_renderer(cols) - images = session.pop("attached_images", []) - prompt = _enrich_with_attached_images(text, images) if images else text + prompt = text + + if isinstance(prompt, str) and "@" in prompt: + from agent.context_references import preprocess_context_references + from agent.model_metadata import get_model_context_length + + ctx_len = get_model_context_length( + getattr(agent, "model", "") or _resolve_model(), + base_url=getattr(agent, "base_url", "") or "", + api_key=getattr(agent, "api_key", "") or "", + ) + ctx = preprocess_context_references( + prompt, + cwd=os.environ.get("TERMINAL_CWD", os.getcwd()), + allowed_root=os.environ.get("TERMINAL_CWD", os.getcwd()), + context_length=ctx_len, + ) + if ctx.blocked: + _emit("error", sid, {"message": "\n".join(ctx.warnings) or "Context injection refused."}) + return + prompt = ctx.message + + prompt = _enrich_with_attached_images(prompt, images) if images else prompt def _stream(delta): payload = {"text": delta} @@ -689,7 +900,10 @@ def _(rid, params: dict) -> dict: if isinstance(result, dict): if isinstance(result.get("messages"), list): - session["history"] = result["messages"] + with session["history_lock"]: + if int(session.get("history_version", 0)) == history_version: + session["history"] = result["messages"] + session["history_version"] = history_version + 1 raw = result.get("final_response", "") status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" else: @@ -703,6 +917,14 @@ def _(rid, params: dict) -> dict: _emit("message.complete", sid, payload) except Exception as e: _emit("error", sid, {"message": str(e)}) + finally: + try: + if approval_token is not None: + reset_current_session_key(approval_token) + except Exception: + pass + with session["history_lock"]: + session["running"] = False threading.Thread(target=run, daemon=True).start() return _ok(rid, {"status": "streaming"}) @@ -733,6 +955,84 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"attached": True, "path": str(img_path), "count": len(session["attached_images"])}) +@method("image.attach") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + raw = str(params.get("path", "") or "").strip() + if not raw: + return _err(rid, 4015, "path required") + try: + from cli import _IMAGE_EXTENSIONS, _resolve_attachment_path, _split_path_input + + path_token, remainder = _split_path_input(raw) + image_path = _resolve_attachment_path(path_token) + if image_path is None: + return _err(rid, 4016, f"image not found: {path_token}") + if image_path.suffix.lower() not in _IMAGE_EXTENSIONS: + return _err(rid, 4016, f"unsupported image: {image_path.name}") + session.setdefault("attached_images", []).append(str(image_path)) + return _ok( + rid, + { + "attached": True, + "path": str(image_path), + "name": image_path.name, + "count": len(session["attached_images"]), + "remainder": remainder, + "text": remainder or f"[User attached image: {image_path.name}]", + }, + ) + except Exception as e: + return _err(rid, 5027, str(e)) + + +@method("input.detect_drop") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + try: + from cli import _detect_file_drop + + raw = str(params.get("text", "") or "") + dropped = _detect_file_drop(raw) + if not dropped: + return _ok(rid, {"matched": False}) + + drop_path = dropped["path"] + remainder = dropped["remainder"] + if dropped["is_image"]: + session.setdefault("attached_images", []).append(str(drop_path)) + text = remainder or f"[User attached image: {drop_path.name}]" + return _ok( + rid, + { + "matched": True, + "is_image": True, + "path": str(drop_path), + "name": drop_path.name, + "count": len(session["attached_images"]), + "text": text, + }, + ) + + text = f"[User attached file: {drop_path}]" + (f"\n{remainder}" if remainder else "") + return _ok( + rid, + { + "matched": True, + "is_image": False, + "path": str(drop_path), + "name": drop_path.name, + "text": text, + }, + ) + except Exception as e: + return _err(rid, 5027, str(e)) + + @method("prompt.background") def _(rid, params: dict) -> dict: text, parent = params.get("text", ""), params.get("session_id", "") @@ -819,39 +1119,94 @@ def _(rid, params: dict) -> dict: @method("config.set") def _(rid, params: dict) -> dict: key, value = params.get("key", ""), params.get("value", "") + session = _sessions.get(params.get("session_id", "")) if key == "model": - os.environ["HERMES_MODEL"] = value - return _ok(rid, {"key": key, "value": value}) + try: + if not value: + return _err(rid, 4002, "model value required") + if session: + value = _apply_model_switch(params.get("session_id", ""), session, value) + else: + os.environ["HERMES_MODEL"] = value + return _ok(rid, {"key": key, "value": value}) + except Exception as e: + return _err(rid, 5001, str(e)) if key == "verbose": cycle = ["off", "new", "all", "verbose"] + cur = session.get("tool_progress_mode", _load_tool_progress_mode()) if session else _load_tool_progress_mode() if value and value != "cycle": - os.environ["HERMES_VERBOSE"] = value - return _ok(rid, {"key": key, "value": value}) - cur = os.environ.get("HERMES_VERBOSE", "all") - try: - idx = cycle.index(cur) - except ValueError: - idx = 2 - nv = cycle[(idx + 1) % len(cycle)] - os.environ["HERMES_VERBOSE"] = nv + nv = str(value).strip().lower() + if nv not in cycle: + return _err(rid, 4002, f"unknown verbose mode: {value}") + else: + try: + idx = cycle.index(cur) + except ValueError: + idx = 2 + nv = cycle[(idx + 1) % len(cycle)] + _write_config_key("display.tool_progress", nv) + if session: + session["tool_progress_mode"] = nv + agent = session.get("agent") + if agent is not None: + agent.verbose_logging = nv == "verbose" return _ok(rid, {"key": key, "value": nv}) if key == "yolo": - nv = "0" if os.environ.get("HERMES_YOLO", "0") == "1" else "1" - os.environ["HERMES_YOLO"] = nv - return _ok(rid, {"key": key, "value": nv}) + try: + if session: + from tools.approval import ( + disable_session_yolo, + enable_session_yolo, + is_session_yolo_enabled, + ) + + current = is_session_yolo_enabled(session["session_key"]) + if current: + disable_session_yolo(session["session_key"]) + nv = "0" + else: + enable_session_yolo(session["session_key"]) + nv = "1" + else: + current = bool(os.environ.get("HERMES_YOLO_MODE")) + if current: + os.environ.pop("HERMES_YOLO_MODE", None) + nv = "0" + else: + os.environ["HERMES_YOLO_MODE"] = "1" + nv = "1" + return _ok(rid, {"key": key, "value": nv}) + except Exception as e: + return _err(rid, 5001, str(e)) if key == "reasoning": - if value in ("show", "on"): - os.environ["HERMES_SHOW_REASONING"] = "1" - return _ok(rid, {"key": key, "value": "show"}) - if value in ("hide", "off"): - os.environ.pop("HERMES_SHOW_REASONING", None) - return _ok(rid, {"key": key, "value": "hide"}) - os.environ["HERMES_REASONING"] = value - return _ok(rid, {"key": key, "value": value}) + try: + from hermes_constants import parse_reasoning_effort + + arg = str(value or "").strip().lower() + if arg in ("show", "on"): + _write_config_key("display.show_reasoning", True) + if session: + session["show_reasoning"] = True + return _ok(rid, {"key": key, "value": "show"}) + if arg in ("hide", "off"): + _write_config_key("display.show_reasoning", False) + if session: + session["show_reasoning"] = False + return _ok(rid, {"key": key, "value": "hide"}) + + parsed = parse_reasoning_effort(arg) + if parsed is None: + return _err(rid, 4002, f"unknown reasoning value: {value}") + _write_config_key("agent.reasoning_effort", arg) + if session and session.get("agent") is not None: + session["agent"].reasoning_config = parsed + return _ok(rid, {"key": key, "value": arg}) + except Exception as e: + return _err(rid, 5001, str(e)) if key in ("prompt", "personality", "skin"): try: @@ -900,6 +1255,12 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) if key == "skin": return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")}) + if key == "mtime": + cfg_path = _hermes_home / "config.yaml" + try: + return _ok(rid, {"mtime": cfg_path.stat().st_mtime if cfg_path.exists() else 0}) + except Exception: + return _ok(rid, {"mtime": 0}) return _err(rid, 4002, f"unknown config key: {key}") @@ -1235,30 +1596,23 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str): try: if name == "model" and arg and agent: - from hermes_cli.model_switch import switch_model - result = switch_model( - raw_input=arg, - current_provider=getattr(agent, "provider", "") or "", - current_model=getattr(agent, "model", "") or "", - current_base_url=getattr(agent, "base_url", "") or "", - current_api_key=getattr(agent, "api_key", "") or "", - ) - if result.success: - agent.switch_model( - new_model=result.new_model, - new_provider=result.target_provider, - api_key=result.api_key, - base_url=result.base_url, - api_mode=result.api_mode, - ) - _emit("session.info", sid, _session_info(agent)) + _apply_model_switch(sid, session, arg) elif name in ("personality", "prompt") and agent: cfg = _load_cfg() new_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" agent.ephemeral_system_prompt = new_prompt or None agent._cached_system_prompt = None elif name == "compress" and agent: - (getattr(agent, "compress_context", None) or getattr(agent, "context_compressor", agent).compress)() + with session["history_lock"]: + _compress_session_history(session) + _emit("session.info", sid, _session_info(agent)) + elif name == "fast" and agent: + mode = arg.lower() + if mode in {"fast", "on"}: + agent.service_tier = "priority" + elif mode in {"normal", "off"}: + agent.service_tier = None + _emit("session.info", sid, _session_info(agent)) elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"): agent.reload_mcp_tools() elif name == "stop": @@ -1384,10 +1738,29 @@ def _(rid, params: dict) -> dict: if err: return err target = params.get("hash", "") + file_path = params.get("file_path", "") if not target: return _err(rid, 4014, "hash required") try: - return _ok(rid, _with_checkpoints(session, lambda mgr, cwd: mgr.restore(cwd, target))) + def go(mgr, cwd): + resolved = _resolve_checkpoint_hash(mgr, cwd, target) + result = mgr.restore(cwd, resolved, file_path=file_path or None) + if result.get("success") and not file_path: + removed = 0 + with session["history_lock"]: + history = session.get("history", []) + while history and history[-1].get("role") in ("assistant", "tool"): + history.pop() + removed += 1 + if history and history[-1].get("role") == "user": + history.pop() + removed += 1 + if removed: + session["history_version"] = int(session.get("history_version", 0)) + 1 + result["history_removed"] = removed + return result + + return _ok(rid, _with_checkpoints(session, go)) except Exception as e: return _err(rid, 5021, str(e)) @@ -1401,7 +1774,7 @@ def _(rid, params: dict) -> dict: if not target: return _err(rid, 4014, "hash required") try: - r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, target)) + r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target))) raw = r.get("diff", "")[:4000] payload = {"stat": r.get("stat", ""), "diff": raw} rendered = render_diff(raw, session.get("cols", 80)) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index d7b6e4ae2..cfe7b7d21 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -189,6 +189,23 @@ function ctxBar(pct: number | undefined, w = 10) { return '█'.repeat(filled) + '░'.repeat(w - filled) } +function fmtDuration(ms: number) { + const total = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(total / 3600) + const mins = Math.floor((total % 3600) / 60) + const secs = total % 60 + + if (hours > 0) { + return `${hours}h ${mins}m` + } + + if (mins > 0) { + return `${mins}m ${secs}s` + } + + return `${secs}s` +} + function StatusRule({ cols, status, @@ -196,6 +213,8 @@ function StatusRule({ model, usage, bgCount, + durationLabel, + voiceLabel, t }: { cols: number @@ -204,6 +223,8 @@ function StatusRule({ model: string usage: Usage bgCount: number + durationLabel?: string + voiceLabel?: string t: Theme }) { const pct = usage.context_percent @@ -218,9 +239,16 @@ function StatusRule({ const pctLabel = pct != null ? `${pct}%` : '' const bar = usage.context_max ? ctxBar(pct) : '' - const segs = [status, model, ctxLabel, bar ? `[${bar}]` : '', pctLabel, bgCount > 0 ? `${bgCount} bg` : ''].filter( - Boolean - ) + const segs = [ + status, + model, + ctxLabel, + bar ? `[${bar}]` : '', + pctLabel, + durationLabel || '', + voiceLabel || '', + bgCount > 0 ? `${bgCount} bg` : '' + ].filter(Boolean) const inner = segs.join(' │ ') const pad = Math.max(0, cols - inner.length - 5) @@ -237,6 +265,8 @@ function StatusRule({ [{bar}] {pctLabel} ) : null} + {durationLabel ? │ {durationLabel} : null} + {voiceLabel ? │ {voiceLabel} : null} {bgCount > 0 ? │ {bgCount} bg : null} {' ' + '─'.repeat(pad)} @@ -314,6 +344,12 @@ export function App({ gw }: { gw: GatewayClient }) { const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null) + const [voiceEnabled, setVoiceEnabled] = useState(false) + const [voiceRecording, setVoiceRecording] = useState(false) + const [voiceProcessing, setVoiceProcessing] = useState(false) + const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) + const [bellOnComplete, setBellOnComplete] = useState(false) + const [clockNow, setClockNow] = useState(() => Date.now()) // ── Refs ───────────────────────────────────────────────────────── @@ -333,6 +369,7 @@ export function App({ gw }: { gw: GatewayClient }) { const statusTimerRef = useRef | null>(null) const busyRef = useRef(busy) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) + const configMtimeRef = useRef(0) colsRef.current = cols busyRef.current = busy reasoningRef.current = reasoning @@ -367,6 +404,12 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + const id = setInterval(() => setClockNow(Date.now()), 1000) + + return () => clearInterval(id) + }, []) + // ── Core actions ───────────────────────────────────────────────── const appendMessage = useCallback((msg: Msg) => { @@ -423,6 +466,44 @@ export function App({ gw }: { gw: GatewayClient }) { [gw, sys] ) + useEffect(() => { + if (!sid) { + return + } + + rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) + rpc('config.get', { key: 'mtime' }).then((r: any) => { + configMtimeRef.current = Number(r?.mtime ?? 0) + }) + rpc('config.get', { key: 'full' }).then((r: any) => { + setBellOnComplete(!!r?.config?.display?.bell_on_complete) + }) + }, [rpc, sid]) + + useEffect(() => { + if (!sid) { + return + } + + const id = setInterval(() => { + rpc('config.get', { key: 'mtime' }).then((r: any) => { + const next = Number(r?.mtime ?? 0) + + if (configMtimeRef.current && next && next !== configMtimeRef.current) { + configMtimeRef.current = next + rpc('reload.mcp', { session_id: sid }).then(() => pushActivity('MCP reloaded after config change')) + rpc('config.get', { key: 'full' }).then((cfg: any) => { + setBellOnComplete(!!cfg?.config?.display?.bell_on_complete) + }) + } else if (!configMtimeRef.current && next) { + configMtimeRef.current = next + } + }) + }, 5000) + + return () => clearInterval(id) + }, [pushActivity, rpc, sid]) + const idle = () => { setThinking(false) setTools([]) @@ -454,6 +535,8 @@ export function App({ gw }: { gw: GatewayClient }) { const resetSession = () => { idle() setReasoning('') + setVoiceRecording(false) + setVoiceProcessing(false) setSid(null as any) // will be set by caller setHistoryItems([]) setMessages([]) @@ -477,6 +560,7 @@ export function App({ gw }: { gw: GatewayClient }) { resetSession() setSid(r.session_id) + setSessionStartedAt(Date.now()) setStatus('ready') if (r.info) { @@ -506,6 +590,7 @@ export function App({ gw }: { gw: GatewayClient }) { .then((r: any) => { resetSession() setSid(r.session_id) + setSessionStartedAt(Date.now()) setInfo(r.info ?? null) const resumed = toTranscriptMessages(r.messages) @@ -667,25 +752,45 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn') } - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - statusTimerRef.current = null + const startSubmit = (displayText: string, submitText: string) => { + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + statusTimerRef.current = null + } + + inflightPasteIdsRef.current = payload.usedIds + setLastUserMsg(text) + appendMessage({ role: 'user', text: displayText }) + setBusy(true) + setStatus('running…') + buf.current = '' + interruptedRef.current = false + + gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { + inflightPasteIdsRef.current = [] + sys(`error: ${e.message}`) + setStatus('ready') + setBusy(false) + }) } - inflightPasteIdsRef.current = payload.usedIds - setLastUserMsg(text) - appendMessage({ role: 'user', text }) - setBusy(true) - setStatus('running…') - buf.current = '' - interruptedRef.current = false + gw.request('input.detect_drop', { session_id: sid, text: payload.text }) + .then((r: any) => { + if (r?.matched) { + if (r.is_image) { + pushActivity(`attached image: ${r.name}`) + } else { + pushActivity(`detected file: ${r.name}`) + } - gw.request('prompt.submit', { session_id: sid, text: payload.text }).catch((e: Error) => { - inflightPasteIdsRef.current = [] - sys(`error: ${e.message}`) - setStatus('ready') - setBusy(false) - }) + startSubmit(r.text || text, r.text || payload.text) + + return + } + + startSubmit(text, payload.text) + }) + .catch(() => startSubmit(text, payload.text)) } const shellExec = (cmd: string) => { @@ -1027,6 +1132,37 @@ export function App({ gw }: { gw: GatewayClient }) { return } + if (ctrl(key, ch, 'b')) { + if (voiceRecording) { + setVoiceRecording(false) + setVoiceProcessing(true) + rpc('voice.record', { action: 'stop' }) + .then((r: any) => { + const transcript = String(r?.text || '').trim() + + if (transcript) { + setInput(prev => (prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript)) + } else { + sys('voice: no speech detected') + } + }) + .catch((e: Error) => sys(`voice error: ${e.message}`)) + .finally(() => { + setVoiceProcessing(false) + setStatus('ready') + }) + } else { + rpc('voice.record', { action: 'start' }) + .then(() => { + setVoiceRecording(true) + setStatus('recording…') + }) + .catch((e: Error) => sys(`voice error: ${e.message}`)) + } + + return + } + if (ctrl(key, ch, 'g')) { return openEditor() } @@ -1184,7 +1320,10 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.start': - setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '' }]) + setTools(prev => [ + ...prev, + { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } + ]) break case 'tool.complete': { @@ -1211,6 +1350,10 @@ export function App({ gw }: { gw: GatewayClient }) { return remaining }) + if (p?.inline_diff) { + sys(p.inline_diff as string) + } + break } @@ -1262,7 +1405,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.delta': if (p?.text && !interruptedRef.current) { - buf.current += p.rendered ?? p.text + buf.current = p.rendered ?? buf.current + p.text setStreaming(buf.current.trimStart()) } @@ -1289,6 +1432,10 @@ export function App({ gw }: { gw: GatewayClient }) { thinking: savedReasoning || undefined, tools: savedTools.length ? savedTools : undefined }) + + if (bellOnComplete && stdout?.isTTY) { + stdout.write('\x07') + } } turnToolsRef.current = [] @@ -1624,14 +1771,31 @@ export function App({ gw }: { gw: GatewayClient }) { if (!arg) { rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`)) } else { - rpc('config.set', { key: 'model', value: arg.replace('--global', '').trim() }).then((r: any) => { - sys(`model → ${r.value}`) - setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) - }) + rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then( + (r: any) => { + sys(`model → ${r.value}`) + setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) + } + ) } return true + case 'image': + rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { + if (!r) { + return + } + + sys(`attached image: ${r.name}`) + + if (r?.remainder) { + setInput(r.remainder) + } + }) + + return true + case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) .then((r: any) => page(r?.output || '(no output)')) @@ -1649,17 +1813,23 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'yolo': - rpc('config.set', { key: 'yolo' }).then((r: any) => sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)) + rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => + sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) + ) return true case 'reasoning': - rpc('config.set', { key: 'reasoning', value: arg || 'medium' }).then((r: any) => sys(`reasoning: ${r.value}`)) + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) => + sys(`reasoning: ${r.value}`) + ) return true case 'verbose': - rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose: ${r.value}`)) + rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => + sys(`verbose: ${r.value}`) + ) return true @@ -1694,6 +1864,7 @@ export function App({ gw }: { gw: GatewayClient }) { rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { if (r?.session_id) { setSid(r.session_id) + setSessionStartedAt(Date.now()) setHistoryItems([]) setMessages([]) sys(`branched → ${r.title}`) @@ -1773,9 +1944,14 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'voice': - rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => + rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { + if (!r) { + return + } + + setVoiceEnabled(!!r?.enabled) sys(`voice${arg === 'on' || arg === 'off' ? '' : ':'} ${r.enabled ? 'on' : 'off'}`) - ) + }) return true @@ -1794,13 +1970,19 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no checkpoints') } - sys(r.checkpoints.map((c: any, i: number) => ` ${i} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) + sys(r.checkpoints.map((c: any, i: number) => ` ${i + 1} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) }) } else { const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub - rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { session_id: sid, hash }).then((r: any) => - sys(r.rendered || r.diff || r.message || 'done') - ) + + const filePath = + sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim() + + rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { + session_id: sid, + hash, + ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) + }).then((r: any) => sys(r.rendered || r.diff || r.message || 'done')) } return true @@ -2003,6 +2185,9 @@ export function App({ gw }: { gw: GatewayClient }) { ? theme.color.warn : theme.color.dim + const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : '' + const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` + // ── Render ─────────────────────────────────────────────────────── return ( @@ -2024,7 +2209,6 @@ export function App({ gw }: { gw: GatewayClient }) { )} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 5bf70b0a5..b32f03cd7 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -39,8 +39,12 @@ export const MessageLine = memo(function MessageLine({ return {msg.text} } + if (msg.role !== 'user' && hasAnsi(msg.text)) { + return {msg.text} + } + if (msg.role === 'assistant') { - return hasAnsi(msg.text) ? {msg.text} : + return } if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) { @@ -63,7 +67,11 @@ export const MessageLine = memo(function MessageLine({ })() return ( - + {msg.thinking && ( 💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index bcee8b7a7..9ec53ddc0 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -25,6 +25,12 @@ const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : i const TreeFork = ({ last }: { last: boolean }) => {last ? '└─ ' : '├─ '} +const fmtElapsed = (ms: number) => { + const sec = Math.max(0, ms) / 1000 + + return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` +} + export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const [spin] = useState(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] @@ -48,16 +54,26 @@ export const ToolTrail = memo(function ToolTrail({ tools = [], trail = [], activity = [], - animateCot = false, - padAfter = false + animateCot = false }: { t: Theme tools?: ActiveTool[] trail?: string[] activity?: ActivityItem[] animateCot?: boolean - padAfter?: boolean }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!tools.length) { + return + } + + const id = setInterval(() => setNow(Date.now()), 200) + + return () => clearInterval(id) + }, [tools.length]) + if (!trail.length && !tools.length && !activity.length) { return null } @@ -70,7 +86,6 @@ export const ToolTrail = memo(function ToolTrail({ <> {trail.map((line, i) => { const lastInBlock = i === rowCount - 1 - const suffix = padAfter && lastInBlock ? '\n' : '' if (isToolTrailResultLine(line)) { return ( @@ -81,7 +96,6 @@ export const ToolTrail = memo(function ToolTrail({ > {line} - {suffix} ) } @@ -91,7 +105,6 @@ export const ToolTrail = memo(function ToolTrail({ {line} - {suffix} ) } @@ -100,34 +113,30 @@ export const ToolTrail = memo(function ToolTrail({ {line} - {suffix} ) })} {tools.map((tool, j) => { const lastInBlock = trail.length + j === rowCount - 1 - const suffix = padAfter && lastInBlock ? '\n' : '' return ( {TOOL_VERBS[tool.name] ?? tool.name} {tool.context ? `: ${tool.context}` : ''} - {suffix} + {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} ) })} {act.map((item, k) => { const lastInBlock = trail.length + tools.length + k === rowCount - 1 - const suffix = padAfter && lastInBlock ? '\n' : '' return ( {activityGlyph(item)} {item.text} - {suffix} ) })} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 0c87b2cc7..164123244 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -2,6 +2,7 @@ export interface ActiveTool { id: string name: string context?: string + startedAt?: number } export interface ActivityItem {