diff --git a/cli.py b/cli.py index c72a3fc26e08..2e009eb54e95 100644 --- a/cli.py +++ b/cli.py @@ -10177,7 +10177,8 @@ def process_command(self, command: str) -> bool: elif canonical == "help": self.show_help() elif canonical == "profile": - self._handle_profile_command() + if self._handle_profile_command(cmd_original): + return False elif canonical == "tools": self._handle_tools_command(cmd_original) elif canonical == "toolsets": diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 4533c66befe9..d67c679d4705 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -340,6 +340,9 @@ async def _handle_profile_command(self, event: MessageEvent) -> str: ``_run_agent`` and ``_reset_notice_session_info`` — and the command reports the active profile and default home, byte-identical to before. """ + if event.get_command_args().strip(): + return "Profile switching is only available in terminal chat." + from hermes_constants import display_hermes_home from hermes_cli.slash_exec import CommandContext, execute_command diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index d4accf472cc9..bf00862f54d5 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -765,10 +765,49 @@ def isatty(self) -> bool: self.new_session() _cprint(f"{_DIM}Session reset. New tool configuration is active.{_RST}") - def _handle_profile_command(self): - """Display active profile name and home directory.""" + def _handle_profile_command(self, command: str = "/profile") -> bool: + """Display the active profile or relaunch chat under another profile. + + Status output stays on the shared ``execute_command("profile")`` path + so CLI/gateway/TUI keep status-output parity. Switching is a process + boundary (sticky selection + deferred relaunch) — not an in-place + HERMES_HOME mutation — and is terminal-CLI only. + """ + from hermes_cli.profiles import get_active_profile_name, set_active_profile from hermes_cli.slash_exec import CommandContext, execute_command + parts = command.strip().split(maxsplit=1) + target = parts[1].strip() if len(parts) > 1 else "" + + if target: + from hermes_cli.profiles import ( + build_profile_switch_relaunch_argv, + normalize_profile_name, + ) + + try: + set_active_profile(target) + except (FileNotFoundError, ValueError) as exc: + print(f" Error: {exc}") + return False + + current = get_active_profile_name() + selected = normalize_profile_name(target) + if selected == current: + print(f" Profile '{current}' is already active.") + return False + + relaunch_argv = build_profile_switch_relaunch_argv(selected, ui="cli") + if "--resume" in relaunch_argv: + print( + f" Switching to profile '{selected}' " + f"(resuming last session)..." + ) + else: + print(f" Switching to profile '{selected}'...") + self._pending_relaunch = relaunch_argv + return True + reply = execute_command("profile", CommandContext(surface="cli")) profile_name = reply.data["profile"] display = reply.data["home"] @@ -777,6 +816,7 @@ def _handle_profile_command(self): print(f" Profile: {profile_name}") print(f" Home: {display}") print() + return False def _handle_handoff_command(self, cmd_original: str) -> bool: """Handle ``/handoff `` — transfer this CLI session to a gateway platform. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index cb8fb4a0f2d2..6bb3b8945a7e 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -182,8 +182,14 @@ class CommandDef: aliases=("ctx",), args_hint="[all]", subcommands=("all",), busy_policy="dispatch"), CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), - CommandDef("profile", "Show active profile name and home directory", "Info", - busy_policy="dispatch", execute="profile"), + CommandDef( + "profile", + "Show the active profile; terminal chat can switch with /profile (resumes last session)", + "Info", + args_hint="[name]", + busy_policy="dispatch", + execute="profile", + ), CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)), CommandDef("resume", "Resume a previously-named session", "Session", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e81b57a493cd..72f01fecdbce 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2439,6 +2439,25 @@ def _launch_tui( print() relaunch(["update"], preserve_inherited=False) + # Exit code 43 = standalone TUI selected a profile. The RPC validated and + # persisted the target before the child exited, so relaunch a TUI process + # under that profile (resuming its last session when one exists) instead + # of mutating HERMES_HOME in place. + if code == 43: + from hermes_cli.profiles import ( + build_profile_switch_relaunch_argv, + get_active_profile, + ) + from hermes_cli.relaunch import relaunch + + profile = get_active_profile() + relaunch_argv = build_profile_switch_relaunch_argv(profile, ui="tui") + if "--resume" in relaunch_argv: + print(f"\nSwitching to profile '{profile}' (resuming last session)...\n") + else: + print(f"\nSwitching to profile '{profile}'...\n") + relaunch(relaunch_argv, preserve_inherited=False) + sys.exit(code) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index fe582aca9843..8715c010d635 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -387,6 +387,90 @@ def profile_exists(name: str) -> bool: return get_profile_dir(canon).is_dir() +def _current_workspace_key() -> Optional[str]: + """Workspace identity for cwd-scoped resume (git root, else cwd).""" + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return os.path.abspath(result.stdout.strip()) + except Exception: + pass + try: + return os.getcwd() + except Exception: + return None + + +def resolve_profile_last_session( + name: str, + *, + source: str = "cli", + workspace_key: Optional[str] = None, +) -> Optional[str]: + """Most recent session id in a profile's ``state.db`` (read-only). + + Mirrors ``hermes -c`` resolution for that profile home: workspace-scoped + MRU first when *workspace_key* is set, then global MRU for *source*. + Returns ``None`` when the profile has no matching session (or no DB). + """ + try: + from hermes_state import SessionDB + + db_path = get_profile_dir(name) / "state.db" + if not db_path.exists(): + return None + db = SessionDB(db_path=db_path, read_only=True) + try: + if workspace_key: + sessions = db.search_sessions( + source=source, limit=1, workspace_key=workspace_key + ) + if sessions: + return sessions[0]["id"] + sessions = db.search_sessions(source=source, limit=1) + return sessions[0]["id"] if sessions else None + finally: + db.close() + except Exception: + return None + + +def build_profile_switch_relaunch_argv(name: str, *, ui: str = "cli") -> list[str]: + """Build argv for a clean chat relaunch under *name*, resuming when possible. + + Always includes ``--profile`` + chat UI flags. When the target profile has + a prior session for this UI (TUI falls back to CLI sessions), appends + ``--resume `` so the relaunch lands on that conversation instead of a + blank chat. Fresh profiles with no sessions get a normal new chat. + """ + selected = normalize_profile_name(name) + if ui == "tui": + argv = ["--profile", selected, "--tui", "chat"] + sources = ("tui", "cli") + else: + argv = ["--profile", selected, "--cli", "chat"] + sources = ("cli",) + + ws_key = _current_workspace_key() + session_id: Optional[str] = None + for source in sources: + session_id = resolve_profile_last_session( + selected, source=source, workspace_key=ws_key + ) + if session_id: + break + if session_id: + argv.extend(["--resume", session_id]) + return argv + + # --------------------------------------------------------------------------- # Alias / wrapper script management # --------------------------------------------------------------------------- diff --git a/tests/cli/test_profile_command.py b/tests/cli/test_profile_command.py new file mode 100644 index 000000000000..65e16f898df2 --- /dev/null +++ b/tests/cli/test_profile_command.py @@ -0,0 +1,179 @@ +"""Tests for in-chat profile switching in the classic CLI.""" + +from types import SimpleNamespace +from unittest.mock import patch + +from cli import HermesCLI + + +def _call(self_, command): + return HermesCLI._handle_profile_command(self_, command) + + +def test_profile_without_name_shows_runtime_profile(capsys): + self_ = SimpleNamespace(_pending_relaunch=None) + + with ( + patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"), + patch("hermes_constants.display_hermes_home", return_value="~/.hermes/profiles/coder"), + ): + result = _call(self_, "/profile") + + assert result is False + assert self_._pending_relaunch is None + output = capsys.readouterr().out + assert "Profile: coder" in output + assert "Home: ~/.hermes/profiles/coder" in output + + +def test_profile_name_sets_sticky_profile_and_requests_clean_relaunch(capsys): + self_ = SimpleNamespace(_pending_relaunch=None) + + with ( + patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), + patch("hermes_cli.profiles.set_active_profile") as set_active, + patch( + "hermes_cli.profiles.build_profile_switch_relaunch_argv", + return_value=["--profile", "coder", "--cli", "chat"], + ) as build_argv, + ): + result = _call(self_, "/profile Coder") + + assert result is True + set_active.assert_called_once_with("Coder") + build_argv.assert_called_once_with("coder", ui="cli") + assert self_._pending_relaunch == ["--profile", "coder", "--cli", "chat"] + out = capsys.readouterr().out + assert "Switching to profile 'coder'" in out + assert "resuming" not in out + + +def test_profile_name_relaunches_with_resume_when_target_has_session(capsys): + self_ = SimpleNamespace(_pending_relaunch=None) + relaunch = [ + "--profile", + "coder", + "--cli", + "chat", + "--resume", + "20260811_120000_abcdef", + ] + + with ( + patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), + patch("hermes_cli.profiles.set_active_profile"), + patch( + "hermes_cli.profiles.build_profile_switch_relaunch_argv", + return_value=relaunch, + ), + ): + result = _call(self_, "/profile coder") + + assert result is True + assert self_._pending_relaunch == relaunch + assert "resuming last session" in capsys.readouterr().out + + +def test_profile_name_error_keeps_current_chat(capsys): + self_ = SimpleNamespace(_pending_relaunch=None) + + with patch( + "hermes_cli.profiles.set_active_profile", + side_effect=FileNotFoundError("Profile 'missing' does not exist"), + ): + result = _call(self_, "/profile missing") + + assert result is False + assert self_._pending_relaunch is None + assert "does not exist" in capsys.readouterr().out + + +def test_profile_switch_relaunch_argv_includes_selected_profile(tmp_path, monkeypatch, capsys): + """E2E-ish: sticky write + relaunch argv resolve the selected profile name.""" + from hermes_cli.profiles import get_active_profile + + profiles_root = tmp_path / "profiles" + coder = profiles_root / "coder" + coder.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default-home")) + # Point sticky active_profile file into tmp root via profiles helpers. + monkeypatch.setattr( + "hermes_cli.profiles._get_active_profile_path", + lambda: tmp_path / "active_profile", + ) + monkeypatch.setattr( + "hermes_cli.profiles._get_profiles_root", + lambda: profiles_root, + ) + monkeypatch.setattr( + "hermes_cli.profiles.profile_exists", + lambda name: name == "coder", + ) + monkeypatch.setattr( + "hermes_cli.profiles.build_profile_switch_relaunch_argv", + lambda name, *, ui="cli": ["--profile", name, f"--{ui}", "chat"], + ) + + self_ = SimpleNamespace(_pending_relaunch=None) + with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"): + result = _call(self_, "/profile coder") + + assert result is True + assert get_active_profile() == "coder" + assert self_._pending_relaunch == ["--profile", "coder", "--cli", "chat"] + assert "Switching to profile 'coder'" in capsys.readouterr().out + + +def test_build_profile_switch_relaunch_argv_appends_resume_when_session_exists(monkeypatch): + from hermes_cli.profiles import build_profile_switch_relaunch_argv + + monkeypatch.setattr( + "hermes_cli.profiles._current_workspace_key", + lambda: "/tmp/ws", + ) + monkeypatch.setattr( + "hermes_cli.profiles.resolve_profile_last_session", + lambda name, *, source="cli", workspace_key=None: ( + "sess-cli" if source == "cli" else None + ), + ) + + assert build_profile_switch_relaunch_argv("Coder", ui="cli") == [ + "--profile", + "coder", + "--cli", + "chat", + "--resume", + "sess-cli", + ] + assert build_profile_switch_relaunch_argv("coder", ui="tui") == [ + "--profile", + "coder", + "--tui", + "chat", + "--resume", + "sess-cli", + ] + + +def test_build_profile_switch_relaunch_argv_omits_resume_when_no_session(monkeypatch): + from hermes_cli.profiles import build_profile_switch_relaunch_argv + + monkeypatch.setattr("hermes_cli.profiles._current_workspace_key", lambda: None) + monkeypatch.setattr( + "hermes_cli.profiles.resolve_profile_last_session", + lambda *a, **k: None, + ) + + assert build_profile_switch_relaunch_argv("coder", ui="cli") == [ + "--profile", + "coder", + "--cli", + "chat", + ] + assert build_profile_switch_relaunch_argv("coder", ui="tui") == [ + "--profile", + "coder", + "--tui", + "chat", + ] diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 799c3aa1b1c3..73f7930755c6 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -380,6 +380,23 @@ async def fake_send_with_retry(chat_id, content, reply_to=None, metadata=None): assert session_key not in adapter._pending_messages, "/status was incorrectly queued" +@pytest.mark.asyncio +async def test_profile_command_does_not_switch_from_gateway(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + + result = await runner._handle_profile_command(_make_event("/profile coder")) + + assert result == "Profile switching is only available in terminal chat." + + @pytest.mark.asyncio async def test_profile_command_reports_source_stamped_profile(monkeypatch, tmp_path): """On a multiplexed gateway, /profile reports the profile SERVING the diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 33ff8f8700f6..3f5dbec879dd 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -254,6 +254,68 @@ def fake_call(argv, cwd=None, env=None): assert env["NODE_ENV"] == "production" +def test_launch_tui_exit_code_43_relaunches_selected_profile(monkeypatch, main_mod): + from unittest.mock import patch + + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), + ) + monkeypatch.setattr(main_mod.subprocess, "call", lambda *args, **kwargs: 43) + + with ( + patch("hermes_cli.profiles.get_active_profile", return_value="coder"), + patch( + "hermes_cli.profiles.build_profile_switch_relaunch_argv", + return_value=["--profile", "coder", "--tui", "chat"], + ) as build_argv, + patch("hermes_cli.relaunch.relaunch") as mock_relaunch, + ): + with pytest.raises(SystemExit) as exc: + main_mod._launch_tui() + + assert exc.value.code == 43 + build_argv.assert_called_once_with("coder", ui="tui") + mock_relaunch.assert_called_once_with( + ["--profile", "coder", "--tui", "chat"], + preserve_inherited=False, + ) + + +def test_launch_tui_exit_code_43_relaunches_with_resume(monkeypatch, main_mod): + from unittest.mock import patch + + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), + ) + monkeypatch.setattr(main_mod.subprocess, "call", lambda *args, **kwargs: 43) + relaunch = [ + "--profile", + "coder", + "--tui", + "chat", + "--resume", + "20260811_120000_abcdef", + ] + + with ( + patch("hermes_cli.profiles.get_active_profile", return_value="coder"), + patch( + "hermes_cli.profiles.build_profile_switch_relaunch_argv", + return_value=relaunch, + ), + patch("hermes_cli.relaunch.relaunch") as mock_relaunch, + ): + with pytest.raises(SystemExit) as exc: + main_mod._launch_tui() + + assert exc.value.code == 43 + mock_relaunch.assert_called_once_with(relaunch, preserve_inherited=False) + + def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 43341730059f..517ef961c327 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5871,6 +5871,78 @@ def test_config_set_yolo_toggles_session_scope(): server._sessions.clear() +def test_profile_switch_status_and_selection(monkeypatch): + monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", lambda: "default") + monkeypatch.setattr("hermes_constants.display_hermes_home", lambda: "~/.hermes") + selected = [] + monkeypatch.setattr("hermes_cli.profiles.set_active_profile", selected.append) + + status = server.handle_request( + {"id": "1", "method": "profile.switch", "params": {}} + ) + switched = server.handle_request( + { + "id": "2", + "method": "profile.switch", + "params": {"name": "Coder"}, + } + ) + + assert status["result"] == { + "profile": "default", + "home": "~/.hermes", + "relaunch": False, + } + assert switched["result"]["profile"] == "coder" + assert switched["result"]["relaunch"] is True + assert selected == ["Coder"] + + +def test_profile_switch_rejects_busy_session(monkeypatch): + monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", lambda: "default") + server._sessions["sid"] = {"running": True} + try: + response = server.handle_request( + { + "id": "1", + "method": "profile.switch", + "params": {"session_id": "sid", "name": "coder"}, + } + ) + finally: + server._sessions.clear() + + assert response["error"]["code"] == 4009 + assert "session busy" in response["error"]["message"] + + +def test_profile_switch_rejects_websocket_transport(monkeypatch): + from tui_gateway.transport import bind_transport, reset_transport + + class _WebTransport: + def write(self, obj): + return True + + def close(self): + return None + + monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", lambda: "default") + token = bind_transport(_WebTransport()) + try: + response = server.handle_request( + { + "id": "1", + "method": "profile.switch", + "params": {"name": "coder"}, + } + ) + finally: + reset_transport(token) + + assert response["error"]["code"] == 4003 + assert "standalone terminal chat" in response["error"]["message"] + + def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatch): """Shift+click the desktop zap -> scope="global" flips persistent approvals.mode.""" import yaml diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 53ccb5d91c0b..f9b33022334c 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -421,6 +421,65 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": False, "error": str(e)}) +@method("profile.switch") +def _(rid, params: dict) -> dict: + """Show the runtime profile or select one for a clean chat relaunch. + + Status (no name) reuses the shared ``execute_command("profile")`` executor + so CLI/gateway/TUI status text stays on one path. Selection is sticky + + process-boundary only and is restricted to standalone stdio terminal chat. + """ + from hermes_cli.profiles import get_active_profile_name, set_active_profile + from hermes_cli.slash_exec import CommandContext, execute_command + + target = str(params.get("name") or "").strip() + if not target: + reply = execute_command("profile", CommandContext(surface="tui")) + return _ok( + rid, + { + "profile": reply.data["profile"], + "home": reply.data["home"], + "relaunch": False, + }, + ) + + transport = current_transport() + if transport is not None and transport is not _stdio_transport: + return _err( + rid, + 4003, + "profile switching is only available in standalone terminal chat", + ) + + session = _sessions.get(str(params.get("session_id") or "")) + if session and session.get("running"): + return _err( + rid, + 4009, + "session busy — /interrupt the current turn before switching profiles", + ) + + try: + set_active_profile(target) + except (FileNotFoundError, ValueError) as exc: + return _err(rid, 4002, str(exc)) + + current = get_active_profile_name() + selected = target.lower() + # Home after sticky write still reflects the *current* process HERMES_HOME + # until the parent relaunches with --profile; report the shared status home. + reply = execute_command("profile", CommandContext(surface="tui")) + return _ok( + rid, + { + "profile": selected, + "home": reply.data["home"], + "relaunch": selected != current, + }, + ) + + def register(server) -> None: """Bind this module's handlers onto ``server``'s globals and registry.""" _registry.install(server) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 14d6dbc2f2fa..83656e4e6123 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10844,6 +10844,7 @@ def _respond(rid, params, key, *, allow_expired=False): # NOTE: config.set intentionally stays in server.py for now — the in-flight # opt/model-resolution-core PR touches its body; move it to methods_config.py # in a follow-up once that PR lands. +# profile.switch lives in methods_config.py (split handler modules). @method("config.set") def _(rid, params: dict) -> dict: key, value = params.get("key", ""), params.get("value", "") diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 4fa7ff2dca88..4345d78c52a1 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -2,7 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSlashHandler } from '../app/createSlashHandler.js' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' -import { DASHBOARD_EXIT_DISABLED_MESSAGE, DASHBOARD_UPDATE_DISABLED_MESSAGE } from '../app/slash/commands/core.js' +import { + DASHBOARD_EXIT_DISABLED_MESSAGE, + DASHBOARD_PROFILE_SWITCH_DISABLED_MESSAGE, + DASHBOARD_UPDATE_DISABLED_MESSAGE +} from '../app/slash/commands/core.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' import type * as EnvModule from '../config/env.js' import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' @@ -172,6 +176,51 @@ describe('createSlashHandler', () => { vi.useRealTimers() }) + it('switches profiles through the native RPC and exits for parent relaunch', async () => { + vi.useFakeTimers() + patchUiState({ sid: 'sid-abc' }) + const rpc = vi.fn(() => Promise.resolve({ profile: 'coder', relaunch: true })) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + expect(createSlashHandler(ctx)('/profile coder')).toBe(true) + expect(rpc).toHaveBeenCalledWith('profile.switch', { + name: 'coder', + session_id: 'sid-abc' + }) + + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalledWith( + "switching to profile 'coder' (will resume last session if any)..." + ) + }) + vi.advanceTimersByTime(150) + expect(ctx.session.dieWithCode).toHaveBeenCalledWith(43) + + vi.useRealTimers() + }) + + it('shows the runtime profile without exiting', async () => { + const rpc = vi.fn(() => Promise.resolve({ home: '~/.hermes/profiles/coder', profile: 'coder', relaunch: false })) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + expect(createSlashHandler(ctx)('/profile')).toBe(true) + + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalledWith('profile: coder\nhome: ~/.hermes/profiles/coder') + }) + expect(ctx.session.dieWithCode).not.toHaveBeenCalled() + }) + + it('directs dashboard users to the existing profile dropdown', () => { + envState.dashboardTuiMode = true + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/profile coder')).toBe(true) + expect(ctx.gateway.rpc).not.toHaveBeenCalled() + expect(ctx.session.dieWithCode).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith(DASHBOARD_PROFILE_SWITCH_DISABLED_MESSAGE) + }) + it('routes /status to live session.status instead of slash worker', async () => { patchUiState({ sid: 'sid-abc' }) const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' })) @@ -820,7 +869,7 @@ describe('createSlashHandler', () => { expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array)) }) - it('lets exact catalog commands win over longer prefix matches', async () => { + it('lets exact local commands win over longer catalog prefix matches', () => { const ctx = buildCtx({ local: { catalog: { @@ -833,12 +882,10 @@ describe('createSlashHandler', () => { }) expect(createSlashHandler(ctx)('/profile')).toBe(true) - await vi.waitFor(() => { - expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', { - command: 'profile', - session_id: null - }) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('profile.switch', { + session_id: null }) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() expect(ctx.transcript.sys).not.toHaveBeenCalledWith(expect.stringContaining('ambiguous command')) }) diff --git a/ui-tui/src/__tests__/slashParity.test.ts b/ui-tui/src/__tests__/slashParity.test.ts index 0b6a6149ff43..dc4a12634437 100644 --- a/ui-tui/src/__tests__/slashParity.test.ts +++ b/ui-tui/src/__tests__/slashParity.test.ts @@ -26,6 +26,7 @@ const MUTATING_COMMANDS = [ 'model', 'new', 'personality', + 'profile', 'queue', 'reasoning', 'reload-mcp', diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 62f64ccaffc2..1a7dd691df32 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -7,6 +7,7 @@ import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from import type { ConfigGetValueResponse, ConfigSetResponse, + ProfileSwitchResponse, SessionSaveResponse, SessionStatusResponse, SessionSteerResponse, @@ -89,6 +90,8 @@ export const DASHBOARD_EXIT_DISABLED_MESSAGE = export const DASHBOARD_UPDATE_DISABLED_MESSAGE = 'update is disabled in hosted dashboard chat — the hosted environment is managed separately' +export const DASHBOARD_PROFILE_SWITCH_DISABLED_MESSAGE = 'profile switching in dashboard chat uses the profile dropdown' + export const coreCommands: SlashCommand[] = [ { help: 'list commands + hotkeys', @@ -163,6 +166,53 @@ export const coreCommands: SlashCommand[] = [ } }, + { + help: 'show or switch the active profile [name] (resumes last session)', + name: 'profile', + run: (arg, ctx) => { + const target = arg.trim() + + if (target && DASHBOARD_TUI_MODE) { + ctx.transcript.sys(DASHBOARD_PROFILE_SWITCH_DISABLED_MESSAGE) + + return + } + + if (target && ctx.ui.busy) { + ctx.transcript.sys('session busy — /interrupt the current turn before switching profiles') + + return + } + + ctx.gateway + .rpc('profile.switch', { + ...(target ? { name: target } : {}), + session_id: ctx.sid + }) + .then( + ctx.guarded(result => { + if (!target) { + ctx.transcript.sys(`profile: ${result.profile}\nhome: ${result.home ?? '(unknown)'}`) + + return + } + + if (!result.relaunch) { + ctx.transcript.sys(`profile '${result.profile}' is already active`) + + return + } + + ctx.transcript.sys( + `switching to profile '${result.profile}' (will resume last session if any)...` + ) + setTimeout(() => ctx.session.dieWithCode(43), 100) + }) + ) + .catch(ctx.guardedErr) + } + }, + { aliases: ['scroll'], help: 'set mouse tracking preset [on|off|toggle|wheel|buttons|all]', diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c5bb9ff0d370..f0a559184ad5 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -153,6 +153,12 @@ export interface ConfigSetResponse { warning?: string } +export interface ProfileSwitchResponse { + home?: string + profile: string + relaunch: boolean +} + export interface SetupStatusResponse { provider_configured?: boolean } diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index c5f646fa6acb..5d409af46f7b 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -134,8 +134,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/copy [number]` | Copy the last assistant response to clipboard (or the Nth-from-last with a number). CLI-only. | | `/image ` | Attach a local image file for your next prompt. | | `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. | -| `/update` | Update Hermes Agent to the latest version. | -| `/profile` | Show active profile name and home directory | +| `/profile [name]` | Show the active profile, or restart terminal chat under another profile and resume that profile's last session when one exists. Dashboard Chat uses its profile dropdown instead. | ### Exit