diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 2d7fbd2071..8915d8a6a7 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -332,24 +332,30 @@ class LogSnapshot: tail_text: str full_text: Optional[str] -def _resolve_log_path(log_name: str) -> Optional[Path]: - """Find the log file for *log_name*, falling back to the .1 rotation. - Returns the path if found, or None. - """ +def _primary_log_path(log_name: str) -> Optional[Path]: + """Where *log_name* would live if present. Doesn't check existence.""" from hermes_cli.logs import LOG_FILES filename = LOG_FILES.get(log_name) - if not filename: + return (get_hermes_home() / "logs" / filename) if filename else None + + +def _resolve_log_path(log_name: str) -> Optional[Path]: + """Find the log file for *log_name*, falling back to the .1 rotation. + + Returns the first non-empty candidate (primary, then .1), or None. + Callers distinguish 'empty primary' from 'truly missing' via + :func:`_primary_log_path`. + """ + primary = _primary_log_path(log_name) + if primary is None: return None - log_dir = get_hermes_home() / "logs" - primary = log_dir / filename if primary.exists() and primary.stat().st_size > 0: return primary - # Fall back to the most recent rotated file (.1). - rotated = log_dir / f"{filename}.1" + rotated = primary.parent / f"{primary.name}.1" if rotated.exists() and rotated.stat().st_size > 0: return rotated @@ -370,12 +376,15 @@ def _capture_log_snapshot( """ log_path = _resolve_log_path(log_name) if log_path is None: - return LogSnapshot(path=None, tail_text="(file not found)", full_text=None) + primary = _primary_log_path(log_name) + tail = "(file empty)" if primary and primary.exists() else "(file not found)" + return LogSnapshot(path=None, tail_text=tail, full_text=None) try: size = log_path.stat().st_size if size == 0: - return LogSnapshot(path=log_path, tail_text="(file not found)", full_text=None) + # race: file was truncated between _resolve_log_path and stat + return LogSnapshot(path=log_path, tail_text="(file empty)", full_text=None) with open(log_path, "rb") as f: if size <= max_bytes: diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py index 91795151bf..4bba56867e 100644 --- a/tests/hermes_cli/test_debug.py +++ b/tests/hermes_cli/test_debug.py @@ -158,14 +158,27 @@ class TestCaptureLogSnapshot: assert snap.full_text is None assert snap.tail_text == "(file not found)" - def test_returns_none_for_empty(self, hermes_home): - # Truncate agent.log to empty + def test_empty_primary_reports_file_empty(self, hermes_home): + """Empty primary (no .1 fallback) surfaces as '(file empty)', not missing.""" (hermes_home / "logs" / "agent.log").write_text("") from hermes_cli.debug import _capture_log_snapshot snap = _capture_log_snapshot("agent", tail_lines=10) assert snap.full_text is None - assert snap.tail_text == "(file not found)" + assert snap.tail_text == "(file empty)" + + def test_race_truncate_after_resolve_reports_empty(self, hermes_home, monkeypatch): + """If the log is truncated between resolve and stat, say 'empty', not 'missing'.""" + log_path = hermes_home / "logs" / "agent.log" + from hermes_cli import debug + + monkeypatch.setattr(debug, "_resolve_log_path", lambda _name: log_path) + log_path.write_text("") + + snap = debug._capture_log_snapshot("agent", tail_lines=10) + assert snap.path == log_path + assert snap.full_text is None + assert snap.tail_text == "(file empty)" def test_truncates_large_file(self, hermes_home): """Files larger than max_bytes get tail-truncated.""" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7a7f632844..ab7b52df01 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -106,11 +106,23 @@ def test_config_set_yolo_toggles_session_scope(): server._sessions["sid"] = _session() try: - resp_on = server.handle_request({"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}}) + 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"}}) + 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: @@ -118,6 +130,36 @@ def test_config_set_yolo_toggles_session_scope(): server._sessions.clear() +def test_config_get_statusbar_survives_non_dict_display(monkeypatch): + monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"}) + + resp = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "statusbar"}} + ) + + assert resp["result"]["value"] == "top" + + +def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): + import yaml + + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({"display": "broken"})) + monkeypatch.setattr(server, "_hermes_home", tmp_path) + + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "statusbar", "value": "bottom"}, + } + ) + + assert resp["result"]["value"] == "bottom" + saved = yaml.safe_load(cfg_path.read_text()) + assert saved["display"]["tui_statusbar"] == "bottom" + + def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) @@ -144,13 +186,21 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat server._sessions["sid"] = _session(agent=agent) resp_effort = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "low"}} + { + "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"}} + { + "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 @@ -162,7 +212,11 @@ def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch server._sessions["sid"] = _session(agent=agent) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}} + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}, + } ) assert resp["result"]["value"] == "verbose" @@ -180,7 +234,11 @@ def test_config_set_model_uses_live_switch_path(monkeypatch): 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"}} + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "model", "value": "new/model"}, + } ) assert resp["result"]["value"] == "new/model" @@ -221,7 +279,15 @@ def test_config_set_model_global_persists(monkeypatch): monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg)) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6 --global"}} + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "anthropic/claude-sonnet-4.6 --global", + }, + } ) assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6" @@ -241,6 +307,7 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch): trying openrouter because the env-var-backed resolvers still saw the old provider. """ + class _Agent: provider = "openrouter" model = "old/model" @@ -262,21 +329,39 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch): server._sessions["sid"] = _session(agent=_Agent()) monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter") - monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kwargs: result) + monkeypatch.setattr( + "hermes_cli.model_switch.switch_model", lambda **_kwargs: result + ) monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "claude-sonnet-4.6 --provider anthropic"}} + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "claude-sonnet-4.6 --provider anthropic", + }, + } ) assert os.environ["HERMES_INFERENCE_PROVIDER"] == "anthropic" def test_config_set_personality_rejects_unknown_name(monkeypatch): - monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."}) + monkeypatch.setattr( + server, + "_available_personalities", + lambda cfg=None: {"helpful": "You are helpful."}, + ) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"key": "personality", "value": "bogus"}} + { + "id": "1", + "method": "config.set", + "params": {"key": "personality", "value": "bogus"}, + } ) assert "error" in resp @@ -284,20 +369,36 @@ def test_config_set_personality_rejects_unknown_name(monkeypatch): def test_config_set_personality_resets_history_and_returns_info(monkeypatch): - session = _session(agent=types.SimpleNamespace(), history=[{"role": "user", "text": "hi"}], history_version=4) + session = _session( + agent=types.SimpleNamespace(), + history=[{"role": "user", "text": "hi"}], + history_version=4, + ) new_agent = types.SimpleNamespace(model="x") emits = [] server._sessions["sid"] = session - monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."}) - monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: new_agent) - monkeypatch.setattr(server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")}) + monkeypatch.setattr( + server, + "_available_personalities", + lambda cfg=None: {"helpful": "You are helpful."}, + ) + monkeypatch.setattr( + server, "_make_agent", lambda sid, key, session_id=None: new_agent + ) + monkeypatch.setattr( + server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")} + ) monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) monkeypatch.setattr(server, "_write_config_key", lambda path, value: None) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "personality", "value": "helpful"}} + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "personality", "value": "helpful"}, + } ) assert resp["result"]["history_reset"] is True @@ -311,11 +412,17 @@ 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, focus_topic=None: (2, {"total": 42})) + monkeypatch.setattr( + server, + "_compress_session_history", + lambda session, focus_topic=None: (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"}}) + resp = server.handle_request( + {"id": "1", "method": "session.compress", "params": {"session_id": "sid"}} + ) assert resp["result"]["removed"] == 2 assert resp["result"]["usage"]["total"] == 42 @@ -328,9 +435,14 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch): captured = {} class _Agent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + 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"}]} + return { + "final_response": "ok", + "messages": [{"role": "assistant", "content": "ok"}], + } class _ImmediateThread: def __init__(self, target=None, daemon=None): @@ -345,7 +457,13 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch): 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"}}) + 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" @@ -359,9 +477,14 @@ def test_prompt_submit_expands_context_refs(monkeypatch): base_url = "" api_key = "" - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): captured["prompt"] = prompt - return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]} + return { + "final_response": "ok", + "messages": [{"role": "assistant", "content": "ok"}], + } class _ImmediateThread: def __init__(self, target=None, daemon=None): @@ -371,8 +494,14 @@ def test_prompt_submit_expands_context_refs(monkeypatch): 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_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 @@ -385,7 +514,13 @@ def test_prompt_submit_expands_context_refs(monkeypatch): 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"}}) + server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "@diff"}, + } + ) assert captured["prompt"] == "expanded prompt" @@ -404,7 +539,13 @@ def test_image_attach_appends_local_image(monkeypatch): 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"}}) + 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" @@ -420,14 +561,21 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch): "is_image": True, "remainder": "", } - fake_cli._split_path_input = lambda raw: ("/tmp/Screenshot", "2026-04-21 at 1.04.43 PM.png") + fake_cli._split_path_input = lambda raw: ( + "/tmp/Screenshot", + "2026-04-21 at 1.04.43 PM.png", + ) fake_cli._resolve_attachment_path = lambda raw: None 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": str(screenshot)}} + { + "id": "1", + "method": "image.attach", + "params": {"session_id": "sid", "path": str(screenshot)}, + } ) assert resp["result"]["attached"] is True @@ -437,20 +585,34 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch): def test_commands_catalog_surfaces_quick_commands(monkeypatch): - monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": { - "build": {"type": "exec", "command": "npm run build"}, - "git": {"type": "alias", "target": "/shell git"}, - "notes": {"type": "exec", "command": "cat NOTES.md", "description": "Open design notes"}, - }}) + monkeypatch.setattr( + server, + "_load_cfg", + lambda: { + "quick_commands": { + "build": {"type": "exec", "command": "npm run build"}, + "git": {"type": "alias", "target": "/shell git"}, + "notes": { + "type": "exec", + "command": "cat NOTES.md", + "description": "Open design notes", + }, + } + }, + ) - resp = server.handle_request({"id": "1", "method": "commands.catalog", "params": {}}) + resp = server.handle_request( + {"id": "1", "method": "commands.catalog", "params": {}} + ) pairs = dict(resp["result"]["pairs"]) assert "npm run build" in pairs["/build"] assert pairs["/git"].startswith("alias →") assert pairs["/notes"] == "Open design notes" - user_cat = next(c for c in resp["result"]["categories"] if c["name"] == "User commands") + user_cat = next( + c for c in resp["result"]["categories"] if c["name"] == "User commands" + ) user_pairs = dict(user_cat["pairs"]) assert set(user_pairs) == {"/build", "/git", "/notes"} @@ -459,14 +621,22 @@ def test_commands_catalog_surfaces_quick_commands(monkeypatch): def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): - monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}}) + monkeypatch.setattr( + server, + "_load_cfg", + lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}}, + ) monkeypatch.setattr( server.subprocess, "run", - lambda *args, **kwargs: types.SimpleNamespace(returncode=1, stdout="", stderr="failed"), + lambda *args, **kwargs: types.SimpleNamespace( + returncode=1, stdout="", stderr="failed" + ), ) - resp = server.handle_request({"id": "1", "method": "command.dispatch", "params": {"name": "boom"}}) + resp = server.handle_request( + {"id": "1", "method": "command.dispatch", "params": {"name": "boom"}} + ) assert "error" in resp assert "failed" in resp["error"]["message"] @@ -474,15 +644,22 @@ def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): def test_plugins_list_surfaces_loader_error(monkeypatch): with patch("hermes_cli.plugins.get_plugin_manager", side_effect=Exception("boom")): - resp = server.handle_request({"id": "1", "method": "plugins.list", "params": {}}) + resp = server.handle_request( + {"id": "1", "method": "plugins.list", "params": {}} + ) assert "error" in resp assert "boom" in resp["error"]["message"] def test_complete_slash_surfaces_completer_error(monkeypatch): - with patch("hermes_cli.commands.SlashCommandCompleter", side_effect=Exception("no completer")): - resp = server.handle_request({"id": "1", "method": "complete.slash", "params": {"text": "/mo"}}) + with patch( + "hermes_cli.commands.SlashCommandCompleter", + side_effect=Exception("no completer"), + ): + resp = server.handle_request( + {"id": "1", "method": "complete.slash", "params": {"text": "/mo"}} + ) assert "error" in resp assert "no completer" in resp["error"]["message"] @@ -500,7 +677,11 @@ def test_input_detect_drop_attaches_image(monkeypatch): 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"}} + { + "id": "1", + "method": "input.detect_drop", + "params": {"session_id": "sid", "text": "/tmp/cat.png"}, + } ) assert resp["result"]["matched"] is True @@ -521,7 +702,9 @@ def test_rollback_restore_resolves_number_and_file_path(): calls["args"] = (cwd, target, file_path) return {"success": True, "message": "done"} - server._sessions["sid"] = _session(agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[]) + server._sessions["sid"] = _session( + agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[] + ) resp = server.handle_request( { "id": "1", @@ -572,7 +755,9 @@ def test_session_steer_calls_agent_steer_when_agent_supports_it(): def test_session_steer_rejects_empty_text(): - server._sessions["sid"] = _session(agent=types.SimpleNamespace(steer=lambda t: True)) + server._sessions["sid"] = _session( + agent=types.SimpleNamespace(steer=lambda t: True) + ) try: resp = server.handle_request( { @@ -632,10 +817,13 @@ def test_session_undo_rejects_while_running(): """Fix for TUI silent-drop #1: /undo must not mutate history while the agent is mid-turn — would either clobber the undo or cause prompt.submit to silently drop the agent's response.""" - server._sessions["sid"] = _session(running=True, history=[ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - ]) + server._sessions["sid"] = _session( + running=True, + history=[ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ], + ) try: resp = server.handle_request( {"id": "1", "method": "session.undo", "params": {"session_id": "sid"}} @@ -651,10 +839,13 @@ def test_session_undo_rejects_while_running(): def test_session_undo_allowed_when_idle(): """Regression guard: when not running, /undo still works.""" - server._sessions["sid"] = _session(running=False, history=[ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - ]) + server._sessions["sid"] = _session( + running=False, + history=[ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ], + ) try: resp = server.handle_request( {"id": "1", "method": "session.undo", "params": {"session_id": "sid"}} @@ -683,7 +874,11 @@ def test_rollback_restore_rejects_full_history_while_running(monkeypatch): server._sessions["sid"] = _session(running=True) try: resp = server.handle_request( - {"id": "1", "method": "rollback.restore", "params": {"session_id": "sid", "hash": "abc"}} + { + "id": "1", + "method": "rollback.restore", + "params": {"session_id": "sid", "hash": "abc"}, + } ) assert resp.get("error"), "full-history rollback should reject while running" assert resp["error"]["code"] == 4009 @@ -701,12 +896,17 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch): session_ref = {"s": None} class _RacyAgent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): # Simulate: something external bumped history_version # while we were running. with session_ref["s"]["history_lock"]: session_ref["s"]["history_version"] += 1 - return {"final_response": "agent reply", "messages": [{"role": "assistant", "content": "agent reply"}]} + return { + "final_response": "agent reply", + "messages": [{"role": "assistant", "content": "agent reply"}], + } class _ImmediateThread: def __init__(self, target=None, daemon=None): @@ -725,7 +925,11 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch): monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a)) resp = server.handle_request( - {"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}} + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "hi"}, + } ) assert resp.get("result"), f"got error: {resp.get('error')}" @@ -742,16 +946,25 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch): "history_version mismatch — otherwise the UI silently " "shows output that was never persisted" ) - assert "not saved" in payload["warning"].lower() or "changed" in payload["warning"].lower() + assert ( + "not saved" in payload["warning"].lower() + or "changed" in payload["warning"].lower() + ) finally: server._sessions.pop("sid", None) def test_prompt_submit_history_version_match_persists_normally(monkeypatch): """Regression guard: the backstop does not affect the happy path.""" + class _Agent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): - return {"final_response": "reply", "messages": [{"role": "assistant", "content": "reply"}]} + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + return { + "final_response": "reply", + "messages": [{"role": "assistant", "content": "reply"}], + } class _ImmediateThread: def __init__(self, target=None, daemon=None): @@ -769,12 +982,18 @@ def test_prompt_submit_history_version_match_persists_normally(monkeypatch): monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a)) resp = server.handle_request( - {"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}} + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "hi"}, + } ) assert resp.get("result") # History was written - assert server._sessions["sid"]["history"] == [{"role": "assistant", "content": "reply"}] + assert server._sessions["sid"]["history"] == [ + {"role": "assistant", "content": "reply"} + ] assert server._sessions["sid"]["history_version"] == 1 # No warning should be attached @@ -818,7 +1037,11 @@ def test_interrupt_only_clears_own_session_pending(): # Interrupt session A. resp = server.handle_request( - {"id": "1", "method": "session.interrupt", "params": {"session_id": "sid_a"}} + { + "id": "1", + "method": "session.interrupt", + "params": {"session_id": "sid_a"}, + } ) assert resp.get("result"), f"got error: {resp.get('error')}" @@ -891,8 +1114,11 @@ def test_respond_unpacks_sid_tuple_correctly(): server._pending["rid-x"] = ("sid_x", ev) try: resp = server.handle_request( - {"id": "1", "method": "clarify.respond", - "params": {"request_id": "rid-x", "answer": "the answer"}} + { + "id": "1", + "method": "clarify.respond", + "params": {"request_id": "rid-x", "answer": "the answer"}, + } ) assert resp.get("result") assert ev.is_set() @@ -902,7 +1128,6 @@ def test_respond_unpacks_sid_tuple_correctly(): server._answers.pop("rid-x", None) - # --------------------------------------------------------------------------- # /model switch and other agent-mutating commands must reject while the # session is running. agent.switch_model() mutates self.model, self.provider, @@ -925,10 +1150,17 @@ def test_config_set_model_rejects_while_running(monkeypatch): server._sessions["sid"] = _session(running=True) try: - resp = server.handle_request({ - "id": "1", "method": "config.set", - "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6"}, - }) + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "anthropic/claude-sonnet-4.6", + }, + } + ) assert resp.get("error") assert resp["error"]["code"] == 4009 assert "session busy" in resp["error"]["message"] @@ -952,10 +1184,13 @@ def test_config_set_model_allowed_when_idle(monkeypatch): server._sessions["sid"] = _session(running=False) try: - resp = server.handle_request({ - "id": "1", "method": "config.set", - "params": {"session_id": "sid", "key": "model", "value": "newmodel"}, - }) + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "model", "value": "newmodel"}, + } + ) assert resp.get("result") assert resp["result"]["value"] == "newmodel" assert seen["called"] @@ -993,9 +1228,9 @@ def test_mirror_slash_side_effects_rejects_mutating_commands_while_running(monke ("/compress", "compress"), ]: warning = server._mirror_slash_side_effects("sid", session, cmd) - assert "session busy" in warning, ( - f"{cmd} should have returned busy warning, got: {warning!r}" - ) + assert ( + "session busy" in warning + ), f"{cmd} should have returned busy warning, got: {warning!r}" assert f"/{expected_name}" in warning # None of the mutating side-effect helpers should have fired. @@ -1068,7 +1303,11 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): # Stub everything _build touches monkeypatch.setattr(server, "_make_agent", _slow_make_agent) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) - monkeypatch.setattr(server, "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None)) + monkeypatch.setattr( + server, + "_get_db", + lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None), + ) monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"}) monkeypatch.setattr(server, "_probe_credentials", lambda _a: None) monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) @@ -1076,25 +1315,36 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): # Shim register/unregister to observe leaks import tools.approval as _approval - monkeypatch.setattr(_approval, "register_gateway_notify", - lambda key, cb: None) - monkeypatch.setattr(_approval, "unregister_gateway_notify", - lambda key: unregistered_keys.append(key)) + + monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None) + monkeypatch.setattr( + _approval, + "unregister_gateway_notify", + lambda key: unregistered_keys.append(key), + ) monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None) # Start: session.create spawns _build thread, returns synchronously - resp = server.handle_request({ - "id": "1", "method": "session.create", "params": {"cols": 80}, - }) + resp = server.handle_request( + { + "id": "1", + "method": "session.create", + "params": {"cols": 80}, + } + ) assert resp.get("result"), f"got error: {resp.get('error')}" sid = resp["result"]["session_id"] # Build thread is blocked in _slow_make_agent. Close the session # NOW — this pops _sessions[sid] before _build can install the # worker/notify. - close_resp = server.handle_request({ - "id": "2", "method": "session.close", "params": {"session_id": sid}, - }) + close_resp = server.handle_request( + { + "id": "2", + "method": "session.close", + "params": {"session_id": sid}, + } + ) assert close_resp.get("result", {}).get("closed") is True # At this point session.close saw slash_worker=None (not yet @@ -1108,11 +1358,12 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): if closed_workers: break import time + time.sleep(0.02) - assert len(closed_workers) == 1, ( - f"orphan worker was not cleaned up — closed_workers={closed_workers}" - ) + assert ( + len(closed_workers) == 1 + ), f"orphan worker was not cleaned up — closed_workers={closed_workers}" # Notify may be unregistered by both session.close (unconditional) # and the orphan-cleanup path; the key guarantee is that the build # thread does at least one unregister call (any prior close @@ -1146,21 +1397,33 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): monkeypatch.setattr(server, "_make_agent", lambda sid, key: _FakeAgent()) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) - monkeypatch.setattr(server, "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None)) + monkeypatch.setattr( + server, + "_get_db", + lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None), + ) monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"}) monkeypatch.setattr(server, "_probe_credentials", lambda _a: None) monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) monkeypatch.setattr(server, "_emit", lambda *a, **kw: None) import tools.approval as _approval + monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None) - monkeypatch.setattr(_approval, "unregister_gateway_notify", - lambda key: unregistered_keys.append(key)) + monkeypatch.setattr( + _approval, + "unregister_gateway_notify", + lambda key: unregistered_keys.append(key), + ) monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None) - resp = server.handle_request({ - "id": "1", "method": "session.create", "params": {"cols": 80}, - }) + resp = server.handle_request( + { + "id": "1", + "method": "session.create", + "params": {"cols": 80}, + } + ) sid = resp["result"]["session_id"] # Wait for the build to finish (ready event inside session dict). @@ -1169,12 +1432,12 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): # Build finished without a close race — nothing should have been # cleaned up by the orphan check. - assert closed_workers == [], ( - f"build thread closed its own worker despite no race: {closed_workers}" - ) - assert unregistered_keys == [], ( - f"build thread unregistered its own notify despite no race: {unregistered_keys}" - ) + assert ( + closed_workers == [] + ), f"build thread closed its own worker despite no race: {closed_workers}" + assert ( + unregistered_keys == [] + ), f"build thread unregistered its own notify despite no race: {unregistered_keys}" # Session should have the live worker installed. assert session.get("slash_worker") is not None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5dd33814d1..3aac771928 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -455,6 +455,17 @@ def _write_config_key(key_path: str, value): _save_cfg(cfg) +_STATUSBAR_MODES = frozenset({"off", "top", "bottom"}) + + +def _coerce_statusbar(raw) -> str: + if raw is False: + return "off" + if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES: + return s + return "top" + + def _load_reasoning_config() -> dict | None: from hermes_constants import parse_reasoning_effort @@ -2499,12 +2510,11 @@ def _(rid, params: dict) -> dict: ) return _ok(rid, {"key": key, "value": nv}) - if key in ("compact", "statusbar"): + if key == "compact": raw = str(value or "").strip().lower() cfg0 = _load_cfg() d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} - def_key = "tui_compact" if key == "compact" else "tui_statusbar" - cur_b = bool(d0.get(def_key, False if key == "compact" else True)) + cur_b = bool(d0.get("tui_compact", False)) if raw in ("", "toggle"): nv_b = not cur_b elif raw == "on": @@ -2512,10 +2522,27 @@ def _(rid, params: dict) -> dict: elif raw == "off": nv_b = False else: - return _err(rid, 4002, f"unknown {key} value: {value}") - _write_config_key(f"display.{def_key}", nv_b) - out = "on" if nv_b else "off" - return _ok(rid, {"key": key, "value": out}) + return _err(rid, 4002, f"unknown compact value: {value}") + _write_config_key("display.tui_compact", nv_b) + return _ok(rid, {"key": key, "value": "on" if nv_b else "off"}) + + if key == "statusbar": + raw = str(value or "").strip().lower() + display = _load_cfg().get("display") + d0 = display if isinstance(display, dict) else {} + current = _coerce_statusbar(d0.get("tui_statusbar", "top")) + + if raw in ("", "toggle"): + nv = "top" if current == "off" else "off" + elif raw == "on": + nv = "top" + elif raw in _STATUSBAR_MODES: + nv = raw + else: + return _err(rid, 4002, f"unknown statusbar value: {value}") + + _write_config_key("display.tui_statusbar", nv) + return _ok(rid, {"key": key, "value": nv}) if key in ("prompt", "personality", "skin"): try: @@ -2633,8 +2660,11 @@ def _(rid, params: dict) -> dict: on = bool(_load_cfg().get("display", {}).get("tui_compact", False)) return _ok(rid, {"value": "on" if on else "off"}) if key == "statusbar": - on = bool(_load_cfg().get("display", {}).get("tui_statusbar", True)) - return _ok(rid, {"value": "on" if on else "off"}) + display = _load_cfg().get("display") + raw = ( + display.get("tui_statusbar", "top") if isinstance(display, dict) else "top" + ) + return _ok(rid, {"value": _coerce_statusbar(raw)}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx index bb18608172..f135d70c68 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx @@ -2,6 +2,7 @@ import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'r import { c as _c } from 'react/compiler-runtime' import instances from '../instances.js' +import { CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from '../termio/csi.js' import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js' import { TerminalWriteContext } from '../useTerminalNotification.js' @@ -51,7 +52,9 @@ export function AlternateScreen(t0: Props) { return } - writeRaw(ENTER_ALT_SCREEN + '\x1B[2J\x1B[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')) + writeRaw( + ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : '') + ) ink?.setAltScreenActive(true, mouseTracking) return () => { diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx index ea2a74c9a6..9459b78a24 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx @@ -69,6 +69,12 @@ const memoizedStylesForWrap: Record, Styles> = { flexDirection: 'row', textWrap: 'wrap' }, + 'wrap-char': { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'wrap-char' + }, 'wrap-trim': { flexGrow: 0, flexShrink: 1, diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index 5c9e62b468..dd7372a092 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -343,7 +343,7 @@ function wrapWithSoftWrap( maxWidth: number, textWrap: Parameters[2] ): { wrapped: string; softWrap: boolean[] | undefined } { - if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') { + if (textWrap !== 'wrap' && textWrap !== 'wrap-char' && textWrap !== 'wrap-trim') { return { wrapped: wrapText(plainText, maxWidth, textWrap), softWrap: undefined diff --git a/ui-tui/packages/hermes-ink/src/ink/styles.ts b/ui-tui/packages/hermes-ink/src/ink/styles.ts index e5321f6e50..0fa6cc66e6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/styles.ts +++ b/ui-tui/packages/hermes-ink/src/ink/styles.ts @@ -55,6 +55,7 @@ export type TextStyles = { export type Styles = { readonly textWrap?: | 'wrap' + | 'wrap-char' | 'wrap-trim' | 'end' | 'middle' diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts index 4d157bc2af..e8290feac7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -50,6 +50,10 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style }) } + if (wrapType === 'wrap-char') { + return wrapAnsi(text, maxWidth, { trim: false, hard: true, wordWrap: false }) + } + if (wrapType === 'wrap-trim') { return wrapAnsi(text, maxWidth, { trim: true, diff --git a/ui-tui/src/__tests__/subagentTree.test.ts b/ui-tui/src/__tests__/subagentTree.test.ts index 887754ce07..bd892d7ac0 100644 --- a/ui-tui/src/__tests__/subagentTree.test.ts +++ b/ui-tui/src/__tests__/subagentTree.test.ts @@ -395,10 +395,7 @@ describe('topLevelSubagents', () => { }) it('excludes children whose parent is present', () => { - const items = [ - makeItem({ id: 'p', index: 0 }), - makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }) - ] + const items = [makeItem({ id: 'p', index: 0 }), makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })] expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p']) }) diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts new file mode 100644 index 0000000000..9414b9fbdb --- /dev/null +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { cursorLayout, offsetFromPosition } from '../components/textInput.js' + +describe('cursorLayout — char-wrap parity with wrap-ansi', () => { + it('places cursor mid-line at its column', () => { + expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 }) + }) + + it('places cursor at end of a non-full line', () => { + expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 }) + }) + + it('wraps to next line when cursor lands exactly at the right edge', () => { + // 8 chars on an 8-col line: text fills the row exactly; the cursor's + // inverted-space cell overflows to col 0 of the next row. + expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 }) + }) + + it('tracks a word across a char-wrap boundary without jumping', () => { + // With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" — + // typing incremental letters doesn't reshuffle the word across lines. + expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 }) + expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 1, line: 1 }) + expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 2, line: 1 }) + }) + + it('honours explicit newlines', () => { + expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 }) + expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 }) + }) + + it('does not wrap when cursor is before the right edge', () => { + expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 }) + }) +}) + +describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => { + it('returns 0 for empty input', () => { + expect(offsetFromPosition('', 0, 0, 10)).toBe(0) + }) + + it('maps clicks within a single line', () => { + expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3) + }) + + it('maps clicks past end to value length', () => { + expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2) + }) + + it('maps clicks on a wrapped second row at cols boundary', () => { + // "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0 + // should land on 'i' (offset 8). + expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8) + }) + + it('maps clicks past a \\n into the target line', () => { + expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6) + }) +}) diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index c14ecff3aa..c5a0a97dc1 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { $uiState, resetUiState } from '../app/uiStore.js' -import { applyDisplay } from '../app/useConfigSync.js' +import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js' describe('applyDisplay', () => { beforeEach(() => { @@ -36,10 +36,20 @@ describe('applyDisplay', () => { expect(s.inlineDiffs).toBe(false) expect(s.showCost).toBe(true) expect(s.showReasoning).toBe(true) - expect(s.statusBar).toBe(false) + expect(s.statusBar).toBe('off') expect(s.streaming).toBe(false) }) + it('coerces legacy true + "on" alias to top', () => { + const setBell = vi.fn() + + applyDisplay({ config: { display: { tui_statusbar: true as unknown as 'on' } } }, setBell) + expect($uiState.get().statusBar).toBe('top') + + applyDisplay({ config: { display: { tui_statusbar: 'on' } } }, setBell) + expect($uiState.get().statusBar).toBe('top') + }) + it('applies v1 parity defaults when display fields are missing', () => { const setBell = vi.fn() @@ -50,7 +60,7 @@ describe('applyDisplay', () => { expect(s.inlineDiffs).toBe(true) expect(s.showCost).toBe(false) expect(s.showReasoning).toBe(false) - expect(s.statusBar).toBe(true) + expect(s.statusBar).toBe('top') expect(s.streaming).toBe(true) }) @@ -64,4 +74,42 @@ describe('applyDisplay', () => { expect(s.inlineDiffs).toBe(true) expect(s.streaming).toBe(true) }) + + it('accepts the new string statusBar modes', () => { + const setBell = vi.fn() + + applyDisplay({ config: { display: { tui_statusbar: 'bottom' } } }, setBell) + expect($uiState.get().statusBar).toBe('bottom') + + applyDisplay({ config: { display: { tui_statusbar: 'top' } } }, setBell) + expect($uiState.get().statusBar).toBe('top') + }) +}) + +describe('normalizeStatusBar', () => { + it('maps legacy bool + on alias to top/off', () => { + expect(normalizeStatusBar(true)).toBe('top') + expect(normalizeStatusBar(false)).toBe('off') + expect(normalizeStatusBar('on')).toBe('top') + }) + + it('passes through the canonical enum', () => { + expect(normalizeStatusBar('off')).toBe('off') + expect(normalizeStatusBar('top')).toBe('top') + expect(normalizeStatusBar('bottom')).toBe('bottom') + }) + + it('defaults missing/unknown values to top', () => { + expect(normalizeStatusBar(undefined)).toBe('top') + expect(normalizeStatusBar(null)).toBe('top') + expect(normalizeStatusBar('sideways')).toBe('top') + expect(normalizeStatusBar(42)).toBe('top') + }) + + it('trims whitespace and folds case', () => { + expect(normalizeStatusBar(' Bottom ')).toBe('bottom') + expect(normalizeStatusBar('TOP')).toBe('top') + expect(normalizeStatusBar(' on ')).toBe('top') + expect(normalizeStatusBar('OFF')).toBe('off') + }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index f14c232f00..c1c427739b 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -27,6 +27,8 @@ export interface StateSetter { (value: SetStateAction): void } +export type StatusBarMode = 'bottom' | 'off' | 'top' + export interface SelectionApi { clearSelection: () => void copySelection: () => string @@ -89,7 +91,7 @@ export interface UiState { showReasoning: boolean sid: null | string status: string - statusBar: boolean + statusBar: StatusBarMode streaming: boolean theme: Theme usage: Usage diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 77eb20dec3..904882c217 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -11,6 +11,7 @@ import type { import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js' import type { DetailsMode, Msg, PanelSection } from '../../../types.js' +import type { StatusBarMode } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' @@ -305,19 +306,29 @@ export const coreCommands: SlashCommand[] = [ { aliases: ['sb'], - help: 'toggle status bar', + help: 'status bar position (on|off|top|bottom)', name: 'statusbar', run: (arg, ctx) => { - const next = flagFromArg(arg, ctx.ui.statusBar) + const mode = arg.trim().toLowerCase() + const toggle: StatusBarMode = ctx.ui.statusBar === 'off' ? 'top' : 'off' - if (next === null) { - return ctx.transcript.sys('usage: /statusbar [on|off|toggle]') + const next: null | StatusBarMode = + !mode || mode === 'toggle' + ? toggle + : mode === 'on' || mode === 'top' + ? 'top' + : mode === 'off' || mode === 'bottom' + ? mode + : null + + if (!next) { + return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]') } patchUiState({ statusBar: next }) - ctx.gateway.rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) + ctx.gateway.rpc('config.set', { key: 'statusbar', value: next }).catch(() => {}) - queueMicrotask(() => ctx.transcript.sys(`status bar ${next ? 'on' : 'off'}`)) + queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`)) } }, diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 81089f1795..fcf2e5d88c 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -16,7 +16,7 @@ const buildUiState = (): UiState => ({ showReasoning: false, sid: null, status: 'summoning hermes…', - statusBar: true, + statusBar: 'top', streaming: true, theme: DEFAULT_THEME, usage: ZERO diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 8a3756342b..9e7c93ce99 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -10,9 +10,20 @@ import type { } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' +import type { StatusBarMode } from './interfaces.js' import { turnController } from './turnController.js' import { patchUiState } from './uiStore.js' +const STATUSBAR_ALIAS: Record = { + bottom: 'bottom', + off: 'off', + on: 'top', + top: 'top' +} + +export const normalizeStatusBar = (raw: unknown): StatusBarMode => + raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top' + const MTIME_POLL_MS = 5000 const quietRpc = async = Record>( @@ -37,7 +48,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea inlineDiffs: d.inline_diffs !== false, showCost: !!d.show_cost, showReasoning: !!d.show_reasoning, - statusBar: d.tui_statusbar !== false, + statusBar: normalizeStatusBar(d.tui_statusbar), streaming: d.streaming !== false }) } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 9d3ccdf09f..72cd5b9e5a 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react' import type { ApprovalRespondResponse, + ConfigSetResponse, SecretRespondResponse, SudoRespondResponse, VoiceRecordResponse @@ -377,6 +378,29 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return cActions.openEditor() } + // shift-tab flips yolo without spending a turn (claude-code parity) + if (key.shift && key.tab && !cState.completions.length) { + if (!live.sid) { + return void actions.sys('yolo needs an active session') + } + + // gateway.rpc swallows errors with its own sys() message and resolves to null, + // so we only speak when it came back with a real shape. null = rpc already spoke. + return void gateway.rpc('config.set', { key: 'yolo', session_id: live.sid }).then(r => { + if (r?.value === '1') { + return actions.sys('yolo on') + } + + if (r?.value === '0') { + return actions.sys('yolo off') + } + + if (r) { + actions.sys('failed to toggle yolo') + } + }) + } + if (key.tab && cState.completions.length) { const row = cState.completions[cState.compIdx] diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index a415d34379..39c4b534c8 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' -import { fmtCwdBranch } from '../domain/paths.js' +import { fmtCwdBranch, shortCwd } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' import type { ClarifyRespondResponse, @@ -314,12 +314,14 @@ export function useMainApp(gw: GatewayClient) { useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) - // ── Terminal tab title ───────────────────────────────────────────── - // Show model name + status so users can identify the Hermes tab. - const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? '' - const titleStatus = ui.busy ? '⏳' : '✓' - const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes' - useTerminalTitle(terminalTitle) + // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. + const model = ui.info?.model?.replace(/^.*\//, '') ?? '' + + const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓' + + const tabCwd = ui.info?.cwd + + useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes') useEffect(() => { if (!ui.sid || !stdout) { diff --git a/ui-tui/src/bootBanner.ts b/ui-tui/src/bootBanner.ts deleted file mode 100644 index 2c85387bd4..0000000000 --- a/ui-tui/src/bootBanner.ts +++ /dev/null @@ -1,26 +0,0 @@ -const GOLD = '\x1b[38;2;255;215;0m' -const AMBER = '\x1b[38;2;255;191;0m' -const BRONZE = '\x1b[38;2;205;127;50m' -const DIM = '\x1b[38;2;184;134;11m' -const RESET = '\x1b[0m' - -const LOGO = [ - '██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', - '██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', - '███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ', - '██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ', - '██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ', - '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ' -] - -const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] as const -const LOGO_WIDTH = 98 - -const TAGLINE = `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}` -const FALLBACK = `\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}` - -export function bootBanner(cols: number = process.stdout.columns || 80): string { - const body = cols >= LOGO_WIDTH ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`).join('\n') : FALLBACK - - return `\n${body}\n${TAGLINE}\n\n` -} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 2fe2e6a5bf..d12a4debff 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -156,7 +156,11 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { return () => clearTimeout(id) }, [t.color.amber, tick]) - return {active ? '♥' : ' '} + if (!active) { + return null + } + + return } export function StatusRule({ @@ -187,7 +191,7 @@ export function StatusRule({ const leftWidth = Math.max(12, cols - cwdLabel.length - 3) return ( - + {'─ '} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 959b6ea70c..cdac992d30 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -183,37 +183,19 @@ const ComposerPane = memo(function ComposerPane({ )} - - {ui.statusBar && ( - - )} - - - + {!isBlocked && ( - + + + {composer.inputBuf.map((line, i) => ( @@ -236,8 +218,9 @@ const ComposerPane = memo(function ComposerPane({ + {/* subtract NoSelect paddingX={1} (2 cols) + pw so wrap-ansi and cursorLayout agree */} & { at: 'bottom' | 'top' }) { + const ui = useStore($uiState) + + if (ui.statusBar !== at) { + return null + } + + return ( + + + + ) +}) + export const AppLayout = memo(function AppLayout({ actions, composer, @@ -295,16 +310,20 @@ export const AppLayout = memo(function AppLayout({ {!overlay.agents && ( - - )} + <> + - {!overlay.agents && } + + + + + )} ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 6c94674e49..e91143c00b 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -167,9 +167,11 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number { return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd)) } -function cursorLayout(value: string, cursor: number, cols: number) { +// mirrors wrap-ansi(..., { wordWrap: false, hard: true }) so the declared +// cursor lines up with what actually renders +export function cursorLayout(value: string, cursor: number, cols: number) { const pos = Math.max(0, Math.min(cursor, value.length)) - const w = Math.max(1, cols - 1) + const w = Math.max(1, cols) let col = 0, line = 0 @@ -200,17 +202,23 @@ function cursorLayout(value: string, cursor: number, cols: number) { col += sw } + // trailing cursor-cell overflows to the next row at the wrap column + if (col >= w) { + line++ + col = 0 + } + return { column: col, line } } -function offsetFromPosition(value: string, row: number, col: number, cols: number) { +export function offsetFromPosition(value: string, row: number, col: number, cols: number) { if (!value.length) { return 0 } const targetRow = Math.max(0, Math.floor(row)) const targetCol = Math.max(0, Math.floor(col)) - const w = Math.max(1, cols - 1) + const w = Math.max(1, cols) let line = 0 let column = 0 @@ -802,7 +810,7 @@ export function TextInput({ }} ref={boxRef} > - {rendered} + {rendered} ) } diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index b0938e18eb..0a58e305b8 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -24,6 +24,6 @@ export const HOTKEYS: [string, string][] = [ ['Home/End', 'start / end of line'], ['Shift+Enter / Alt+Enter', 'insert newline'], ['\\+Enter', 'multi-line continuation (fallback)'], - ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'] + ['!', 'run a shell command (e.g. !ls, !git status)'], + ['{!}', 'interpolate shell output inline (e.g. "branch is {!git branch --show-current}")'] ] diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 6f1506e5aa..8fdf9f68fb 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,5 +1,4 @@ #!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc -import { bootBanner } from './bootBanner.js' import { GatewayClient } from './gatewayClient.js' import { setupGracefulExit } from './lib/gracefulExit.js' import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js' @@ -10,8 +9,6 @@ if (!process.stdin.isTTY) { process.exit(0) } -process.stdout.write(bootBanner()) - const gw = new GatewayClient() gw.start() diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 975ec117e6..1dc8ea5bed 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -60,7 +60,7 @@ export interface ConfigDisplayConfig { streaming?: boolean thinking_mode?: string tui_compact?: boolean - tui_statusbar?: boolean + tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean } export interface ConfigFullResponse {