From dd0f4c39c8fdf9c32afa2fbda61ab363c414886b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:18:58 -0700 Subject: [PATCH 01/25] fix(pi): stop OMP tab icon flashing to Pi while working (#9077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OMP runs as a `shell → omp → pi` process tree and Orca recognizes both `omp` and `pi` as distinct agents, so the foreground-process reader alternates between reporting the two across command boundaries. In `resolveTabAgentFromSignals` every identity signal is re-owned onto the pane's durable owner within its title-identity group (pi and omp share one) — except the process signal, which was consumed raw. Because it ranks above launchAgent, each `pi` read repainted an OMP-owned tab's icon to the Pi glyph and the next read flipped it back. Re-own the process signal the same way the hook and title signals already are, so a same-group read collapses onto the owner while a genuine cross-group process (e.g. Codex) still stands and can reclaim a reused pane. The tab title text was unaffected — its owner resolution already excludes the process signal — so this is icon-only. Adds regression coverage for the oscillation, the mirrored/restored pane (launchAgent gone, durable hook record), and the cross-group scope guard. --- .../src/lib/use-tab-agent-pi-identity.test.ts | 58 +++++++++++++++++++ src/renderer/src/lib/use-tab-agent.ts | 11 +++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts index abe494ee174..2dea897814f 100644 --- a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -132,6 +132,64 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => { expect(afterHookCleared).toBe('omp') }) + it('does not flap between OMP and Pi as the foreground process oscillates', () => { + // The reported flicker: OMP wraps Pi (`shell → omp → pi`), so the foreground + // reader alternates between reporting `omp` and `pi` at command boundaries. + // The process signal outranks launchAgent, so without owner-normalization the + // OMP-owned tab's icon flips to Pi on every `pi` read. Both reads must land on + // the launched owner. + const readsPi = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ OMP', + hookAgent: null, + processAgent: 'pi', + launchAgent: 'omp' + }) + const readsOmp = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ OMP', + hookAgent: null, + processAgent: 'omp', + launchAgent: 'omp' + }) + expect(readsPi).toBe('omp') + expect(readsOmp).toBe('omp') + }) + + it('re-owns a Pi foreground read to a durable OMP identity when launchAgent is gone', () => { + // A mirrored/restored OMP pane keeps only its completed-hook identity; a `pi` + // foreground read (OMP's nested child) must not repaint it to Pi. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ OMP', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + processAgent: 'pi', + launchAgent: undefined + }) + ).toBe('omp') + }) + + it('still lets a genuine cross-group foreground process reclaim a reused OMP pane', () => { + // Scope guard: a different-group process (Codex is not Pi-compatible) is + // real-time proof the pane was reused, so it overrides the OMP launch owner + // instead of collapsing onto it. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + processAgent: 'codex', + launchAgent: 'omp' + }) + ).toBe('codex') + }) + it('keeps a launchAgent-less Pi pane on Pi and rejects a stale OMP session record', () => { // The fallback must not over-reach: a genuine Pi pane (recent Pi hook) stays // Pi even if a stale hibernated OMP record is present. diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 93051d41d4f..b75273befbe 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -168,7 +168,13 @@ export function resolveTabAgentFromSignals(args: { processShellForeground: args.processShellForeground }) const activeLaunchAgent = launchedAgentExited ? null : launchAgent - const processAgent = args.processAgent ?? null + // Why: the foreground process, like the hook and title signals, must be + // re-owned within its title-identity group. OMP wraps Pi as `shell → omp → pi`, + // so the foreground reader oscillates between reporting `omp` and `pi` across + // command boundaries; taken raw it outranks launchAgent and flips an OMP-owned + // tab's icon between the two glyphs. Re-owning collapses the same-group read + // onto the durable owner while a genuine cross-group process still stands. + const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner) const sleepingSessionAgent = args.sleepingSessionAgent ?? null // Identity-first precedence. The live focused hook is ground truth while the // agent works; process identity covers agents with neither hook nor title; the @@ -199,7 +205,8 @@ export function resolveTabAgentFromSignals(args: { * 2. Process identity — the recognized foreground process, read at OSC 133 * command boundaries (local panes only); covers agents that emit neither * hooks nor titles, and its shell-foreground mark is title-independent exit - * evidence. + * evidence. Re-owned within its title-identity group, so OMP's nested `pi` + * child (the `shell → omp → pi` tree) cannot flip the icon back to Pi. * 3. Title — only as a reuse override (it names a DIFFERENT-group agent than the * pane's known identity, proving reuse) or as a legacy standalone identity * when the pane has no hook. Within the same title-identity group it carries From 8461df213916c8fbfc68a893cd8f0ebb783e948a Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 17 Jul 2026 07:38:20 +0800 Subject: [PATCH 02/25] Fix Ctrl+Alt terminal input on Windows by repairing xterm's AltGr misclassification (#8810) * fix(terminal): forward Windows Ctrl Alt chords * fix(terminal): route rescued Windows Ctrl+Alt chords through xterm's own key encoders Replace the hand-rolled Alt-prefix encoder in the custom key handler with a narrow repair of xterm's third-level-shift classification. xterm's keyboard service already computes the correct bytes for every input protocol (legacy ESC-prefixed, kitty CSI-u, win32-input-mode) before _isThirdLevelShift discards them on Windows Ctrl+Alt; rescuing only provably-genuine chords (Chromium's layout-wide AltGraph simulation, crbug 762557) lets those encoders deliver protocol-correct, layout-aware bytes with no duplicated encoding knowledge in Orca. Fixes vs the previous approach: kitty-mode TUIs now receive CSI-u instead of legacy bytes, digits/punctuation no longer alias to plain Alt chords, letters follow the logical layout (Dvorak/Colemak), Ctrl+Alt+Shift and Ctrl+Alt+F-keys gain Linux parity, and handled keys get xterm's stock preventDefault/stopPropagation. Firefox web clients keep stock behavior; a real-Terminal contract test fails loudly if an xterm upgrade removes the seam, degrading at runtime to the historical dropped-chord behavior. Co-authored-by: Orca * Refactor Windows Ctrl+Alt chord test helpers and clarify AltGraph commen - Extract a shared getCore() helper in the test file to dedupe repeated `_core` casts across third-level-shift and keyboard-service lookups. - Correct the AltGraph comment: Chromium simulates AltGraph per composing keypress, not for the whole Ctrl+Alt press duration. - Warn via console when xterm no longer exposes `_core._isThirdLevelShift`, so a silent classification-repair failure is diagnosable in the wild. * Clarify comment explaining why Windows Ctrl+Alt chords bypass AltGraph c The comment previously implied Chromium always sets AltGraph=true for composable chords; the revised wording states the actual mechanism (Alt+Ctrl modifiers get replaced by AltGraph) so the inverse case is unambiguous. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../src/lib/pane-manager/pane-dom-creation.ts | 2 + ...dows-ctrl-alt-chord-classification.test.ts | 185 ++++++++++++++++++ ...l-windows-ctrl-alt-chord-classification.ts | 76 +++++++ tests/e2e/terminal-shortcuts.spec.ts | 72 ++----- 4 files changed, 281 insertions(+), 54 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 365bfd80178..9d1038c4580 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -13,6 +13,7 @@ import { buildDefaultTerminalOptions } from './pane-terminal-options' import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus' import { ENABLE_WEBGL_RENDERER } from './pane-webgl-renderer' import { installGuardedLinkProviderRegistration } from './terminal-link-provider-guard' +import { installWindowsCtrlAltChordRepair } from './terminal-windows-ctrl-alt-chord-classification' function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') @@ -55,6 +56,7 @@ export function createPaneDOM( // line) escapes to window.onerror and gets the renderer killed. Guard every // provider registered after this point — addon-internal and Orca's own. installGuardedLinkProviderRegistration(terminal) + installWindowsCtrlAltChordRepair(terminal) const fitAddon = new FitAddon() const searchAddon = new SearchAddon() const unicode11Addon = new Unicode11Addon() diff --git a/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts new file mode 100644 index 00000000000..d0d9f92ef4a --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/xterm' +import { + installWindowsCtrlAltChordRepair, + isGenuineWindowsCtrlAltChord, + shouldRepairWindowsCtrlAltChords +} from './terminal-windows-ctrl-alt-chord-classification' + +const WINDOWS_ELECTRON_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'orca/1.0.0 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36' +const WINDOWS_FIREFOX_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0' +const MAC_ELECTRON_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'orca/1.0.0 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36' + +type ClassificationEvent = { + type: string + keyCode: number + ctrlKey: boolean + altKey: boolean + metaKey: boolean + shiftKey?: boolean + getModifierState?: (keyArg: string) => boolean +} + +function chord(overrides: Partial = {}): ClassificationEvent { + return { + type: 'keydown', + keyCode: 85, + ctrlKey: true, + altKey: true, + metaKey: false, + getModifierState: () => false, + ...overrides + } +} + +type CoreThirdLevelShift = ( + browser: { isMac?: boolean; isWindows?: boolean }, + event: ClassificationEvent +) => boolean + +type XtermCoreInternals = { + _isThirdLevelShift?: CoreThirdLevelShift + _keyboardService?: { evaluateKeyDown?: (event: unknown) => { key?: string } | undefined } + coreService?: { kittyKeyboard?: { flags: number } } +} + +function getCore(terminal: Terminal): XtermCoreInternals { + return (terminal as unknown as { _core?: XtermCoreInternals })._core ?? {} +} + +function getThirdLevelShift(terminal: Terminal): CoreThirdLevelShift { + const core = getCore(terminal) + const classify = core._isThirdLevelShift + if (typeof classify !== 'function') { + throw new Error('xterm no longer exposes _core._isThirdLevelShift') + } + return classify.bind(core) +} + +describe('isGenuineWindowsCtrlAltChord', () => { + it('accepts Ctrl+Alt chords whose AltGraph state is false', () => { + expect(isGenuineWindowsCtrlAltChord(chord())).toBe(true) + expect(isGenuineWindowsCtrlAltChord(chord({ shiftKey: true }))).toBe(true) + // Synthetic events without getModifierState cannot be AltGr composition. + expect(isGenuineWindowsCtrlAltChord(chord({ getModifierState: undefined }))).toBe(true) + }) + + it('rejects AltGr composition and non-Ctrl+Alt chords', () => { + expect( + isGenuineWindowsCtrlAltChord(chord({ getModifierState: (key) => key === 'AltGraph' })) + ).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ metaKey: true }))).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ altKey: false }))).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ ctrlKey: false }))).toBe(false) + }) +}) + +describe('shouldRepairWindowsCtrlAltChords', () => { + it('repairs only Windows Chromium clients', () => { + expect(shouldRepairWindowsCtrlAltChords(WINDOWS_ELECTRON_UA)).toBe(true) + // Why: Firefox does not rewrite composing Ctrl+Alt presses to AltGraph, so + // a false AltGraph state there does not prove the chord is genuine. + expect(shouldRepairWindowsCtrlAltChords(WINDOWS_FIREFOX_UA)).toBe(false) + expect(shouldRepairWindowsCtrlAltChords(MAC_ELECTRON_UA)).toBe(false) + }) +}) + +describe('installWindowsCtrlAltChordRepair', () => { + it('finds the internal classification seam on the real Terminal', () => { + // Upgrade tripwire: if a future xterm rename removes the seam, this fails + // loudly instead of silently reverting to dropped Ctrl+Alt chords. + const terminal = new Terminal() + try { + expect(getThirdLevelShift(terminal)({ isWindows: true }, chord())).toBe(true) + expect(installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA)).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('reclassifies only genuine Windows Ctrl+Alt chords', () => { + const terminal = new Terminal() + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const classify = getThirdLevelShift(terminal) + const windows = { isWindows: true } + expect(classify(windows, chord())).toBe(false) + expect(classify(windows, chord({ shiftKey: true }))).toBe(false) + // AltGr composition keeps xterm's third-level-shift handling. + expect(classify(windows, chord({ getModifierState: (key) => key === 'AltGraph' }))).toBe(true) + // macOS option-as-third-level-shift is untouched. + expect( + classify({ isMac: true }, chord({ ctrlKey: false, getModifierState: () => false })) + ).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('declines on clients without trustworthy AltGraph state', () => { + const terminal = new Terminal() + try { + expect(installWindowsCtrlAltChordRepair(terminal, WINDOWS_FIREFOX_UA)).toBe(false) + expect(getThirdLevelShift(terminal)({ isWindows: true }, chord())).toBe(true) + } finally { + terminal.dispose() + } + }) +}) + +// Why: the repair intentionally adds no encoder — rescued chords must produce +// whatever bytes xterm's own keyboard service computes for the protocol the +// foreground app negotiated. These pin that contract for both protocol tiers. +describe('rescued chords are encoded by xterm, not Orca', () => { + function keyDownChord(overrides: Record): Record { + return { ...chord(), repeat: false, ...overrides } + } + + function getEvaluateKeyDown( + terminal: Terminal + ): (event: unknown) => { key?: string } | undefined { + const service = getCore(terminal)._keyboardService + if (typeof service?.evaluateKeyDown !== 'function') { + throw new Error('xterm no longer exposes _core._keyboardService.evaluateKeyDown') + } + return service.evaluateKeyDown.bind(service) + } + + it('legacy encoder emits Alt-prefixed bytes matching the Windows E2E', () => { + const terminal = new Terminal() + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const evaluate = getEvaluateKeyDown(terminal) + expect(evaluate(keyDownChord({ key: 'u', code: 'KeyU', keyCode: 85 }))?.key).toBe('\x1b\x15') + expect(evaluate(keyDownChord({ key: '2', code: 'Digit2', keyCode: 50 }))?.key).toBe('\x1b2') + expect(evaluate(keyDownChord({ key: ';', code: 'Semicolon', keyCode: 186 }))?.key).toBe( + '\x1b;' + ) + } finally { + terminal.dispose() + } + }) + + it('kitty encoder takes over once the app negotiates progressive flags', () => { + const terminal = new Terminal({ vtExtensions: { kittyKeyboard: true } }) + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const kitty = getCore(terminal).coreService?.kittyKeyboard + expect(kitty).toBeTruthy() + kitty!.flags = 1 + const evaluate = getEvaluateKeyDown(terminal) + expect(evaluate(keyDownChord({ key: 'u', code: 'KeyU', keyCode: 85 }))?.key).toBe( + '\x1b[117;7u' + ) + } finally { + terminal.dispose() + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts new file mode 100644 index 00000000000..f73da741ad1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts @@ -0,0 +1,76 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm misclassifies Windows Ctrl+Alt chords as AltGr and drops the ones +// that never compose a keypress (#8734); repairing the classification lets +// xterm's own protocol-aware key encoders emit the bytes. +type ThirdLevelShiftBrowserInfo = { isWindows?: boolean } + +type ThirdLevelShiftKeyboardEvent = Pick & { + getModifierState?: (keyArg: string) => boolean +} + +type TerminalWithThirdLevelShift = { + _core?: { + _isThirdLevelShift?: ( + browser: ThirdLevelShiftBrowserInfo, + event: ThirdLevelShiftKeyboardEvent + ) => boolean + } +} + +/** + * Returns whether a Windows Ctrl+Alt chord is genuine keyboard input rather + * than AltGr composition, and must therefore reach xterm's key encoders. + * + * When a Ctrl+Alt keydown composes a printable character on the active + * layout, Chromium replaces the Control+Alt modifiers with AltGraph + * (crbug 762557), so a chord still reporting Ctrl+Alt without AltGraph + * cannot compose text. + */ +export function isGenuineWindowsCtrlAltChord(event: ThirdLevelShiftKeyboardEvent): boolean { + return ( + event.ctrlKey && event.altKey && !event.metaKey && event.getModifierState?.('AltGraph') !== true + ) +} + +/** Returns whether this client's AltGraph modifier state is trustworthy. */ +export function shouldRepairWindowsCtrlAltChords(userAgent: string): boolean { + // Why: only Chromium rewrites composing Ctrl+Alt presses to AltGraph. Paired + // web clients on Firefox keep stock classification so Ctrl+Alt-alias AltGr + // typing there is never misread as a chord. + return userAgent.includes('Windows') && userAgent.includes('Chrome/') +} + +/** + * Narrow xterm's Windows third-level-shift classification so genuine + * Ctrl+Alt chords flow into its protocol-aware key encoders instead of + * being dropped. Only ever flips a third-level verdict to false — AltGr, + * macOS option handling, and every non-Windows path are untouched. + * + * Returns false when the internal seam is unavailable (e.g. after an xterm + * upgrade), degrading to the historical drop-the-chord behavior. + */ +export function installWindowsCtrlAltChordRepair( + terminal: Terminal, + userAgent: string = navigator.userAgent +): boolean { + if (!shouldRepairWindowsCtrlAltChords(userAgent)) { + return false + } + const core = (terminal as unknown as TerminalWithThirdLevelShift)._core + const stockClassification = core?._isThirdLevelShift + if (!core || typeof stockClassification !== 'function') { + console.warn( + 'xterm no longer exposes _core._isThirdLevelShift; Windows Ctrl+Alt chords will be dropped' + ) + return false + } + core._isThirdLevelShift = function (browser, event) { + const thirdLevel = stockClassification.call(this, browser, event) + if (!thirdLevel || browser?.isWindows !== true) { + return thirdLevel + } + return !isGenuineWindowsCtrlAltChord(event) + } + return true +} diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index f120d53ffe4..5c921376ca5 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -29,60 +29,11 @@ import { focusActiveTerminalInput } from './helpers/terminal' import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' - -// Why: contextBridge freezes window.api so the renderer cannot spy on -// pty.write directly. Intercept in the main process instead — pty:write is an -// ipcMain.on listener, so prepending a listener lets us capture every call -// without disturbing the real handler. -async function installMainProcessPtyWriteSpy(app: ElectronApplication): Promise { - await app.evaluate(({ ipcMain }) => { - const g = globalThis as unknown as { - __ptyWriteLog?: { id: string; data: string }[] - __ptyWriteSpyInstalled?: boolean - __ptyWriteAcceptedSpyInstalled?: boolean - } - if (g.__ptyWriteSpyInstalled) { - return - } - g.__ptyWriteLog = [] - g.__ptyWriteSpyInstalled = true - ipcMain.prependListener('pty:write', (_event: unknown, args: { id: string; data: string }) => { - g.__ptyWriteLog!.push({ id: args.id, data: args.data }) - }) - const invokeHandlers = ( - ipcMain as unknown as { - _invokeHandlers?: Map< - string, - (event: unknown, args: { id: string; data: string }) => unknown - > - } - )._invokeHandlers - const writeAcceptedHandler = invokeHandlers?.get('pty:writeAccepted') - if (writeAcceptedHandler && !g.__ptyWriteAcceptedSpyInstalled) { - g.__ptyWriteAcceptedSpyInstalled = true - invokeHandlers?.set('pty:writeAccepted', (event, args) => { - g.__ptyWriteLog!.push({ id: args.id, data: args.data }) - return writeAcceptedHandler(event, args) - }) - } - }) -} - -async function clearPtyWriteLog(app: ElectronApplication): Promise { - await app.evaluate(() => { - const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } - if (g.__ptyWriteLog) { - g.__ptyWriteLog.length = 0 - } - }) -} - -async function getPtyWrites(app: ElectronApplication): Promise { - return app.evaluate(() => { - const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } - return (g.__ptyWriteLog ?? []).map((e) => e.data) - }) -} +import { + clearTerminalPtyWriteLog as clearPtyWriteLog, + installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, + readTerminalPtyWrites as getPtyWrites +} from './helpers/terminal-pty-write-spy' async function setActivePaneForegroundAgent( page: Page, @@ -562,6 +513,19 @@ test.describe('Terminal Shortcuts', () => { } }) + test('Windows forwards genuine Ctrl+Alt text chords to the PTY', async ({ + orcaPage, + electronApp + }) => { + test.skip(process.platform !== 'win32', 'Windows xterm AltGr classification regression') + await installMainProcessPtyWriteSpy(electronApp) + await waitForActivePanePtyId(orcaPage) + + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+u', '\x1b\x15') + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+2', '\x1b2') + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+;', '\x1b;') + }) + test('Ctrl+Enter writes the kitty modified-enter chord for terminal TUIs', async ({ orcaPage, electronApp From 88068f55bdad211afaf15f27470f1aa96f0dd697 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:57:08 -0700 Subject: [PATCH 03/25] Preserve native OpenCode session titles (#9080) Co-authored-by: Orca --- .../lib/agent-status-terminal-title.test.ts | 21 +++++++ src/shared/opencode-terminal-title.test.ts | 16 +++++ src/shared/opencode-terminal-title.ts | 4 ++ src/shared/synthetic-agent-title.test.ts | 8 +++ src/shared/synthetic-agent-title.ts | 9 ++- src/shared/tab-title-resolution.test.ts | 60 +++++++++++++++++++ src/shared/tab-title-resolution.ts | 9 ++- 7 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 src/shared/opencode-terminal-title.test.ts create mode 100644 src/shared/opencode-terminal-title.ts diff --git a/src/renderer/src/lib/agent-status-terminal-title.test.ts b/src/renderer/src/lib/agent-status-terminal-title.test.ts index 7cba70a463b..756dd79b9e3 100644 --- a/src/renderer/src/lib/agent-status-terminal-title.test.ts +++ b/src/renderer/src/lib/agent-status-terminal-title.test.ts @@ -58,4 +58,25 @@ describe('resolveAgentStatusTerminalTitle', () => { resolveAgentStatusTerminalTitle({ agentType: 'devin', state: 'waiting' }, '\u280b Devin') ).toBe('Devin - action required') }) + + it('preserves native OpenCode titles through hook status transitions', () => { + expect( + resolveAgentStatusTerminalTitle( + { agentType: 'opencode', state: 'done' }, + 'OC | Native Stable Session' + ) + ).toBe('OC | Native Stable Session') + expect( + resolveAgentStatusTerminalTitle( + { agentType: 'opencode', state: 'waiting' }, + 'OC | Native Stable Session' + ) + ).toBe('OC | Native Stable Session') + }) + + it('does not invent an OpenCode title when no native title exists', () => { + expect( + resolveAgentStatusTerminalTitle({ agentType: 'opencode', state: 'done' }, undefined) + ).toBeUndefined() + }) }) diff --git a/src/shared/opencode-terminal-title.test.ts b/src/shared/opencode-terminal-title.test.ts new file mode 100644 index 00000000000..fc1b7806e35 --- /dev/null +++ b/src/shared/opencode-terminal-title.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' + +describe('OpenCode terminal titles', () => { + it('recognizes native session titles', () => { + expect(isMeaningfulOpenCodeTerminalTitle('OC | Native Stable Session')).toBe(true) + expect(isMeaningfulOpenCodeTerminalTitle(' OC|Session ')).toBe(true) + }) + + it('rejects generic or incomplete OpenCode titles', () => { + expect(isMeaningfulOpenCodeTerminalTitle('OpenCode')).toBe(false) + expect(isMeaningfulOpenCodeTerminalTitle('OpenCode ready')).toBe(false) + expect(isMeaningfulOpenCodeTerminalTitle('OC |')).toBe(false) + expect(isMeaningfulOpenCodeTerminalTitle(undefined)).toBe(false) + }) +}) diff --git a/src/shared/opencode-terminal-title.ts b/src/shared/opencode-terminal-title.ts new file mode 100644 index 00000000000..64312b94be2 --- /dev/null +++ b/src/shared/opencode-terminal-title.ts @@ -0,0 +1,4 @@ +export function isMeaningfulOpenCodeTerminalTitle(title: string | null | undefined): boolean { + // Why: bare OpenCode labels are status/identity; `OC | …` carries native session identity. + return /^OC\s*\|\s*\S/u.test(title?.trim() ?? '') +} diff --git a/src/shared/synthetic-agent-title.test.ts b/src/shared/synthetic-agent-title.test.ts index dc51838ed43..8b7b518fa3d 100644 --- a/src/shared/synthetic-agent-title.test.ts +++ b/src/shared/synthetic-agent-title.test.ts @@ -15,6 +15,14 @@ describe('synthetic agent titles', () => { expect(shouldDriveSyntheticAgentTitleFromHook('codex', 'done')).toBe(true) }) + it('does not synthesize OpenCode titles over native session titles', () => { + expect(getSyntheticAgentTerminalTitle('opencode', 'done')).toBeNull() + expect(getSyntheticAgentTerminalTitle('opencode', 'waiting')).toBeNull() + expect(shouldDriveSyntheticAgentTitleFromHook('opencode', 'working')).toBe(false) + expect(shouldDriveSyntheticAgentTitleFromHook('opencode', 'done')).toBe(false) + expect(shouldDriveSyntheticAgentTitleFromHook('opencode', 'waiting')).toBe(false) + }) + it('provides Devin titles for hook-driven status updates', () => { expect(getSyntheticAgentTerminalTitle('devin', 'done')).toBe('Devin ready') expect(getSyntheticAgentTerminalTitle('devin', 'waiting')).toBe('Devin - action required') diff --git a/src/shared/synthetic-agent-title.ts b/src/shared/synthetic-agent-title.ts index ecac929fe94..61229cea850 100644 --- a/src/shared/synthetic-agent-title.ts +++ b/src/shared/synthetic-agent-title.ts @@ -5,6 +5,7 @@ export type SyntheticAgentTitleProfile = { permissionLabel: string idleLabel: string titleIdentityGroup?: string + synthesizeTerminalTitle?: boolean synthesizeWorkingTitle?: boolean } @@ -25,7 +26,9 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record { ).toBe('Payments') }) + it('uses meaningful native OpenCode session titles before generated titles', () => { + expect( + resolveTerminalTabTitle( + { + customTitle: null, + generatedTitle: 'Refactor auth', + title: 'OC | Native Stable Session' + }, + true + ) + ).toBe('OC | Native Stable Session') + }) + + it('keeps generated titles ahead of generic OpenCode titles', () => { + expect( + resolveTerminalTabTitle( + { customTitle: null, generatedTitle: 'Refactor auth', title: 'OpenCode' }, + true + ) + ).toBe('Refactor auth') + }) + it('places quick command labels between manual and generated titles', () => { expect( resolveTerminalTabTitle( @@ -73,4 +95,42 @@ describe('tab title resolution', () => { ) ).toBe('Run build') }) + + it('uses meaningful native OpenCode labels before generated unified labels', () => { + expect( + resolveUnifiedTabLabel( + { + customLabel: null, + generatedLabel: 'Fix flaky tests', + label: 'OC | Native Stable Session' + }, + true + ) + ).toBe('OC | Native Stable Session') + }) + + it('keeps manual and quick command labels ahead of native OpenCode labels', () => { + expect( + resolveUnifiedTabLabel( + { + customLabel: 'Manual label', + quickCommandLabel: 'Run build', + generatedLabel: 'Fix flaky tests', + label: 'OC | Native Stable Session' + }, + true + ) + ).toBe('Manual label') + expect( + resolveUnifiedTabLabel( + { + customLabel: null, + quickCommandLabel: 'Run build', + generatedLabel: 'Fix flaky tests', + label: 'OC | Native Stable Session' + }, + true + ) + ).toBe('Run build') + }) }) diff --git a/src/shared/tab-title-resolution.ts b/src/shared/tab-title-resolution.ts index d3ac33b95ad..6f1d51042ce 100644 --- a/src/shared/tab-title-resolution.ts +++ b/src/shared/tab-title-resolution.ts @@ -1,15 +1,18 @@ import type { Tab, TerminalTab } from './types' +import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' export function resolveTerminalTabTitle( tab: Pick, generatedTitlesEnabled: boolean, fallback = '' ): string { + const liveTitle = tab.title?.trim() ?? '' return ( tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || + (isMeaningfulOpenCodeTerminalTitle(liveTitle) ? liveTitle : '') || (generatedTitlesEnabled ? tab.generatedTitle?.trim() : '') || - tab.title?.trim() || + liveTitle || fallback ) } @@ -19,11 +22,13 @@ export function resolveUnifiedTabLabel( generatedTitlesEnabled: boolean, fallback = '' ): string { + const liveLabel = tab?.label?.trim() ?? '' return ( tab?.customLabel?.trim() || tab?.quickCommandLabel?.trim() || + (isMeaningfulOpenCodeTerminalTitle(liveLabel) ? liveLabel : '') || (generatedTitlesEnabled ? tab?.generatedLabel?.trim() : '') || - tab?.label?.trim() || + liveLabel || fallback ) } From 5b75a87dd305924057b797ef131935e469efa4d8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:12:44 -0700 Subject: [PATCH 04/25] feat(native-chat): add per-model session option pickers with verified Claude switch (#9085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add per-model session-option pickers (model/effort/fast-mode) to native Introduces a shared agent session-option catalog (Claude/Codex/Gemini/Cursor) with model-scoped options, launch-command composition, mid-session dispatch via slash commands, and per-model persisted defaults. Wires the new NativeChatSessionOptionPickers UI into the composer, threads sessionOptions through every startup-plan builder (worktree creation, onboarding, source control actions, folder workspace, direct work-item launches), and adds localized strings and tests across the affected surfaces. * Add per-model session-option pickers with a verified Claude model switch Native chat's model/effort picker now dispatches option commands through a body-then-verified-Enter write path and, for Claude, arms a PTY observer that classifies the cached-history confirmation prompt as applied/rejected/needs interaction before the picker returns — falling back to the terminal only when Claude genuinely requires manual input, and clearing stale truth otherwise. Also reorders the composer's model/effort pills, disambiguates their tooltip labels, and switches disabled-reason strings to a closed enum so producer and localized copy can't drift. * Add live model/effort detection from Claude's TUI header - Reads the mounted xterm's main-buffer snapshot (falling back to the rendered screen when the alternate screen owns the buffer) to parse Claude's header for the currently active model and effort, so the native chat picker reflects reality instead of only dispatched state - Extracts command-apply recording and reported-value application into dedicated modules, and factors file-link click handling into a hook, to keep native-chat-pty-session-options.ts focused --- src/main/persistence.test.ts | 25 ++ .../FloatingTerminalWindowControls.tsx | 7 + .../native-chat/NativeChatComposer.test.tsx | 285 ++++++++++++++- .../native-chat/NativeChatComposer.tsx | 101 +++--- .../NativeChatComposerActions.test.tsx | 62 ++++ .../native-chat/NativeChatComposerActions.tsx | 56 ++- .../native-chat/NativeChatComposerField.tsx | 12 +- .../NativeChatSessionOptionPickers.test.tsx | 230 ++++++++++++ .../NativeChatSessionOptionPickers.tsx | 276 ++++++++++++++ .../components/native-chat/NativeChatView.tsx | 44 +-- .../claude-model-switch-confirmation.test.ts | 176 +++++++++ .../claude-model-switch-confirmation.ts | 199 ++++++++++ .../claude-terminal-session-options.test.ts | 53 +++ .../claude-terminal-session-options.ts | 54 +++ .../native-chat-pty-session-options.test.ts | 340 ++++++++++++++++++ .../native-chat-pty-session-options.ts | 276 ++++++++++++++ .../native-chat-runtime-send.test.ts | 51 ++- .../native-chat/native-chat-runtime-send.ts | 45 ++- .../native-chat-session-option-cache.ts | 84 +++++ ...ive-chat-session-option-command-builder.ts | 40 +++ ...ve-chat-session-option-command-dispatch.ts | 17 + ...ve-chat-session-option-command-matching.ts | 31 ++ ...e-chat-session-option-command-recording.ts | 105 ++++++ .../native-chat-session-option-discovery.ts | 57 +++ ...ive-chat-session-option-enrichment.test.ts | 61 ++++ .../native-chat-session-option-enrichment.ts | 87 +++++ .../native-chat-session-option-labels.ts | 135 +++++++ .../native-chat-session-option-reporting.ts | 28 ++ .../native-chat-session-option-snapshot.ts | 178 +++++++++ .../use-native-chat-dictation-actions.ts | 28 ++ ...use-native-chat-file-attachment-actions.ts | 27 ++ .../use-native-chat-file-link-click.ts | 27 ++ .../use-native-chat-session-option-command.ts | 114 ++++++ .../use-native-chat-session-options.ts | 155 ++++++++ .../onboarding-folder-agent-startup.test.ts | 8 +- .../buildSourceControlAgentDeliveryPlan.ts | 7 + .../folder-workspace-composer-submit.ts | 10 + .../components/terminal-pane/TerminalPane.tsx | 8 + src/renderer/src/hooks/useComposerState.ts | 30 ++ src/renderer/src/i18n/locales/en.json | 25 +- src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 25 +- src/renderer/src/i18n/locales/ko.json | 25 +- src/renderer/src/i18n/locales/zh.json | 25 +- .../src/i18n/native-chat-locales.test.ts | 20 ++ .../src/lib/launch-agent-in-new-tab.test.ts | 25 +- .../src/lib/launch-agent-in-new-tab.ts | 12 +- .../src/lib/launch-work-item-direct-agent.ts | 16 +- .../src/lib/launch-work-item-direct.test.ts | 3 + .../lib/onboarding-folder-agent-startup.ts | 8 + .../lib/source-control-agent-action-plan.ts | 12 +- .../worktree-activation-created-agent.test.ts | 4 +- src/renderer/src/lib/worktree-activation.ts | 24 ++ .../src/lib/worktree-creation-flow.ts | 11 + .../runtime/runtime-terminal-stream.test.ts | 54 +++ .../src/runtime/runtime-terminal-stream.ts | 53 ++- .../repos-onboarding-folder-startup.test.ts | 4 +- ...ent-session-option-catalog-claude-codex.ts | 153 ++++++++ ...nt-session-option-catalog-gemini-cursor.ts | 112 ++++++ .../agent-session-option-catalog-types.ts | 63 ++++ .../agent-session-option-catalog.test.ts | 112 ++++++ src/shared/agent-session-option-catalog.ts | 80 +++++ src/shared/agent-session-option-launch.ts | 64 ++++ src/shared/constants.ts | 1 + .../native-chat-session-option-defaults.ts | 72 ++++ src/shared/native-chat-session-options.ts | 57 +++ src/shared/tui-agent-launch-command.ts | 62 ++++ .../tui-agent-startup-session-options.test.ts | 100 ++++++ src/shared/tui-agent-startup.ts | 61 ++-- src/shared/types.ts | 4 + 70 files changed, 4674 insertions(+), 167 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx create mode 100644 src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts create mode 100644 src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts create mode 100644 src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts create mode 100644 src/renderer/src/components/native-chat/claude-terminal-session-options.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pty-session-options.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-cache.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-labels.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-session-options.ts create mode 100644 src/shared/agent-session-option-catalog-claude-codex.ts create mode 100644 src/shared/agent-session-option-catalog-gemini-cursor.ts create mode 100644 src/shared/agent-session-option-catalog-types.ts create mode 100644 src/shared/agent-session-option-catalog.test.ts create mode 100644 src/shared/agent-session-option-catalog.ts create mode 100644 src/shared/agent-session-option-launch.ts create mode 100644 src/shared/native-chat-session-option-defaults.ts create mode 100644 src/shared/native-chat-session-options.ts create mode 100644 src/shared/tui-agent-launch-command.ts create mode 100644 src/shared/tui-agent-startup-session-options.test.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index acde5dd02c3..2cd0c36ef4c 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -690,6 +690,31 @@ describe('Store', () => { expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) + it('persists native chat session options with per-model isolation', async () => { + const store = await createStore() + store.updateSettings({ + nativeChatSessionOptions: { + claude: { + model: 'opus', + valuesByModel: { + opus: { effort: 'xhigh', fastMode: true }, + sonnet: { effort: 'medium' } + } + } + } + }) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getSettings().nativeChatSessionOptions?.claude).toEqual({ + model: 'opus', + valuesByModel: { + opus: { effort: 'xhigh', fastMode: true }, + sonnet: { effort: 'medium' } + } + }) + }) + it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => { const store = await createStore() // Why: a renderer-supplied non-bool must never persist as a truthy non-bool diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 03213e9820e..a0699332738 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -17,6 +17,8 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' +import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -73,6 +75,10 @@ export function FloatingTerminalWindowControls({ cmdOverrides: state.settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(defaultAgent, state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(defaultAgent, state.settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + state.settings?.nativeChatSessionOptions, + defaultAgent + ), platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true }) @@ -87,6 +93,7 @@ export function FloatingTerminalWindowControls({ return } const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false }) + seedNativeChatAppliedSessionOptions(tab.id, defaultAgent, startupPlan.sessionOptions) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index c8be699f8f6..f1f1794cc3b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -1,21 +1,48 @@ // @vitest-environment happy-dom -import { act, cleanup, render } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' const mocks = vi.hoisted(() => ({ cancelPendingSends: vi.fn(), - fieldProps: null as { onSend?: () => void; onStop?: () => void } | null, + fieldProps: null as { + onSend?: () => void + onStop?: () => void + sessionOptionsSurface?: SessionOptionsSurface | null + sessionOptionsSnapshot?: SessionOptionDescriptor[] + } | null, + modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown', + confirmationObserver: null as { + ready: Promise + result: Promise<'applied' | 'rejected' | 'interaction-required' | 'unknown'> + arm: ReturnType + startDetection: ReturnType + dispose: ReturnType + } | null, + createClaudeModelSwitchConfirmationObserver: vi.fn(), + getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), + sendNativeChatMessageVerified: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn() })) -vi.mock('../../store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ dictationState: 'idle', settings: { voice: { enabled: false } } }) -})) +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + updateSettings: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, @@ -26,15 +53,27 @@ vi.mock('@/lib/agent-paste-draft', () => ({ })) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), + sendNativeChatMessageVerified: (...args: unknown[]) => + mocks.sendNativeChatMessageVerified(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) -vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] })) -vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('./claude-model-switch-confirmation', () => ({ + createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => + mocks.createClaudeModelSwitchConfirmationObserver(...args) +})) +vi.mock('./native-chat-agent-commands', () => ({ + getAgentSlashCommands: () => [] +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn() +})) vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft }) })) -vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' })) +vi.mock('./native-chat-draft-cache', () => ({ + readNativeChatDraftCache: () => '' +})) vi.mock('./NativeChatComposerField', () => ({ NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => { mocks.fieldProps = props @@ -51,7 +90,10 @@ vi.mock('./use-native-chat-composer-attachments', () => ({ }) })) vi.mock('./use-native-chat-composer-paste', () => ({ - useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() }) + useNativeChatComposerPaste: () => ({ + handlePaste: vi.fn(), + pasteFromClipboard: vi.fn() + }) })) vi.mock('./use-native-chat-external-attachments', () => ({ useNativeChatExternalAttachments: () => ({ @@ -59,7 +101,9 @@ vi.mock('./use-native-chat-external-attachments', () => ({ resolveAttachmentOwner: vi.fn() }) })) -vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() })) +vi.mock('../dictation/dictation-control-events', () => ({ + dispatchDictationControl: vi.fn() +})) vi.mock('./use-native-chat-composer-keydown', () => ({ useNativeChatComposerKeyDown: () => vi.fn() })) @@ -75,11 +119,31 @@ import { NativeChatComposer } from './NativeChatComposer' describe('NativeChatComposer', () => { beforeEach(() => { vi.clearAllMocks() + clearNativeChatSessionOptionCacheForTests() mocks.fieldProps = null + mocks.modelSwitchOutcome = 'applied' + mocks.confirmationObserver = null + mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { + const observer = { + ready: Promise.resolve(), + result: Promise.resolve(mocks.modelSwitchOutcome), + arm: vi.fn(), + startDetection: vi.fn(), + dispose: vi.fn() + } + mocks.confirmationObserver = observer + return observer + }) + mocks.getMainBufferSnapshot.mockResolvedValue(null) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) + mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, - value: { ui: { onFileDrop: () => vi.fn() } } + value: { + pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot }, + ui: { onFileDrop: () => vi.fn() } + } }) }) @@ -122,4 +186,201 @@ describe('NativeChatComposer', () => { expect(onOptimisticSend).toHaveBeenCalledWith('hello', []) expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + + it('shows the model already selected in the Claude TUI when chat opens', async () => { + mocks.getMainBufferSnapshot.mockResolvedValue({ + data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing', + cols: 120, + rows: 40 + }) + render( + null} + /> + ) + + await waitFor(() => + expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'opus' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + ) + expect(mocks.getMainBufferSnapshot).toHaveBeenCalledWith('pty-1', { scrollbackRows: 0 }) + }) + + it('reads Claude state from mounted xterm while its alternate screen is active', async () => { + mocks.getMainBufferSnapshot.mockResolvedValue({ + data: 'Claude Code v2.1.211\r\nOpus 4.8 with high effort · stale main buffer', + cols: 120, + rows: 40, + alternateScreen: true + }) + render( + + '\u001b[?1049h\u001b[HClaude Codev2.1.211\r\n' + + 'Sonnet 5 with medium effort · API Usage Billing' + } + /> + ) + + await waitFor(() => + expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'sonnet' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + ) + }) + + it('observes a fresh Claude model choice and stays native on success', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSlashCommand = vi.fn() + const onOptimisticSend = vi.fn() + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'opus') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model opus', + expect.any(AbortSignal) + ) + expect(onSlashCommand).toHaveBeenCalledWith('/model opus') + expect(onOptimisticSend).not.toHaveBeenCalled() + expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Opus 4.8' + }) + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + + it('keeps a successful Claude model change after a conversation in native chat', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model fable', + expect.any(AbortSignal) + ) + expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5' + }) + expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce() + expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan( + mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + expect(mocks.confirmationObserver?.startDetection).toHaveBeenCalledOnce() + expect(mocks.confirmationObserver?.startDetection.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY + ) + expect(mocks.confirmationObserver?.dispose).toHaveBeenCalledOnce() + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + + it('reveals Claude interaction only when the model switch needs user input', async () => { + mocks.sendHandle.settleAfterMs = 0 + mocks.modelSwitchOutcome = 'interaction-required' + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model fable', + expect.any(AbortSignal) + ) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) + + it('waits for the Codex picker command before switching to the terminal', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', '') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model', + expect.any(AbortSignal) + ) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 41b254639dc..dd624ba06ed 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,15 +1,6 @@ -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState -} from 'react' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { @@ -43,9 +34,12 @@ import { useNativeChatSkills } from './use-native-chat-skills' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' -import { dispatchDictationControl } from '../dictation/dictation-control-events' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' +import { useNativeChatSessionOptions } from './use-native-chat-session-options' +import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' +import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' +import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' // Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a // PTY (matching how xterm forwards Escape). The richer interrupt-intent @@ -79,6 +73,10 @@ export type NativeChatComposerProps = { * a small "Ran /clear" system line — slash commands aren't chat turns and * otherwise leave no visible trace that anything happened. */ onSlashCommand?: (command: string) => void + /** Picker-only agent commands continue in the hosted TUI after dispatch. */ + onSwitchToTerminal?: () => void + /** Reads the hosted TUI's current rendered screen when chat is entered. */ + readTerminalScreen?: () => string | null } export type NativeChatComposerHandle = { @@ -116,7 +114,9 @@ export const NativeChatComposer = forwardRef { - return window.api.ui.onFileDrop((payload) => { - if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) { - return - } - attachExternalPaths(payload.paths) + const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths) + const { toggleDictation, startHoldDictation, stopHoldDictation } = + useNativeChatDictationActions({ textareaRef, setDictationPressed }) + const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } = + useNativeChatSessionOptionCommand({ + agent, + disabled, + onSlashCommand, + resolveTarget, + setHistory }) - }, [attachExternalPaths]) - const pickAttachment = useCallback(() => { - void (async () => { - const filePath = await window.api.shell.pickAttachment() - if (!filePath) { - return - } - attachExternalPaths([filePath]) - })() - }, [attachExternalPaths]) - - const focusForDictation = useCallback(() => { - textareaRef.current?.focus() - }, []) - - const toggleDictation = useCallback(() => { - focusForDictation() - dispatchDictationControl('toggle') - }, [focusForDictation]) - - const startHoldDictation = useCallback(() => { - setDictationPressed(true) - focusForDictation() - dispatchDictationControl('start') - }, [focusForDictation]) - - const stopHoldDictation = useCallback(() => { - setDictationPressed(false) - dispatchDictationControl('stop') - }, []) + const { surface: sessionOptionsSurface, snapshot: sessionOptionsSnapshot } = + useNativeChatSessionOptions({ + agent, + terminalTabId, + targetPtyId, + dispatchCommand: dispatchSessionOptionCommand, + onAgentPicker: onSwitchToTerminal, + readTerminalScreen + }) const send = useCallback(() => { const text = draft @@ -293,6 +276,12 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx new file mode 100644 index 00000000000..2e1b02ac249 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: { + children: ReactNode + variant?: string + size?: string + } & React.ButtonHTMLAttributes) => +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('./NativeChatSessionOptionPickers', () => ({ + NativeChatSessionOptionPickers: () =>
+})) + +import { NativeChatComposerActions } from './NativeChatComposerActions' + +afterEach(() => cleanup()) + +describe('NativeChatComposerActions', () => { + it('places session option pickers immediately beside dictation', () => { + render( + + ) + + const pickers = screen.getByTestId('session-option-pickers') + const dictation = screen.getByRole('button', { name: 'Start dictation' }) + expect(pickers.nextElementSibling).toBe(dictation) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index fb67917b332..aee0f79c3c0 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -2,6 +2,11 @@ import { ArrowUp, Mic, Plus, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' export type NativeChatComposerActionsProps = { attachDisabled: boolean @@ -16,6 +21,8 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + sessionOptionsSurface: SessionOptionsSurface | null + sessionOptionsSnapshot: SessionOptionDescriptor[] } export function NativeChatComposerActions({ @@ -30,32 +37,43 @@ export function NativeChatComposerActions({ onDictationHoldStart, onDictationHoldEnd, onSend, - onStop + onStop, + sessionOptionsSurface, + sessionOptionsSnapshot }: NativeChatComposerActionsProps): React.JSX.Element { const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') return (
- - - - - - {translate('components.native-chat.composer.attach', 'Attach file')} - - +
+ + + + + + {translate('components.native-chat.composer.attach', 'Attach file')} + + +
+ {/* Why: keep session controls beside the actions they affect; the + model trigger is ordered last so it sits directly next to dictation. */} +
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx new file mode 100644 index 00000000000..8d40a791788 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ + children, + disabled + }: { + children: React.ReactNode + disabled?: boolean + }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuItem: ({ children, disabled }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuCheckboxItem: ({ + children, + disabled + }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuRadioItem: ({ + children, + disabled, + value + }: React.ButtonHTMLAttributes & { value: string }) => ( + + ) +})) + +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' + +const surface = { + getSnapshot: vi.fn(() => []), + setOption: vi.fn(), + subscribe: vi.fn(() => vi.fn()) +} + +function model(overrides: Partial = {}): SessionOptionDescriptor { + return { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'opus', + choices: [ + { value: 'opus', label: 'Opus 4.8' }, + { value: 'sonnet', label: 'Sonnet 5' } + ] + }, + valueSource: 'applied', + settable: true, + ...overrides + } +} + +const effort: SessionOptionDescriptor = { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + currentValue: 'high', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + valueSource: 'applied', + settable: true +} + +const fast: SessionOptionDescriptor = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', currentValue: true }, + valueSource: 'applied', + settable: true +} + +afterEach(() => cleanup()) + +describe('NativeChatSessionOptionPickers', () => { + it('renders model and joined option labels, and hides an empty options pill', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model: Opus 4.8') + expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain( + 'Effort: High · Fast' + ) + expect( + screen + .getByRole('button', { name: 'Effort' }) + .compareDocumentPosition(screen.getByRole('button', { name: 'Model' })) & + Node.DOCUMENT_POSITION_FOLLOWING + ).not.toBe(0) + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull() + }) + + it('names a lone unknown effort control explicitly', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain('Effort') + }) + + it('disables both picker triggers while the agent is working', () => { + render( + + ) + expect( + screen.getByRole('button', { name: 'Model' }).parentElement?.getAttribute('data-disabled') + ).toBe('true') + expect( + screen.getByRole('button', { name: 'Effort' }).parentElement?.getAttribute('data-disabled') + ).toBe('true') + }) + + it('does not duplicate titles for unknown values or misname generic controls', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model') + expect(screen.getByRole('button', { name: 'Model' }).textContent).not.toContain('Model: Model') + expect(screen.getByRole('button', { name: 'Effort' }).textContent).not.toContain( + 'Effort: Effort' + ) + + rerender( + + ) + expect(screen.getByRole('button', { name: 'Session options' }).textContent).toContain('Fast') + expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull() + }) + + it('shows the unconfirmed hint for dispatched values', () => { + render( + + ) + expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + }) + + it('renders agent-picker routes as one action instead of radio choices', () => { + render( + + ) + expect(screen.getByRole('button', { name: 'Choose in agent picker…' })).not.toBeNull() + expect(screen.queryByText('GPT-5.5')).toBeNull() + expect(screen.queryByText('GPT-5.2 Codex')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx new file mode 100644 index 00000000000..15852d27db5 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -0,0 +1,276 @@ +import { memo, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import { + nativeChatModelPillLabel, + nativeChatOptionsPillLabel, + nativeChatOptionsPillTitle, + nativeChatSessionChoiceLabel, + nativeChatSessionOptionDisabledReason, + nativeChatSessionOptionLabel +} from './native-chat-session-option-labels' + +export type NativeChatSessionOptionPickersProps = { + surface: SessionOptionsSurface | null + snapshot: SessionOptionDescriptor[] + isWorking: boolean +} + +const CATEGORY_ORDER: Record = { + thought_level: 0, + model_config: 1, + mode: 2 +} + +function sortedOptions(snapshot: readonly SessionOptionDescriptor[]): SessionOptionDescriptor[] { + return snapshot + .filter((descriptor) => descriptor.category !== 'model') + .sort((left, right) => { + const leftOrder = CATEGORY_ORDER[left.category ?? ''] ?? 3 + const rightOrder = CATEGORY_ORDER[right.category ?? ''] ?? 3 + return leftOrder - rightOrder + }) +} + +function PickerTooltipContent(props: { + label: string + disabledReason?: string | null + dispatched: boolean +}): React.JSX.Element { + return ( +
+
{props.disabledReason ?? props.label}
+ {props.dispatched ? ( +
+ {translate( + 'components.native-chat.composer.sentNotConfirmed', + 'Sent to the agent — not confirmed' + )} +
+ ) : null} +
+ ) +} + +function PickerTrigger(props: { + label: string + tooltipLabel: string + disabled: boolean + disabledReason?: string | null + dispatched: boolean +}): React.JSX.Element { + return ( + + + + + + + + + + + ) +} + +function ChoiceBody(props: { label: string; description?: string }): React.JSX.Element { + return ( +
+
{props.label}
+ {props.description ? ( +
{props.description}
+ ) : null} +
+ ) +} + +function actionLabel(descriptor: SessionOptionDescriptor): string { + if (descriptor.action?.type === 'agent-picker') { + return translate( + 'components.native-chat.composer.chooseInAgentPicker', + 'Choose in agent picker…' + ) + } + return translate('components.native-chat.composer.toggleOption', 'Toggle {{value0}}', { + value0: nativeChatSessionOptionLabel(descriptor).toLowerCase() + }) +} + +function DescriptorMenuRows(props: { + descriptor: SessionOptionDescriptor + pending: boolean + setValue: (value: SessionOptionValue) => void +}): React.JSX.Element { + const { descriptor, pending, setValue } = props + if (descriptor.action) { + return ( + + setValue( + descriptor.kind.type === 'boolean' + ? !(descriptor.kind.currentValue ?? false) + : (descriptor.kind.currentValue ?? '') + ) + } + > + {actionLabel(descriptor)} + + ) + } + if (descriptor.kind.type === 'boolean') { + return ( + setValue(checked === true)} + > + {nativeChatSessionOptionLabel(descriptor)} + + ) + } + return ( + setValue(value)} + > + {descriptor.kind.choices.map((choice) => ( + + + + ))} + + ) +} + +function NativeChatSessionOptionPickersInner({ + surface, + snapshot, + isWorking +}: NativeChatSessionOptionPickersProps): React.JSX.Element | null { + const [pendingId, setPendingId] = useState(null) + const model = snapshot.find((descriptor) => descriptor.category === 'model') + const options = sortedOptions(snapshot) + if (!surface || !model) { + return null + } + + const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { + setPendingId(descriptor.id) + void surface + .setOption(descriptor.id, value) + .catch((error) => { + toast.error( + translate( + 'components.native-chat.composer.optionUpdateFailed', + 'Could not update option' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + }) + .finally(() => setPendingId(null)) + } + + const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason) + const modelTooltip = translate('components.native-chat.composer.model', 'Model') + const optionsTooltip = nativeChatOptionsPillTitle(options) + const optionsReason = + options.length > 0 && options.every((descriptor) => !descriptor.settable) + ? nativeChatSessionOptionDisabledReason(options[0]?.disabledReason) + : null + + return ( +
+ {options.length > 0 ? ( + + descriptor.valueSource === 'dispatched')} + /> + + {options.map((descriptor, index) => { + const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason) + return ( +
+ {index > 0 ? : null} + {nativeChatSessionOptionLabel(descriptor)} + {reason && !descriptor.settable ? ( + {reason} + ) : null} + setOption(descriptor, value)} + /> +
+ ) + })} +
+
+ ) : null} + + + + {modelReason && !model.settable ? ( + {modelReason} + ) : null} + setOption(model, value)} + /> + + +
+ ) +} + +export const NativeChatSessionOptionPickers = memo(NativeChatSessionOptionPickersInner) diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 8caf7d835c8..d2267281eca 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -48,14 +48,10 @@ import { useNativeChatContextMenu } from './use-native-chat-context-menu' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' -import { - resolveNativeChatFileLink, - resolveNativeChatFileLinkContext -} from './native-chat-file-link' +import { resolveNativeChatFileLinkContext } from './native-chat-file-link' import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' -import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' -import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' export type NativeChatViewProps = { /** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */ @@ -70,17 +66,12 @@ export type NativeChatViewProps = { resolvedAgent?: TuiAgent | null /** Return this pane to the hosted terminal surface. */ onSwitchToTerminal?: () => void + /** Current xterm screen reader used to recover agent-reported session state. */ + readTerminalScreen?: () => string | null contextMenuActions?: Omit } -/** - * Native chat surface for an agent terminal. Resolves the pane to its agent + - * session id, streams the assembled conversation via the U4 live-session hook, - * and renders the message list, live status, and all empty/loading/error - * states. When no session id is known yet the hook surfaces live hook state on - * an empty transcript; a true scrollback-scrape fallback (U6) is wired but only - * runs when scrollback is obtainable — it degrades to the empty state otherwise. - */ +/** Resolves an agent terminal into its native conversation and composer UI. */ export default function NativeChatView({ terminalTabId, paneKey: preferredPaneKey, @@ -88,6 +79,7 @@ export default function NativeChatView({ launchAgent, resolvedAgent, onSwitchToTerminal, + readTerminalScreen, contextMenuActions }: NativeChatViewProps): React.JSX.Element { // Select only this tab's status entry (shallow-compared) so an unrelated @@ -120,6 +112,7 @@ export default function NativeChatView({ targetPtyId={targetPtyId} terminalTabId={terminalTabId} onSwitchToTerminal={onSwitchToTerminal} + readTerminalScreen={readTerminalScreen} contextMenuActions={contextMenuActions} /> )} @@ -135,6 +128,7 @@ function NativeChatResolvedView({ targetPtyId, terminalTabId, onSwitchToTerminal, + readTerminalScreen, contextMenuActions }: { paneKey: string @@ -144,6 +138,7 @@ function NativeChatResolvedView({ targetPtyId: string | null terminalTabId: string onSwitchToTerminal?: () => void + readTerminalScreen?: () => string | null contextMenuActions?: Omit }): React.JSX.Element { // Primitive owner selection (no useShallow): routes the pane's read/subscribe to @@ -356,24 +351,7 @@ function NativeChatResolvedView({ setWorkingInterrupted(true) interactiveSend.cancel() }, [interactiveSend]) - const openNativeChatFileLink = useCallback( - (event, href) => { - const target = resolveNativeChatFileLink(href, fileLinkContext) - if (!target || !fileLinkContext) { - return - } - event.preventDefault() - event.stopPropagation() - openDetectedFilePath(target.absolutePath, target.line, target.column, { - worktreeId: fileLinkContext.worktreeId, - worktreePath: fileLinkContext.worktreePath, - runtimeEnvironmentId: fileLinkContext.runtimeEnvironmentId, - openWithSystemDefault: event.shiftKey - }) - }, - [fileLinkContext] - ) - const nativeChatFileLinkClick = fileLinkContext ? openNativeChatFileLink : undefined + const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so // the chord is inert on the loading/empty/error states and elsewhere. @@ -460,6 +438,8 @@ function NativeChatResolvedView({ onOptimisticSend={onOptimisticSend} onOptimisticSendCanceled={onOptimisticSendCanceled} onSlashCommand={onSlashCommand} + onSwitchToTerminal={onSwitchToTerminal} + readTerminalScreen={readTerminalScreen} /> )} {contextMenu.menu} diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts new file mode 100644 index 00000000000..e57f42e2efe --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from 'vitest' +import { + createClaudeModelSwitchConfirmationObserver, + hasClaudeModelSwitchConfirmation +} from './claude-model-switch-confirmation' + +describe('Claude model switch confirmation detection', () => { + it('recognizes the rendered cache warning across ANSI and wrapped whitespace', () => { + expect( + hasClaudeModelSwitchConfirmation( + '\u001b[1mSwitch model?\u001b[0m\r\nThis\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached\r\nfor the current model.' + ) + ).toBe(true) + expect(hasClaudeModelSwitchConfirmation('Set model to Haiku 4.5')).toBe(false) + }) + + it('reports matching success output split across PTY chunks', async () => { + const dataObserver = { current: (_data: string): void => {} } + const unsubscribe = vi.fn(() => {}) + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return unsubscribe + }, + timeoutMs: 100 + }) + + await observer.ready + dataObserver.current('historical Set model to Fable 5') + observer.arm() + dataObserver.current('\u001b[2mSet\u001b[10Gmo') + dataObserver.current('del\u001b[16Gto\u001b[19GFable 5 and saved as your default\u001b[0m') + + await expect(observer.result).resolves.toBe('applied') + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('accepts the exact cached-history confirmation once and keeps observing', async () => { + const dataObserver = { current: (_data: string): void => {} } + const submitConfirmation = vi.fn() + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + submitConfirmation, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Switch model? This\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached ') + dataObserver.current('for\u001b[36Gthe\u001b[40Gcurrent\u001b[48Gmodel.') + dataObserver.current(' redraw of the same Switch model? prompt') + + expect(submitConfirmation).toHaveBeenCalledOnce() + dataObserver.current('Set model to Fable 5 and saved as your default for new sessions') + + await expect(observer.result).resolves.toBe('applied') + }) + + it('reports a canceled model switch without opening an interaction', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Haiku', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('\u001b[2mKept\u001b[9Gmodel\u001b[16Gas ') + dataObserver.current('Fable 5\u001b[0m') + + await expect(observer.result).resolves.toBe('rejected') + }) + + it('requests interaction for Fable one-time usage-credit consent', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Fable 5 uses usage credits and needs a one-time consent — ') + dataObserver.current('pick Fable from /model in an interactive session to set it up') + + await expect(observer.result).resolves.toBe('interaction-required') + }) + + it('reports unknown when the PTY observer cannot be established', async () => { + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => Promise.reject(new Error('unavailable')), + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + await expect(observer.result).resolves.toBe('unknown') + }) + + it('reports unknown on timeout instead of requesting the terminal', async () => { + vi.useFakeTimers() + try { + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => vi.fn(() => {}), + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + observer.startDetection() + await vi.advanceTimersByTimeAsync(100) + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) + + it('does not start the detection timeout until startDetection() is called', async () => { + vi.useFakeTimers() + try { + let settled = false + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => vi.fn(() => {}), + timeoutMs: 100 + }) + void observer.result.then(() => { + settled = true + }) + + await observer.ready + observer.arm() + // Simulates slow SSH send latency between arm() and command delivery: the + // clock must not run yet, or a successful switch would time out to unknown. + await vi.advanceTimersByTimeAsync(500) + expect(settled).toBe(false) + + observer.startDetection() + await vi.advanceTimersByTimeAsync(100) + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts new file mode 100644 index 00000000000..64828018181 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -0,0 +1,199 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions' +import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' +import { NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +const DETECTION_TIMEOUT_MS = 5_000 +const MAX_OBSERVED_BYTES = 64 * 1024 + +type SubscribeToData = (watcher: (data: string) => void) => Promise<() => void> | (() => void) + +export type ClaudeModelSwitchOutcome = 'applied' | 'rejected' | 'interaction-required' | 'unknown' + +export type ClaudeModelSwitchConfirmationObserver = { + ready: Promise + result: Promise + arm(): void + startDetection(): void + dispose(): void +} + +export function hasClaudeModelSwitchConfirmation(buffer: string): boolean { + const text = compactTerminalText(buffer) + return ( + text.includes('switchmodel?') && text.includes('thisconversationiscachedforthecurrentmodel') + ) +} + +function compactTerminalText(buffer: string): string { + // Why: Claude positions TUI words with cursor-column escapes instead of + // emitting literal spaces, so matching must not depend on rendered gaps. + return stripScrollbackAnsi(buffer).replace(/\s+/g, '').toLowerCase() +} + +function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean { + const text = compactTerminalText(buffer) + const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}` + return text.includes(marker) +} + +function hasClaudeModelSwitchRejection(buffer: string): boolean { + return compactTerminalText(buffer).includes('keptmodelas') +} + +function hasClaudeModelSwitchInteraction(buffer: string): boolean { + const text = compactTerminalText(buffer) + return ( + text.includes('fable5usesusagecreditsandneedsaone-timeconsent') || + text.includes('pickfablefrom/modelinaninteractivesessiontosetitup') || + (text.includes('switchtofable5?') && text.includes('usagecredits')) + ) +} + +function subscribeToClaudeModelSwitchData(args: { + ptyId: string + settings: Pick | null | undefined + subscribeToData?: SubscribeToData + watcher: (data: string) => void +}): Promise<() => void> | (() => void) { + if (args.subscribeToData) { + return args.subscribeToData(args.watcher) + } + if (isRemoteRuntimePtyId(args.ptyId)) { + return subscribeToRuntimeTerminalData( + args.settings, + args.ptyId, + `desktop:native-chat-model-switch:${args.ptyId}`, + args.watcher, + { startAtLiveTail: true } + ) + } + return subscribeToPtyData(args.ptyId, args.watcher) +} + +export function createClaudeModelSwitchConfirmationObserver(args: { + ptyId: string + settings: Pick | null | undefined + expectedModelLabel: string | null + subscribeToData?: SubscribeToData + submitConfirmation?: () => boolean | void + timeoutMs?: number +}): ClaudeModelSwitchConfirmationObserver { + let armed = false + let settled = false + let confirmationSubmitted = false + let observed = '' + let timeout: ReturnType | null = null + let unsubscribe: (() => void) | null = null + let resolveResult!: (outcome: ClaudeModelSwitchOutcome) => void + let resolveReady!: () => void + const result = new Promise((resolve) => { + resolveResult = resolve + }) + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + + const finish = (outcome: ClaudeModelSwitchOutcome): void => { + if (settled) { + return + } + settled = true + if (timeout !== null) { + clearTimeout(timeout) + timeout = null + } + unsubscribe?.() + unsubscribe = null + resolveResult(outcome) + } + + const scheduleTimeout = (): void => { + if (timeout !== null) { + clearTimeout(timeout) + } + timeout = setTimeout(() => finish('unknown'), args.timeoutMs ?? DETECTION_TIMEOUT_MS) + } + + const observeData = (data: string): void => { + if (!armed || settled) { + return + } + observed = `${observed}${data}`.slice(-MAX_OBSERVED_BYTES) + if (args.expectedModelLabel && hasClaudeModelSwitchSuccess(observed, args.expectedModelLabel)) { + finish('applied') + return + } + if (hasClaudeModelSwitchRejection(observed)) { + finish('rejected') + return + } + if (hasClaudeModelSwitchInteraction(observed)) { + finish('interaction-required') + return + } + if (!confirmationSubmitted && hasClaudeModelSwitchConfirmation(observed)) { + confirmationSubmitted = true + try { + // Why: the picker selection already expresses consent to switch; this + // exact Claude warning defaults to “Yes” and needs only one Enter. + const accepted = args.submitConfirmation + ? args.submitConfirmation() !== false + : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT) + if (!accepted) { + finish('unknown') + return + } + scheduleTimeout() + } catch { + finish('unknown') + } + } + } + + try { + const subscription = subscribeToClaudeModelSwitchData({ + ptyId: args.ptyId, + settings: args.settings, + subscribeToData: args.subscribeToData, + watcher: observeData + }) + void Promise.resolve(subscription) + .then((dispose) => { + if (settled) { + dispose() + } else { + unsubscribe = dispose + } + }) + .catch(() => finish('unknown')) + .finally(resolveReady) + } catch { + finish('unknown') + resolveReady() + } + + return { + ready, + result, + arm: () => { + if (settled || armed) { + return + } + armed = true + }, + startDetection: () => { + // Why: measure the detection window from when the command is actually + // delivered, not from arm(). On SSH/remote the body+Enter round-trips can + // otherwise burn the timeout before the agent has even responded, turning + // a successful switch into a false "could not verify the model change". + if (settled) { + return + } + scheduleTimeout() + }, + dispose: () => finish('unknown') + } +} diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts new file mode 100644 index 00000000000..e095adf1132 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' + +describe('Claude terminal session option detection', () => { + it('reads the current model and effort from Claude header chrome', () => { + const screen = + '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + + '\u001b[38;2;102;102;102mOpus 4.8 with high effort · API Usage Billing\r\n' + + '~/Documents/projects/orca' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus', + effort: 'high' + }) + }) + + it('reads a Claude header whose xterm serialization joins the version to the title', () => { + const screen = + '\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211\r\n' + + '▝▜█████▛▘Sonnet 5 with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'sonnet', + effort: 'medium' + }) + }) + + it('does not mistake old conversation output for the current model', () => { + const screen = + 'Set model to Opus 4.8 and saved as your default\r\n' + + 'Claude Code v2.1.211\r\n' + + 'Sonnet 5 with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'sonnet', + effort: 'medium' + }) + }) + + it('reports an option-less Haiku model without inventing effort', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + 'Claude Code v2.1.211\r\nHaiku · API Usage Billing\r\n~/repo' + ) + ).toEqual({ model: 'haiku' }) + }) + + it('ignores text without Claude header chrome', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.') + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts new file mode 100644 index 00000000000..7a1e5b8fbd8 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -0,0 +1,54 @@ +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +const EFFORT_ID_BY_LABEL: Record = { + low: 'low', + medium: 'medium', + high: 'high', + 'extra high': 'xhigh', + xhigh: 'xhigh', + max: 'max' +} + +function normalizedScreenLines(screen: string): string[] { + return stripScrollbackAnsi(screen) + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) +} + +export function readClaudeSessionOptionsFromTerminalScreen( + screen: string | null | undefined +): Record | null { + if (!screen) { + return null + } + const lines = normalizedScreenLines(screen) + const headerIndex = lines.findIndex((line) => + // xterm serialization can remove cursor-positioning cells between the + // product name and version, producing `Claude Codev2.1.211`. + /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) + ) + if (headerIndex < 0) { + return null + } + // Why: only Claude's fixed header rows describe live state; conversation + // text and old command confirmations elsewhere in the buffer can be stale. + const header = lines.slice(headerIndex, headerIndex + 3).join(' ') + const catalog = getAgentSessionOptionCatalog('claude') + const model = [...(catalog?.models ?? [])] + .sort((left, right) => right.label.length - left.label.length) + .find(({ label }) => header.toLowerCase().includes(label.toLowerCase())) + if (!model) { + return null + } + const result: Record = { model: model.id } + const effortLabel = header.match( + /\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i + )?.[1] + const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined + if (effort && model.options.some((option) => option.id === 'effort')) { + result.effort = effort + } + return result +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts new file mode 100644 index 00000000000..771daacd23e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -0,0 +1,340 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearNativeChatSessionOptionCacheForTests, + seedNativeChatAppliedSessionOptions +} from './native-chat-session-option-cache' +import { createNativeChatPtySessionOptions } from './native-chat-pty-session-options' + +describe('native chat PTY session options', () => { + beforeEach(() => clearNativeChatSessionOptionCacheForTests()) + + it('starts attached sessions unknown and hides model-scoped options', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ + id: 'model', + valueSource: 'unknown' + }) + }) + + it('uses model and effort reported by the live Claude terminal', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + reportedValues: { model: 'opus', effort: 'medium' }, + dispatchCommand: vi.fn() + })! + + expect(surface.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'opus' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + }) + + it('restores launch-backed values through the tab-to-PTY cache handoff', () => { + seedNativeChatAppliedSessionOptions('tab-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + fallbackScopeKey: 'tab-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'model', valueSource: 'applied' }), + expect.objectContaining({ id: 'effort', valueSource: 'applied' }), + expect.objectContaining({ id: 'fastMode', valueSource: 'unknown' }) + ]) + ) + }) + + it('dispatches a Claude effort setter and publishes the full snapshot', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const dispatch = vi.fn() + const persist = vi.fn() + const listener = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + persistSelection: persist + })! + surface.subscribe(listener) + + const effortResult = await surface.setOption('effort', 'high') + expect(dispatch).toHaveBeenCalledWith('/effort high') + expect(effortResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode']) + expect(effortResult.snapshot.find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + expect(listener).toHaveBeenCalledOnce() + expect(listener.mock.calls.every(([snapshot]) => Array.isArray(snapshot))).toBe(true) + expect(persist).toHaveBeenCalledWith({ + modelId: 'opus', + optionId: 'effort', + value: 'high' + }) + }) + + it('keeps a normal Claude model choice native and dispatches the selected model', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'sonnet', + effort: 'high' + }) + const dispatch = vi.fn().mockResolvedValue({ outcome: 'applied' }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + expect(surface.getSnapshot()[0]?.action).toBeUndefined() + + const result = await surface.setOption('model', 'fable') + + expect(dispatch).toHaveBeenCalledWith('/model fable', { + detectAgentInteraction: 'claude-model-switch-confirmation', + expectedChoiceLabel: 'Fable 5' + }) + expect(onAgentPicker).not.toHaveBeenCalled() + expect(result.snapshot[0]).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'fable' } + }) + }) + + it('reveals the terminal only when Claude actually requires model-switch interaction', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'sonnet' }) + const dispatch = vi.fn().mockResolvedValue({ outcome: 'interaction-required' }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + + const result = await surface.setOption('model', 'haiku') + + expect(dispatch).toHaveBeenCalledWith('/model haiku', { + detectAgentInteraction: 'claude-model-switch-confirmation', + expectedChoiceLabel: 'Haiku' + }) + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('keeps the prior model and persistence when Claude rejects the switch', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'fable', + effort: 'high' + }) + const persist = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'rejected' }), + persistSelection: persist, + onAgentPicker + })! + + await expect(surface.setOption('model', 'haiku')).rejects.toThrow( + 'Claude kept the current model.' + ) + + expect(surface.getSnapshot()[0]).toMatchObject({ + valueSource: 'applied', + kind: { currentValue: 'fable' } + }) + expect(persist).not.toHaveBeenCalled() + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('stays native and clears stale truth when the switch cannot be verified', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'fable', + effort: 'high' + }) + const persist = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'unknown' }), + persistSelection: persist, + onAgentPicker + })! + + await expect(surface.setOption('model', 'haiku')).rejects.toThrow( + 'Could not verify the model change; open the terminal to check.' + ) + + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' }) + expect(persist).not.toHaveBeenCalled() + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('keeps an unknown toggle unknown after the one-shot action', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'high' + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch + })! + const fastBefore = surface.getSnapshot().find(({ id }) => id === 'fastMode') + expect(fastBefore?.action?.type).toBe('toggle-command') + + const result = await surface.setOption('fastMode', true) + expect(dispatch).toHaveBeenCalledWith('/fast') + expect(result.snapshot.find(({ id }) => id === 'fastMode')).toMatchObject({ + valueSource: 'unknown', + action: { type: 'toggle-command' } + }) + }) + + it('hands Codex model changes to the TUI picker and drops stale truth', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'codex', { + model: 'gpt-5.5', + effort: 'high' + }) + const dispatch = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'codex', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.action?.type).toBe( + 'agent-picker' + ) + + const result = await surface.setOption('effort', 'xhigh') + expect(dispatch).toHaveBeenCalledWith('/model') + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(result.snapshot).toHaveLength(1) + expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('tracks typed effort commands and downgrades typed toggles', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + surface.recordOutgoingCommand('/effort high') + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + surface.recordOutgoingCommand('/fast') + expect(surface.getSnapshot().find(({ id }) => id === 'fastMode')).toMatchObject({ + valueSource: 'unknown' + }) + }) + + it('switches to the terminal and drops stale truth for a typed picker command', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn(), + onAgentPicker + })! + + surface.recordOutgoingCommand('/model') + + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('passes an unknown persisted model through as a literal choice', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'future-model' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + const model = surface.getSnapshot()[0] + expect(model.kind).toMatchObject({ + currentValue: 'future-model', + choices: expect.arrayContaining([{ value: 'future-model', label: 'future-model' }]) + }) + }) + + it('recomposes Cursor model slugs for live option changes', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'cursor', { + model: 'gpt-5.3-codex', + effort: 'medium', + fastMode: true + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'cursor', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch + })! + expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.settable).toBe(true) + + await surface.setOption('effort', 'high') + + expect(dispatch).toHaveBeenCalledWith('/model gpt-5.3-codex-high-fast') + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts new file mode 100644 index 00000000000..41ce304011c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -0,0 +1,276 @@ +import { + findCatalogModel, + findCatalogOption, + getAgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel, + type CatalogOptionApply +} from '../../../../shared/agent-session-option-catalog' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import { + createNativeChatSessionOptionRecord, + readNativeChatSessionOptionCache, + writeNativeChatSessionOptionCache +} from './native-chat-session-option-cache' +import { + buildNativeChatSessionOptionSnapshot, + flattenNativeChatSessionOptionRecord, + type NativeChatSessionOptionMode +} from './native-chat-session-option-snapshot' +import { buildNativeChatSessionOptionCommand } from './native-chat-session-option-command-builder' +import type { + NativeChatSessionOptionDispatchCommand, + NativeChatSessionOptionDispatchResult +} from './native-chat-session-option-command-dispatch' +import { applyNativeChatReportedSessionOptions } from './native-chat-session-option-reporting' +import { recordNativeChatSessionOptionCommand } from './native-chat-session-option-command-recording' + +type PersistSelection = (args: { + modelId: string + optionId: string + value: SessionOptionValue +}) => Promise | void + +export type NativeChatPtySessionOptionsSurface = SessionOptionsSurface & { + recordOutgoingCommand(command: string): void + reportSessionOptions(values: Record): void + replaceModels(models: CatalogModel[]): void +} + +export type CreateNativeChatPtySessionOptionsArgs = { + agent: AgentType + scopeKey: string + fallbackScopeKey?: string + initialModels?: readonly CatalogModel[] + mode: NativeChatSessionOptionMode + reportedValues?: Record | null + dispatchCommand: NativeChatSessionOptionDispatchCommand + onAgentPicker?: () => void + persistSelection?: PersistSelection + onDraftValuesChanged?: (values: Record) => void +} + +export function createNativeChatPtySessionOptions( + args: CreateNativeChatPtySessionOptionsArgs +): NativeChatPtySessionOptionsSurface | null { + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog) { + return null + } + let models = [...(args.initialModels ?? catalog.models)] + let record = + readNativeChatSessionOptionCache(args.scopeKey, args.fallbackScopeKey) ?? + createNativeChatSessionOptionRecord(args.agent) + if (record.agent !== args.agent) { + record = createNativeChatSessionOptionRecord(args.agent) + } + + if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) { + writeNativeChatSessionOptionCache(args.scopeKey, record) + } + let snapshot = buildNativeChatSessionOptionSnapshot({ + catalog, + models, + record, + mode: args.mode + }) + const listeners = new Set<(value: SessionOptionDescriptor[]) => void>() + + const publish = (): SessionOptionDescriptor[] => { + writeNativeChatSessionOptionCache(args.scopeKey, record) + snapshot = buildNativeChatSessionOptionSnapshot({ + catalog, + models, + record, + mode: args.mode + }) + for (const listener of listeners) { + listener(snapshot) + } + return snapshot + } + + const clearModelTruth = (): void => { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + record.model = undefined + if (modelId) { + delete record.valuesByModel[modelId] + } + } + + const setTrackedValue = ( + optionId: string, + value: SessionOptionValue, + source: 'applied' | 'dispatched' + ): string | null => { + if (optionId === 'model') { + record.model = { value, source } + return typeof value === 'string' ? value : null + } + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (!modelId) { + return null + } + record.valuesByModel[modelId] = { + ...record.valuesByModel[modelId], + [optionId]: { value, source } + } + return modelId + } + + const persist = (modelId: string | null, optionId: string, value: SessionOptionValue): void => { + if (modelId) { + void args.persistSelection?.({ modelId, optionId, value }) + } + } + + const currentApply = ( + optionId: string + ): { apply: CatalogOptionApply; modelId: string | null } | null => { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (optionId === 'model') { + return { apply: catalog.modelApply, modelId } + } + const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined + const option = findCatalogOption(model, optionId) + return option ? { apply: option.apply, modelId } : null + } + + const handleAgentPicker = async (midSession: CatalogMidSessionApply): Promise => { + if (midSession.kind !== 'agent-picker') { + return + } + await args.dispatchCommand(midSession.command) + clearModelTruth() + publish() + args.onAgentPicker?.() + } + + const setOption = async (id: string, value: SessionOptionValue) => { + const resolved = currentApply(id) + if (!resolved) { + throw new Error(`Unknown session option: ${id}`) + } + const { apply, modelId: previousModelId } = resolved + if (args.mode === 'live' && apply.midSession?.kind === 'agent-picker') { + await handleAgentPicker(apply.midSession) + return { snapshot } + } + const source = args.mode === 'live' ? 'dispatched' : 'applied' + let dispatchResult: NativeChatSessionOptionDispatchResult | void = undefined + const toggleWasKnown = + apply.midSession?.kind === 'toggle-command' && previousModelId + ? record.valuesByModel[previousModelId]?.[id] !== undefined + : false + if (args.mode === 'live') { + const command = buildNativeChatSessionOptionCommand({ + optionId: id, + value, + apply, + modelId: previousModelId, + catalog, + models, + record + }) + if (!command) { + throw new Error('This option can only be set when the session starts.') + } + const detectAgentInteraction = + apply.midSession?.kind === 'command' + ? apply.midSession.detectAgentInteraction + : apply.composedIntoModel && catalog.modelApply.midSession?.kind === 'command' + ? catalog.modelApply.midSession.detectAgentInteraction + : undefined + const expectedChoiceLabel = + id === 'model' && typeof value === 'string' + ? (findCatalogModel({ ...catalog, models }, value)?.label ?? value) + : undefined + dispatchResult = detectAgentInteraction + ? await args.dispatchCommand(command, { + detectAgentInteraction, + expectedChoiceLabel + }) + : await args.dispatchCommand(command) + } else if (!apply.launchArgs && !apply.composedIntoModel) { + throw new Error('This option is only available after the session starts.') + } + + if (dispatchResult?.outcome === 'rejected') { + throw new Error('Claude kept the current model.') + } + if (dispatchResult?.outcome === 'unknown') { + clearModelTruth() + publish() + throw new Error('Could not verify the model change; open the terminal to check.') + } + if (dispatchResult?.outcome === 'interaction-required') { + clearModelTruth() + publish() + args.onAgentPicker?.() + return { snapshot } + } + + if (apply.midSession?.kind === 'toggle-command' && !toggleWasKnown) { + return { snapshot: publish() } + } + if (id === 'model' && previousModelId !== value) { + record.model = undefined + if (args.mode === 'live' && typeof value === 'string') { + // Why: switching models can reset its effort/toggles. A value cached + // from an earlier visit to that model is no longer live evidence. + delete record.valuesByModel[value] + } + } + const modelId = setTrackedValue(id, value, source) + if (apply.midSession?.kind === 'toggle-command' && previousModelId && source === 'dispatched') { + record.valuesByModel[previousModelId] = { + ...record.valuesByModel[previousModelId], + [id]: { value, source } + } + } + persist(modelId ?? previousModelId, id, value) + const next = publish() + if (args.mode === 'draft' && typeof record.model?.value === 'string') { + args.onDraftValuesChanged?.(flattenNativeChatSessionOptionRecord(record, record.model.value)) + } + return { snapshot: next } + } + + return { + getSnapshot: () => snapshot, + setOption, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + recordOutgoingCommand: (command) => { + const result = recordNativeChatSessionOptionCommand({ + catalog, + models, + record, + command, + persist + }) + if (result.changed) { + publish() + } + if (result.opensAgentPicker) { + args.onAgentPicker?.() + } + }, + reportSessionOptions: (values) => { + if (applyNativeChatReportedSessionOptions(record, values)) { + publish() + } + }, + replaceModels: (nextModels) => { + models = [...nextModels] + publish() + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 626e126ffa5..730ad84f1a6 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Mock the IO seam so the test stays pure: we only assert the write order and // the inter-write delay, not the local-vs-remote pty branching. const sendRuntimePtyInput = vi.fn() +const sendRuntimePtyInputVerified = vi.fn() vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args) + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) })) import { sendNativeChatMessage, + sendNativeChatMessageVerified, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -75,6 +78,52 @@ describe('sendNativeChatMessage', () => { }) }) +describe('sendNativeChatMessageVerified', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('awaits body acceptance before the delayed Enter write', async () => { + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet') + await Promise.resolve() + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( + SETTINGS, + PTY, + buildNativeChatPasteBytes('/model sonnet') + ) + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(await result).toBe(true) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + }) + + it('does not send Enter when the body is rejected', async () => { + sendRuntimePtyInputVerified.mockResolvedValueOnce(false) + + await expect(sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')).resolves.toBe(false) + await vi.runAllTimersAsync() + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending Enter when its composer detaches', async () => { + const controller = new AbortController() + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal) + await Promise.resolve() + controller.abort() + + expect(await result).toBe(false) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 59ff443fd24..f1f97d0b530 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -2,7 +2,10 @@ // as a SEPARATE delayed pty write. Kept apart from the pure byte builders in // native-chat-send.ts so those stay IO-free and unit-testable without aliases. -import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { + sendRuntimePtyInput, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' import { @@ -47,6 +50,46 @@ export function sendNativeChatMessage( return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS } } +function waitForNativeChatSubmit(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + let timer: ReturnType | null = null + const finish = (completed: boolean): void => { + if (timer === null) { + return + } + clearTimeout(timer) + timer = null + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + timer = setTimeout(() => finish(true), NATIVE_CHAT_SUBMIT_DELAY_MS) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +export async function sendNativeChatMessageVerified( + settings: ReturnType, + ptyId: string, + text: string, + signal?: AbortSignal +): Promise { + // Why: option commands await remote/SSH acceptance so the Enter cannot race + // ahead of the body while a model-change observer is already armed. + const bodyAccepted = await sendRuntimePtyInputVerified( + settings, + ptyId, + buildNativeChatPasteBytes(text) + ) + if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { + return false + } + return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) +} + export function sendNativeChatMessageWithImageAttachments( settings: ReturnType, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts new file mode 100644 index 00000000000..ae9418cd650 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts @@ -0,0 +1,84 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + SessionOptionValue, + SessionOptionValueSource +} from '../../../../shared/native-chat-session-options' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' + +export type TrackedNativeChatSessionOption = { + value: SessionOptionValue + source: Exclude +} + +export type NativeChatSessionOptionRecord = { + agent: AgentType + model?: TrackedNativeChatSessionOption + valuesByModel: Record> +} + +const sessionOptionCache = new Map() + +export function createNativeChatSessionOptionRecord( + agent: AgentType +): NativeChatSessionOptionRecord { + return { agent, valuesByModel: {} } +} + +export function cloneNativeChatSessionOptionRecord( + record: NativeChatSessionOptionRecord +): NativeChatSessionOptionRecord { + return { + agent: record.agent, + ...(record.model ? { model: { ...record.model } } : {}), + valuesByModel: Object.fromEntries( + Object.entries(record.valuesByModel).map(([modelId, values]) => [ + modelId, + Object.fromEntries(Object.entries(values).map(([id, tracked]) => [id, { ...tracked }])) + ]) + ) + } +} + +export function readNativeChatSessionOptionCache( + scopeKey: string, + fallbackScopeKey?: string +): NativeChatSessionOptionRecord | null { + const record = sessionOptionCache.get(scopeKey) ?? sessionOptionCache.get(fallbackScopeKey ?? '') + return record ? cloneNativeChatSessionOptionRecord(record) : null +} + +export function writeNativeChatSessionOptionCache( + scopeKey: string, + record: NativeChatSessionOptionRecord +): void { + setBoundedScopeCacheEntry( + sessionOptionCache, + scopeKey, + cloneNativeChatSessionOptionRecord(record) + ) +} + +export function seedNativeChatAppliedSessionOptions( + scopeKey: string, + agent: AgentType, + values: Record | null | undefined +): void { + const modelId = typeof values?.model === 'string' ? values.model : null + if (!modelId) { + return + } + const record = createNativeChatSessionOptionRecord(agent) + record.model = { value: modelId, source: 'applied' } + const modelValues: Record = {} + for (const [id, value] of Object.entries(values ?? {})) { + if (id !== 'model') { + modelValues[id] = { value, source: 'applied' } + } + } + record.valuesByModel[modelId] = modelValues + writeNativeChatSessionOptionCache(scopeKey, record) +} + +export function clearNativeChatSessionOptionCacheForTests(): void { + sessionOptionCache.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts new file mode 100644 index 00000000000..94de397b0ef --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts @@ -0,0 +1,40 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogModel, + type CatalogOptionApply +} from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' +import { flattenNativeChatSessionOptionRecord } from './native-chat-session-option-snapshot' + +export function buildNativeChatSessionOptionCommand(args: { + optionId: string + value: SessionOptionValue + apply: CatalogOptionApply + modelId: string | null + catalog: AgentSessionOptionCatalog + models: CatalogModel[] + record: NativeChatSessionOptionRecord +}): string | null { + const midSession = args.apply.midSession + if (midSession?.kind === 'command') { + return midSession.build(args.value) + } + if (midSession?.kind === 'toggle-command' || midSession?.kind === 'agent-picker') { + return midSession.command + } + if (!args.apply.composedIntoModel || !args.modelId || !args.catalog.composeModelValue) { + return null + } + const model = findCatalogModel({ ...args.catalog, models: args.models }, args.modelId) + const values = flattenNativeChatSessionOptionRecord(args.record, args.modelId) + for (const option of model?.options ?? []) { + values[option.id] ??= option.kind.defaultValue + } + values[args.optionId] = args.value + const composed = args.catalog.composeModelValue(args.modelId, values) + return args.catalog.modelApply.midSession?.kind === 'command' + ? args.catalog.modelApply.midSession.build(composed) + : null +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts new file mode 100644 index 00000000000..c510990dc5e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -0,0 +1,17 @@ +import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' + +export type NativeChatSessionOptionDispatchResult = { + outcome?: ClaudeModelSwitchOutcome +} + +export type NativeChatSessionOptionDispatchCommand = ( + command: string, + options?: { + detectAgentInteraction?: CatalogAgentInteractionDetection + expectedChoiceLabel?: string + } +) => + | Promise + | NativeChatSessionOptionDispatchResult + | void diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts new file mode 100644 index 00000000000..08509c36935 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts @@ -0,0 +1,31 @@ +import type { CatalogMidSessionApply } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' + +export function parseBuiltSessionOptionCommand( + build: (value: SessionOptionValue) => string, + command: string +): string | null { + const marker = '__orca_session_option_value__' + const template = build(marker) + const markerIndex = template.indexOf(marker) + if (markerIndex < 0) { + return null + } + const prefix = template.slice(0, markerIndex) + const suffix = template.slice(markerIndex + marker.length) + if (!command.startsWith(prefix) || !command.endsWith(suffix)) { + return null + } + const value = command.slice(prefix.length, command.length - suffix.length).trim() + return value || null +} + +export function isSessionOptionAgentPickerCommand( + midSession: CatalogMidSessionApply | undefined, + command: string +): boolean { + return ( + (midSession?.kind === 'agent-picker' && command === midSession.command) || + (midSession?.kind === 'command' && command === midSession.pickerCommand) + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts new file mode 100644 index 00000000000..051e14b1c1d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts @@ -0,0 +1,105 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' +import { + isSessionOptionAgentPickerCommand, + parseBuiltSessionOptionCommand +} from './native-chat-session-option-command-matching' + +function clearModel(record: NativeChatSessionOptionRecord): void { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + record.model = undefined + if (modelId) { + delete record.valuesByModel[modelId] + } +} + +function recordCommandApply(args: { + record: NativeChatSessionOptionRecord + optionId: string + midSession: CatalogMidSessionApply | undefined + command: string + persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void +}): boolean { + const { record, optionId, midSession, command, persist } = args + if (!midSession || midSession.kind === 'unsupported') { + return false + } + if (midSession.kind === 'toggle-command' && command === midSession.command) { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (modelId) { + delete record.valuesByModel[modelId]?.[optionId] + } + return true + } + if (isSessionOptionAgentPickerCommand(midSession, command)) { + clearModel(record) + return true + } + if (midSession.kind !== 'command') { + return false + } + const value = parseBuiltSessionOptionCommand(midSession.build, command) + if (!value) { + return false + } + const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null + if (optionId === 'model') { + if (previousModelId !== value) { + // Why: a model command can reset model-scoped state, so an older value + // from a prior visit is no longer evidence about this live session. + delete record.valuesByModel[value] + } + record.model = { value, source: 'dispatched' } + persist(value, optionId, value) + return true + } + if (!previousModelId) { + return true + } + record.valuesByModel[previousModelId] = { + ...record.valuesByModel[previousModelId], + [optionId]: { value, source: 'dispatched' } + } + persist(previousModelId, optionId, value) + return true +} + +export function recordNativeChatSessionOptionCommand(args: { + catalog: AgentSessionOptionCatalog + models: CatalogModel[] + record: NativeChatSessionOptionRecord + command: string + persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void +}): { changed: boolean; opensAgentPicker: boolean } { + const { catalog, models, record, persist } = args + const command = args.command.trim() + let opensAgentPicker = isSessionOptionAgentPickerCommand(catalog.modelApply.midSession, command) + let changed = recordCommandApply({ + record, + optionId: 'model', + midSession: catalog.modelApply.midSession, + command, + persist + }) + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined + for (const option of model?.options ?? []) { + opensAgentPicker = + opensAgentPicker || isSessionOptionAgentPickerCommand(option.apply.midSession, command) + changed = + recordCommandApply({ + record, + optionId: option.id, + midSession: option.apply.midSession, + command, + persist + }) || changed + } + return { changed, opensAgentPicker } +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts new file mode 100644 index 00000000000..bdf8ee6d1a2 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -0,0 +1,57 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { + discoverRuntimeCommitMessageModels, + getRuntimeGitScope, + type RuntimeGitContext +} from '@/runtime/runtime-git-client' +import { useAppStore } from '@/store' + +export type NativeChatModelDiscoveryContext = { + hostKey: string + runtime: RuntimeGitContext +} + +export function resolveNativeChatModelDiscoveryContext( + terminalTabId: string +): NativeChatModelDiscoveryContext | null { + const state = useAppStore.getState() + const worktreeId = + Object.entries(state.tabsByWorktree ?? {}).find(([, tabs]) => + tabs.some((tab) => tab.id === terminalTabId) + )?.[0] ?? null + const connectionId = getConnectionIdFromState(state, worktreeId) + if (worktreeId && connectionId === undefined) { + return null + } + const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId) + const worktreePath = worktreeId ? (state.getKnownWorktreeById?.(worktreeId)?.path ?? '') : '' + const scope = getRuntimeGitScope(settings, connectionId) + return { + hostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope), + runtime: { + settings, + worktreeId, + worktreePath, + ...(connectionId ? { connectionId } : {}) + } + } +} + +export async function discoverNativeChatCatalogModels( + agent: AgentType, + context: RuntimeGitContext +): Promise { + const result = await discoverRuntimeCommitMessageModels(context, agent) + if (!result.success || result.models.length === 0) { + return null + } + return result.models.map((model) => ({ + id: model.id, + label: model.label, + options: [] + })) +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts new file mode 100644 index 00000000000..008d857ec9c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { + clearNativeChatModelEnrichmentForTests, + ensureNativeChatModelEnrichment, + readNativeChatEnrichedModels, + subscribeNativeChatEnrichedModels +} from './native-chat-session-option-enrichment' + +describe('native chat session option enrichment', () => { + beforeEach(() => clearNativeChatModelEnrichmentForTests()) + + it('keeps reads synchronous while one host-scoped probe is in flight', async () => { + let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined + const discover = vi.fn( + () => + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + const listener = vi.fn() + subscribeNativeChatEnrichedModels('cursor', 'ssh:one', listener) + + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover }) + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover }) + + expect(readNativeChatEnrichedModels('cursor', 'ssh:one')).toBeNull() + expect(discover).toHaveBeenCalledOnce() + + resolveDiscovery?.([ + { id: 'gpt-5.3-codex', label: 'GPT 5.3 live', options: [] }, + { id: 'account-model', label: 'Account model', options: [] } + ]) + await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce()) + + const models = readNativeChatEnrichedModels('cursor', 'ssh:one')! + expect(models.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({ + label: 'GPT 5.3 live', + options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })]) + }) + expect(models.at(-1)).toMatchObject({ id: 'account-model' }) + expect(readNativeChatEnrichedModels('cursor', 'ssh:two')).toBeNull() + }) + + it('falls back permanently to the seed after a failed once-per-host probe', async () => { + const discover = vi.fn().mockRejectedValue(new Error('offline')) + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover }) + await vi.waitFor(() => expect(discover).toHaveBeenCalledOnce()) + await Promise.resolve() + + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover }) + expect(discover).toHaveBeenCalledOnce() + expect(readNativeChatEnrichedModels('cursor', 'local')).toBeNull() + }) + + it('does not probe agents whose catalogs have no discovery command', () => { + const discover = vi.fn() + ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover }) + expect(discover).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts new file mode 100644 index 00000000000..5fde5625c52 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -0,0 +1,87 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import { + getAgentSessionOptionCatalog, + mergeCatalogModels, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' + +type CatalogEnrichmentEntry = { + state: 'idle' | 'pending' | 'settled' + models: CatalogModel[] | null + listeners: Set<(models: CatalogModel[]) => void> +} + +const enrichmentByAgentHost = new Map() + +function enrichmentKey(agent: AgentType, hostKey: string): string { + return JSON.stringify([agent, hostKey]) +} + +export function readNativeChatEnrichedModels( + agent: AgentType, + hostKey: string +): CatalogModel[] | null { + const models = enrichmentByAgentHost.get(enrichmentKey(agent, hostKey))?.models + return models ? [...models] : null +} + +export function subscribeNativeChatEnrichedModels( + agent: AgentType, + hostKey: string, + listener: (models: CatalogModel[]) => void +): () => void { + const key = enrichmentKey(agent, hostKey) + const entry = enrichmentByAgentHost.get(key) ?? { + state: 'idle' as const, + models: null, + listeners: new Set<(models: CatalogModel[]) => void>() + } + entry.listeners.add(listener) + enrichmentByAgentHost.set(key, entry) + return () => entry.listeners.delete(listener) +} + +export function ensureNativeChatModelEnrichment(args: { + agent: AgentType + hostKey: string + discover: () => Promise +}): void { + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog?.listModels) { + return + } + const key = enrichmentKey(args.agent, args.hostKey) + const existing = enrichmentByAgentHost.get(key) + if (existing?.state === 'pending' || existing?.state === 'settled') { + return + } + const entry: CatalogEnrichmentEntry = existing ?? { + state: 'idle', + models: null, + listeners: new Set() + } + entry.state = 'pending' + enrichmentByAgentHost.set(key, entry) + + // Why: model discovery must never delay rendering or launching; the seed is + // immediately usable while this once-per-host probe runs in the background. + void args + .discover() + .then((discovered) => { + entry.state = 'settled' + if (!discovered || discovered.length === 0) { + return + } + entry.models = mergeCatalogModels(catalog.models, discovered) + for (const listener of entry.listeners) { + listener([...entry.models]) + } + }) + .catch(() => { + entry.state = 'settled' + }) +} + +export function clearNativeChatModelEnrichmentForTests(): void { + enrichmentByAgentHost.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts b/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts new file mode 100644 index 00000000000..33b940d88f3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts @@ -0,0 +1,135 @@ +import type { + SessionOptionDescriptor, + SessionOptionDisabledReason, + SessionOptionSelectChoice +} from '../../../../shared/native-chat-session-options' +import { translate } from '@/i18n/i18n' + +export function nativeChatSessionOptionLabel(descriptor: SessionOptionDescriptor): string { + switch (descriptor.id) { + case 'model': + return translate('components.native-chat.composer.model', 'Model') + case 'effort': + return translate('components.native-chat.composer.effort', descriptor.label) + case 'fastMode': + return translate('components.native-chat.composer.fastMode', 'Fast mode') + case 'thinking': + return translate('components.native-chat.composer.thinking', 'Thinking') + default: + return descriptor.label + } +} + +export function nativeChatSessionChoiceLabel(choice: SessionOptionSelectChoice): string { + switch (choice.value) { + case 'minimal': + return translate('components.native-chat.composer.optionValue.minimal', 'Minimal') + case 'low': + return translate('components.native-chat.composer.optionValue.low', 'Low') + case 'medium': + return translate('components.native-chat.composer.optionValue.medium', 'Medium') + case 'high': + return translate('components.native-chat.composer.optionValue.high', 'High') + case 'xhigh': + return translate('components.native-chat.composer.optionValue.xhigh', 'Extra high') + case 'max': + return translate('components.native-chat.composer.optionValue.max', 'Max') + default: + return choice.label + } +} + +export function nativeChatSessionOptionDisabledReason( + reason: SessionOptionDisabledReason | undefined +): string | null { + // Exhaustive over SessionOptionDisabledReason: a new key is a compile error + // here, so the localized label can never silently drift from the producer. + switch (reason) { + case 'set-when-session-starts': + return translate( + 'components.native-chat.composer.setWhenSessionStarts', + 'Set when the session starts.' + ) + case 'available-after-session-start': + return translate( + 'components.native-chat.composer.availableAfterSessionStarts', + 'Available after the session starts.' + ) + default: + return null + } +} + +export function nativeChatModelPillLabel(descriptor: SessionOptionDescriptor): string { + const modelLabel = translate('components.native-chat.composer.model', 'Model') + if ( + descriptor.valueSource === 'unknown' || + descriptor.kind.type !== 'select' || + !descriptor.kind.currentValue + ) { + return modelLabel + } + const valueLabel = nativeChatSessionChoiceLabel( + descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue) ?? { + value: descriptor.kind.currentValue, + label: descriptor.kind.currentValue + } + ) + return translate('components.native-chat.composer.modelWithValue', 'Model: {{value0}}', { + value0: valueLabel + }) +} + +export function nativeChatOptionsPillTitle( + descriptors: readonly SessionOptionDescriptor[] +): string { + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + // Why: an effort-backed group is primarily the effort picker, even when it also reports modes. + return effort + ? nativeChatSessionOptionLabel(effort) + : translate('components.native-chat.composer.sessionOptions', 'Session options') +} + +export function nativeChatOptionsPillLabel( + descriptors: readonly SessionOptionDescriptor[] +): string { + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + const labels: string[] = [] + for (const descriptor of descriptors) { + if (descriptor.valueSource === 'unknown') { + continue + } + if (descriptor.kind.type === 'select' && descriptor.kind.currentValue) { + const choice = descriptor.kind.choices.find( + (candidate) => candidate.value === descriptor.kind.currentValue + ) + labels.push( + nativeChatSessionChoiceLabel( + choice ?? { + value: descriptor.kind.currentValue, + label: descriptor.kind.currentValue + } + ) + ) + } else if (descriptor.kind.type === 'boolean' && descriptor.kind.currentValue === true) { + labels.push( + descriptor.id === 'fastMode' + ? translate('components.native-chat.composer.optionValue.fast', 'Fast') + : nativeChatSessionOptionLabel(descriptor) + ) + } + } + if (labels.length > 0) { + const joinedLabels = labels.join(' · ') + if (effort) { + return translate('components.native-chat.composer.effortWithValue', 'Effort: {{value0}}', { + value0: joinedLabels + }) + } + return joinedLabels + } + if (effort) { + return nativeChatSessionOptionLabel(effort) + } + return translate('components.native-chat.composer.options', 'Options') +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts b/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts new file mode 100644 index 00000000000..411a0f988d7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts @@ -0,0 +1,28 @@ +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' + +export function applyNativeChatReportedSessionOptions( + record: NativeChatSessionOptionRecord, + values: Record +): boolean { + const modelId = typeof values.model === 'string' ? values.model : null + if (!modelId) { + return false + } + const modelChanged = record.model?.value !== modelId + let changed = modelChanged || record.model?.source !== 'reported' + record.model = { value: modelId, source: 'reported' } + const modelValues = modelChanged ? {} : { ...record.valuesByModel[modelId] } + for (const [id, value] of Object.entries(values)) { + if (id === 'model') { + continue + } + const current = modelValues[id] + if (current?.value !== value || current.source !== 'reported') { + changed = true + } + modelValues[id] = { value, source: 'reported' } + } + record.valuesByModel[modelId] = modelValues + return changed +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts new file mode 100644 index 00000000000..987e66edf4b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts @@ -0,0 +1,178 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel, + type CatalogOption +} from '../../../../shared/agent-session-option-catalog' +import type { + SessionOptionDescriptor, + SessionOptionSelectChoice, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import type { + NativeChatSessionOptionRecord, + TrackedNativeChatSessionOption +} from './native-chat-session-option-cache' +import { translate } from '@/i18n/i18n' + +export type NativeChatSessionOptionMode = 'draft' | 'live' + +function choiceWithCurrent( + choices: readonly SessionOptionSelectChoice[], + tracked: TrackedNativeChatSessionOption | undefined +): SessionOptionSelectChoice[] { + const result = [...choices] + const current = typeof tracked?.value === 'string' ? tracked.value : null + if (current && !result.some((choice) => choice.value === current)) { + result.push({ value: current, label: current }) + } + return result +} + +function settableState(args: { + mode: NativeChatSessionOptionMode + apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply } + composedModelApply?: { midSession?: CatalogMidSessionApply } +}): Pick { + if (args.mode === 'draft') { + return args.apply.launchArgs || args.apply.composedIntoModel + ? { settable: true } + : { settable: false, disabledReason: 'available-after-session-start' } + } + const midSession = args.apply.midSession + if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') { + return { settable: true } + } + return midSession && midSession.kind !== 'unsupported' + ? { settable: true } + : { settable: false, disabledReason: 'set-when-session-starts' } +} + +function actionForApply( + apply: { midSession?: CatalogMidSessionApply }, + tracked: TrackedNativeChatSessionOption | undefined, + mode: NativeChatSessionOptionMode +): SessionOptionDescriptor['action'] { + if (mode !== 'live') { + return undefined + } + if (apply.midSession?.kind === 'agent-picker') { + return { type: 'agent-picker' } + } + if (apply.midSession?.kind === 'toggle-command' && !tracked) { + return { type: 'toggle-command' } + } + return undefined +} + +function optionDescriptor(args: { + option: CatalogOption + tracked: TrackedNativeChatSessionOption | undefined + mode: NativeChatSessionOptionMode + composedModelApply: AgentSessionOptionCatalog['modelApply'] +}): SessionOptionDescriptor | null { + const { option, tracked, mode, composedModelApply } = args + const action = actionForApply(option.apply, tracked, mode) + const settable = settableState({ mode, apply: option.apply, composedModelApply }) + if (option.kind.type === 'select') { + const choices = choiceWithCurrent(option.kind.choices, tracked) + if (choices.length <= 1) { + return null + } + return { + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.category ? { category: option.category } : {}), + kind: { + type: 'select', + ...(typeof tracked?.value === 'string' ? { currentValue: tracked.value } : {}), + choices + }, + valueSource: tracked?.source ?? 'unknown', + ...settable, + ...(action ? { action } : {}) + } + } + return { + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.category ? { category: option.category } : {}), + kind: { + type: 'boolean', + ...(typeof tracked?.value === 'boolean' ? { currentValue: tracked.value } : {}) + }, + valueSource: tracked?.source ?? 'unknown', + ...settable, + ...(action ? { action } : {}) + } +} + +export function buildNativeChatSessionOptionSnapshot(args: { + catalog: AgentSessionOptionCatalog + models: readonly CatalogModel[] + record: NativeChatSessionOptionRecord + mode: NativeChatSessionOptionMode +}): SessionOptionDescriptor[] { + const { catalog, models, record, mode } = args + const modelTracked = record.model + const modelChoices = choiceWithCurrent( + models.map(({ id, label, description }) => ({ + value: id, + label, + ...(description ? { description } : {}) + })), + modelTracked + ) + const modelSettable = settableState({ mode, apply: catalog.modelApply }) + const modelAction = actionForApply(catalog.modelApply, modelTracked, mode) + const snapshot: SessionOptionDescriptor[] = [ + { + id: 'model', + label: translate('components.native-chat.composer.model', 'Model'), + category: 'model', + kind: { + type: 'select', + ...(typeof modelTracked?.value === 'string' ? { currentValue: modelTracked.value } : {}), + choices: modelChoices + }, + valueSource: modelTracked?.source ?? 'unknown', + ...modelSettable, + ...(modelAction ? { action: modelAction } : {}) + } + ] + if (typeof modelTracked?.value !== 'string') { + return snapshot + } + const model = findCatalogModel({ ...catalog, models: [...models] }, modelTracked.value) + const trackedValues = record.valuesByModel[modelTracked.value] ?? {} + for (const option of model?.options ?? []) { + const descriptor = optionDescriptor({ + option, + tracked: trackedValues[option.id], + mode, + composedModelApply: catalog.modelApply + }) + if (descriptor) { + snapshot.push(descriptor) + } + } + return snapshot +} + +export function flattenNativeChatSessionOptionRecord( + record: NativeChatSessionOptionRecord, + modelId: string +): Record { + return { + model: modelId, + ...Object.fromEntries( + Object.entries(record.valuesByModel[modelId] ?? {}).map(([id, tracked]) => [ + id, + tracked.value + ]) + ) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts new file mode 100644 index 00000000000..7d4caf72b8b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts @@ -0,0 +1,28 @@ +import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' +import { dispatchDictationControl } from '../dictation/dictation-control-events' + +export function useNativeChatDictationActions(args: { + textareaRef: RefObject + setDictationPressed: Dispatch> +}): { + toggleDictation: () => void + startHoldDictation: () => void + stopHoldDictation: () => void +} { + const { setDictationPressed, textareaRef } = args + const focusForDictation = useCallback(() => textareaRef.current?.focus(), [textareaRef]) + const toggleDictation = useCallback(() => { + focusForDictation() + dispatchDictationControl('toggle') + }, [focusForDictation]) + const startHoldDictation = useCallback(() => { + setDictationPressed(true) + focusForDictation() + dispatchDictationControl('start') + }, [focusForDictation, setDictationPressed]) + const stopHoldDictation = useCallback(() => { + setDictationPressed(false) + dispatchDictationControl('stop') + }, [setDictationPressed]) + return { toggleDictation, startHoldDictation, stopHoldDictation } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts new file mode 100644 index 00000000000..5a6094cfea3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect } from 'react' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' + +export function useNativeChatFileAttachmentActions( + attachExternalPaths: (paths: string[]) => void +): { pickAttachment: () => void } { + useEffect( + () => + window.api.ui.onFileDrop((payload) => { + if (payload.target === NATIVE_FILE_DROP_TARGET.composer) { + attachExternalPaths(payload.paths) + } + }), + [attachExternalPaths] + ) + + const pickAttachment = useCallback(() => { + void (async () => { + const filePath = await window.api.shell.pickAttachment() + if (filePath) { + attachExternalPaths([filePath]) + } + })() + }, [attachExternalPaths]) + + return { pickAttachment } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts new file mode 100644 index 00000000000..3e745bcd2bc --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import { resolveNativeChatFileLink, type NativeChatFileLinkContext } from './native-chat-file-link' + +export function useNativeChatFileLinkClick( + context: NativeChatFileLinkContext | null +): CommentMarkdownLinkClickHandler | undefined { + const openFileLink = useCallback( + (event, href) => { + const target = resolveNativeChatFileLink(href, context) + if (!target || !context) { + return + } + event.preventDefault() + event.stopPropagation() + openDetectedFilePath(target.absolutePath, target.line, target.column, { + worktreeId: context.worktreeId, + worktreePath: context.worktreePath, + runtimeEnvironmentId: context.runtimeEnvironmentId, + openWithSystemDefault: event.shiftKey + }) + }, + [context] + ) + return context ? openFileLink : undefined +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts new file mode 100644 index 00000000000..9720ab9f950 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { pushHistory, type HistoryState } from './native-chat-composer-state' +import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { + createClaudeModelSwitchConfirmationObserver, + type ClaudeModelSwitchConfirmationObserver +} from './claude-model-switch-confirmation' +import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch' + +export function useNativeChatSessionOptionCommand(args: { + agent: AgentType + disabled: boolean + onSlashCommand?: (command: string) => void + resolveTarget: () => NativeChatResolvedTarget | null + setHistory: Dispatch> +}): { dispatch: NativeChatSessionOptionDispatchCommand; isDispatching: boolean } { + const { agent, disabled, onSlashCommand, resolveTarget, setHistory } = args + const mountedRef = useRef(true) + const activeObserversRef = useRef(new Set()) + const activeSendsRef = useRef(new Set()) + // Why: expose an in-flight flag so the composer can block a normal send while + // an option command's body+delayed-Enter (and its confirmation observer) is + // still writing to the same pty — otherwise the two write sequences interleave + // on one input line and corrupt both. + const [isDispatching, setIsDispatching] = useState(false) + + useEffect(() => { + mountedRef.current = true + const observers = activeObserversRef.current + const sends = activeSendsRef.current + return () => { + mountedRef.current = false + for (const send of sends) { + send.abort() + } + sends.clear() + for (const observer of observers) { + observer.dispose() + } + observers.clear() + } + }, []) + + const dispatch = useCallback( + async (command, options) => { + const target = resolveTarget() + if (!target || disabled) { + throw new Error('No live terminal is available.') + } + const detectClaudeConfirmation = + options?.detectAgentInteraction === 'claude-model-switch-confirmation' + let observer: ClaudeModelSwitchConfirmationObserver | null = null + if (detectClaudeConfirmation) { + observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: target.ptyId, + settings: target.settings, + expectedModelLabel: options?.expectedChoiceLabel ?? null + }) + activeObserversRef.current.add(observer) + await observer.ready + if (!mountedRef.current) { + activeObserversRef.current.delete(observer) + observer.dispose() + throw new Error('Native chat command was canceled because the composer closed.') + } + // Why: arm only after the observer reaches the live PTY tail, then + // submit immediately so historical output cannot satisfy the match. + observer.arm() + } + const sendController = new AbortController() + activeSendsRef.current.add(sendController) + setIsDispatching(true) + try { + const accepted = await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) + if (!accepted) { + throw new Error('The terminal did not accept the command.') + } + // Why: start the model-switch detection clock only now that the command + // has been accepted, so SSH/remote send latency doesn't eat the window + // before the agent has responded. + observer?.startDetection() + onSlashCommand?.(command.trim()) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((previous) => pushHistory(previous, command)) + const outcome = observer ? await observer.result : undefined + return { outcome } + } finally { + activeSendsRef.current.delete(sendController) + setIsDispatching(activeSendsRef.current.size > 0) + if (observer) { + activeObserversRef.current.delete(observer) + observer.dispose() + } + } + }, + [agent, disabled, onSlashCommand, resolveTarget, setHistory] + ) + + return { dispatch, isDispatching } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts new file mode 100644 index 00000000000..23c5f87a294 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { useAppStore } from '../../store' +import { + createNativeChatPtySessionOptions, + type NativeChatPtySessionOptionsSurface +} from './native-chat-pty-session-options' +import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch' +import { + ensureNativeChatModelEnrichment, + readNativeChatEnrichedModels, + subscribeNativeChatEnrichedModels +} from './native-chat-session-option-enrichment' +import { + discoverNativeChatCatalogModels, + resolveNativeChatModelDiscoveryContext +} from './native-chat-session-option-discovery' +import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' + +const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = [] +const subscribeEmpty = (): (() => void) => () => {} +const getEmptySnapshot = (): SessionOptionDescriptor[] => EMPTY_SNAPSHOT + +export function useNativeChatSessionOptions(args: { + agent: AgentType + terminalTabId: string + targetPtyId: string | null + dispatchCommand: NativeChatSessionOptionDispatchCommand + onAgentPicker?: () => void + readTerminalScreen?: () => string | null +}): { + surface: NativeChatPtySessionOptionsSurface | null + snapshot: SessionOptionDescriptor[] +} { + const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } = + args + const discoveryContext = useMemo( + () => resolveNativeChatModelDiscoveryContext(terminalTabId), + [terminalTabId] + ) + const surface = useMemo(() => { + // Why: native chat currently attaches only after startup is already queued; + // exposing a draft picker here would claim it can still mutate that command. + if (!targetPtyId) { + return null + } + const scopeKey = targetPtyId ?? terminalTabId + const reportedValues = + agent === 'claude' ? readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) : null + let settingsWrite = Promise.resolve() + return createNativeChatPtySessionOptions({ + agent, + scopeKey, + ...(targetPtyId ? { fallbackScopeKey: terminalTabId } : {}), + ...(discoveryContext + ? { + initialModels: + readNativeChatEnrichedModels(agent, discoveryContext.hostKey) ?? undefined + } + : {}), + mode: targetPtyId ? 'live' : 'draft', + reportedValues, + dispatchCommand, + onAgentPicker, + persistSelection: async ({ modelId, optionId, value }) => { + // Why: read the live persisted defaults at write time (after any prior + // write in this chain settles) and merge only this selection onto them, + // rather than a baseline captured once at surface creation. A frozen + // baseline would let a second same-agent pane's write be clobbered, + // since updateSettings shallow-merges nativeChatSessionOptions. Chaining + // still keeps rapid consecutive picks in selection order. + settingsWrite = settingsWrite + .catch(() => undefined) + .then(() => { + const base = useAppStore.getState().settings?.nativeChatSessionOptions + const next = updateNativeChatSessionOptionDefaults({ + persisted: base, + agent, + modelId, + optionId, + value + }) + return useAppStore.getState().updateSettings({ nativeChatSessionOptions: next }) + }) + await settingsWrite + } + }) + }, [ + agent, + dispatchCommand, + discoveryContext, + onAgentPicker, + readTerminalScreen, + targetPtyId, + terminalTabId + ]) + + useEffect(() => { + if (!surface || agent !== 'claude') { + return + } + let cancelled = false + const reportCurrentValues = async (): Promise => { + let authoritativeScreen: string | null = null + if (targetPtyId && window.api?.pty?.getMainBufferSnapshot) { + try { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 0 + }) + // Why: the API snapshots the main buffer, which is stale while a TUI + // owns the alternate screen. The mounted xterm is authoritative then. + authoritativeScreen = snapshot?.alternateScreen ? null : (snapshot?.data ?? null) + } catch { + // The mounted renderer buffer remains a transport-neutral fallback. + } + } + const reportedValues = + readClaudeSessionOptionsFromTerminalScreen(authoritativeScreen) ?? + readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) + if (!cancelled && reportedValues) { + surface.reportSessionOptions(reportedValues) + } + } + void reportCurrentValues() + return () => { + cancelled = true + } + }, [agent, readTerminalScreen, surface, targetPtyId]) + + useEffect(() => { + if (!surface || !discoveryContext) { + return + } + const unsubscribe = subscribeNativeChatEnrichedModels( + agent, + discoveryContext.hostKey, + (models) => surface.replaceModels(models) + ) + ensureNativeChatModelEnrichment({ + agent, + hostKey: discoveryContext.hostKey, + discover: () => discoverNativeChatCatalogModels(agent, discoveryContext.runtime) + }) + return unsubscribe + }, [agent, discoveryContext, surface]) + + const snapshot = useSyncExternalStore( + surface?.subscribe ?? subscribeEmpty, + surface?.getSnapshot ?? getEmptySnapshot, + surface?.getSnapshot ?? getEmptySnapshot + ) + return { surface, snapshot } +} diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index 73571a2f330..1e57102600f 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -14,7 +14,8 @@ describe('buildOnboardingFolderAgentStartup', () => { }) expect(startup).toEqual({ - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -22,6 +23,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', @@ -97,7 +99,8 @@ describe('buildOnboardingFolderAgentStartup', () => { false ) ).toEqual({ - command: "echo onboarding-folder-agent '--dangerously-bypass-approvals-and-sandbox'", + command: + "echo onboarding-folder-agent '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -105,6 +108,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index 336a3882b3d..d3afd06e139 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import type { TuiAgent } from '../../../../shared/types' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' +import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null @@ -34,6 +35,12 @@ export function buildSourceControlAgentDeliveryPlan({ agent: selectedAgent, commandInput, agentArgs, + sessionOptions: selectedAgent + ? resolveNativeChatSessionOptionDefaults( + useAppStore.getState().settings?.nativeChatSessionOptions, + selectedAgent + ) + : undefined, promptDelivery, detectedAgents, disabledAgents: useAppStore.getState().settings?.disabledTuiAgents, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index ee48eac66f2..faf2b6ede75 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -20,6 +20,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths' import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { LaunchSource } from '../../../../shared/telemetry-events' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { getLinkedItemDisplayName, @@ -46,6 +47,7 @@ type SubmitFolderWorkspaceCreateParams = { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + sessionOptions?: Record terminalWindowsShell?: string | null isRemote?: boolean launchSource?: LaunchSource @@ -71,6 +73,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + sessionOptions?: Record platform: NodeJS.Platform shell?: AgentStartupShell isRemote: boolean @@ -87,6 +90,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, + sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote @@ -99,6 +103,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -114,6 +119,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, + sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote, @@ -159,6 +165,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + sessionOptions, terminalWindowsShell, launchSource = 'sidebar', runtimeEnvironmentId = null, @@ -189,6 +196,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote @@ -200,6 +208,7 @@ export async function submitFolderWorkspaceCreate({ cmdOverrides: agentCmdOverrides ?? {}, agentArgs, agentEnv, + sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote, @@ -247,6 +256,7 @@ export async function submitFolderWorkspaceCreate({ launchConfig: startupPlan.launchConfig, ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: quickAgent, + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 975aad10f94..db963a29f67 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -813,6 +813,13 @@ export default function TerminalPane({ } toggleNativeChatForLeaf(activeLeafId) }, [toggleNativeChatForLeaf]) + const readNativeChatTerminalScreen = useCallback((): string | null => { + if (!chatLeafId) { + return null + } + const pane = managerRef.current?.getPanes().find((candidate) => candidate.leafId === chatLeafId) + return pane?.serializeAddon.serialize({ scrollback: 0 }) ?? null + }, [chatLeafId]) const setTabLayout = useAppStore((store) => store.setTabLayout) const expectedLayoutLeafIdsAttr = expectedLayoutLeafIds.length > 0 ? expectedLayoutLeafIds.join(' ') : undefined @@ -3112,6 +3119,7 @@ export default function TerminalPane({ launchAgent={chatPaneLaunchAgent} resolvedAgent={chatPaneResolvedAgent} onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)} + readTerminalScreen={readNativeChatTerminalScreen} contextMenuActions={{ onSplitRight: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index aaad1b6a622..42f2ef4dd5b 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -23,6 +23,8 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agen import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -3356,6 +3358,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs) : undefined, agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined, + sessionOptions: agent + ? resolveNativeChatSessionOptionDefaults(settings?.nativeChatSessionOptions, agent) + : undefined, terminalWindowsShell: settings?.terminalWindowsShell, isRemote: folderTargetIsRemote, launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', @@ -3412,6 +3417,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.nativeChatSessionOptions, settings?.terminalWindowsShell, telemetrySource ] @@ -3611,6 +3617,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + settings?.nativeChatSessionOptions, + tuiAgent + ), platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote @@ -3731,6 +3741,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } : {}) }) + if (startupPlan) { + const optionScopeKey = + (activation !== false ? activation.primaryTabId : null) ?? result.startupTerminal?.tabId + if (optionScopeKey) { + seedNativeChatAppliedSessionOptions(optionScopeKey, tuiAgent, startupPlan.sessionOptions) + } + } if (startupPlan && !backendSpawnedStartup) { void ensureAgentStartupInTerminal({ worktreeId: worktree.id, @@ -3792,6 +3809,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.nativeChatSessionOptions, smartNameMode, setSidebarOpen, setupDecision, @@ -4030,6 +4048,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + settings?.nativeChatSessionOptions, + agent + ), platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote @@ -4043,6 +4065,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions + ? { sessionOptions: draftLaunchPlan.sessionOptions } + : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -4055,6 +4080,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + settings?.nativeChatSessionOptions, + agent + ), platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, @@ -4244,6 +4273,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.nativeChatSessionOptions, smartNameMode, disabledTuiAgents, setupDecision, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f275b3923b6..45f8b3fb8bc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "Pasted image", "imagePasteFailed": "Image paste failed.", "worktreeNotReady": "Worktree not ready — try again in a moment.", - "uploadingAttachments": "Uploading {{value0}} file(s) to remote…" + "uploadingAttachments": "Uploading {{value0}} file(s) to remote…", + "model": "Model", + "modelWithValue": "Model: {{value0}}", + "effort": "Effort", + "effortWithValue": "Effort: {{value0}}", + "fastMode": "Fast mode", + "thinking": "Thinking", + "options": "Options", + "sessionOptions": "Session options", + "chooseInAgentPicker": "Choose in agent picker…", + "toggleOption": "Toggle {{value0}}", + "sentNotConfirmed": "Sent to the agent — not confirmed", + "setWhenSessionStarts": "Set when the session starts.", + "availableAfterSessionStarts": "Available after the session starts.", + "optionUpdateFailed": "Could not update option", + "optionValue": { + "fast": "Fast", + "minimal": "Minimal", + "low": "Low", + "medium": "Medium", + "high": "High", + "xhigh": "Extra high", + "max": "Max" + } }, "tool": { "running": "Running…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9261f9971d0..48b2a4b5284 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "Imagen pegada", "imagePasteFailed": "Error al pegar imagen.", "worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.", - "uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…" + "uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…", + "model": "Modelo", + "modelWithValue": "Modelo: {{value0}}", + "effort": "Esfuerzo", + "effortWithValue": "Esfuerzo: {{value0}}", + "fastMode": "Modo rápido", + "thinking": "Razonamiento", + "options": "Opciones", + "sessionOptions": "Opciones de sesión", + "chooseInAgentPicker": "Elegir en el selector del agente…", + "toggleOption": "Alternar {{value0}}", + "sentNotConfirmed": "Enviado al agente — sin confirmar", + "setWhenSessionStarts": "Configúralo al iniciar la sesión.", + "availableAfterSessionStarts": "Disponible después de iniciar la sesión.", + "optionUpdateFailed": "No se pudo actualizar la opción", + "optionValue": { + "fast": "Rápido", + "minimal": "Mínimo", + "low": "Bajo", + "medium": "Medio", + "high": "Alto", + "xhigh": "Muy alto", + "max": "Máximo" + } }, "tool": { "running": "Ejecutando…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e1cf3313cdc..538b1ef1b61 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "貼り付けた画像", "imagePasteFailed": "画像の貼り付けに失敗しました。", "worktreeNotReady": "Worktreeが準備できていません — しばらくしてから再試行してください。", - "uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…" + "uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…", + "model": "モデル", + "modelWithValue": "モデル: {{value0}}", + "effort": "推論レベル", + "effortWithValue": "推論レベル: {{value0}}", + "fastMode": "高速モード", + "thinking": "思考", + "options": "オプション", + "sessionOptions": "セッションオプション", + "chooseInAgentPicker": "エージェントの選択画面で選ぶ…", + "toggleOption": "{{value0}}を切り替える", + "sentNotConfirmed": "エージェントに送信済み — 未確認", + "setWhenSessionStarts": "セッション開始時に設定してください。", + "availableAfterSessionStarts": "セッション開始後に利用できます。", + "optionUpdateFailed": "オプションを更新できませんでした", + "optionValue": { + "fast": "高速", + "minimal": "最小", + "low": "低", + "medium": "中", + "high": "高", + "xhigh": "最高", + "max": "最大" + } }, "tool": { "running": "実行中…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d22ad9a5db8..ccb2d833551 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "붙여넣은 이미지", "imagePasteFailed": "이미지 붙여넣기 실패.", "worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.", - "uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…" + "uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…", + "model": "모델", + "modelWithValue": "모델: {{value0}}", + "effort": "추론 수준", + "effortWithValue": "추론 수준: {{value0}}", + "fastMode": "빠른 모드", + "thinking": "사고", + "options": "옵션", + "sessionOptions": "세션 옵션", + "chooseInAgentPicker": "에이전트 선택기에서 선택…", + "toggleOption": "{{value0}} 전환", + "sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음", + "setWhenSessionStarts": "세션을 시작할 때 설정하세요.", + "availableAfterSessionStarts": "세션이 시작된 후 사용할 수 있습니다.", + "optionUpdateFailed": "옵션을 업데이트하지 못했습니다", + "optionValue": { + "fast": "빠름", + "minimal": "최소", + "low": "낮음", + "medium": "중간", + "high": "높음", + "xhigh": "매우 높음", + "max": "최대" + } }, "tool": { "running": "실행 중…", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b9fe5b678d6..86769650574 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "粘贴的图片", "imagePasteFailed": "图片粘贴失败。", "worktreeNotReady": "工作树未就绪 — 请稍后重试。", - "uploadingAttachments": "正在上传 {{value0}} 个文件到远程…" + "uploadingAttachments": "正在上传 {{value0}} 个文件到远程…", + "model": "模型", + "modelWithValue": "模型:{{value0}}", + "effort": "推理强度", + "effortWithValue": "推理强度:{{value0}}", + "fastMode": "快速模式", + "thinking": "思考", + "options": "选项", + "sessionOptions": "会话选项", + "chooseInAgentPicker": "在代理选择器中选择…", + "toggleOption": "切换{{value0}}", + "sentNotConfirmed": "已发送给代理 — 尚未确认", + "setWhenSessionStarts": "请在会话开始时设置。", + "availableAfterSessionStarts": "会话开始后可用。", + "optionUpdateFailed": "无法更新选项", + "optionValue": { + "fast": "快速", + "minimal": "最小", + "low": "低", + "medium": "中", + "high": "高", + "xhigh": "极高", + "max": "最大" + } }, "tool": { "running": "正在运行…", diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index 950080dc323..9e27c12b207 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -8,6 +8,7 @@ import zh from './locales/zh.json' const localizedCatalogs = { es, ja, ko, zh } const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat const englishSearch = en.auto.components.settings.experimental.search.nativeChat +const englishComposer = en.components['native-chat'].composer describe('native chat locale copy', () => { it.each(Object.entries(localizedCatalogs))( @@ -25,6 +26,25 @@ describe('native chat locale copy', () => { expect(localized).not.toBe(english) } expect(search.grok).toBe('grok') + const composer = catalog.components['native-chat'].composer + for (const key of [ + 'model', + 'modelWithValue', + 'effort', + 'effortWithValue', + 'fastMode', + 'thinking', + 'options', + 'sessionOptions', + 'chooseInAgentPicker', + 'sentNotConfirmed' + ] as const) { + expect(composer[key].trim()).not.toBe('') + expect(composer[key]).not.toBe(englishComposer[key]) + } + for (const key of ['fast', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const) { + expect(composer.optionValue[key]).not.toBe(englishComposer.optionValue[key]) + } } ) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index c43f98ef969..b3c5029a317 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -340,7 +340,8 @@ describe('launchAgentInNewTab', () => { environmentId: 'web-runtime', targetGroupId: 'group-1', activate: true, - command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: 'shell-ready', launchConfig: { @@ -485,7 +486,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'" + command: + "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob''s change'" }) ) }) @@ -503,7 +505,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: 'claude "--dangerously-skip-permissions"' + command: 'claude "--model" "sonnet" "--effort" "high" "--dangerously-skip-permissions"' }) ) }) @@ -521,7 +523,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" + command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" }) ) }) @@ -541,7 +543,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: 'codex "--model" "gpt-5" "fix the spinner"' + command: + 'codex "-m" "gpt-5.6-sol" "-c" "model_reasoning_effort=medium" "--model" "gpt-5" "fix the spinner"' }) ) }) @@ -561,7 +564,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + command: + "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -579,7 +583,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" + command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" }) ) }) @@ -616,7 +620,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + command: + "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -637,7 +642,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" + command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" }) ) expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( @@ -843,7 +848,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "codex '--model' 'gpt-5.5'" + command: "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5.5'" }) ) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 292cc9e4626..44e1f204811 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -28,6 +28,8 @@ import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' import { translate } from '@/i18n/i18n' import { getConnectionIdFromState } from '@/lib/connection-context' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -129,7 +131,11 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI shell: queuedShell, isRemote, agentArgs: effectiveAgentArgs, - agentEnv + agentEnv, + sessionOptions: resolveNativeChatSessionOptionDefaults( + store.settings?.nativeChatSessionOptions, + agent + ) } const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 @@ -169,6 +175,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions + ? { sessionOptions: draftLaunchPlan.sessionOptions } + : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -244,6 +253,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI quickCommandLabel, ...initialViewModeProps }) + seedNativeChatAppliedSessionOptions(tab.id, agent, startupPlan.sessionOptions) store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 6e259f9a2e1..08fb556a66f 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -16,6 +16,8 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -27,6 +29,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agentCmdOverrides?: Partial> agentDefaultArgs?: Partial> agentDefaultEnv?: Partial>> + nativeChatSessionOptions?: PersistedNativeChatSessionOptions } | null | undefined @@ -48,6 +51,10 @@ export function buildDirectWorkItemAgentStartupPlan(args: { ? resolveTuiAgentLaunchArgs(args.agent, args.settings?.agentDefaultArgs) : args.agentArgs const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings?.agentDefaultEnv) + const sessionOptions = resolveNativeChatSessionOptionDefaults( + args.settings?.nativeChatSessionOptions, + args.agent + ) const draftLaunchPlan = args.promptDelivery === 'submit-after-ready' ? null @@ -58,7 +65,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { platform: args.launchPlatform, isRemote: args.isRemote, agentArgs: effectiveAgentArgs, - agentEnv: effectiveAgentEnv + agentEnv: effectiveAgentEnv, + sessionOptions }) if (draftLaunchPlan) { @@ -69,6 +77,9 @@ export function buildDirectWorkItemAgentStartupPlan(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions + ? { sessionOptions: draftLaunchPlan.sessionOptions } + : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -87,6 +98,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { isRemote: args.isRemote, agentArgs: effectiveAgentArgs, agentEnv: effectiveAgentEnv, + sessionOptions, allowEmptyPromptLaunch: true }) if (startupPlan && args.promptDelivery === 'draft') { @@ -110,6 +122,7 @@ export function buildDirectWorkItemStartupOpts( launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent draftPrompt?: string + sessionOptions?: AgentStartupPlan['sessionOptions'] startupCommandDelivery?: StartupCommandDelivery telemetry?: AgentStartedTelemetry } @@ -126,6 +139,7 @@ export function buildDirectWorkItemStartupOpts( command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}), launchConfig: plan.launchConfig, + ...(plan.sessionOptions ? { sessionOptions: plan.sessionOptions } : {}), ...(agent ? { launchAgent: agent } : {}), ...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}), ...(plan.startupCommandDelivery diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 04da6b1d341..9eedb05be75 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -402,6 +402,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--dangerously-skip-permissions', agentEnv: {}, + sessionOptions: { model: 'sonnet', effort: 'high' }, platform: 'win32', isRemote: false }) @@ -525,6 +526,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, + sessionOptions: { model: 'auto' }, platform: 'linux', isRemote: true }) @@ -534,6 +536,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, + sessionOptions: { model: 'auto' }, platform: 'linux', isRemote: true, allowEmptyPromptLaunch: true diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 81bb5431609..57a1feb39b8 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -9,6 +9,8 @@ import type { AgentStartedTelemetry } from '@/lib/worktree-activation' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../shared/types' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' export type OnboardingFolderAgentStartup = { command: string @@ -16,6 +18,7 @@ export type OnboardingFolderAgentStartup = { launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + sessionOptions?: Record telemetry: AgentStartedTelemetry } @@ -45,6 +48,10 @@ export function buildOnboardingFolderAgentStartup( cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + settings.nativeChatSessionOptions, + agent + ), platform: getClientPlatform(), allowEmptyPromptLaunch: true }) @@ -57,6 +64,7 @@ export function buildOnboardingFolderAgentStartup( ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index c865f576ed9..05bc1de3c28 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -10,6 +10,7 @@ import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../shared/types' import { translate } from '@/i18n/i18n' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' export type SourceControlLaunchPlanDelivery = | 'argv' @@ -36,6 +37,7 @@ export function planSourceControlAgentActionLaunch(args: { disabledAgents?: TuiAgent[] cmdOverrides?: Partial> agentArgs?: string | null + sessionOptions?: Record platform?: NodeJS.Platform terminalWindowsShell?: string | null /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only @@ -107,6 +109,7 @@ export function planSourceControlAgentActionLaunch(args: { shell, isRemote, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, allowEmptyPromptLaunch: true }) delivery = 'paste-submit' @@ -118,7 +121,8 @@ export function planSourceControlAgentActionLaunch(args: { platform, shell, isRemote, - agentArgs: args.agentArgs + agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions }) if (draftLaunchPlan) { startupPlan = { @@ -127,6 +131,9 @@ export function planSourceControlAgentActionLaunch(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions + ? { sessionOptions: draftLaunchPlan.sessionOptions } + : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -142,6 +149,7 @@ export function planSourceControlAgentActionLaunch(args: { shell, isRemote, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, allowEmptyPromptLaunch: true }) delivery = 'draft-paste' @@ -155,6 +163,7 @@ export function planSourceControlAgentActionLaunch(args: { shell, isRemote, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, allowEmptyPromptLaunch: true }) delivery = 'draft-paste' @@ -167,6 +176,7 @@ export function planSourceControlAgentActionLaunch(args: { shell, isRemote, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, allowEmptyPromptLaunch: false }) delivery = 'argv' diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 174c1fab32d..89fcb83c92d 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -96,7 +96,8 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -105,6 +106,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { agentEnv: {} }, launchToken: expect.any(String), + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 52004e147dd..5c5ad3bde39 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -65,6 +65,9 @@ import { toast } from 'sonner' import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode' import { getConnectionId } from '@/lib/connection-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' /** Telemetry payload threaded from the launch site to `pty:spawn`. Main * fires `agent_started` only after the spawn succeeds — see @@ -82,6 +85,7 @@ export type WorktreeStartupPayload = { draftPrompt?: string startupCommandDelivery?: StartupCommandDelivery initialAgentStatus?: { agent: TuiAgent; prompt: string } + sessionOptions?: Record telemetry?: AgentStartedTelemetry } @@ -259,6 +263,10 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayl cmdOverrides: state.settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + state.settings?.nativeChatSessionOptions, + agent + ), platform: launchPlatform, isRemote: repo ? repoIsRemote(repo) : false, allowEmptyPromptLaunch: true @@ -272,6 +280,7 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayl ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), @@ -602,6 +611,13 @@ export function ensureWorktreeHasInitialTerminal( // pane so the main terminal begins in the requested agent session instead of // opening to an idle shell and forcing the user to repeat the same prompt. if (sequencedStartup) { + if (launchAgent) { + seedNativeChatAppliedSessionOptions( + terminalTab.id, + launchAgent, + sequencedStartup.sessionOptions + ) + } store.queueTabStartupCommand(terminalTab.id, sequencedStartup) } queueSetupAndIssueCommands( @@ -683,6 +699,14 @@ function applyDefaultTerminalTabs( store.setActiveTab(firstTabId) } if (startup) { + const startupAgent = + startup.launchAgent ?? + (startup.telemetry + ? (agentKindToTuiAgent(startup.telemetry.agent_kind) ?? undefined) + : undefined) + if (startupAgent) { + seedNativeChatAppliedSessionOptions(firstTabId, startupAgent, startup.sessionOptions) + } store.queueTabStartupCommand(firstTabId, startup) } queueSetupAndIssueCommands( diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 6bdac108c23..a975405de26 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -25,6 +25,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' type ContinueBackgroundWorktreeCreationOptions = { revealCreationSurface?: boolean @@ -254,6 +255,16 @@ async function executeWorktreeCreation( // Why: clearing synchronously right after activation lets React commit the // panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash. useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false }) + if (preparedRequest.startupPlan && preparedRequest.agent) { + const optionScopeKey = primaryTabId ?? result.startupTerminal?.tabId + if (optionScopeKey) { + seedNativeChatAppliedSessionOptions( + optionScopeKey, + preparedRequest.agent, + preparedRequest.startupPlan.sessionOptions + ) + } + } if (preparedRequest.startupPlan && !backendSpawned) { void ensureAgentStartupInTerminal({ worktreeId: worktree.id, diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 776c080e9bc..385c7d9feae 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -132,6 +132,60 @@ describe('remote runtime terminal data subscriptions', () => { expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0) }) + it('can start at the live tail without replaying the initial snapshot', async () => { + const watcher = vi.fn() + const subscription = subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + watcher, + { startAtLiveTail: true } + ) + + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) + const subscribePayload = + subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload) + const streamId = subscribePayload!.streamId + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 0, + payload: encodeTerminalStreamJson({ seq: 0 }) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 0, + payload: encodeTerminalStreamText('historical') + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 0, + payload: new Uint8Array() + }) + ) + const dispose = await subscription + + expect(watcher).not.toHaveBeenCalled() + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 4, + payload: encodeTerminalStreamText('live') + }) + ) + expect(watcher).toHaveBeenCalledWith('live') + dispose() + }) + it('keeps the shared terminal multiplexer until the last watcher closes', async () => { const firstDispose = await subscribeToRuntimeTerminalData( { activeRuntimeEnvironmentId: 'env-fallback' }, diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts index 7dbdf3eb002..314aa01b9d3 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -4,6 +4,7 @@ import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-m const REMOTE_PTY_ID_PREFIX = 'remote:' const REMOTE_PTY_OWNER_SEPARATOR = '@@' +const LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS = 10_000 export type RemoteRuntimePtyIdParts = { environmentId: string | null @@ -56,7 +57,8 @@ export async function subscribeToRuntimeTerminalData( settings: Pick | null | undefined, ptyId: string, clientId: string, - watcher: (data: string) => void + watcher: (data: string) => void, + options?: { startAtLiveTail?: boolean } ): Promise<() => void> { const terminal = getRemoteRuntimeTerminalHandle(ptyId) const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) @@ -67,14 +69,61 @@ export async function subscribeToRuntimeTerminalData( return () => {} } + let resolveLiveTail: (() => void) | null = null + let rejectLiveTail: ((error: Error) => void) | null = null + const liveTailReady = options?.startAtLiveTail + ? new Promise((resolve, reject) => { + resolveLiveTail = resolve + rejectLiveTail = reject + }) + : null + const rejectPendingLiveTail = (message: string): void => { + rejectLiveTail?.(new Error(message)) + resolveLiveTail = null + rejectLiveTail = null + } + const stream = await getRemoteRuntimeTerminalMultiplexer(target.environmentId).subscribeTerminal({ terminal, client: { id: clientId, type: 'desktop' }, callbacks: { onData: (data) => watcher(data), - onSnapshot: watcher + onSnapshot: (data) => { + if (!options?.startAtLiveTail) { + watcher(data) + } + }, + onSubscribed: () => { + resolveLiveTail?.() + resolveLiveTail = null + rejectLiveTail = null + }, + onEnd: () => rejectPendingLiveTail('Remote terminal ended before live output was ready.'), + onError: (message) => rejectPendingLiveTail(message), + onTransportClose: () => + rejectPendingLiveTail('Remote terminal closed before live output was ready.') } }) + if (liveTailReady) { + let timeout: ReturnType | null = setTimeout( + () => rejectPendingLiveTail('Timed out waiting for remote terminal live output.'), + LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS + ) + try { + // Why: outcome observers must ignore historical snapshots and be armed + // before the command whose output they classify, including over SSH. + await liveTailReady + } catch (error) { + stream.close() + throw error + } finally { + if (timeout !== null) { + clearTimeout(timeout) + timeout = null + } + } + } + return () => stream.close() } diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index a18a2733dcc..b5c44f9ca40 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -58,7 +58,8 @@ describe('repo slice skipped-onboarding folder startup', () => { { sidebarRevealBehavior: 'auto', startup: { - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -66,6 +67,7 @@ describe('repo slice skipped-onboarding folder startup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts new file mode 100644 index 00000000000..47fa8226af7 --- /dev/null +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -0,0 +1,153 @@ +import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' + +function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean { + return tokens.some((token) => + flags.some( + (flag) => + token === flag || + token.startsWith(`${flag}=`) || + (flag.startsWith('-') && !flag.startsWith('--') && token.startsWith(flag)) + ) + ) +} + +function hasCodexEffortOverride(tokens: readonly string[]): boolean { + if (hasFlag(tokens, ['--reasoning-effort'])) { + return true + } + return tokens.some((token, index) => { + const previous = tokens[index - 1] + return ( + (token.startsWith('model_reasoning_effort=') && + (previous === '-c' || previous === '--config')) || + token.startsWith('-cmodel_reasoning_effort=') || + token.startsWith('-c=model_reasoning_effort=') || + token.startsWith('--config=model_reasoning_effort=') + ) + }) +} + +const STANDARD_EFFORT_CHOICES = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } +] + +const EXTENDED_EFFORT_CHOICES = [ + ...STANDARD_EFFORT_CHOICES, + { value: 'xhigh', label: 'Extra high' }, + { value: 'max', label: 'Max' } +] + +function claudeEffort(extended: boolean): CatalogOption { + return { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + choices: extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES, + defaultValue: 'high' + }, + apply: { + launchArgs: (value) => ['--effort', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), + midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } + } + } +} + +const CLAUDE_FAST_MODE: CatalogOption = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', defaultValue: false }, + apply: { midSession: { kind: 'toggle-command', command: '/fast' } } +} + +export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + models: [ + { + id: 'fable', + label: 'Fable 5', + options: [claudeEffort(true)] + }, + { + id: 'opus', + label: 'Opus 4.8', + options: [claudeEffort(true), CLAUDE_FAST_MODE] + }, + { + id: 'sonnet', + label: 'Sonnet 5', + isDefault: true, + options: [claudeEffort(true)] + }, + { + id: 'haiku', + label: 'Haiku', + options: [] + } + ], + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + midSession: { + kind: 'command', + build: (value) => `/model ${String(value)}`, + pickerCommand: '/model', + // Why: Claude sometimes confirms a cached-history switch. Detect the + // actual prompt so ordinary model changes stay in native chat. + detectAgentInteraction: 'claude-model-switch-confirmation' + } + } +} + +const CODEX_EFFORT_CHOICES = [ + { value: 'minimal', label: 'Minimal' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'Extra high' } +] + +function codexEffort(includeExtraHigh: boolean): CatalogOption { + return { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: includeExtraHigh + ? CODEX_EFFORT_CHOICES + : CODEX_EFFORT_CHOICES.filter((choice) => choice.value !== 'xhigh'), + defaultValue: 'medium' + }, + apply: { + launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], + agentArgsOverride: hasCodexEffortOverride, + midSession: { kind: 'agent-picker', command: '/model' } + } + } +} + +export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + // Why: Codex model access depends on auth. Keep this seed short and allow + // unknown persisted ids to pass through instead of claiming a complete list. + models: [ + { id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', options: [codexEffort(true)] }, + { id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', options: [codexEffort(true)] }, + { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', options: [codexEffort(false)] }, + { id: 'gpt-5.5', label: 'GPT-5.5', options: [codexEffort(true)] }, + { + id: 'gpt-5.2-codex', + label: 'GPT-5.2 Codex', + options: [codexEffort(true)] + } + ], + modelApply: { + launchArgs: (value) => ['-m', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), + midSession: { kind: 'agent-picker', command: '/model' } + } +} diff --git a/src/shared/agent-session-option-catalog-gemini-cursor.ts b/src/shared/agent-session-option-catalog-gemini-cursor.ts new file mode 100644 index 00000000000..b655c8e0637 --- /dev/null +++ b/src/shared/agent-session-option-catalog-gemini-cursor.ts @@ -0,0 +1,112 @@ +import type { + AgentSessionOptionCatalog, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog-types' + +function hasModelFlag(tokens: readonly string[]): boolean { + return tokens.some( + (token) => + token === '-m' || + token === '--model' || + token.startsWith('-m=') || + (token.startsWith('-m') && !token.startsWith('--')) || + token.startsWith('--model=') + ) +} + +export const GEMINI_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + models: [ + { id: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview', options: [] }, + { id: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview', options: [] }, + { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', options: [] }, + { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', options: [] } + ], + modelApply: { + launchArgs: (value) => ['-m', String(value)], + agentArgsOverride: hasModelFlag, + midSession: { kind: 'agent-picker', command: '/model' } + } +} + +const CURSOR_EFFORT: CatalogOption = { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ], + defaultValue: 'high' + }, + apply: { composedIntoModel: true } +} + +const CURSOR_FAST: CatalogOption = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', defaultValue: false }, + apply: { composedIntoModel: true } +} + +const CURSOR_THINKING: CatalogOption = { + id: 'thinking', + label: 'Thinking', + category: 'model_config', + kind: { type: 'boolean', defaultValue: true }, + apply: { composedIntoModel: true } +} + +function parseCursorModels(stdout: string): CatalogModel[] { + const seen = new Set() + const models: CatalogModel[] = [] + for (const line of stdout.split(/\r?\n/)) { + const match = line.trim().match(/^(?:[-*]\s+)?([a-z0-9][a-z0-9._-]*)(?:\s+\(.*\))?$/i) + const id = match?.[1] + if (!id || id.toLowerCase() === 'models' || seen.has(id)) { + continue + } + seen.add(id) + models.push({ id, label: id === 'auto' ? 'Auto' : id, options: [] }) + } + return models +} + +export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + models: [ + { id: 'auto', label: 'Auto', isDefault: true, options: [] }, + { + id: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + options: [CURSOR_EFFORT, CURSOR_FAST] + }, + { + id: 'claude-opus-4-8', + label: 'Claude Opus 4.8', + options: [CURSOR_THINKING, CURSOR_EFFORT] + } + ], + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: hasModelFlag, + midSession: { kind: 'command', build: (value) => `/model ${String(value)}` } + }, + composeModelValue: (modelId, values) => { + if (modelId === 'auto') { + return modelId + } + if (modelId.startsWith('claude-')) { + const thinking = values.thinking === true ? '-thinking' : '' + const effort = typeof values.effort === 'string' ? `-${values.effort}` : '' + return `${modelId}${thinking}${effort}` + } + const effort = typeof values.effort === 'string' ? `-${values.effort}` : '' + const fast = values.fastMode === true ? '-fast' : '' + return `${modelId}${effort}${fast}` + }, + listModels: { command: 'cursor-agent models', parse: parseCursorModels } +} diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts new file mode 100644 index 00000000000..a416b10913e --- /dev/null +++ b/src/shared/agent-session-option-catalog-types.ts @@ -0,0 +1,63 @@ +import type { AgentType } from './agent-status-types' +import type { + SessionOptionDescriptor, + SessionOptionSelectChoice, + SessionOptionValue +} from './native-chat-session-options' + +export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation' + +export type CatalogMidSessionApply = + | { + kind: 'command' + build: (value: SessionOptionValue) => string + pickerCommand?: string + detectAgentInteraction?: CatalogAgentInteractionDetection + } + | { kind: 'toggle-command'; command: string } + | { kind: 'agent-picker'; command: string } + | { kind: 'unsupported' } + +export type CatalogOptionApply = { + launchArgs?: (value: SessionOptionValue) => string[] + /** Why: later free-form args win, so the launch record must discard any + * picker value that those args may have replaced. */ + agentArgsOverride?: (tokens: readonly string[]) => boolean + composedIntoModel?: true + midSession?: CatalogMidSessionApply +} + +export type CatalogOption = { + id: string + label: string + description?: string + category?: SessionOptionDescriptor['category'] + kind: + | { + type: 'select' + choices: SessionOptionSelectChoice[] + defaultValue: string + } + | { type: 'boolean'; defaultValue: boolean } + apply: CatalogOptionApply +} + +export type CatalogModel = { + id: string + label: string + description?: string + isDefault?: boolean + options: CatalogOption[] +} + +export type AgentSessionOptionCatalog = { + models: CatalogModel[] + modelApply: CatalogOptionApply + composeModelValue?: (modelId: string, values: Record) => string + listModels?: { + command: string + parse: (stdout: string) => CatalogModel[] + } +} + +export type AgentSessionOptionCatalogMap = Partial> diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts new file mode 100644 index 00000000000..c4e7eca1404 --- /dev/null +++ b/src/shared/agent-session-option-catalog.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { getAgentSessionOptionCatalog, mergeCatalogModels } from './agent-session-option-catalog' +import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import { + resolveNativeChatSessionOptionDefaults, + updateNativeChatSessionOptionDefaults +} from './native-chat-session-option-defaults' + +describe('agent session option catalog', () => { + it('returns no catalog for unknown agents', () => { + expect(getAgentSessionOptionCatalog('future-agent')).toBeNull() + }) + + it('keeps Claude option sets model-scoped', () => { + const catalog = getAgentSessionOptionCatalog('claude') + expect( + catalog?.models.find((model) => model.id === 'opus')?.options.map(({ id }) => id) + ).toEqual(['effort', 'fastMode']) + expect(catalog?.models.find((model) => model.id === 'haiku')?.options).toEqual([]) + }) + + it('merges discovered labels while preserving cataloged option shapes', () => { + const seed = getAgentSessionOptionCatalog('cursor')!.models + const merged = mergeCatalogModels(seed, [ + { id: 'gpt-5.3-codex', label: 'GPT 5.3 (live)', options: [] }, + { id: 'new-account-model', label: 'new-account-model', options: [] } + ]) + expect(merged.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({ + label: 'GPT 5.3 (live)', + options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })]) + }) + expect(merged.at(-1)).toEqual({ + id: 'new-account-model', + label: 'new-account-model', + options: [] + }) + }) + + it('parses Cursor model discovery without treating headings as models', () => { + const parsed = getAgentSessionOptionCatalog('cursor')!.listModels!.parse( + 'Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n' + ) + expect(parsed.map(({ id }) => id)).toEqual(['auto', 'gpt-5.3-codex']) + }) + + it('composes Cursor effort and fast mode into the supported slug form', () => { + const resolved = resolveAgentSessionOptionLaunch('cursor', { + model: 'gpt-5.3-codex', + effort: 'high', + fastMode: true + }) + expect(resolved.args).toEqual(['--model', 'gpt-5.3-codex-high-fast']) + expect(resolved.appliedValues).toEqual({ + model: 'gpt-5.3-codex', + effort: 'high', + fastMode: true + }) + }) + + it('passes unknown model and option values through launch mappings', () => { + expect( + resolveAgentSessionOptionLaunch('claude', { + model: 'claude-future', + effort: 'future-effort' + }) + ).toEqual({ args: ['--model', 'claude-future'], appliedValues: { model: 'claude-future' } }) + expect( + resolveAgentSessionOptionLaunch('claude', { model: 'opus', effort: 'future-effort' }) + ).toMatchObject({ + args: ['--model', 'opus', '--effort', 'future-effort'], + appliedValues: { model: 'opus', effort: 'future-effort' } + }) + }) + + it('restores defaults per model without leaking values across models', () => { + let persisted = updateNativeChatSessionOptionDefaults({ + persisted: undefined, + agent: 'claude', + modelId: 'opus', + optionId: 'model', + value: 'opus' + }) + persisted = updateNativeChatSessionOptionDefaults({ + persisted, + agent: 'claude', + modelId: 'opus', + optionId: 'effort', + value: 'xhigh' + }) + persisted = updateNativeChatSessionOptionDefaults({ + persisted, + agent: 'claude', + modelId: 'sonnet', + optionId: 'model', + value: 'sonnet' + }) + + expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({ + model: 'sonnet', + effort: 'high' + }) + expect(persisted.claude?.valuesByModel?.opus).toEqual({ effort: 'xhigh' }) + }) + + it('seeds a fresh launch from the catalog defaults', () => { + expect(resolveNativeChatSessionOptionDefaults(undefined, 'claude')).toEqual({ + model: 'sonnet', + effort: 'high' + }) + expect(resolveNativeChatSessionOptionDefaults({}, 'future-agent')).toBeUndefined() + }) +}) diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts new file mode 100644 index 00000000000..b5520afaa51 --- /dev/null +++ b/src/shared/agent-session-option-catalog.ts @@ -0,0 +1,80 @@ +import type { AgentType } from './agent-status-types' +import { + CLAUDE_SESSION_OPTION_CATALOG, + CODEX_SESSION_OPTION_CATALOG +} from './agent-session-option-catalog-claude-codex' +import { + CURSOR_SESSION_OPTION_CATALOG, + GEMINI_SESSION_OPTION_CATALOG +} from './agent-session-option-catalog-gemini-cursor' +import type { + AgentSessionOptionCatalog, + AgentSessionOptionCatalogMap, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog-types' +import type { SessionOptionValue } from './native-chat-session-options' + +export type { + AgentSessionOptionCatalog, + CatalogAgentInteractionDetection, + CatalogMidSessionApply, + CatalogModel, + CatalogOption, + CatalogOptionApply +} from './agent-session-option-catalog-types' + +const CATALOGS: AgentSessionOptionCatalogMap = { + claude: CLAUDE_SESSION_OPTION_CATALOG, + codex: CODEX_SESSION_OPTION_CATALOG, + gemini: GEMINI_SESSION_OPTION_CATALOG, + cursor: CURSOR_SESSION_OPTION_CATALOG +} + +export function getAgentSessionOptionCatalog(agent: AgentType): AgentSessionOptionCatalog | null { + return CATALOGS[agent] ?? null +} + +export function findCatalogModel( + catalog: AgentSessionOptionCatalog, + modelId: string +): CatalogModel | undefined { + return catalog.models.find((model) => model.id === modelId) +} + +export function findCatalogOption( + model: CatalogModel | undefined, + optionId: string +): CatalogOption | undefined { + return model?.options.find((option) => option.id === optionId) +} + +export function catalogDefaultModel(catalog: AgentSessionOptionCatalog): CatalogModel | undefined { + return catalog.models.find((model) => model.isDefault) ?? catalog.models[0] +} + +export function catalogDefaultValues(model: CatalogModel): Record { + return Object.fromEntries(model.options.map((option) => [option.id, option.kind.defaultValue])) +} + +/** Merge live rows over the static seed while retaining only option shapes Orca + * can actually map. Newly discovered ids remain model-only until cataloged. */ +export function mergeCatalogModels( + seed: readonly CatalogModel[], + discovered: readonly CatalogModel[] +): CatalogModel[] { + const discoveredById = new Map(discovered.map((model) => [model.id, model])) + const merged = seed.map((model) => { + const live = discoveredById.get(model.id) + if (!live) { + return model + } + discoveredById.delete(model.id) + return { ...model, ...live, options: model.options } + }) + return [...merged, ...discoveredById.values()] +} + +export function sessionOptionValueIsValid(value: unknown): value is SessionOptionValue { + return typeof value === 'string' || typeof value === 'boolean' +} diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts new file mode 100644 index 00000000000..9181039f0a7 --- /dev/null +++ b/src/shared/agent-session-option-launch.ts @@ -0,0 +1,64 @@ +import type { AgentType } from './agent-status-types' +import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import type { SessionOptionValue } from './native-chat-session-options' + +export type ResolvedSessionOptionLaunch = { + args: string[] + appliedValues: Record +} + +export function resolveAgentSessionOptionLaunch( + agent: AgentType, + values: Record | null | undefined, + trailingAgentArgs: readonly string[] = [] +): ResolvedSessionOptionLaunch { + const catalog = getAgentSessionOptionCatalog(agent) + const modelId = typeof values?.model === 'string' ? values.model : null + if (!catalog || !values || !modelId) { + return { args: [], appliedValues: {} } + } + + const model = findCatalogModel(catalog, modelId) + const appliedValues: Record = {} + const args: string[] = [] + const modelValues = model + ? Object.fromEntries( + model.options.map((option) => [option.id, values[option.id] ?? option.kind.defaultValue]) + ) + : {} + const composedModelId = catalog.composeModelValue + ? catalog.composeModelValue(modelId, modelValues) + : modelId + const modelOverridden = catalog.modelApply.agentArgsOverride?.(trailingAgentArgs) === true + + if (catalog.modelApply.launchArgs) { + args.push(...catalog.modelApply.launchArgs(composedModelId)) + if (!modelOverridden) { + appliedValues.model = modelId + } + } + if (!model) { + return { args, appliedValues } + } + + for (const option of model.options) { + const value = modelValues[option.id] + if (value === undefined) { + continue + } + if (option.apply.composedIntoModel) { + if (catalog.modelApply.launchArgs && !modelOverridden) { + appliedValues[option.id] = value + } + continue + } + if (!option.apply.launchArgs) { + continue + } + args.push(...option.apply.launchArgs(value)) + if (!modelOverridden && !option.apply.agentArgsOverride?.(trailingAgentArgs)) { + appliedValues[option.id] = value + } + } + return { args, appliedValues } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0573c89ec93..d5ee81998b8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -286,6 +286,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { openLinksInAppPreferencePrompted: false, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, + nativeChatSessionOptions: {}, openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], rightSidebarOpenByDefault: true, showGitIgnoredFiles: true, diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts new file mode 100644 index 00000000000..98bd0187113 --- /dev/null +++ b/src/shared/native-chat-session-option-defaults.ts @@ -0,0 +1,72 @@ +import type { AgentType } from './agent-status-types' +import { + catalogDefaultModel, + findCatalogModel, + getAgentSessionOptionCatalog, + sessionOptionValueIsValid +} from './agent-session-option-catalog' +import type { + PersistedNativeChatSessionOptions, + SessionOptionValue +} from './native-chat-session-options' + +export function resolveNativeChatSessionOptionDefaults( + persisted: PersistedNativeChatSessionOptions | null | undefined, + agent: AgentType +): Record | undefined { + const catalog = getAgentSessionOptionCatalog(agent) + const entry = persisted?.[agent] + // Why: fresh launches must be authoritative too, so resolve the catalog's + // declared fallback before the launch command is assembled. + const modelId = + typeof entry?.model === 'string' && entry.model.trim() + ? entry.model + : catalog + ? catalogDefaultModel(catalog)?.id + : undefined + if (!modelId) { + return undefined + } + const values: Record = { model: modelId } + const storedValues = entry?.valuesByModel?.[modelId] + if (storedValues && typeof storedValues === 'object') { + for (const [id, value] of Object.entries(storedValues)) { + if (sessionOptionValueIsValid(value)) { + values[id] = value + } + } + } + + const model = catalog ? findCatalogModel(catalog, modelId) : undefined + for (const option of model?.options ?? []) { + values[option.id] ??= option.kind.defaultValue + } + return values +} + +export function updateNativeChatSessionOptionDefaults(args: { + persisted: PersistedNativeChatSessionOptions | null | undefined + agent: AgentType + modelId: string + optionId: string + value: SessionOptionValue +}): PersistedNativeChatSessionOptions { + const currentAgent = args.persisted?.[args.agent] + const currentModelValues = currentAgent?.valuesByModel?.[args.modelId] ?? {} + const valuesByModel = { + ...currentAgent?.valuesByModel, + ...(args.optionId === 'model' + ? {} + : { + [args.modelId]: { ...currentModelValues, [args.optionId]: args.value } + }) + } + return { + ...args.persisted, + [args.agent]: { + ...currentAgent, + model: args.optionId === 'model' ? String(args.value) : args.modelId, + valuesByModel + } + } +} diff --git a/src/shared/native-chat-session-options.ts b/src/shared/native-chat-session-options.ts new file mode 100644 index 00000000000..d159919e6a7 --- /dev/null +++ b/src/shared/native-chat-session-options.ts @@ -0,0 +1,57 @@ +export type SessionOptionValue = string | boolean + +export type SessionOptionSelectChoice = { + value: string + label: string + description?: string +} + +export type SessionOptionValueSource = 'applied' | 'dispatched' | 'reported' | 'unknown' + +/** Closed set of reasons an option is not settable in the current mode. A key + * (not free English) so the producer and the localized label stay in sync — + * an exhaustive switch turns any drift into a type error instead of leaking + * untranslated text. */ +export type SessionOptionDisabledReason = + | 'available-after-session-start' + | 'set-when-session-starts' + +export type SessionOptionDescriptor = { + id: string + label: string + description?: string + category?: 'model' | 'thought_level' | 'model_config' | 'mode' + kind: + | { + type: 'select' + currentValue?: string + choices: SessionOptionSelectChoice[] + } + | { type: 'boolean'; currentValue?: boolean } + valueSource: SessionOptionValueSource + settable: boolean + disabledReason?: SessionOptionDisabledReason + /** Why: picker-only and toggle-only PTY commands cannot be represented as + * a truthful radio/checkbox state, so the producer exposes an action row. */ + action?: { type: 'agent-picker' | 'toggle-command' } +} + +export type SessionOptionSetResult = { + snapshot: SessionOptionDescriptor[] +} + +export type PersistedNativeChatSessionOptions = Partial< + Record< + string, + { + model?: string + valuesByModel?: Record> + } + > +> + +export type SessionOptionsSurface = { + getSnapshot(): SessionOptionDescriptor[] + setOption(id: string, value: SessionOptionValue): Promise + subscribe(listener: (snapshot: SessionOptionDescriptor[]) => void): () => void +} diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts new file mode 100644 index 00000000000..d433fc61721 --- /dev/null +++ b/src/shared/tui-agent-launch-command.ts @@ -0,0 +1,62 @@ +import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import type { SessionOptionValue } from './native-chat-session-options' +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' +import { + planAgentCliArgsSuffix, + quoteStartupArg, + tokenizeStartupCommand, + type AgentStartupShell +} from './tui-agent-startup-shell' +import type { TuiAgent } from './types' + +export type ResolvedAgentLaunchCommand = + | { + ok: true + command: string + commandWithoutSessionOptions: string + appliedSessionOptions: Record + } + | { ok: false; error: string } + +export function resolveAgentLaunchCommand(args: { + agent: TuiAgent + cmdOverrides: Partial> + platform: NodeJS.Platform + shell: AgentStartupShell + agentArgs?: string | null + sessionOptions?: Record + isRemote?: boolean +}): ResolvedAgentLaunchCommand { + const override = args.cmdOverrides[args.agent] + const command = + override || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { + isRemote: args.isRemote + }) + const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) + if (!suffix.ok) { + return suffix + } + const trailingTokens = args.agentArgs?.trim() + ? tokenizeStartupCommand(args.agentArgs.trim(), args.shell) + : { ok: true as const, tokens: [] } + if (!trailingTokens.ok) { + return { ok: false, error: `CLI arguments are invalid: ${trailingTokens.error}` } + } + const resolvedOptions = resolveAgentSessionOptionLaunch( + args.agent, + args.sessionOptions, + trailingTokens.tokens + ) + const optionSuffix = resolvedOptions.args.map((arg) => quoteStartupArg(arg, args.shell)).join(' ') + const commandWithOptions = optionSuffix ? `${command} ${optionSuffix}` : command + const commandWithoutSessionOptions = suffix.suffix ? `${command} ${suffix.suffix}` : command + // Why: session flags precede the free-form suffix so the user's explicit + // repeated flag remains the final, winning occurrence. + return { + ok: true, + command: suffix.suffix ? `${commandWithOptions} ${suffix.suffix}` : commandWithOptions, + commandWithoutSessionOptions, + appliedSessionOptions: resolvedOptions.appliedValues + } +} diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts new file mode 100644 index 00000000000..af97826f530 --- /dev/null +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { + buildAgentDraftLaunchPlan, + buildAgentResumeStartupPlan, + buildAgentStartupPlan +} from './tui-agent-startup' + +describe('tui agent startup session options', () => { + it('emits catalog options before user arguments without recording an overridden model', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'opus', effort: 'xhigh', fastMode: true }, + agentArgs: '--model haiku' + }) + expect(plan?.launchCommand).toBe("claude '--model' 'opus' '--effort' 'xhigh' '--model' 'haiku'") + expect(plan?.sessionOptions).toBeUndefined() + }) + + it('keeps the model record but drops an effort overridden by user arguments', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'opus', effort: 'xhigh' }, + agentArgs: '--effort low' + }) + expect(plan?.sessionOptions).toEqual({ model: 'opus' }) + }) + + it('recognizes a long Codex model flag overriding the generated short flag', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + agentArgs: '--model gpt-5.5' + }) + expect(plan?.sessionOptions).toBeUndefined() + }) + + it('keeps one-time picker flags out of the command captured for resume', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + agentArgs: '--dangerously-bypass-approvals-and-sandbox' + }) + expect(plan?.launchConfig.agentCommand).toBe( + "codex '--dangerously-bypass-approvals-and-sandbox'" + ) + }) + + it('quotes option values for a remote POSIX launch', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + isRemote: true, + allowEmptyPromptLaunch: true, + sessionOptions: { model: "team's-model", effort: 'high' } + }) + expect(plan?.launchCommand).toContain("'team'\\''s-model'") + }) + + it('threads options through native draft launches', () => { + const plan = buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'review this', + cmdOverrides: {}, + platform: 'linux', + sessionOptions: { model: 'opus', effort: 'high' } + }) + expect(plan?.launchCommand).toContain("claude '--model' 'opus' '--effort' 'high'") + expect(plan?.sessionOptions).toEqual({ model: 'opus', effort: 'high' }) + }) + + it('never injects session options into resume commands', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'codex', + providerSession: { key: 'session_id', id: 'thread-1' }, + cmdOverrides: {}, + platform: 'linux', + sessionOptions: { model: 'gpt-5.5', effort: 'high' } + }) + expect(plan?.launchCommand).toBe("codex 'resume' 'thread-1'") + expect(plan?.sessionOptions).toBeUndefined() + }) +}) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 6e3a334dbc1..ff9b44d3f02 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -8,17 +8,18 @@ import { import { clearEnvCommand, commandSeparator, - planAgentCliArgsSuffix, quoteStartupArg, resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' -import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' +import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' import { planHermesStartupQuery } from './hermes-startup-query' import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './types' +import type { SessionOptionValue } from './native-chat-session-options' +import { resolveAgentLaunchCommand } from './tui-agent-launch-command' export type AgentStartupPlan = { agent: TuiAgent @@ -30,29 +31,15 @@ export type AgentStartupPlan = { draftPrompt?: string | null env?: Record startupCommandDelivery?: StartupCommandDelivery + /** Values actually emitted into this launch command, kept as base model ids + * so the native-chat surface can render only launch-backed state. */ + sessionOptions?: Record } -function resolveBaseCommand(args: { - agent: TuiAgent - cmdOverrides: Partial> - platform: NodeJS.Platform - shell: AgentStartupShell - agentArgs?: string | null - isRemote?: boolean -}): { ok: true; command: string } | { ok: false; error: string } { - const override = args.cmdOverrides[args.agent] - const command = - override || - getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { - isRemote: args.isRemote - }) - const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) - if (!suffix.ok) { - return suffix - } - // Why: Codex status hooks live in Orca's runtime CODEX_HOME; adding - // --profile-v2 makes Codex load a second hook representation and warn. - return { ok: true, command: suffix.suffix ? `${command} ${suffix.suffix}` : command } +function appliedSessionOptionProps( + values: Record +): Pick { + return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} } export function buildAgentStartupPlan(args: { @@ -64,6 +51,7 @@ export function buildAgentStartupPlan(args: { allowEmptyPromptLaunch?: boolean agentArgs?: string | null agentEnv?: Record | null + sessionOptions?: Record /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must be skipped for remote launches. */ isRemote?: boolean @@ -73,12 +61,13 @@ export function buildAgentStartupPlan(args: { const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] const usesQuery = config.promptInjectionMode === 'hermes-query' && Boolean(trimmedPrompt) - const baseCommand = resolveBaseCommand({ + const baseCommand = resolveAgentLaunchCommand({ agent, cmdOverrides, platform, shell, agentArgs: usesQuery ? null : args.agentArgs, + sessionOptions: args.sessionOptions, isRemote: args.isRemote }) if (!baseCommand.ok) { @@ -86,7 +75,9 @@ export function buildAgentStartupPlan(args: { } const launchConfig = buildSleepingAgentLaunchConfig({ ...args, - agentCommand: baseCommand.command + // Why: picker flags are a one-time launch choice; a resumed provider + // session restores its own state and must retain only explicit user args. + agentCommand: baseCommand.commandWithoutSessionOptions }) if (!trimmedPrompt) { @@ -99,6 +90,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -113,6 +105,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } @@ -125,6 +118,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -150,6 +144,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(queryPlan.env ? { env: queryPlan.env } : {}) } } @@ -161,6 +156,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -172,6 +168,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -182,6 +179,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: trimmedPrompt, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -195,6 +193,7 @@ export function buildAgentResumeStartupPlan(args: { agentArgs?: string | null agentEnv?: Record | null agentCommand?: string | null + sessionOptions?: Record /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ isRemote?: boolean }): AgentStartupPlan | null { @@ -207,7 +206,7 @@ export function buildAgentResumeStartupPlan(args: { const resolvedAgentCommand = args.agentCommand?.trim() const baseCommand = resolvedAgentCommand ? ({ ok: true, command: resolvedAgentCommand } as const) - : resolveBaseCommand({ + : resolveAgentLaunchCommand({ agent: args.agent, cmdOverrides: args.cmdOverrides, platform: args.platform, @@ -244,6 +243,7 @@ export type AgentDraftLaunchPlan = { launchConfig: SleepingAgentLaunchConfig env?: Record startupCommandDelivery?: StartupCommandDelivery + sessionOptions?: Record } export function buildAgentDraftLaunchPlan(args: { @@ -254,6 +254,7 @@ export function buildAgentDraftLaunchPlan(args: { shell?: AgentStartupShell agentArgs?: string | null agentEnv?: Record | null + sessionOptions?: Record /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ isRemote?: boolean }): AgentDraftLaunchPlan | null { @@ -264,12 +265,13 @@ export function buildAgentDraftLaunchPlan(args: { if (!trimmed) { return null } - const baseCommand = resolveBaseCommand({ + const baseCommand = resolveAgentLaunchCommand({ agent, cmdOverrides, platform, shell, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, isRemote: args.isRemote }) if (!baseCommand.ok) { @@ -277,7 +279,8 @@ export function buildAgentDraftLaunchPlan(args: { } const launchConfig = buildSleepingAgentLaunchConfig({ ...args, - agentCommand: baseCommand.command + // Why: see the new-session path above — resume must not replay picker flags. + agentCommand: baseCommand.commandWithoutSessionOptions }) let plan: AgentDraftLaunchPlan | null = null if (config.draftPromptFlag) { @@ -287,6 +290,7 @@ export function buildAgentDraftLaunchPlan(args: { launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`, expectedProcess: config.expectedProcess, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), // Why: native draft flags carry user text on argv and must survive rc-file startup. ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) @@ -298,6 +302,7 @@ export function buildAgentDraftLaunchPlan(args: { launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`, expectedProcess: config.expectedProcess, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed } } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 3de8e943d16..c805b9a0131 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,6 +41,7 @@ import type { LocalWindowsRuntimePreference } from './project-execution-runtime' import type { UsagePercentageDisplay } from './usage-percentage-display' +import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -2680,6 +2681,9 @@ export type GlobalSettings = { /** Experimental: native chat surface for Claude/Codex terminal sessions. * Off by default while the desktop UX is still being exercised. */ experimentalNativeChat?: boolean + /** Last explicit native-chat model and model-scoped option selections. Live + * panes still require an applied/dispatched record before showing a value. */ + nativeChatSessionOptions?: PersistedNativeChatSessionOptions /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ openInApplications?: OpenInApplication[] /** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */ From 3a74721f8951eb8884acd90a3bd4826b95c511fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:17:03 -0700 Subject: [PATCH 05/25] fix(skills): hide split icon and stop update-terminal respawn on refocus (#9082) * fix(skills): hide split icon and stop update-terminal respawn on refocus The "Update skills" modal embeds a one-off command terminal via OnboardingInlineCommandTerminal. Two issues: 1. The pane header showed the regular terminal's split ("Split Terminal Right") icon, which makes no sense for an ephemeral single-command terminal. Add an `allowSplit` prop (default true) threaded TerminalPane -> TerminalPaneHeaderOverlay, and have all onboarding inline command terminals pass `allowSplit={false}` to omit it. 2. Alt-tabbing out of Orca and back respawned the terminal. On window focus, useSkillFreshness republishes {inventory: null, loading: true} to retract stale update authority; the dialog reacts by nulling the draft command, then re-sets it after the scan, and the `key={terminalCommand}` remount spawns a fresh PTY. Add a ref-counted suspendSkillFreshnessFocusRescan(); the dialog holds focus rescans while a live terminal is showing so refocus no longer tears it down. Explicit Re-check and install-changed refreshes still run. * fix(skills): preserve freshness checks without respawning terminal * fix(skills): keep revalidated terminal safe and focused --- resources/skills/current-manifest.json | 18 +-- .../OnboardingInlineCommandTerminal.tsx | 1 + .../SkillFreshnessUpdateDialog.test.tsx | 56 +++++++++- .../skills/SkillFreshnessUpdateDialog.tsx | 105 ++++++++++++++---- .../components/terminal-pane/TerminalPane.tsx | 5 + .../TerminalPaneHeaderOverlay.test.tsx | 13 +++ .../TerminalPaneHeaderOverlay.tsx | 5 +- 7 files changed, 164 insertions(+), 39 deletions(-) diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index fca1162fa5e..51cbb6af5cc 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "skills": [ { "name": "computer-use", "sourcePath": "skills/computer-use", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 5, "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", @@ -24,7 +24,7 @@ { "name": "linear-tickets", "sourcePath": "skills/linear-tickets", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 4, "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", @@ -43,7 +43,7 @@ { "name": "orca-cli", "sourcePath": "skills/orca-cli", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 32, "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", @@ -62,7 +62,7 @@ { "name": "orca-emulator", "sourcePath": "skills/orca-emulator", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 4, "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", @@ -81,7 +81,7 @@ { "name": "orca-emulator-android", "sourcePath": "skills/orca-emulator-android", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 2, "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", @@ -100,7 +100,7 @@ { "name": "orca-linear", "sourcePath": "skills/orca-linear", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 2, "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", @@ -119,7 +119,7 @@ { "name": "orca-per-workspace-env", "sourcePath": "skills/orca-per-workspace-env", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 2, "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", @@ -138,7 +138,7 @@ { "name": "orchestration", "sourcePath": "skills/orchestration", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 24, "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 6beb281bbc5..a0b8e894b47 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -294,6 +294,7 @@ export function OnboardingInlineCommandTerminal({ cwd={cwd} isActive isVisible + showSplitButton={false} onPtyExit={() => { onTerminalExit?.() closeTab(tabId, { recordInteraction: false, reason: 'pty-exit' }) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index a810ae06cc6..fc9926f6ed6 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, useState, type ReactNode } from 'react' +import { act, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void onTerminalExit?: () => void }[], + terminalCommits: [] as [command: string, inert: boolean][], notifyChanged: vi.fn() })) @@ -42,8 +43,20 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => { + const terminalRef = useRef(null) mocks.terminalProps.push(props) - return
{props.command}
+ useLayoutEffect(() => { + mocks.terminalCommits.push([ + props.command, + terminalRef.current?.parentElement?.inert ?? false + ]) + }) + return ( +
+ {props.command} +