diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 017e9913bd..2efd64fe40 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -124,6 +124,7 @@ "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", @@ -501,31 +502,6 @@ "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", @@ -1700,6 +1676,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1710,6 +1687,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1720,6 +1698,7 @@ "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", @@ -1749,6 +1728,7 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -2066,6 +2046,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2468,6 +2449,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3203,6 +3185,7 @@ "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", @@ -3334,6 +3317,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4242,6 +4226,7 @@ "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" @@ -5678,6 +5663,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5787,6 +5773,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6611,6 +6598,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6737,6 +6725,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6846,6 +6835,7 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -7261,6 +7251,7 @@ "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 3c504454d1..9180651f51 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -43,6 +43,28 @@ describe('createSlashHandler', () => { }) }) + it('honors TUI picker session scope without adding --global', async () => { + patchUiState({ sid: 'sid-abc' }) + + const ctx = buildCtx({ + gateway: { + ...buildGateway(), + rpc: vi.fn(() => Promise.resolve({ value: 'anthropic/claude-sonnet-4.6' })) + } + }) + + expect( + createSlashHandler(ctx)( + '/model anthropic/claude-sonnet-4.6 --provider openrouter --tui-session' + ) + ).toBe(true) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { + key: 'model', + session_id: 'sid-abc', + value: 'anthropic/claude-sonnet-4.6 --provider openrouter' + }) + }) + it('does not duplicate --global for explicit persistent model switches', () => { patchUiState({ sid: 'sid-abc' }) const ctx = buildCtx() diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index fc4fb8dafe..a6408ec1f1 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -17,12 +17,29 @@ import type { SlashCommand } from '../types.js' const GLOBAL_MODEL_FLAG_RE = /(?:^|\s)--global(?:\s|$)/ +/** Stripped before `config.set`; TUI model picker uses this for session-scoped switches. */ +const TUI_SESSION_MODEL_RE = /(?:^|\s)--tui-session(?:\s|$)/ + const persistedModelArg = (arg: string) => { const trimmed = arg.trim() return !trimmed || GLOBAL_MODEL_FLAG_RE.test(trimmed) ? trimmed : `${trimmed} --global` } +const modelValueForConfigSet = (arg: string) => { + const trimmed = arg.trim() + + if (!trimmed) { + return trimmed + } + + if (TUI_SESSION_MODEL_RE.test(trimmed)) { + return trimmed.replace(/\s*--tui-session\b\s*/g, ' ').replace(/\s+/g, ' ').trim() + } + + return persistedModelArg(trimmed) +} + export const sessionCommands: SlashCommand[] = [ { aliases: ['bg', 'btw'], @@ -60,7 +77,7 @@ export const sessionCommands: SlashCommand[] = [ } ctx.gateway - .rpc('config.set', { key: 'model', session_id: ctx.sid, value: persistedModelArg(arg) }) + .rpc('config.set', { key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) }) .then( ctx.guarded(r => { if (!r.value) { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 15f1ce5a3e..676d74fe2f 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -662,7 +662,7 @@ export function useMainApp(gw: GatewayClient) { const onModelSelect = useCallback((value: string) => { patchOverlayState({ modelPicker: false }) - slashRef.current(`/model ${value} --global`) + slashRef.current(`/model ${value}`) }, []) const hasReasoning = useTurnSelector(state => Boolean(state.reasoning.trim())) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 83c8abaab7..ec9acaa779 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -52,6 +52,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke ) ) setModelIdx(0) + setStage('provider') setErr('') setLoading(false) }) @@ -110,7 +111,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const model = models[modelIdx] if (provider && model) { - onSelect(`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) + onSelect( + `${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ' --tui-session'}` + ) } else { setStage('provider') } @@ -136,7 +139,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke setProviderIdx(next) } } else if (provider && models[offset + n - 1]) { - onSelect(`${models[offset + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) + onSelect( + `${models[offset + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ' --tui-session'}` + ) } } }) @@ -173,11 +178,15 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( - Select Provider + Select provider (step 1/2) - Current model: {currentModel || '(unknown)'} + Full model IDs on the next step · Enter to continue + + + + Current: {currentModel || '(unknown)'} {provider?.warning ? `warning: ${provider.warning}` : ' '} @@ -225,11 +234,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( - Select Model + Select model (step 2/2) - {names[providerIdx] || '(unknown provider)'} + {names[providerIdx] || '(unknown provider)'} · Esc back {provider?.warning ? `warning: ${provider.warning}` : ' '} @@ -254,6 +263,8 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke ) } + const prefix = modelIdx === idx ? '▸ ' : row === currentModel ? '* ' : ' ' + return ( - {modelIdx === idx ? '▸ ' : ' '} + {prefix} {i + 1}. {row} ) diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 5b0c2659ed..08bd4945d7 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -42,6 +42,14 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient return } + // `/model` / `/provider` use the two-step ModelPicker (real curated IDs). + // Slash completion here only showed short aliases + vendor/family meta. + if (isSlash && /^\/(?:model|provider)(?:\s|$)/.test(input)) { + clear() + + return + } + const pathReplace = input.length - (pathWord?.length ?? 0) const t = setTimeout(() => {