diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index f7eacb68590..2639d8028e4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -347,6 +347,30 @@ def test_complete_slash_includes_provider_alias(): assert any(item["text"] == "provider" for item in resp["result"]["items"]) +def test_complete_slash_includes_tui_details_command(): + resp = server.handle_request( + {"id": "1", "method": "complete.slash", "params": {"text": "/det"}} + ) + + assert any(item["text"] == "/details" for item in resp["result"]["items"]) + + +def test_complete_slash_details_args(): + resp_section = server.handle_request( + {"id": "1", "method": "complete.slash", "params": {"text": "/details t"}} + ) + resp_mode = server.handle_request( + { + "id": "2", + "method": "complete.slash", + "params": {"text": "/details thinking e"}, + } + ) + + assert any(item["text"] == "thinking" for item in resp_section["result"]["items"]) + assert any(item["text"] == "expanded" for item in resp_mode["result"]["items"]) + + def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): monkeypatch.setattr(server, "_hermes_home", tmp_path) agent = types.SimpleNamespace(reasoning_config=None) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03631bf1745..b0b379d0d34 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3710,6 +3710,65 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"items": items}) +def _details_completion_item(value: str, meta: str = "") -> dict: + return {"text": value, "display": value, "meta": meta} + + +def _details_completions(text: str) -> list[dict] | None: + if not text.lower().startswith("/details"): + return None + + stripped = text.strip() + if stripped and not "/details".startswith(stripped.lower().split()[0]): + return None + + body = text[len("/details"):] + if body.startswith(" "): + body = body[1:] + parts = body.split() + has_trailing_space = text.endswith(" ") + sections = ("thinking", "tools", "subagents", "activity") + modes = ("hidden", "collapsed", "expanded") + + if not body or (len(parts) == 0 and has_trailing_space): + return [ + *[_details_completion_item(mode, "global mode") for mode in modes], + _details_completion_item("cycle", "cycle global mode"), + *[_details_completion_item(section, "section override") for section in sections], + ] + + if len(parts) == 1 and not has_trailing_space: + prefix = parts[0].lower() + candidates = [*modes, "cycle", *sections] + return [ + _details_completion_item( + candidate, + "section override" if candidate in sections else "global mode", + ) + for candidate in candidates + if candidate.startswith(prefix) and candidate != prefix + ] + + 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("reset", f"clear {parts[0].lower()} override"), + ] + + if len(parts) == 2 and not has_trailing_space and parts[0].lower() in sections: + prefix = parts[1].lower() + return [ + _details_completion_item( + candidate, + 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 + ] + + return [] + + @method("complete.slash") def _(rid, params: dict) -> dict: text = params.get("text", "") @@ -3742,6 +3801,11 @@ def _(rid, params: dict) -> dict: "display": "/compact", "meta": "Toggle compact display mode", }, + { + "text": "/details", + "display": "/details", + "meta": "Control agent detail visibility", + }, { "text": "/logs", "display": "/logs", @@ -3753,6 +3817,14 @@ def _(rid, params: dict) -> dict: item["text"] == extra["text"] for item in items ): items.append(extra) + + details_items = _details_completions(text) + if details_items is not None: + return _ok( + rid, + {"items": details_items, "replace_from": text.rfind(" ") + 1}, + ) + return _ok( rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}, diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 5d63d0adbf2..32c92c00ab3 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -25,6 +25,36 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) + it('persists typed /model switches by default', async () => { + patchUiState({ sid: 'sid-abc' }) + + const ctx = buildCtx({ + gateway: { + ...buildGateway(), + rpc: vi.fn(() => Promise.resolve({ value: 'x-model' })) + } + }) + + expect(createSlashHandler(ctx)('/model x-model')).toBe(true) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { + key: 'model', + session_id: 'sid-abc', + value: 'x-model --global' + }) + }) + + it('does not duplicate --global for explicit persistent model switches', () => { + patchUiState({ sid: 'sid-abc' }) + const ctx = buildCtx() + + createSlashHandler(ctx)('/model x-model --global') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { + key: 'model', + session_id: 'sid-abc', + value: 'x-model --global' + }) + }) + it('opens the skills hub locally for bare /skills', () => { const ctx = buildCtx() diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 1049ee34d8e..7cb7fcf8351 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -16,6 +16,14 @@ import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' +const GLOBAL_MODEL_FLAG_RE = /(?:^|\s)--global(?:\s|$)/ + +const persistedModelArg = (arg: string) => { + const trimmed = arg.trim() + + return GLOBAL_MODEL_FLAG_RE.test(trimmed) ? trimmed : `${trimmed} --global` +} + export const sessionCommands: SlashCommand[] = [ { aliases: ['bg'], @@ -69,21 +77,23 @@ export const sessionCommands: SlashCommand[] = [ return patchOverlayState({ modelPicker: true }) } - ctx.gateway.rpc('config.set', { key: 'model', session_id: ctx.sid, value: arg.trim() }).then( - ctx.guarded(r => { - if (!r.value) { - return ctx.transcript.sys('error: invalid response: model switch') - } + ctx.gateway + .rpc('config.set', { key: 'model', session_id: ctx.sid, value: persistedModelArg(arg) }) + .then( + ctx.guarded(r => { + if (!r.value) { + return ctx.transcript.sys('error: invalid response: model switch') + } - ctx.transcript.sys(`model → ${r.value}`) - ctx.local.maybeWarn(r) + ctx.transcript.sys(`model → ${r.value}`) + ctx.local.maybeWarn(r) - patchUiState(state => ({ - ...state, - info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} } - })) - }) - ) + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} } + })) + }) + ) } }, diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 31f228eb6bc..7d87be11257 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -627,7 +627,7 @@ export function useMainApp(gw: GatewayClient) { const onModelSelect = useCallback((value: string) => { patchOverlayState({ modelPicker: false }) - slashRef.current(`/model ${value}`) + slashRef.current(`/model ${value} --global`) }, []) const hasReasoning = Boolean(turn.reasoning.trim())