diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 417924fc997..d96e1bddd40 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.8m + + downloads: 5.9m @@ -15,7 +15,7 @@ downloads downloads - 5.8m - 5.8m + 5.9m + 5.9m diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index b4e5fc8e26c..ae8c834d894 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2210,7 +2210,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\PortableGit\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ CHERE_INVOKING: '1' }) diff --git a/src/main/index.ts b/src/main/index.ts index efdd7ee9a6e..b120f62c8a5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2178,7 +2178,15 @@ app.whenReady().then(async () => { enableWebSocket: true, ...(isE2E ? { wsPort: 0 } : {}), ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), - ...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}), + ...(serveOptions?.wsPort !== undefined + ? { + wsPort: serveOptions.wsPort, + // Why: only an explicit `orca serve --port` pin prefers the requested + // port over a stale STA-1511 fallback (issue #8535). Default 6768 / + // dev 6769 keep fallback-first so mobile pairings stay stable. + preferPinnedWsPort: true + } + : {}), webClientRoot: getBundledWebClientRoot() }) registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus }) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4f08581282b..64047cb1c46 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6497,7 +6497,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ env: expect.objectContaining({ CHERE_INVOKING: '1' }) }) diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts new file mode 100644 index 00000000000..9cc70f1d7e0 --- /dev/null +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import type { + WatcherProcessCallback, + WatcherProcessHooks +} from './parcel-watcher-process-subscription' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' +import { startGitCommonWatch } from './worktree-git-common-watch' + +vi.mock('./parcel-watcher-process', () => ({ + subscribeViaWatcherProcess: vi.fn() +})) + +const POLL_MS = 25 + +type ChildSubscription = { + dir: string + callback: WatcherProcessCallback + hooks: WatcherProcessHooks + unsubscribe: ReturnType +} + +describe('worktree git-common narrow watch (darwin)', () => { + const cleanups: (() => Promise)[] = [] + const subscribeMock = vi.mocked(subscribeViaWatcherProcess) + let childSubscriptions: ChildSubscription[] = [] + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + childSubscriptions = [] + subscribeMock.mockReset() + }) + + function installSubscribeMock(): void { + subscribeMock.mockImplementation(async (dir, callback, _opts, hooks = {}) => { + const unsubscribe = vi.fn(async () => {}) + childSubscriptions.push({ dir, callback, hooks, unsubscribe }) + return { unsubscribe } + }) + } + + async function makeCommonDir(withWorktrees: boolean): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-git-common-watch-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const commonDir = await realpath(root) + if (withWorktrees) { + await mkdir(join(commonDir, 'worktrees')) + } + return commonDir + } + + function makeTarget(path: string): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${path}`, + kind: 'git-common', + path, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } + } + + async function startWatch(commonDir: string, received: WorktreeBasePollEvent[][]): Promise { + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin' + ) + cleanups.push(() => watch.unsubscribe()) + } + + it('hosts the narrow stream in the watcher child, not in-process', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + expect(subscribeMock).toHaveBeenCalledTimes(1) + const [dir, , opts] = subscribeMock.mock.calls[0] + expect(dir).toBe(join(commonDir, 'worktrees')) + expect(opts).toEqual({}) + + const entryPath = join(commonDir, 'worktrees', 'wt-a') + childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }]) + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + }) + + it('tears down and re-arms when the watched root is deleted', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + await rm(worktreesDir, { recursive: true, force: true }) + childSubscriptions[0].callback(null, [{ type: 'delete', path: worktreesDir }]) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + }) + expect(received.flat()).toContainEqual({ type: 'delete', path: worktreesDir }) + + // The existence poll re-subscribes once a new first worktree recreates it. + await mkdir(worktreesDir) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(2) + }) + expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) + }) + + it('tears down and re-arms on watcher errors', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + childSubscriptions[0].callback(new Error('watcher child reported failure'), []) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + }) + // The error is surfaced as a structural change so worktrees re-sync. + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + + // The dir still exists, so the existence poll re-subscribes. + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(2) + }) + + const receivedAfterRearm = received.length + childSubscriptions[0].callback(new Error('late error from replaced watcher'), []) + childSubscriptions[0].callback(null, [ + { type: 'create', path: join(worktreesDir, 'late-old-event') } + ]) + childSubscriptions[0].hooks.onInterruption?.() + + // A replaced watch cannot tear down its successor or report stale events. + expect(received).toHaveLength(receivedAfterRearm) + expect(childSubscriptions[1].unsubscribe).not.toHaveBeenCalled() + }) + + it('reports a structural change after a watcher-child interruption', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + childSubscriptions[0].hooks.onInterruption?.() + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + // The supervisor resubscribed the same record; no teardown should happen. + expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled() + }) + + it('arms via existence polling when the worktrees dir appears later', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(false) + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + expect(subscribeMock).not.toHaveBeenCalled() + + await mkdir(join(commonDir, 'worktrees')) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(1) + }) + }) + + it('stops forwarding events and unsubscribes the child on dispose', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin' + ) + await watch.unsubscribe() + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + + received.length = 0 + childSubscriptions[0].callback(null, [ + { type: 'create', path: join(commonDir, 'worktrees', 'late') } + ]) + expect(received).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 39899b1e149..705963ed726 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -1,5 +1,6 @@ import { stat } from 'node:fs/promises' import { join } from 'node:path' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, @@ -20,6 +21,10 @@ import { // included). Other platforms: dir-listing poll (no fseventsd to protect, and // on Windows an open directory handle on `worktrees/` could interfere with // `git worktree prune` removing it). +// The native stream is hosted in the crash-isolated watcher child, never the +// Electron main process: watcher.node teardown races heap-corrupt the hosting +// process when unsubscribe overlaps in-flight callbacks (issue #8732), and +// root deletion via `git worktree prune` makes that overlap routine here. // Why: branch switches and commits made in the primary checkout rewrite these // top-level files (linked-worktree equivalents live under `worktrees/`). @@ -157,12 +162,14 @@ async function startGitCommonNarrowWatch( return false } let errored = false + let active = true // Why: parcel tears its native stream down when the watched root is // deleted (e.g. `git worktree prune` removing an empty worktrees dir) — // sometimes surfaced as an error, sometimes as a delete event for the // root. Either way: notify, drop the dead stream, and let the existence // poll re-arm when a future worktree add recreates the dir. const teardownAndRearm = (): void => { + active = false errored = true const current = subscription subscription = null @@ -172,26 +179,38 @@ async function startGitCommonNarrowWatch( armExistencePoll() } try { - const watcher = await import('@parcel/watcher') - const sub = await watcher.subscribe(worktreesDir, (error, events) => { - if (disposed) { - return - } - if (error) { - onEvents([{ type: 'update', path: worktreesDir }]) - teardownAndRearm() - return - } - if (events.length > 0) { - const rootGone = events.some( - (event) => event.type === 'delete' && event.path === worktreesDir - ) - onEvents(events.map((event) => ({ type: event.type, path: event.path }))) - if (rootGone) { + const sub = await subscribeViaWatcherProcess( + worktreesDir, + (error, events) => { + if (disposed || !active) { + return + } + if (error) { + onEvents([{ type: 'update', path: worktreesDir }]) teardownAndRearm() + return + } + if (events.length > 0) { + const rootGone = events.some( + (event) => event.type === 'delete' && event.path === worktreesDir + ) + onEvents(events.map((event) => ({ type: event.type, path: event.path }))) + if (rootGone) { + teardownAndRearm() + } + } + }, + {}, + { + // Why: a watcher-child crash drops events during the automatic + // resubscribe gap; report a structural change so worktrees re-sync. + onInterruption: () => { + if (!disposed && active) { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } - }) + ) if (disposed || errored) { void sub.unsubscribe().catch(() => {}) return !errored 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/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index f69505161f2..54f8c2a110c 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -936,7 +936,7 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 41e9b3b9488..273763f7017 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -196,14 +196,27 @@ describe('resolveWindowsShellLaunchArgs', () => { ]) }) - it('starts Git Bash as an interactive login shell without changing cwd', () => { + it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { const result = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice\\code', 'C:\\Users\\alice' ) - expect(result.shellArgs).toEqual(['--login', '-i']) + // Why: Git Bash inherits the ConPTY OEM code page (CP437), so a byte-writing + // UTF-8 TUI mojibakes unless the console is switched to UTF-8 before it runs. + // Assert the contract behaviorally: chcp.com 65001 must precede the exec'd + // interactive login shell (which preserves `--login -i` and the shell's + // foreground identity), rather than pinning the exact command string. + expect(result.shellArgs[0]).toBe('-c') + const bashCommand = result.shellArgs[1] + expect(bashCommand).toContain('chcp.com 65001') + expect(bashCommand).toMatch(/exec "\$BASH" --login -i$/) + // The UTF-8 switch must run before the login shell is exec'd. + expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec')) + // Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the + // exec and kill the terminal on startup. + expect(bashCommand).not.toContain('&&') expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 4977cc11158..523aee72a6a 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -16,6 +16,13 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' +// Why: Git for Windows' bash inherits the ConPTY console's OEM code page +// (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents +// like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake +// (`❯` -> `Γ¥»`). Switch the console to UTF-8, then exec the normal interactive +// login shell; cmd.exe and PowerShell already do the equivalent. The `;` (not +// `&&`) keeps startup working even if chcp.com is missing. +const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' /** Result of resolving a Windows shell to its launch args + effective cwd. * @@ -176,7 +183,7 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['--login', '-i'], + shellArgs: ['-c', GIT_BASH_UTF8_LOGIN_COMMAND], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e38d0adf0d0..562dbb5602b 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -252,6 +252,8 @@ describe('RuntimeFileCommands', () => { it('opens text files through the renderer host (inheriting active runtime env)', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/readme.md') + statMock.mockResolvedValue({ isDirectory: () => false }) const result = await commands.openMobileFile('id:wt-1', 'docs/readme.md') @@ -272,6 +274,8 @@ describe('RuntimeFileCommands', () => { it('opens previewable images through the renderer host as an image tab', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/assets/logo.png') + statMock.mockResolvedValue({ isDirectory: () => false }) const result = await commands.openMobileFile('id:wt-1', 'assets/logo.png') @@ -296,6 +300,7 @@ describe('RuntimeFileCommands', () => { const result = await commands.openMobileFile('id:wt-1', 'dist/bundle.zip') expect(openFile).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() expect(result).toEqual({ worktree: 'wt-1', relativePath: 'dist/bundle.zip', @@ -304,6 +309,43 @@ describe('RuntimeFileCommands', () => { }) }) + it('rejects missing local files without creating an editor tab', async () => { + const openFile = vi.fn() + const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/missing.md') + statMock.mockRejectedValue(enoent()) + + await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( + "ENOENT: no such file or directory, open '/repo/docs/missing.md'" + ) + expect(openFile).not.toHaveBeenCalled() + }) + + it('rejects missing remote files without creating an editor tab', async () => { + const openFile = vi.fn() + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/remote/repo' + }, + connectionId: 'ssh-1' + })) + const { commands } = createRuntimeFileCommands({ + openFile, + path: '/remote/repo', + resolveRuntimeFileTarget + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')) + } as never) + + await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( + "ENOENT: no such file or directory, open '/remote/repo/docs/missing.md'" + ) + expect(openFile).not.toHaveBeenCalled() + }) + it('does not follow symlinks when reading runtime-local file explorer dirs', async () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockResolvedValue('/repo') diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 5af3d0d22a3..822006701fd 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -498,7 +498,7 @@ export class RuntimeFileCommands { worktreeSelector: string, relativePath: string ): Promise { - const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector) if (!isSafeMobileRelativePath(relativePath)) { throw new Error('invalid_relative_path') } @@ -515,6 +515,9 @@ export class RuntimeFileCommands { return { worktree: worktree.id, relativePath, kind, opened: false } } const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + // Why: CLI/agents treat opened:true as success. Stat first so missing paths + // fail the RPC instead of creating a ghost editor tab that only errors on read. + await this.assertMobileOpenTargetExists(filePath, connectionId) // Why: the service's internal runtimeId is not a registered runtime env selector // (those live in orca-environments.json). Passing it caused Unknown environment // errors on content load for CLI-initiated opens (via files.open from orca cli @@ -525,6 +528,25 @@ export class RuntimeFileCommands { return { worktree: worktree.id, relativePath, kind, opened: true } } + private async assertMobileOpenTargetExists( + filePath: string, + connectionId?: string + ): Promise { + try { + await (connectionId + ? this.statRemoteTerminalPath(filePath, connectionId) + : stat(await resolveAuthorizedPath(filePath, this.host.requireStore()))) + } catch (error) { + if ( + isENOENT(error) || + (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + ) { + throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) + } + throw error + } + } + async openMobileDiff( worktreeSelector: string, relativePath: string, diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 391e5e18284..ca34340ccb1 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -472,6 +472,43 @@ describe('WebSocketTransport', () => { expect(transport.resolvedPort).toBe(fallbackPort) }) + it('binds the preferred port first when preferPinnedPort is set and both are free', async () => { + // Why: issue #8535 — `orca serve --port

` clients dial the pin. A + // free but stale mobile-ws-fallback-port.json must not pre-empt it. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort, + preferPinnedPort: true + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('falls back when preferPinnedPort is set but the preferred port is taken', async () => { + // Why: explicit pins still degrade to the STA-1511 fallback on + // EADDRINUSE so previously-paired mobile devices remain reachable. + const preferredHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(preferredHolder) + await preferredHolder.start() + const preferredPort = preferredHolder.resolvedPort + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort, + preferPinnedPort: true + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(fallbackPort) + }) + it('binds the preferred port when the persisted fallback is taken', async () => { const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(fallbackHolder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 8b88c7a820e..0d5403c5991 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -54,6 +54,11 @@ export type WebSocketTransportOptions = { // the (now free) preferred port instead would strand those pairings // (STA-1511). Callers pass the previously assigned fallback port here. fallbackPort?: number + // Why: `orca serve --port

` clients dial the pinned port. Prefer that port + // first (fallback second) so a stale mobile-ws-fallback-port.json cannot + // silently steal the pin (issue #8535). Default auto/desktop keeps + // fallback-first for STA-1511 pairing stability. + preferPinnedPort?: boolean } export class WebSocketTransport implements RpcTransport { @@ -65,6 +70,7 @@ export class WebSocketTransport implements RpcTransport { private readonly preAuthTimeoutMs: number private readonly staticRoot: string | undefined private readonly fallbackPort: number | undefined + private readonly preferPinnedPort: boolean private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private heartbeatTimer: ReturnType | null = null @@ -89,7 +95,8 @@ export class WebSocketTransport implements RpcTransport { heartbeatIntervalMs, preAuthTimeoutMs, staticRoot, - fallbackPort + fallbackPort, + preferPinnedPort }: WebSocketTransportOptions) { this.host = host this.port = port @@ -99,6 +106,7 @@ export class WebSocketTransport implements RpcTransport { this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS this.staticRoot = staticRoot this.fallbackPort = fallbackPort + this.preferPinnedPort = preferPinnedPort === true } onMessage(handler: WebSocketMessageHandler): void { @@ -150,10 +158,12 @@ export class WebSocketTransport implements RpcTransport { return } - // Why: a persisted fallback port is bound FIRST — devices paired while it - // was active store ws://ip: and would be permanently stranded if - // a later launch grabbed the (now free) preferred port instead (STA-1511). - // Without a persisted fallback the preferred port is tried first. On + // Why: default order binds a persisted fallback FIRST — devices paired + // while it was active store ws://ip: and would be stranded if a + // later launch grabbed the (now free) preferred port instead (STA-1511). + // Explicit serve --port flips the order so the pinned port wins when free + // (issue #8535); fallback remains a secondary candidate for EADDRINUSE. + // Without a persisted fallback only the preferred port is tried. On // EADDRINUSE each candidate falls through to the next, ending at port 0 // (OS-assigned) so mobile pairing still works when everything is taken. // The QR code reads resolvedPort after start, so it always advertises the @@ -163,7 +173,11 @@ export class WebSocketTransport implements RpcTransport { ? this.fallbackPort : undefined const candidatePorts = - persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port] + persistedFallbackPort === undefined + ? [this.port] + : this.preferPinnedPort + ? [this.port, persistedFallbackPort] + : [persistedFallbackPort, this.port] for (const port of candidatePorts) { try { await this.tryListen(port) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index d7623065634..5c7c44574d4 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -53,6 +53,10 @@ type OrcaRuntimeRpcServerOptions = { platform?: NodeJS.Platform enableWebSocket?: boolean wsPort?: number + // Why: true when the caller set an explicit port (e.g. `orca serve --port`). + // Distinguishes that pin from the DEFAULT_WS_PORT default so transport bind + // order can prefer the pin over a stale STA-1511 fallback (issue #8535). + preferPinnedWsPort?: boolean webClientRoot?: string // Why: test-only overrides for the two time-bound constants below. // Production callers must not pass these — defaults are set by the design @@ -450,6 +454,7 @@ export class OrcaRuntimeRpcServer { private readonly platform: NodeJS.Platform private readonly enableWebSocket: boolean private readonly wsPort: number + private readonly preferPinnedWsPort: boolean private readonly webClientRoot: string | undefined private readonly authToken = randomBytes(24).toString('hex') private readonly keepaliveIntervalMs: number @@ -482,6 +487,7 @@ export class OrcaRuntimeRpcServer { platform = process.platform, enableWebSocket = false, wsPort = DEFAULT_WS_PORT, + preferPinnedWsPort = false, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP @@ -493,6 +499,7 @@ export class OrcaRuntimeRpcServer { this.platform = platform this.enableWebSocket = enableWebSocket this.wsPort = wsPort + this.preferPinnedWsPort = preferPinnedWsPort this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap @@ -863,10 +870,11 @@ export class OrcaRuntimeRpcServer { staticRoot: this.webClientRoot, // Why: keep the fallback port stable across restarts so paired // devices' stored endpoints stay valid (STA-1511) — the transport - // binds a persisted fallback before the preferred port. wsPort 0 - // means the caller explicitly wants a random port (E2E) — don't - // pin it. - ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) + // binds a persisted fallback before the preferred port unless the + // caller explicitly pinned a port (serve --port). wsPort 0 means + // the caller wants a random port (E2E) — don't pin it. + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}), + ...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {}) }) const mobileSocketWiring = new MobileSocketWiring({ deviceRegistry: this.deviceRegistry, diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts index b45854e2e86..7f9824ead94 100644 --- a/src/relay/git-working-file-read.test.ts +++ b/src/relay/git-working-file-read.test.ts @@ -61,4 +61,33 @@ describe('readWorkingDiffFile', () => { missing: true }) }) + + it('returns base64 content for previewable image working-tree files', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'image.png') + // Why: PNG signature + trailing null byte forces the binary-detection heuristic to trip. + const pngBuffer = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from([0x00]) + ]) + await writeFile(filePath, pngBuffer) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: pngBuffer.toString('base64'), + isBinary: true, + missing: false + }) + }) + + it('returns empty content for non-previewable binary working-tree files', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'archive.bin') + await writeFile(filePath, Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03])) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: '', + isBinary: true, + missing: false + }) + }) }) 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..a1dca86b65f 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 { @@ -20,7 +23,6 @@ export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHA export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 - /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { @@ -47,6 +49,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/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/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/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} +