fix(tui): persist model switches by default

This commit is contained in:
Brooklyn Nicholson
2026-04-26 02:15:10 -05:00
parent 14fcff60c9
commit 458ce792d2
5 changed files with 150 additions and 14 deletions

View File

@@ -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)

View File

@@ -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},

View File

@@ -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()

View File

@@ -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<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: arg.trim() }).then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return ctx.transcript.sys('error: invalid response: model switch')
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: persistedModelArg(arg) })
.then(
ctx.guarded<ConfigSetResponse>(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: {} }
}))
})
)
}
},

View File

@@ -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())