From 6fb98f343a7b315ddba4589a8942ba5b5a9c5ab1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 15:19:50 -0500 Subject: [PATCH] fix(tui): address copilot review on #14103 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeStatusBar: trim/lowercase + 'on' → 'top' alias so user-edited YAML variants (Top, " bottom ", on) coerce correctly - shift-tab yolo: no-op with sys note when no live session; success-gated echo and catch fallback so RPC failures don't report as 'yolo off' - tui_gateway config.set/get statusbar: isinstance(display, dict) guards mirroring the compact branch so a malformed display scalar in config.yaml can't raise Tests: +1 vitest for trim/case/on, +2 pytest for non-dict display survival. --- tests/test_tui_gateway_server.py | 469 ++++++++++++++++----- tui_gateway/server.py | 8 +- ui-tui/src/__tests__/useConfigSync.test.ts | 7 + ui-tui/src/app/useConfigSync.ts | 20 +- ui-tui/src/app/useInputHandlers.ts | 7 +- 5 files changed, 399 insertions(+), 112 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7a7f63284..ab7b52df0 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 49cd9660b..7e0bef9a1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2528,7 +2528,8 @@ def _(rid, params: dict) -> dict: if key == "statusbar": raw = str(value or "").strip().lower() - d0 = _load_cfg().get("display") or {} + cfg0 = _load_cfg() + d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} current = _coerce_statusbar(d0.get("tui_statusbar", "top")) if raw in ("", "toggle"): @@ -2659,7 +2660,10 @@ 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": - raw = _load_cfg().get("display", {}).get("tui_statusbar", "top") + 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" diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index b5b25ddd8..c5a0a97dc 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -105,4 +105,11 @@ describe('normalizeStatusBar', () => { 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/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index f50dcbd10..fb0e679a1 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -16,12 +16,20 @@ import { patchUiState } from './uiStore.js' const STATUSBAR_MODES = new Set(['bottom', 'off', 'top']) -export const normalizeStatusBar = (raw: unknown): StatusBarMode => - raw === false - ? 'off' - : typeof raw === 'string' && STATUSBAR_MODES.has(raw as StatusBarMode) - ? (raw as StatusBarMode) - : 'top' +export const normalizeStatusBar = (raw: unknown): StatusBarMode => { + if (raw === false) { + return 'off' + } + + if (typeof raw !== 'string') { + return 'top' + } + + const v = raw.trim().toLowerCase() + const mode = (v === 'on' ? 'top' : v) as StatusBarMode + + return STATUSBAR_MODES.has(mode) ? mode : 'top' +} const MTIME_POLL_MS = 5000 diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 07f241cb5..715d775ee 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -380,9 +380,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { // 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') + } + return void gateway .rpc('config.set', { key: 'yolo', session_id: live.sid }) - .then(r => actions.sys(`yolo ${r?.value === '1' ? 'on' : 'off'}`)) + .then(r => actions.sys(r ? `yolo ${r.value === '1' ? 'on' : 'off'}` : 'failed to toggle yolo')) + .catch(() => actions.sys('failed to toggle yolo')) } if (key.tab && cState.completions.length) {