diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 0816fb93a0..e0b2a64c67 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3294,6 +3294,7 @@ class DiscordAdapter(BasePlatformAdapter): chat_topic = self._get_effective_topic(message.channel, is_thread=is_thread) # Build source + guild = getattr(message, "guild", None) source = self.build_source( chat_id=str(effective_channel.id), chat_name=chat_name, @@ -3303,7 +3304,7 @@ class DiscordAdapter(BasePlatformAdapter): thread_id=thread_id, chat_topic=chat_topic, is_bot=getattr(message.author, "bot", False), - guild_id=str(message.guild.id) if message.guild else None, + guild_id=str(guild.id) if guild else None, parent_chat_id=parent_channel_id, message_id=str(message.id), ) diff --git a/nix/tui.nix b/nix/tui.nix index 4fddebfecb..7453fa2673 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -17,6 +17,7 @@ pkgs.buildNpmPackage (npm // { inherit src npmDeps version; doCheck = false; + npmFlags = [ "--legacy-peer-deps" ]; installPhase = '' runHook preInstall diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index fef44b40e7..99f42b0af4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -100,20 +100,36 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch): def get_messages_as_conversation(self, target, include_ancestors=False): captured.setdefault("history_calls", []).append((target, include_ancestors)) - return [ - {"role": "user", "content": "root prompt"}, - {"role": "assistant", "content": "root answer"}, - ] if include_ancestors else [{"role": "user", "content": "tip prompt"}] + return ( + [ + {"role": "user", "content": "root prompt"}, + {"role": "assistant", "content": "root answer"}, + ] + if include_ancestors + else [{"role": "user", "content": "tip prompt"}] + ) monkeypatch.setattr(server, "_get_db", lambda: FakeDB()) monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) monkeypatch.setattr(server, "_set_session_context", lambda target: []) monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None) - monkeypatch.setattr(server, "_make_agent", lambda *args, **kwargs: types.SimpleNamespace(model="test")) - monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "test", "tools": {}, "skills": {}}) - monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None) + monkeypatch.setattr( + server, + "_make_agent", + lambda *args, **kwargs: types.SimpleNamespace(model="test"), + ) + monkeypatch.setattr( + server, + "_session_info", + lambda agent: {"model": "test", "tools": {}, "skills": {}}, + ) + monkeypatch.setattr( + server, "_init_session", lambda sid, key, agent, history, cols=80: None + ) - resp = server.handle_request({"id": "1", "method": "session.resume", "params": {"session_id": "tip"}}) + resp = server.handle_request( + {"id": "1", "method": "session.resume", "params": {"session_id": "tip"}} + ) assert resp["result"]["messages"] == [ {"role": "user", "text": "root prompt"}, @@ -258,6 +274,307 @@ def _session(agent=None, **extra): } +def test_session_title_queues_when_db_row_not_ready(monkeypatch): + class _FakeDB: + def get_session_title(self, _key): + return None + + def get_session(self, _key): + return None + + def set_session_title(self, _key, _title): + return False + + server._sessions["sid"] = _session(pending_title=None) + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + try: + set_resp = server.handle_request( + { + "id": "1", + "method": "session.title", + "params": {"session_id": "sid", "title": "queued title"}, + } + ) + + assert set_resp["result"]["pending"] is True + assert set_resp["result"]["title"] == "queued title" + assert server._sessions["sid"]["pending_title"] == "queued title" + + get_resp = server.handle_request( + {"id": "2", "method": "session.title", "params": {"session_id": "sid"}} + ) + assert get_resp["result"]["title"] == "queued title" + finally: + server._sessions.pop("sid", None) + + +def test_session_title_clears_pending_after_persist(monkeypatch): + class _FakeDB: + def __init__(self): + self.title = "old" + + def get_session_title(self, _key): + return self.title + + def get_session(self, _key): + return {"id": _key, "title": self.title} + + def set_session_title(self, _key, title): + self.title = title + return True + + db = _FakeDB() + server._sessions["sid"] = _session(pending_title="stale") + monkeypatch.setattr(server, "_get_db", lambda: db) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.title", + "params": {"session_id": "sid", "title": "fresh"}, + } + ) + + assert resp["result"]["pending"] is False + assert resp["result"]["title"] == "fresh" + assert server._sessions["sid"]["pending_title"] is None + finally: + server._sessions.pop("sid", None) + + +def test_session_title_does_not_queue_noop_when_row_exists(monkeypatch): + class _FakeDB: + def __init__(self): + self.title = "same title" + + def get_session_title(self, _key): + return self.title + + def get_session(self, _key): + return {"id": _key, "title": self.title} + + def set_session_title(self, _key, _title): + # Simulate sqlite UPDATE rowcount==0 for no-op update. + return False + + server._sessions["sid"] = _session(pending_title="stale") + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.title", + "params": {"session_id": "sid", "title": "same title"}, + } + ) + + assert resp["result"]["pending"] is False + assert resp["result"]["title"] == "same title" + assert server._sessions["sid"]["pending_title"] is None + finally: + server._sessions.pop("sid", None) + + +def test_session_title_get_falls_back_to_pending_when_db_read_throws(monkeypatch): + class _FakeDB: + def get_session_title(self, _key): + raise RuntimeError("db temporarily locked") + + server._sessions["sid"] = _session(pending_title="queued title") + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + try: + resp = server.handle_request( + {"id": "1", "method": "session.title", "params": {"session_id": "sid"}} + ) + assert resp["result"]["title"] == "queued title" + finally: + server._sessions.pop("sid", None) + + +def test_session_title_get_retries_persist_for_pending_title(monkeypatch): + class _FakeDB: + def __init__(self): + self.title = "" + + def get_session_title(self, _key): + return self.title + + def set_session_title(self, _key, title): + self.title = title + return True + + def get_session(self, _key): + return {"id": _key, "title": self.title} + + db = _FakeDB() + server._sessions["sid"] = _session(pending_title="queued title") + monkeypatch.setattr(server, "_get_db", lambda: db) + try: + resp = server.handle_request( + {"id": "1", "method": "session.title", "params": {"session_id": "sid"}} + ) + assert resp["result"]["title"] == "queued title" + assert server._sessions["sid"]["pending_title"] is None + finally: + server._sessions.pop("sid", None) + + +def test_session_title_get_retries_pending_even_when_db_has_title(monkeypatch): + class _FakeDB: + def __init__(self): + self.title = "auto title" + + def get_session_title(self, _key): + return self.title + + def set_session_title(self, _key, title): + self.title = title + return True + + def get_session(self, _key): + return {"id": _key, "title": self.title} + + db = _FakeDB() + server._sessions["sid"] = _session(pending_title="queued title") + monkeypatch.setattr(server, "_get_db", lambda: db) + try: + resp = server.handle_request( + {"id": "1", "method": "session.title", "params": {"session_id": "sid"}} + ) + assert resp["result"]["title"] == "queued title" + assert server._sessions["sid"]["pending_title"] is None + finally: + server._sessions.pop("sid", None) + + +def test_session_title_rejects_empty_title_with_specific_error_code(monkeypatch): + class _FakeDB: + def get_session_title(self, _key): + return "" + + server._sessions["sid"] = _session() + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.title", + "params": {"session_id": "sid", "title": " "}, + } + ) + assert "error" in resp + assert resp["error"]["code"] == 4021 + finally: + server._sessions.pop("sid", None) + + +def test_session_title_set_maps_valueerror_to_user_error(monkeypatch): + class _FakeDB: + def get_session_title(self, _key): + return "" + + def get_session(self, _key): + return {"id": _key} + + def set_session_title(self, _key, _title): + raise ValueError("Title already in use") + + server._sessions["sid"] = _session() + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.title", + "params": {"session_id": "sid", "title": "dup"}, + } + ) + assert "error" in resp + assert resp["error"]["code"] == 4022 + assert "already in use" in resp["error"]["message"] + finally: + server._sessions.pop("sid", None) + + +def test_session_title_set_errors_when_row_lookup_fails_after_noop(monkeypatch): + class _FakeDB: + def get_session_title(self, _key): + return "" + + def get_session(self, _key): + raise RuntimeError("row lookup failed") + + def set_session_title(self, _key, _title): + return False + + server._sessions["sid"] = _session() + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.title", + "params": {"session_id": "sid", "title": "fresh"}, + } + ) + assert "error" in resp + assert resp["error"]["code"] == 5007 + assert "row lookup failed" in resp["error"]["message"] + finally: + server._sessions.pop("sid", None) + + +def test_session_create_drops_pending_title_on_valueerror(monkeypatch): + unblock_agent = threading.Event() + + class _FakeWorker: + def __init__(self, key, model): + self.key = key + + def close(self): + return None + + class _FakeAgent: + model = "x" + provider = "openrouter" + base_url = "" + api_key = "" + + class _FakeDB: + def create_session(self, _key, source="tui", model=None): + return None + + def set_session_title(self, _key, _title): + raise ValueError("Title already in use") + + def _make_agent(_sid, _key): + unblock_agent.wait(timeout=2.0) + return _FakeAgent() + + monkeypatch.setattr(server, "_make_agent", _make_agent) + monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + 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, "load_permanent_allowlist", lambda: None) + + resp = server.handle_request({"id": "1", "method": "session.create", "params": {"cols": 80}}) + sid = resp["result"]["session_id"] + session = server._sessions[sid] + session["pending_title"] = "duplicate title" + unblock_agent.set() + session["agent_ready"].wait(timeout=2.0) + + assert session["pending_title"] is None + server._sessions.pop(sid, None) + + def test_config_set_yolo_toggles_session_scope(): from tools.approval import clear_session, is_session_yolo_enabled @@ -1798,6 +2115,7 @@ def test_session_create_continues_when_state_db_is_unavailable(monkeypatch): monkeypatch.setattr(server, "_emit", lambda *a, **kw: emits.append(a)) import tools.approval as _approval + monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None) monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None) @@ -1905,6 +2223,7 @@ def test_model_options_propagates_list_exception(monkeypatch): # prompt.submit — auto-title # --------------------------------------------------------------------------- + class _ImmediateThread: """Runs the target callable synchronously so assertions can follow.""" @@ -1919,7 +2238,9 @@ def test_prompt_submit_auto_titles_session_on_complete(monkeypatch): """maybe_auto_title is called after a successful (complete) prompt.""" class _Agent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): return { "final_response": "Rome was founded in 753 BC.", "messages": [ @@ -1955,7 +2276,9 @@ def test_prompt_submit_skips_auto_title_when_interrupted(monkeypatch): """maybe_auto_title must NOT be called when the agent was interrupted.""" class _Agent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): return { "final_response": "partial answer", "interrupted": True, @@ -1985,7 +2308,9 @@ def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch): """maybe_auto_title must NOT be called when the agent returns an empty reply.""" class _Agent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): return { "final_response": "", "messages": [], diff --git a/tests/tools/test_dockerfile_pid1_reaping.py b/tests/tools/test_dockerfile_pid1_reaping.py index 1e47b64f6e..7538162798 100644 --- a/tests/tools/test_dockerfile_pid1_reaping.py +++ b/tests/tools/test_dockerfile_pid1_reaping.py @@ -39,7 +39,8 @@ def _dockerfile_instructions(dockerfile_text: str) -> list[str]: if not line or line.startswith("#"): continue - current = f"{current} {line.removesuffix('\\').strip()}".strip() + continued = line.removesuffix("\\").strip() + current = f"{current} {continued}".strip() if not line.endswith("\\"): instructions.append(current) current = "" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ae1c0d90fb..b7cda00ff4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -759,8 +759,11 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: custom_provs = None try: from hermes_cli.config import get_compatible_custom_providers, load_config + cfg = load_config() - user_provs = [{"provider": k, **v} for k, v in (cfg.get("providers") or {}).items()] + user_provs = [ + {"provider": k, **v} for k, v in (cfg.get("providers") or {}).items() + ] custom_provs = get_compatible_custom_providers(cfg) except Exception: pass @@ -918,7 +921,10 @@ def _probe_config_health(cfg: dict) -> str: def _session_info(agent) -> dict: reasoning_config = getattr(agent, "reasoning_config", None) reasoning_effort = "" - if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is not False: + if ( + isinstance(reasoning_config, dict) + and reasoning_config.get("enabled") is not False + ): reasoning_effort = str(reasoning_config.get("effort", "") or "") service_tier = getattr(agent, "service_tier", None) or "" info: dict = { @@ -1042,7 +1048,11 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): if _tool_progress_enabled(sid): # tool.complete is the source of truth for todos (full list from the # tool result). args.todos here may be a partial merge update. - _emit("tool.start", sid, {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}) + _emit( + "tool.start", + sid, + {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}, + ) def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str): @@ -1530,6 +1540,7 @@ def _(rid, params: dict) -> dict: "history_lock": threading.Lock(), "history_version": 0, "image_counter": 0, + "pending_title": None, "running": False, "session_key": key, "show_reasoning": _load_show_reasoning(), @@ -1567,6 +1578,42 @@ def _(rid, params: dict) -> dict: db = _get_db() if db is not None: db.create_session(key, source="tui", model=_resolve_model()) + pending_title = (session.get("pending_title") or "").strip() + if pending_title: + try: + title_applied = db.set_session_title(key, pending_title) + if title_applied: + session["pending_title"] = None + else: + existing_row = db.get_session(key) + existing_title = ( + (existing_row or {}).get("title") or "" + ).strip() + if existing_title == pending_title: + session["pending_title"] = None + else: + logger.info( + "Pending title still queued for session %s (wanted=%r, current=%r)", + sid, + pending_title, + existing_title, + ) + except ValueError as e: + # Queued title can become invalid/duplicate between queue time + # and DB row creation. Drop the queue and log the reason so + # future /title reads don't surface a stuck pending value. + session["pending_title"] = None + logger.info( + "Dropping pending title for session %s: %s", + sid, + e, + ) + except Exception: + logger.warning( + "Failed to apply pending title for session %s", + sid, + exc_info=True, + ) session["agent"] = agent try: @@ -1706,7 +1753,9 @@ def _(rid, params: dict) -> dict: try: db.reopen_session(target) history = db.get_messages_as_conversation(target) - display_history = db.get_messages_as_conversation(target, include_ancestors=True) + display_history = db.get_messages_as_conversation( + target, include_ancestors=True + ) messages = _history_to_messages(display_history) tokens = _set_session_context(target) try: @@ -1736,12 +1785,57 @@ def _(rid, params: dict) -> dict: db = _get_db() if db is None: return _db_unavailable_error(rid, code=5007) - title, key = params.get("title", ""), session["session_key"] + key = session["session_key"] + if "title" not in params: + fallback = session.get("pending_title") or "" + try: + resolved_title = db.get_session_title(key) or "" + if fallback: + if db.set_session_title(key, fallback): + session["pending_title"] = None + resolved_title = fallback + else: + existing_row = db.get_session(key) + existing_title = ((existing_row or {}).get("title") or "").strip() + if existing_title == fallback: + session["pending_title"] = None + resolved_title = fallback + elif not resolved_title: + resolved_title = fallback + elif resolved_title: + session["pending_title"] = None + except Exception: + resolved_title = fallback + return _ok( + rid, + { + "title": resolved_title, + "session_key": key, + }, + ) + title = (params.get("title", "") or "").strip() if not title: - return _ok(rid, {"title": db.get_session_title(key) or "", "session_key": key}) + return _err(rid, 4021, "title required") try: - db.set_session_title(key, title) - return _ok(rid, {"title": title}) + if db.set_session_title(key, title): + session["pending_title"] = None + return _ok(rid, {"pending": False, "title": title}) + # rowcount == 0 can mean "same value" as well as "missing row". + # Queue only when the session row truly does not exist yet. + existing_row = db.get_session(key) + if existing_row: + session["pending_title"] = None + return _ok( + rid, + { + "pending": False, + "title": (existing_row.get("title") or title), + }, + ) + session["pending_title"] = title + return _ok(rid, {"pending": True, "title": title}) + except ValueError as e: + return _err(rid, 4022, str(e)) except Exception as e: return _err(rid, 5007, str(e)) @@ -1761,7 +1855,9 @@ def _(rid, params: dict) -> dict: db = _get_db() if db is not None and session.get("session_key"): try: - history = db.get_messages_as_conversation(session["session_key"], include_ancestors=True) + history = db.get_messages_as_conversation( + session["session_key"], include_ancestors=True + ) except Exception: pass return _ok( @@ -2899,7 +2995,11 @@ def _(rid, params: dict) -> dict: if key == "mouse": raw = str(value or "").strip().lower() - display = _load_cfg().get("display") if isinstance(_load_cfg().get("display"), dict) else {} + display = ( + _load_cfg().get("display") + if isinstance(_load_cfg().get("display"), dict) + else {} + ) current = bool(display.get("tui_mouse", True)) if raw in ("", "toggle"): @@ -3763,7 +3863,9 @@ def _details_completion_item(value: str, meta: str = "") -> dict: return {"text": value, "display": value, "meta": meta} -def _details_root_completion_item(value: str, meta: str, needs_leading_space: bool) -> dict: +def _details_root_completion_item( + value: str, meta: str, needs_leading_space: bool +) -> dict: return _details_completion_item( f" {value}" if needs_leading_space else value, meta, @@ -3778,7 +3880,7 @@ def _details_completions(text: str) -> list[dict] | None: if stripped and not "/details".startswith(stripped.lower().split()[0]): return None - body = text[len("/details"):] + body = text[len("/details") :] if body.startswith(" "): body = body[1:] parts = body.split() @@ -3789,12 +3891,18 @@ def _details_completions(text: str) -> list[dict] | None: if not body or (len(parts) == 0 and has_trailing_space): return [ *[ - _details_root_completion_item(mode, "global mode", not has_trailing_space) + _details_root_completion_item( + mode, "global mode", not has_trailing_space + ) for mode in modes ], - _details_root_completion_item("cycle", "cycle global mode", not has_trailing_space), + _details_root_completion_item( + "cycle", "cycle global mode", not has_trailing_space + ), *[ - _details_root_completion_item(section, "section override", not has_trailing_space) + _details_root_completion_item( + section, "section override", not has_trailing_space + ) for section in sections ], ] @@ -3808,9 +3916,7 @@ def _details_completions(text: str) -> list[dict] | None: ( "section override" if candidate in sections - else "cycle global mode" - if candidate == "cycle" - else "global mode" + else "cycle global mode" if candidate == "cycle" else "global mode" ), ) for candidate in candidates @@ -3819,7 +3925,10 @@ def _details_completions(text: str) -> list[dict] | None: if len(parts) == 1 and has_trailing_space and parts[0].lower() in sections: return [ - *[_details_completion_item(mode, f"set {parts[0].lower()}") for mode in modes], + *[ + _details_completion_item(mode, f"set {parts[0].lower()}") + for mode in modes + ], _details_completion_item("reset", f"clear {parts[0].lower()} override"), ] @@ -3828,7 +3937,11 @@ def _details_completions(text: str) -> list[dict] | None: return [ _details_completion_item( candidate, - f"clear {parts[0].lower()} override" if candidate == "reset" else f"set {parts[0].lower()}", + ( + f"clear {parts[0].lower()} override" + if candidate == "reset" + else f"set {parts[0].lower()}" + ), ) for candidate in (*modes, "reset") if candidate.startswith(prefix) and candidate != prefix @@ -4712,7 +4825,11 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"skills": get_available_skills()}) if action == "search": - from tools.skills_hub import GitHubAuth, create_source_router, unified_search + from tools.skills_hub import ( + GitHubAuth, + create_source_router, + unified_search, + ) raw = ( unified_search( diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 2efd64fe40..017e9913bd 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -124,7 +124,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -502,6 +501,31 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1676,7 +1700,6 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1687,7 +1710,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1698,7 +1720,6 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1728,7 +1749,6 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -2046,7 +2066,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2449,7 +2468,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3185,7 +3203,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3317,7 +3334,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4226,7 +4242,6 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", - "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5663,7 +5678,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5773,7 +5787,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6598,7 +6611,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6725,7 +6737,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6835,7 +6846,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -7251,7 +7261,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index b47efb3d52..dba3548712 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -397,6 +397,34 @@ describe('createSlashHandler', () => { expect(rpc).not.toHaveBeenCalled() expect(ctx.transcript.sys).toHaveBeenCalledWith('no active session — nothing to save') }) + + it('/title uses session.title RPC and bypasses slash.exec', async () => { + patchUiState({ sid: 'sid-abc' }) + const rpc = vi.fn(() => Promise.resolve({ pending: false, title: 'my title' })) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + createSlashHandler(ctx)('/title my title') + + expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc', title: 'my title' }) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalledWith('session title set: my title') + }) + }) + + it('/title with no args fetches and displays the current title', async () => { + patchUiState({ sid: 'sid-abc' }) + const rpc = vi.fn(() => Promise.resolve({ title: 'demo title' })) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + createSlashHandler(ctx)('/title') + + expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc' }) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalledWith('title: demo title') + }) + }) }) const buildCtx = (overrides: Partial = {}): Ctx => ({ diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 4c14fde4f1..91f06bb570 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -6,6 +6,7 @@ import type { ConfigGetValueResponse, ConfigSetResponse, SessionSaveResponse, + SessionTitleResponse, SessionSteerResponse, SessionUndoResponse } from '../../../gatewayTypes.js' @@ -151,6 +152,47 @@ export const coreCommands: SlashCommand[] = [ } }, + { + help: 'set or show current session title', + name: 'title', + run: (arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('no active session') + } + + const title = arg.trim() + + if (!arg) { + ctx.gateway + .rpc('session.title', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const current = (r?.title ?? '').trim() + ctx.transcript.sys(current ? `title: ${current}` : 'no title set') + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (!title) { + return ctx.transcript.sys('usage: /title ') + } + + ctx.gateway + .rpc('session.title', { session_id: ctx.sid, title }) + .then( + ctx.guarded(r => { + const next = (r?.title ?? title).trim() + const suffix = r?.pending ? ' (queued while session initializes)' : '' + ctx.transcript.sys(`session title set: ${next}${suffix}`) + }) + ) + .catch(ctx.guardedErr) + } + }, + { help: 'toggle compact transcript', name: 'compact', diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c645393268..dbaecd4d3d 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -119,6 +119,12 @@ export interface SessionListResponse { sessions?: SessionListItem[] } +export interface SessionTitleResponse { + pending?: boolean + session_key?: string + title?: string +} + export interface SessionSaveResponse { file?: string }