From f27d45e2880b46a2239b184ecc8ab88ecfd2843d Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Thu, 30 Jul 2026 05:18:30 -0500 Subject: [PATCH] feat(tui): reach the model picker without wrecking your draft, and switch mid-turn (#74756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tui): Ctrl+O opens the model picker without clearing your draft Reaching the model picker meant typing /model, which forces you to wipe whatever you'd already drafted. Bind Ctrl+O to open the same picker overlay directly, leaving the composer untouched. Ctrl+O is added to the textInput pass-through allowlist so the composer doesn't swallow it, mirroring the existing Ctrl+X session-switcher path. * feat(tui): apply a mid-turn model switch at the next turn instead of rejecting it Picking a model while a turn was streaming hit a 4009 'session busy' reject: switch_model() mutates the agent's model/provider/base_url/client in place and the worker thread reads those every iteration. Now config.set queues the pick in session[pending_model_switch] and _apply_pending_model_switch applies it on the turn thread at the next turn start, before any model call — no race, no interrupt, no waiting on the client rebuild. The TUI paints the pick optimistically and notes '(applies next turn)'. --- tests/test_tui_gateway_server.py | 52 ++++++++--- tui_gateway/server.py | 90 ++++++++++++++++--- .../__tests__/textInputPassThrough.test.ts | 1 + ui-tui/src/app/slash/commands/session.ts | 11 +-- ui-tui/src/app/useInputHandlers.ts | 9 ++ ui-tui/src/components/textInput.tsx | 1 + ui-tui/src/content/hotkeys.ts | 1 + ui-tui/src/gatewayTypes.ts | 3 + 8 files changed, 139 insertions(+), 29 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index b3714bbf12d..0904c237002 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9598,14 +9598,16 @@ def test_respond_unpacks_sid_tuple_correctly(): # /model switch and other agent-mutating commands must reject while the # session is running. agent.switch_model() mutates self.model, self.provider, # self.base_url, self.client etc. in place — the worker thread running -# agent.run_conversation is reading those on every iteration. Same class of -# bug as the session.undo / session.compress mid-run silent-drop; same fix -# pattern: reject with 4009 while running. +# agent.run_conversation is reading those on every iteration. So a mid-turn +# config.set model must NOT switch in place; instead it queues the pick +# (session["pending_model_switch"]) and _apply_pending_model_switch applies it +# on the turn thread at the next turn start, where nothing is in flight. # --------------------------------------------------------------------------- -def test_config_set_model_rejects_while_running(monkeypatch): - """/model via config.set must reject during an in-flight turn.""" +def test_config_set_model_defers_while_running(monkeypatch): + """/model via config.set queues the pick during an in-flight turn instead + of rejecting or racing the worker thread.""" seen = {"called": False} def _fake_apply(sid, session, raw, **_kwargs): @@ -9627,17 +9629,47 @@ def test_config_set_model_rejects_while_running(monkeypatch): }, } ) - assert resp.get("error") - assert resp["error"]["code"] == 4009 - assert "session busy" in resp["error"]["message"] + assert not resp.get("error") + result = resp["result"] + assert result["deferred"] is True + assert result["value"] == "anthropic/claude-sonnet-4.6" assert not seen["called"], ( - "_apply_model_switch was called mid-turn — would race with " - "the worker thread reading agent.model / agent.client" + "_apply_model_switch ran mid-turn — would race the worker thread " + "reading agent.model / agent.client; it must defer to turn start" ) + pending = server._sessions["sid"].get("pending_model_switch") + assert pending and pending["raw"] == "anthropic/claude-sonnet-4.6" finally: server._sessions.pop("sid", None) +def test_apply_pending_model_switch_runs_queued_pick(monkeypatch): + """The queued pick is consumed once, on the turn thread, via + _apply_model_switch — and cleared so it can't re-fire next turn.""" + calls = [] + + def _fake_apply(sid, session, raw, **kwargs): + calls.append(raw) + return {"value": raw, "warning": "", "confirm_required": False} + + monkeypatch.setattr(server, "_apply_model_switch", _fake_apply) + + session = _session(running=False) + session["agent"] = object() + session["pending_model_switch"] = { + "raw": "anthropic/claude-sonnet-4.6", + "confirm_expensive_model": False, + } + + server._apply_pending_model_switch("sid", session) + assert calls == ["anthropic/claude-sonnet-4.6"] + assert "pending_model_switch" not in session + + # Idempotent: a second turn start with nothing queued is a no-op. + server._apply_pending_model_switch("sid", session) + assert calls == ["anthropic/claude-sonnet-4.6"] + + def test_config_set_model_allowed_when_idle(monkeypatch): """Regression guard: idle sessions can still switch models.""" seen = {"called": False} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 011940dc9f8..b3be3ee1b0d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4294,6 +4294,44 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None: ) +def _apply_pending_model_switch(sid: str, session: dict) -> None: + """Apply a model switch queued while a turn was running. + + ``config.set model`` on a busy session doesn't mutate the live agent (the + worker thread is reading model/client mid-request); it stashes the pick in + ``session["pending_model_switch"]``. This runs on the TURN thread at turn + start — before the first model call, nothing in flight — so the in-place + swap (client rebuild, the slow part) is safe here. A failed switch keeps + the current model and never blocks the turn, matching + ``_sync_agent_model_with_config``. + """ + pending = session.pop("pending_model_switch", None) + if not pending or session.get("agent") is None: + return + try: + result = _apply_model_switch( + sid, + session, + pending["raw"], + confirm_expensive_model=bool(pending.get("confirm_expensive_model")), + ) + # A queued pick is a deliberate user action; honour the expensive-model + # confirm by NOT applying it silently — surface the warning and drop the + # switch rather than spend on a pricey model the user never confirmed. + if result.get("confirm_required"): + _emit( + "error", + sid, + {"message": result.get("confirm_message") or result.get("warning") or ""}, + ) + except Exception as e: + _emit( + "error", + sid, + {"message": f"Could not switch model: {e}"}, + ) + + class CompressionLockHeld(Exception): """Raised by _compress_session_history when compression skipped due to a concurrent lock on the session's compression_locks row.""" @@ -8989,6 +9027,11 @@ def _run_prompt_submit( # (#29923 review defect). Any config.yaml change is adopted on # the NEXT turn, after the finally-restore below. if not one_turn_restore: + # A model picked mid-turn was queued (not applied in-place) — + # apply it now, on the turn thread before the first model call, + # so this turn runs on the model the user chose. Runs before the + # config sync so an explicit pick wins over a config.yaml change. + _apply_pending_model_switch(sid, session) _sync_agent_model_with_config(sid, session) cwd = _session_cwd(session) _register_session_cwd(session) @@ -9956,22 +9999,41 @@ def _(rid, params: dict) -> dict: if not value: return _err(rid, 4002, "model value required") if session: - # Reject during an in-flight turn. agent.switch_model() - # mutates self.model / self.provider / self.base_url / - # self.client in place; the worker thread running - # agent.run_conversation is reading those on every - # iteration. A mid-turn swap can send an HTTP request - # with the new base_url but old model (or vice versa), - # producing 400/404s the user never asked for. Parity - # with the gateway's running-agent /model guard. - if session.get("running"): - return _err( - rid, - 4009, - "session busy — /interrupt the current turn before switching models", - ) from hermes_cli.model_switch import parse_model_switch_args + # A live swap can't run in-place while a turn streams: + # agent.switch_model() mutates self.model / self.provider / + # self.base_url / self.client, and the worker thread running + # agent.run_conversation reads those every iteration — a + # mid-turn swap can fire an HTTP request with the new base_url + # but old model (400/404s). So instead of rejecting the pick + # (the old 4009), stash it and apply it at the NEXT turn start + # (_apply_pending_model_switch), where nothing is in flight. + # The user gets to pick, keep typing, and send the next turn on + # the new model without waiting for the swap or interrupting. + if session.get("running"): + try: + pending_model = parse_model_switch_args(value).model_input + except Exception: + pending_model = str(value) + session["pending_model_switch"] = { + "raw": value, + "confirm_expensive_model": bool( + params.get("confirm_expensive_model", False) + ), + } + return _ok( + rid, + { + "key": key, + "value": pending_model, + "warning": "", + "confirm_required": False, + "confirm_message": "", + "scope": "session", + "deferred": True, + }, + ) parsed_flags = parse_model_switch_args(value) explicit_provider = parsed_flags.explicit_provider if session.get("agent") is None and not explicit_provider.strip(): diff --git a/ui-tui/src/__tests__/textInputPassThrough.test.ts b/ui-tui/src/__tests__/textInputPassThrough.test.ts index 05e214c0c19..ff8c29ebcd1 100644 --- a/ui-tui/src/__tests__/textInputPassThrough.test.ts +++ b/ui-tui/src/__tests__/textInputPassThrough.test.ts @@ -44,6 +44,7 @@ describe('shouldPassThroughToGlobalHandler', () => { it('always passes through non-voice global control keys', () => { expect(shouldPassThroughToGlobalHandler('c', key({ ctrl: true }))).toBe(true) expect(shouldPassThroughToGlobalHandler('x', key({ ctrl: true }))).toBe(true) + expect(shouldPassThroughToGlobalHandler('o', key({ ctrl: true }))).toBe(true) expect(shouldPassThroughToGlobalHandler('', key({ escape: true }))).toBe(true) expect(shouldPassThroughToGlobalHandler('', key({ tab: true }))).toBe(true) expect(shouldPassThroughToGlobalHandler('', key({ pageUp: true }))).toBe(true) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 3fc60e8564a..a4129490191 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -104,10 +104,11 @@ export const sessionCommands: SlashCommand[] = [ help: 'change or show model', name: 'model', run: (arg, ctx) => { - if (ctx.session.guardBusySessionSwitch('change models')) { - return - } - + // No busy guard here (unlike session switching). A model change is a + // session-scoped config.set: idle it switches immediately; mid-turn the + // gateway QUEUES it and applies it at the next turn start (returning + // deferred:true) instead of rejecting. Either way the pick sticks without + // interrupting the stream or waiting on the swap. if (!arg.trim()) { return patchOverlayState({ modelPicker: true }) } @@ -145,7 +146,7 @@ export const sessionCommands: SlashCommand[] = [ return ctx.transcript.sys('error: invalid response: model switch') } - ctx.transcript.sys(`model → ${r.value}`) + ctx.transcript.sys(r.deferred ? `model → ${r.value} (applies next turn)` : `model → ${r.value}`) ctx.local.maybeWarn(r) patchUiState(state => ({ diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 9624c6ec87d..ef503a9e67e 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -585,6 +585,15 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ sessions: true }) } + // Ctrl+O opens the model picker without disturbing a typed draft — the + // same overlay `/model` opens, but reachable without clearing what you've + // typed to run the command. Works mid-stream: picking a model writes the + // session model (config.set), which the next turn reads while the in-flight + // turn keeps streaming. + if (isCtrl(key, ch, 'o')) { + return patchOverlayState({ modelPicker: true }) + } + if (key.ctrl && ch.toLowerCase() === 'c') { if (live.busy && live.sid) { return turnController.interruptTurn({ diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 4f2f4cea47f..eb202ca1ba8 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -1540,6 +1540,7 @@ export const shouldPassThroughToGlobalHandler = ( ): boolean => (key.ctrl && input === 'c') || (key.ctrl && input === 'x') || + (key.ctrl && input === 'o') || key.tab || (key.shift && key.tab) || key.pageUp || diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 273700114c0..d243b84d85e 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -25,6 +25,7 @@ export const HOTKEYS: [string, string][] = [ ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], ['Ctrl+X', 'open live session switcher (deletes queued message while editing)'], + ['Ctrl+O', 'open model picker (keeps your draft; applies to next turn mid-stream)'], [action + '+A/E', 'home / end of line'], [action + '+Z / ' + action + '+Y', 'undo / redo input edits'], [action + '+W', 'delete word'], diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 3066b83848f..c5bb9ff0d37 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -144,6 +144,9 @@ export interface ConfigSetResponse { confirm_message?: string confirm_required?: boolean credential_warning?: string + // A model pick made mid-turn is queued and applied at the next turn start, + // not live yet — the handler says "next turn" instead of "model → X". + deferred?: boolean history_reset?: boolean info?: SessionInfo value?: string