Add per-model session-option pickers with a verified Claude model switch

Native chat's model/effort picker now dispatches option commands through a
body-then-verified-Enter write path and, for Claude, arms a PTY observer that
classifies the cached-history confirmation prompt as applied/rejected/needs
interaction before the picker returns — falling back to the terminal only
when Claude genuinely requires manual input, and clearing stale truth
otherwise. Also reorders the composer's model/effort pills, disambiguates
their tooltip labels, and switches disabled-reason strings to a closed enum
so producer and localized copy can't drift.
This commit is contained in:
Jinjing
2026-07-16 16:32:03 -07:00
parent 19b7c79c9d
commit 2e800e56e7
31 changed files with 1395 additions and 218 deletions
@@ -11,8 +11,18 @@ const mocks = vi.hoisted(() => ({
onStop?: () => void
sessionOptionsSurface?: SessionOptionsSurface | null
} | null,
modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown',
confirmationObserver: null as {
ready: Promise<void>
result: Promise<'applied' | 'rejected' | 'interaction-required' | 'unknown'>
arm: ReturnType<typeof vi.fn>
startDetection: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
} | null,
createClaudeModelSwitchConfirmationObserver: vi.fn(),
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
sendNativeChatMessage: vi.fn(),
sendNativeChatMessageVerified: vi.fn(),
trackPendingSend: vi.fn(),
setDraft: vi.fn()
}))
@@ -37,15 +47,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
@@ -62,7 +84,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: () => ({
@@ -70,7 +95,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()
}))
@@ -87,7 +114,21 @@ describe('NativeChatComposer', () => {
beforeEach(() => {
vi.clearAllMocks()
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.sendNativeChatMessage.mockReturnValue(mocks.sendHandle)
mocks.sendNativeChatMessageVerified.mockResolvedValue(true)
mocks.sendHandle.settleAfterMs = 500
Object.defineProperty(window, 'api', {
configurable: true,
@@ -135,10 +176,11 @@ describe('NativeChatComposer', () => {
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
})
it('dispatches a model pick through the slash path without a user bubble', async () => {
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(
<NativeChatComposer
terminalTabId="tab-1"
@@ -146,6 +188,7 @@ describe('NativeChatComposer', () => {
agent="claude"
onSlashCommand={onSlashCommand}
onOptimisticSend={onOptimisticSend}
onSwitchToTerminal={onSwitchToTerminal}
/>
)
@@ -153,9 +196,85 @@ describe('NativeChatComposer', () => {
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'opus')
})
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/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(
<NativeChatComposer
terminalTabId="tab-1"
targetPtyId="pty-1"
agent="claude"
onSwitchToTerminal={onSwitchToTerminal}
/>
)
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(
<NativeChatComposer
terminalTabId="tab-1"
targetPtyId="pty-1"
agent="claude"
onSwitchToTerminal={onSwitchToTerminal}
/>
)
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 () => {
@@ -174,7 +293,12 @@ describe('NativeChatComposer', () => {
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', '')
})
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/model')
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
{},
'pty-1',
'/model',
expect.any(AbortSignal)
)
expect(onSwitchToTerminal).toHaveBeenCalledOnce()
})
})
@@ -248,14 +248,14 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths)
const { toggleDictation, startHoldDictation, stopHoldDictation } =
useNativeChatDictationActions({ textareaRef, setDictationPressed })
const dispatchSessionOptionCommand = useNativeChatSessionOptionCommand({
agent,
disabled,
onSlashCommand,
resolveTarget,
setHistory,
trackPendingSend
})
const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } =
useNativeChatSessionOptionCommand({
agent,
disabled,
onSlashCommand,
resolveTarget,
setHistory
})
const { surface: sessionOptionsSurface, snapshot: sessionOptionsSnapshot } =
useNativeChatSessionOptions({
@@ -272,6 +272,12 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
if ((text.trim() === '' && imagePaths.length === 0) || disabled) {
return
}
// Why: block a normal send while a session-option command (e.g. /model) is
// still writing its body+delayed-Enter to the same pty, so the two write
// sequences can't interleave on one input line.
if (isDispatchingSessionOption) {
return
}
const target = resolveTarget()
if (!target) {
return
@@ -328,6 +334,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
draft,
imageAttachments,
disabled,
isDispatchingSessionOption,
resolveTarget,
onOptimisticSend,
onSlashCommand,
@@ -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<HTMLButtonElement>) => <button {...props}>{children}</button>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('./NativeChatSessionOptionPickers', () => ({
NativeChatSessionOptionPickers: () => <div data-testid="session-option-pickers" />
}))
import { NativeChatComposerActions } from './NativeChatComposerActions'
afterEach(() => cleanup())
describe('NativeChatComposerActions', () => {
it('places session option pickers immediately beside dictation', () => {
render(
<NativeChatComposerActions
attachDisabled={false}
dictationDisabled={false}
sendDisabled={false}
isWorking={false}
isDictating={false}
isDictationHoldMode={false}
onAttach={vi.fn()}
onDictationToggle={vi.fn()}
onDictationHoldStart={vi.fn()}
onDictationHoldEnd={vi.fn()}
onSend={vi.fn()}
sessionOptionsSurface={null}
sessionOptionsSnapshot={[]}
/>
)
const pickers = screen.getByTestId('session-option-pickers')
const dictation = screen.getByRole('button', { name: 'Start dictation' })
expect(pickers.nextElementSibling).toBe(dictation)
})
})
@@ -65,13 +65,15 @@ export function NativeChatComposerActions({
{translate('components.native-chat.composer.attach', 'Attach file')}
</TooltipContent>
</Tooltip>
</div>
<div className="ml-auto flex items-center gap-1.5">
{/* Why: keep session controls beside the actions they affect; the
model trigger is ordered last so it sits directly next to dictation. */}
<NativeChatSessionOptionPickers
surface={sessionOptionsSurface}
snapshot={sessionOptionsSnapshot}
isWorking={isWorking}
/>
</div>
<div className="ml-auto flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -117,15 +117,36 @@ describe('NativeChatSessionOptionPickers', () => {
isWorking={false}
/>
)
expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Opus 4.8')
expect(screen.getByRole('button', { name: 'Session options' }).textContent).toContain(
'High · Fast'
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(
<NativeChatSessionOptionPickers surface={surface} snapshot={[model()]} isWorking={false} />
)
expect(screen.queryByRole('button', { name: 'Session options' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull()
})
it('names a lone unknown effort control explicitly', () => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[
model(),
{ ...effort, kind: { ...effort.kind, currentValue: undefined }, valueSource: 'unknown' }
]}
isWorking={false}
/>
)
expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain('Effort')
})
it('disables both picker triggers while the agent is working', () => {
@@ -136,12 +157,41 @@ describe('NativeChatSessionOptionPickers', () => {
screen.getByRole('button', { name: 'Model' }).parentElement?.getAttribute('data-disabled')
).toBe('true')
expect(
screen
.getByRole('button', { name: 'Session options' })
.parentElement?.getAttribute('data-disabled')
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(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[
model({
kind: { type: 'select', choices: [] },
valueSource: 'unknown'
}),
{ ...effort, kind: { ...effort.kind, currentValue: undefined }, valueSource: 'unknown' }
]}
isWorking={false}
/>
)
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(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), fast]}
isWorking={false}
/>
)
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(
<NativeChatSessionOptionPickers
@@ -23,6 +23,7 @@ import type {
import {
nativeChatModelPillLabel,
nativeChatOptionsPillLabel,
nativeChatOptionsPillTitle,
nativeChatSessionChoiceLabel,
nativeChatSessionOptionDisabledReason,
nativeChatSessionOptionLabel
@@ -211,10 +212,7 @@ function NativeChatSessionOptionPickersInner({
const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason)
const modelTooltip = translate('components.native-chat.composer.model', 'Model')
const optionsTooltip = translate(
'components.native-chat.composer.sessionOptions',
'Session options'
)
const optionsTooltip = nativeChatOptionsPillTitle(options)
const optionsReason =
options.length > 0 && options.every((descriptor) => !descriptor.settable)
? nativeChatSessionOptionDisabledReason(options[0]?.disabledReason)
@@ -222,25 +220,6 @@ function NativeChatSessionOptionPickersInner({
return (
<div className="flex min-w-0 items-center gap-0.5">
<DropdownMenu>
<PickerTrigger
label={nativeChatModelPillLabel(model)}
tooltipLabel={modelTooltip}
disabled={isWorking || pendingId !== null}
disabledReason={modelReason}
dispatched={model.valueSource === 'dispatched'}
/>
<DropdownMenuContent align="start" className="w-64">
{modelReason && !model.settable ? (
<DropdownMenuLabel className="font-normal">{modelReason}</DropdownMenuLabel>
) : null}
<DescriptorMenuRows
descriptor={model}
pending={pendingId !== null}
setValue={(value) => setOption(model, value)}
/>
</DropdownMenuContent>
</DropdownMenu>
{options.length > 0 ? (
<DropdownMenu>
<PickerTrigger
@@ -271,6 +250,25 @@ function NativeChatSessionOptionPickersInner({
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<PickerTrigger
label={nativeChatModelPillLabel(model)}
tooltipLabel={modelTooltip}
disabled={isWorking || pendingId !== null}
disabledReason={modelReason}
dispatched={model.valueSource === 'dispatched'}
/>
<DropdownMenuContent align="start" className="w-64">
{modelReason && !model.settable ? (
<DropdownMenuLabel className="font-normal">{modelReason}</DropdownMenuLabel>
) : null}
<DescriptorMenuRows
descriptor={model}
pending={pendingId !== null}
setValue={(value) => setOption(model, value)}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -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()
}
})
})
@@ -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<void>
result: Promise<ClaudeModelSwitchOutcome>
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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<typeof setTimeout> | null = null
let unsubscribe: (() => void) | null = null
let resolveResult!: (outcome: ClaudeModelSwitchOutcome) => void
let resolveReady!: () => void
const result = new Promise<ClaudeModelSwitchOutcome>((resolve) => {
resolveResult = resolve
})
const ready = new Promise<void>((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')
}
}
@@ -16,7 +16,10 @@ describe('native chat PTY session options', () => {
dispatchCommand: vi.fn()
})!
expect(surface.getSnapshot()).toHaveLength(1)
expect(surface.getSnapshot()[0]).toMatchObject({ id: 'model', valueSource: 'unknown' })
expect(surface.getSnapshot()[0]).toMatchObject({
id: 'model',
valueSource: 'unknown'
})
})
it('restores launch-backed values through the tab-to-PTY cache handoff', () => {
@@ -40,7 +43,11 @@ describe('native chat PTY session options', () => {
)
})
it('dispatches Claude setters and publishes full dependent snapshots', async () => {
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()
@@ -53,25 +60,132 @@ describe('native chat PTY session options', () => {
})!
surface.subscribe(listener)
const modelResult = await surface.setOption('model', 'opus')
expect(dispatch).toHaveBeenCalledWith('/model opus')
expect(modelResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode'])
expect(modelResult.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
expect(modelResult.snapshot[1]).toMatchObject({ valueSource: 'unknown' })
const effortResult = await surface.setOption('effort', 'high')
expect(dispatch).toHaveBeenLastCalledWith('/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).toHaveBeenCalledTimes(2)
expect(listener).toHaveBeenCalledOnce()
expect(listener.mock.calls.every(([snapshot]) => Array.isArray(snapshot))).toBe(true)
expect(persist).toHaveBeenCalledWith({ modelId: 'opus', optionId: 'effort', value: 'high' })
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' })
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
model: 'opus',
effort: 'high'
})
const dispatch = vi.fn()
const surface = createNativeChatPtySessionOptions({
agent: 'claude',
@@ -115,30 +229,7 @@ describe('native chat PTY session options', () => {
expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
})
it('tracks typed direct commands and downgrades typed toggles', () => {
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
model: 'sonnet',
effort: 'high'
})
const surface = createNativeChatPtySessionOptions({
agent: 'claude',
scopeKey: 'pty-1',
mode: 'live',
dispatchCommand: vi.fn()
})!
surface.recordOutgoingCommand('/model opus')
expect(surface.getSnapshot().map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode'])
expect(surface.getSnapshot()[0]).toMatchObject({
valueSource: 'dispatched',
kind: { currentValue: 'opus' }
})
surface.recordOutgoingCommand('/fast')
expect(surface.getSnapshot().find(({ id }) => id === 'fastMode')).toMatchObject({
valueSource: 'unknown'
})
})
it('drops stale model-scoped truth when typed model commands switch away and back', () => {
it('tracks typed effort commands and downgrades typed toggles', () => {
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
model: 'opus',
effort: 'xhigh'
@@ -149,17 +240,42 @@ describe('native chat PTY session options', () => {
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'
})
})
surface.recordOutgoingCommand('/model sonnet')
surface.recordOutgoingCommand('/model opus')
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
})!
const effort = surface.getSnapshot().find(({ id }) => id === 'effort')
expect(effort).toMatchObject({ valueSource: 'unknown' })
expect(effort?.kind).not.toHaveProperty('currentValue')
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' })
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
model: 'future-model'
})
const surface = createNativeChatPtySessionOptions({
agent: 'claude',
scopeKey: 'pty-1',
@@ -22,6 +22,15 @@ import {
flattenNativeChatSessionOptionRecord,
type NativeChatSessionOptionMode
} from './native-chat-session-option-snapshot'
import {
isSessionOptionAgentPickerCommand,
parseBuiltSessionOptionCommand
} from './native-chat-session-option-command-matching'
import { buildNativeChatSessionOptionCommand } from './native-chat-session-option-command-builder'
import type {
NativeChatSessionOptionDispatchCommand,
NativeChatSessionOptionDispatchResult
} from './native-chat-session-option-command-dispatch'
type PersistSelection = (args: {
modelId: string
@@ -40,31 +49,12 @@ export type CreateNativeChatPtySessionOptionsArgs = {
fallbackScopeKey?: string
initialModels?: readonly CatalogModel[]
mode: NativeChatSessionOptionMode
dispatchCommand: (command: string) => Promise<void> | void
dispatchCommand: NativeChatSessionOptionDispatchCommand
onAgentPicker?: () => void
persistSelection?: PersistSelection
onDraftValuesChanged?: (values: Record<string, SessionOptionValue>) => void
}
function parseBuiltCommand(
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 createNativeChatPtySessionOptions(
args: CreateNativeChatPtySessionOptionsArgs
): NativeChatPtySessionOptionsSurface | null {
@@ -79,12 +69,22 @@ export function createNativeChatPtySessionOptions(
if (record.agent !== args.agent) {
record = createNativeChatSessionOptionRecord(args.agent)
}
let snapshot = buildNativeChatSessionOptionSnapshot({ catalog, models, record, mode: args.mode })
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 })
snapshot = buildNativeChatSessionOptionSnapshot({
catalog,
models,
record,
mode: args.mode
})
for (const listener of listeners) {
listener(snapshot)
}
@@ -137,34 +137,6 @@ export function createNativeChatPtySessionOptions(
return option ? { apply: option.apply, modelId } : null
}
const commandForValue = (
optionId: string,
value: SessionOptionValue,
apply: CatalogOptionApply,
modelId: string | null
): string | null => {
const midSession = apply.midSession
if (midSession?.kind === 'command') {
return midSession.build(value)
}
if (midSession?.kind === 'toggle-command' || midSession?.kind === 'agent-picker') {
return midSession.command
}
if (!apply.composedIntoModel || !modelId || !catalog.composeModelValue) {
return null
}
const model = findCatalogModel({ ...catalog, models }, modelId)
const values = flattenNativeChatSessionOptionRecord(record, modelId)
for (const option of model?.options ?? []) {
values[option.id] ??= option.kind.defaultValue
}
values[optionId] = value
const composed = catalog.composeModelValue(modelId, values)
return catalog.modelApply.midSession?.kind === 'command'
? catalog.modelApply.midSession.build(composed)
: null
}
const handleAgentPicker = async (midSession: CatalogMidSessionApply): Promise<void> => {
if (midSession.kind !== 'agent-picker') {
return
@@ -186,25 +158,61 @@ export function createNativeChatPtySessionOptions(
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 = commandForValue(id, value, apply, previousModelId)
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.')
}
await args.dispatchCommand(command)
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(),
notice: 'Sent to the agent — current value is unknown'
}
return { snapshot: publish() }
}
if (id === 'model' && previousModelId !== value) {
record.model = undefined
@@ -226,10 +234,7 @@ export function createNativeChatPtySessionOptions(
if (args.mode === 'draft' && typeof record.model?.value === 'string') {
args.onDraftValuesChanged?.(flattenNativeChatSessionOptionRecord(record, record.model.value))
}
return {
snapshot: next,
...(source === 'dispatched' ? { notice: 'Sent to the agent — not confirmed' } : {})
}
return { snapshot: next }
}
const recordCommandApply = (
@@ -247,14 +252,14 @@ export function createNativeChatPtySessionOptions(
}
return true
}
if (midSession.kind === 'agent-picker' && command === midSession.command) {
if (isSessionOptionAgentPickerCommand(midSession, command)) {
clearModelTruth()
return true
}
if (midSession.kind !== 'command') {
return false
}
const value = parseBuiltCommand(midSession.build, command)
const value = parseBuiltSessionOptionCommand(midSession.build, command)
if (!value) {
return false
}
@@ -280,15 +285,24 @@ export function createNativeChatPtySessionOptions(
},
recordOutgoingCommand: (command) => {
const trimmed = command.trim()
let opensAgentPicker = isSessionOptionAgentPickerCommand(
catalog.modelApply.midSession,
trimmed
)
let changed = recordCommandApply('model', catalog.modelApply.midSession, trimmed)
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, trimmed)
changed = recordCommandApply(option.id, option.apply.midSession, trimmed) || changed
}
if (changed) {
publish()
}
if (opensAgentPicker) {
args.onAgentPicker?.()
}
},
replaceModels: (nextModels) => {
models = [...nextModels]
@@ -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()
@@ -2,7 +2,10 @@
// as a SEPARATE delayed pty write. Kept apart from the pure byte builders in
// native-chat-send.ts so those stay IO-free and unit-testable without aliases.
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import {
sendRuntimePtyInput,
sendRuntimePtyInputVerified
} from '@/runtime/runtime-terminal-inspection'
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt'
import {
@@ -47,6 +50,46 @@ export function sendNativeChatMessage(
return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS }
}
function waitForNativeChatSubmit(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) {
return Promise.resolve(false)
}
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | 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<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
text: string,
signal?: AbortSignal
): Promise<boolean> {
// 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<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
@@ -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
}
@@ -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>
| NativeChatSessionOptionDispatchResult
| void
@@ -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)
)
}
@@ -1,5 +1,6 @@
import type {
SessionOptionDescriptor,
SessionOptionDisabledReason,
SessionOptionSelectChoice
} from '../../../../shared/native-chat-session-options'
import { translate } from '@/i18n/i18n'
@@ -38,37 +39,61 @@ export function nativeChatSessionChoiceLabel(choice: SessionOptionSelectChoice):
}
}
export function nativeChatSessionOptionDisabledReason(reason: string | undefined): string | null {
if (reason === 'Set when the session starts.') {
return translate(
'components.native-chat.composer.setWhenSessionStarts',
'Set when the session starts.'
)
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
}
if (reason === 'Available after the session starts.') {
return translate(
'components.native-chat.composer.availableAfterSessionStarts',
'Available after the session starts.'
)
}
return reason ?? null
}
export function nativeChatModelPillLabel(descriptor: SessionOptionDescriptor): string {
if (descriptor.valueSource === 'unknown' || descriptor.kind.type !== 'select') {
return translate('components.native-chat.composer.model', 'Model')
const modelLabel = translate('components.native-chat.composer.model', 'Model')
if (
descriptor.valueSource === 'unknown' ||
descriptor.kind.type !== 'select' ||
!descriptor.kind.currentValue
) {
return modelLabel
}
return nativeChatSessionChoiceLabel(
const valueLabel = nativeChatSessionChoiceLabel(
descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue) ?? {
value: descriptor.kind.currentValue ?? '',
label: 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') {
@@ -94,7 +119,17 @@ export function nativeChatOptionsPillLabel(
)
}
}
return labels.length > 0
? labels.join(' · ')
: translate('components.native-chat.composer.options', 'Options')
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')
}
@@ -38,7 +38,7 @@ function settableState(args: {
if (args.mode === 'draft') {
return args.apply.launchArgs || args.apply.composedIntoModel
? { settable: true }
: { settable: false, disabledReason: 'Available after the session starts.' }
: { settable: false, disabledReason: 'available-after-session-start' }
}
const midSession = args.apply.midSession
if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') {
@@ -46,7 +46,7 @@ function settableState(args: {
}
return midSession && midSession.kind !== 'unsupported'
? { settable: true }
: { settable: false, disabledReason: 'Set when the session starts.' }
: { settable: false, disabledReason: 'set-when-session-starts' }
}
function actionForApply(
@@ -1,4 +1,4 @@
import { useCallback, type Dispatch, type SetStateAction } from 'react'
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 {
@@ -6,8 +6,12 @@ import {
type NativeChatResolvedTarget
} from './native-chat-composer-target'
import { pushHistory, type HistoryState } from './native-chat-composer-state'
import { sendNativeChatMessage } from './native-chat-runtime-send'
import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle'
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
@@ -15,27 +19,96 @@ export function useNativeChatSessionOptionCommand(args: {
onSlashCommand?: (command: string) => void
resolveTarget: () => NativeChatResolvedTarget | null
setHistory: Dispatch<SetStateAction<HistoryState>>
trackPendingSend: NativeChatSendLifecycle['trackPendingSend']
}): (command: string) => Promise<void> {
const { agent, disabled, onSlashCommand, resolveTarget, setHistory, trackPendingSend } = args
return useCallback(
async (command: string): Promise<void> => {
}): { dispatch: NativeChatSessionOptionDispatchCommand; isDispatching: boolean } {
const { agent, disabled, onSlashCommand, resolveTarget, setHistory } = args
const mountedRef = useRef(true)
const activeObserversRef = useRef(new Set<ClaudeModelSwitchConfirmationObserver>())
const activeSendsRef = useRef(new Set<AbortController>())
// 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 handle = sendNativeChatMessage(target.settings, target.ptyId, command)
trackPendingSend(handle)
onSlashCommand?.(command.trim())
emitNativeChatMessageSent({
agent,
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
})
setHistory((previous) => pushHistory(previous, command))
// Why: picker actions switch views; wait for the delayed Enter so
// unmount cleanup cannot cancel the command halfway through dispatch.
await new Promise((resolve) => setTimeout(resolve, handle.settleAfterMs + 10))
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, trackPendingSend]
[agent, disabled, onSlashCommand, resolveTarget, setHistory]
)
return { dispatch, isDispatching }
}
@@ -7,6 +7,7 @@ import {
createNativeChatPtySessionOptions,
type NativeChatPtySessionOptionsSurface
} from './native-chat-pty-session-options'
import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch'
import {
ensureNativeChatModelEnrichment,
readNativeChatEnrichedModels,
@@ -25,7 +26,7 @@ export function useNativeChatSessionOptions(args: {
agent: AgentType
terminalTabId: string
targetPtyId: string | null
dispatchCommand: (command: string) => Promise<void> | void
dispatchCommand: NativeChatSessionOptionDispatchCommand
onAgentPicker?: () => void
}): {
surface: NativeChatPtySessionOptionsSurface | null
@@ -42,7 +43,6 @@ export function useNativeChatSessionOptions(args: {
return null
}
const scopeKey = args.targetPtyId ?? args.terminalTabId
let persisted = useAppStore.getState().settings?.nativeChatSessionOptions
let settingsWrite = Promise.resolve()
return createNativeChatPtySessionOptions({
agent: args.agent,
@@ -58,21 +58,25 @@ export function useNativeChatSessionOptions(args: {
dispatchCommand: args.dispatchCommand,
onAgentPicker: args.onAgentPicker,
persistSelection: async ({ modelId, optionId, value }) => {
persisted = updateNativeChatSessionOptionDefaults({
persisted,
agent: args.agent,
modelId,
optionId,
value
})
const nextPersisted = persisted
// Why: rapid consecutive picks must reach electron-store in selection
// order or a slower older write can erase the newer model-scoped 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(() =>
useAppStore.getState().updateSettings({ nativeChatSessionOptions: nextPersisted })
)
.then(() => {
const base = useAppStore.getState().settings?.nativeChatSessionOptions
const next = updateNativeChatSessionOptionDefaults({
persisted: base,
agent: args.agent,
modelId,
optionId,
value
})
return useAppStore.getState().updateSettings({ nativeChatSessionOptions: next })
})
await settingsWrite
}
})
+2
View File
@@ -13186,7 +13186,9 @@
"worktreeNotReady": "Worktree not ready — try again in a moment.",
"uploadingAttachments": "Uploading {{value0}} file(s) to remote…",
"model": "Model",
"modelWithValue": "Model: {{value0}}",
"effort": "Effort",
"effortWithValue": "Effort: {{value0}}",
"fastMode": "Fast mode",
"thinking": "Thinking",
"options": "Options",
+2
View File
@@ -13186,7 +13186,9 @@
"worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.",
"uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…",
"model": "Modelo",
"modelWithValue": "Modelo: {{value0}}",
"effort": "Esfuerzo",
"effortWithValue": "Esfuerzo: {{value0}}",
"fastMode": "Modo rápido",
"thinking": "Razonamiento",
"options": "Opciones",
+2
View File
@@ -13186,7 +13186,9 @@
"worktreeNotReady": "Worktreeが準備できていません — しばらくしてから再試行してください。",
"uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…",
"model": "モデル",
"modelWithValue": "モデル: {{value0}}",
"effort": "推論レベル",
"effortWithValue": "推論レベル: {{value0}}",
"fastMode": "高速モード",
"thinking": "思考",
"options": "オプション",
+2
View File
@@ -13186,7 +13186,9 @@
"worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.",
"uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…",
"model": "모델",
"modelWithValue": "모델: {{value0}}",
"effort": "추론 수준",
"effortWithValue": "추론 수준: {{value0}}",
"fastMode": "빠른 모드",
"thinking": "사고",
"options": "옵션",
+2
View File
@@ -13186,7 +13186,9 @@
"worktreeNotReady": "工作树未就绪 — 请稍后重试。",
"uploadingAttachments": "正在上传 {{value0}} 个文件到远程…",
"model": "模型",
"modelWithValue": "模型:{{value0}}",
"effort": "推理强度",
"effortWithValue": "推理强度:{{value0}}",
"fastMode": "快速模式",
"thinking": "思考",
"options": "选项",
@@ -29,7 +29,9 @@ describe('native chat locale copy', () => {
const composer = catalog.components['native-chat'].composer
for (const key of [
'model',
'modelWithValue',
'effort',
'effortWithValue',
'fastMode',
'thinking',
'options',
@@ -132,6 +132,60 @@ describe('remote runtime terminal data subscriptions', () => {
expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0)
})
it('can start at the live tail without replaying the initial snapshot', async () => {
const watcher = vi.fn()
const subscription = subscribeToRuntimeTerminalData(
{ activeRuntimeEnvironmentId: 'env-fallback' },
'remote:env-1@@terminal-1',
'watcher-1',
watcher,
{ startAtLiveTail: true }
)
await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled())
const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0])
const subscribePayload =
subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload)
const streamId = subscribePayload!.streamId
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotStart,
streamId,
seq: 0,
payload: encodeTerminalStreamJson({ seq: 0 })
})
)
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotChunk,
streamId,
seq: 0,
payload: encodeTerminalStreamText('historical')
})
)
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotEnd,
streamId,
seq: 0,
payload: new Uint8Array()
})
)
const dispose = await subscription
expect(watcher).not.toHaveBeenCalled()
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: 4,
payload: encodeTerminalStreamText('live')
})
)
expect(watcher).toHaveBeenCalledWith('live')
dispose()
})
it('keeps the shared terminal multiplexer until the last watcher closes', async () => {
const firstDispose = await subscribeToRuntimeTerminalData(
{ activeRuntimeEnvironmentId: 'env-fallback' },
@@ -4,6 +4,7 @@ import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-m
const REMOTE_PTY_ID_PREFIX = 'remote:'
const REMOTE_PTY_OWNER_SEPARATOR = '@@'
const LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS = 10_000
export type RemoteRuntimePtyIdParts = {
environmentId: string | null
@@ -56,7 +57,8 @@ export async function subscribeToRuntimeTerminalData(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string,
clientId: string,
watcher: (data: string) => void
watcher: (data: string) => void,
options?: { startAtLiveTail?: boolean }
): Promise<() => void> {
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
@@ -67,14 +69,61 @@ export async function subscribeToRuntimeTerminalData(
return () => {}
}
let resolveLiveTail: (() => void) | null = null
let rejectLiveTail: ((error: Error) => void) | null = null
const liveTailReady = options?.startAtLiveTail
? new Promise<void>((resolve, reject) => {
resolveLiveTail = resolve
rejectLiveTail = reject
})
: null
const rejectPendingLiveTail = (message: string): void => {
rejectLiveTail?.(new Error(message))
resolveLiveTail = null
rejectLiveTail = null
}
const stream = await getRemoteRuntimeTerminalMultiplexer(target.environmentId).subscribeTerminal({
terminal,
client: { id: clientId, type: 'desktop' },
callbacks: {
onData: (data) => watcher(data),
onSnapshot: watcher
onSnapshot: (data) => {
if (!options?.startAtLiveTail) {
watcher(data)
}
},
onSubscribed: () => {
resolveLiveTail?.()
resolveLiveTail = null
rejectLiveTail = null
},
onEnd: () => rejectPendingLiveTail('Remote terminal ended before live output was ready.'),
onError: (message) => rejectPendingLiveTail(message),
onTransportClose: () =>
rejectPendingLiveTail('Remote terminal closed before live output was ready.')
}
})
if (liveTailReady) {
let timeout: ReturnType<typeof setTimeout> | null = setTimeout(
() => rejectPendingLiveTail('Timed out waiting for remote terminal live output.'),
LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS
)
try {
// Why: outcome observers must ignore historical snapshots and be armed
// before the command whose output they classify, including over SSH.
await liveTailReady
} catch (error) {
stream.close()
throw error
} finally {
if (timeout !== null) {
clearTimeout(timeout)
timeout = null
}
}
}
return () => stream.close()
}
@@ -92,7 +92,14 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
modelApply: {
launchArgs: (value) => ['--model', String(value)],
agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']),
midSession: { kind: 'command', build: (value) => `/model ${String(value)}` }
midSession: {
kind: 'command',
build: (value) => `/model ${String(value)}`,
pickerCommand: '/model',
// Why: Claude sometimes confirms a cached-history switch. Detect the
// actual prompt so ordinary model changes stay in native chat.
detectAgentInteraction: 'claude-model-switch-confirmation'
}
}
}
@@ -5,8 +5,15 @@ import type {
SessionOptionValue
} from './native-chat-session-options'
export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation'
export type CatalogMidSessionApply =
| { kind: 'command'; build: (value: SessionOptionValue) => string }
| {
kind: 'command'
build: (value: SessionOptionValue) => string
pickerCommand?: string
detectAgentInteraction?: CatalogAgentInteractionDetection
}
| { kind: 'toggle-command'; command: string }
| { kind: 'agent-picker'; command: string }
| { kind: 'unsupported' }
@@ -17,6 +17,7 @@ import type { SessionOptionValue } from './native-chat-session-options'
export type {
AgentSessionOptionCatalog,
CatalogAgentInteractionDetection,
CatalogMidSessionApply,
CatalogModel,
CatalogOption,
+9 -2
View File
@@ -8,6 +8,14 @@ export type SessionOptionSelectChoice = {
export type SessionOptionValueSource = 'applied' | 'dispatched' | 'reported' | 'unknown'
/** Closed set of reasons an option is not settable in the current mode. A key
* (not free English) so the producer and the localized label stay in sync —
* an exhaustive switch turns any drift into a type error instead of leaking
* untranslated text. */
export type SessionOptionDisabledReason =
| 'available-after-session-start'
| 'set-when-session-starts'
export type SessionOptionDescriptor = {
id: string
label: string
@@ -22,7 +30,7 @@ export type SessionOptionDescriptor = {
| { type: 'boolean'; currentValue?: boolean }
valueSource: SessionOptionValueSource
settable: boolean
disabledReason?: string
disabledReason?: SessionOptionDisabledReason
/** Why: picker-only and toggle-only PTY commands cannot be represented as
* a truthful radio/checkbox state, so the producer exposes an action row. */
action?: { type: 'agent-picker' | 'toggle-command' }
@@ -30,7 +38,6 @@ export type SessionOptionDescriptor = {
export type SessionOptionSetResult = {
snapshot: SessionOptionDescriptor[]
notice?: string
}
export type PersistedNativeChatSessionOptions = Partial<