diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7e4ac17933f..0eb894a487b 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -3675,6 +3675,7 @@ export default function SessionScreen() { getActiveWorktreeConnectionId, beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, + readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft, showToast, onNativeChatSendError: nativeChatSendError.show, onSuccess: triggerSelection, diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index c987e3f9774..46ac4730f2e 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' function sendResult(accepted: boolean, id = 'send'): RpcSuccess { return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } } @@ -101,3 +102,52 @@ describe('pasteMobileNativeChatImagePaths', () => { } }) }) + +describe('clearing a parked multi-line launch draft before the image paste', () => { + it('leads with the caller-sized burst instead of one Ctrl+U', async () => { + // One Ctrl+U kills only the LAST line, so the draft's earlier lines would + // survive and ride along with the image as part of the prompt body. + const client = clientWithResponses([sendResult(true), sendResult(true)]) + const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png'], + clearInput + }) + + expect(client.calls[0]?.params.text).toBe(clearInput) + expect(client.calls[0]?.params.text).not.toBe('\x15') + }) + + it('clears once, before the paste — never between or after the image writes', async () => { + const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const clearInput = buildAgentTuiClearInputForText('a\nb\nc') + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png', '/tmp/b.png'], + clearInput + }) + + expect(client.calls.filter((call) => call.params.text === clearInput)).toHaveLength(1) + expect(client.calls[0]?.params.text).toBe(clearInput) + }) + + it('falls back to a single Ctrl+U when no draft is parked', async () => { + const client = clientWithResponses([sendResult(true), sendResult(true)]) + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png'] + }) + + expect(client.calls[0]?.params.text).toBe('\x15') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts index 4d52c59b0bb..adb996b7612 100644 --- a/mobile/src/session/mobile-native-chat-image-send.ts +++ b/mobile/src/session/mobile-native-chat-image-send.ts @@ -26,6 +26,10 @@ type PasteImagesArgs = { /** Budget shared with the rest of the user action (the text body that follows, or * the send this is healing for). Omit to open a fresh one for this paste alone. */ readonly deadline?: number + /** Bytes for the leading clear. Defaults to a single Ctrl+U, which clears only + * ONE logical line — callers holding a parked multi-line launch draft must + * pass a burst, or its earlier lines survive and glue onto the message. */ + readonly clearInput?: string } /** Clears the agent's unsubmitted input line, then pastes each uploaded image @@ -38,7 +42,8 @@ export async function pasteMobileNativeChatImagePaths({ terminal, deviceToken, imagePaths, - deadline: sharedDeadline + deadline: sharedDeadline, + clearInput }: PasteImagesArgs): Promise { const mobileClient: MobileTerminalClient | null = deviceToken ? { id: deviceToken, type: 'mobile' } @@ -49,7 +54,7 @@ export async function pasteMobileNativeChatImagePaths({ // once and let each write draw from what's left. const deadline = sharedDeadline ?? openMobileNativeChatSendBudget() for (const text of [ - MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, ...imagePaths.map(buildMobileImagePastePayload) ]) { const remainingMs = deadline - Date.now() diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 1f3d57985ac..fecc73fd11a 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -8,6 +8,7 @@ import { sendMobileNativeChatMessage, sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' function clientWithResponse(response: unknown): RpcClient { return { @@ -285,3 +286,68 @@ describe('sendMobileNativeChatMessage', () => { expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS) }) }) + +describe('clearing a parked multi-line launch draft', () => { + const accepted = { + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } + const sentText = (client: RpcClient): string => + (vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text + + it('uses the caller-sized burst instead of one Ctrl+U', async () => { + // One Ctrl+U kills only the LAST line, so the draft's earlier lines would + // survive and glue onto this message. + const client = clientWithResponse(accepted) + const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + clearInputFirst: true, + clearInput + }) + expect(sentText(client)).toBe(`${clearInput}hello`) + expect(sentText(client)).not.toBe(`\x15hello`) + }) + + it('rides the clear in the SAME write as the body so nothing can land between', async () => { + const client = clientWithResponse(accepted) + const clearInput = buildAgentTuiClearInputForText('a\nb') + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'body', + clearInputFirst: true, + clearInput + }) + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(sentText(client).endsWith('body')).toBe(true) + }) + + it('falls back to a single Ctrl+U when no draft is parked', async () => { + const client = clientWithResponse(accepted) + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + clearInputFirst: true + }) + expect(sentText(client)).toBe('\x15hello') + }) + + it('never prefixes a clear when the caller already pasted (image sends)', async () => { + // A second clear here would wipe the image that was just pasted. + const client = clientWithResponse(accepted) + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'caption', + clearInputFirst: false, + clearInput: buildAgentTuiClearInputForText('a\nb') + }) + expect(sentText(client)).toBe('caption') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ec8a0f97861..1098b9aaa8c 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -11,6 +11,11 @@ type MobileTerminalClient = { // Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the // same byte before its body), so a launch-context prefill parked there cannot // concatenate with a mobile chat message. The host writes text bytes verbatim. +// +// One Ctrl+U clears ONE logical line. A parked launch draft is routinely +// multi-line (every Linear block is), so callers that know one is parked pass +// `clearInput` built by buildAgentTuiClearInputForText — see +// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law. const CLEAR_UNSUBMITTED_INPUT = '\x15' type MobileNativeChatSendArgs = { @@ -19,6 +24,9 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean clearInputFirst?: boolean + /** Bytes used to clear the input line. Defaults to a single Ctrl+U, which is + * enough only when the line cannot be holding a multi-line parked draft. */ + clearInput?: string mobileClient?: MobileTerminalClient /** Shared budget for a whole user action (heal → paste → text, or one selector's * keystroke sequence). Omit to give this write its own full budget. */ @@ -57,7 +65,11 @@ export async function sendMobileNativeChatMessageWithOutcome( 'terminal.send', { terminal: args.terminal, - text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, + // The clear rides in the SAME write as the body, so the TUI consumes it + // first by stream order — no window for anything to land between them. + text: args.clearInputFirst + ? `${args.clearInput ?? CLEAR_UNSUBMITTED_INPUT}${args.text}` + : args.text, enter: args.enter ?? true, ...(args.mobileClient ? { client: args.mobileClient } : {}) }, diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index db285aecb42..9ebf45db81e 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -35,6 +35,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ setComposerText: vi.fn(), pending: [], captureSendOrigin, + readSeededLaunchDraft: () => null, clearDraftForSend, restoreRejectedDraft, acceptSend, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 255e28981d9..fe0f0a53395 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -73,6 +73,9 @@ export type MobileNativeChatController = { images?: string[], deadline?: number ) => Promise + /** Launch-context text still parked on the agent's TUI input line, or null. + * Image sends read it to size their leading clear (one Ctrl+U per line). */ + readSeededLaunchDraft: () => string | null } /** Owns mobile native-chat state and teardown outside the already dense session @@ -134,6 +137,7 @@ export function useMobileNativeChatController(args: { setComposerText: setChatComposerText, pending: chatPending, captureSendOrigin, + readSeededLaunchDraft, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -243,6 +247,7 @@ export function useMobileNativeChatController(args: { handleRef: activeHandleRef, deviceTokenRef, captureSendOrigin, + readSeededLaunchDraft, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -278,6 +283,7 @@ export function useMobileNativeChatController(args: { loadNativeChatFiles, handleNativeChatQuestionAnswer, handleNativeChatSend, - handleNativeChatSendWithOutcome + handleNativeChatSendWithOutcome, + readSeededLaunchDraft } } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 79b82ad5cc6..2009b591987 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -8,6 +8,7 @@ import { type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' export type MobileNativeChatPendingMessage = { id: string @@ -55,6 +56,10 @@ export function useMobileNativeChatDrafts(args: { setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + /** Launch-context text still believed to be parked on the agent's TUI input + * line, or null once it has been declined or retired. Send paths size their + * pre-clear from it, since one Ctrl+U clears only one logical line. */ + readSeededLaunchDraft: () => string | null /** Clear the composer at send time, before the RPC settles. */ clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void /** Put the text back after a definite rejection, unless newer edits exist. */ @@ -91,62 +96,14 @@ export function useMobileNativeChatDrafts(args: { activePendingKeyRef.current = pendingKey const mountedRef = useRef(false) - // Seeded launch-context text per tab; '' marks a permanent decline so a - // cleared composer never resurrects the prefill. - const seededLaunchDraftByKeyRef = useRef(new Map()) - - // Why: launch context delivered as a TUI-input prefill is invisible in chat; - // adopt it once as the composer draft so mobile shows the same context. - useEffect(() => { - if ( - !draftKey || - !chatActive || - !launchDraft?.trim() || - seededLaunchDraftByKeyRef.current.has(draftKey) - ) { - return - } - // Why: `session.tabs` carries launchDraft before the transcript read settles, - // and an empty (or previous tab's) list would let the decline below misjudge - // an already-submitted prefill — long enough for a send to duplicate it. - if (transcriptLoading) { - return - } - // A user turn already in the transcript means the one-line TUI prefill was - // submitted or deliberately cleared; decline instead of resurrecting it. - if (messages.some((message) => normalizedUserText(message) !== null)) { - seededLaunchDraftByKeyRef.current.set(draftKey, '') - return - } - seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft) - setDrafts((previous) => - (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous - ) - }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) - - // Drop an untouched adopted copy once the prefill is resolved elsewhere — a - // user turn landed (sent or cleared TUI-side) or the host stopped publishing - // it (desktop sent or reconciled it). User edits are always kept. - useEffect(() => { - // Same gates as the seed: off-chat there is no retraction to read (the tab - // publishes no draft to us), and an untrusted transcript would wipe an - // untouched copy on the strength of another tab's user turns. - if (!draftKey || !chatActive || transcriptLoading) { - return - } - const seeded = seededLaunchDraftByKeyRef.current.get(draftKey) - if (!seeded) { - return - } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) - if (!hasUserTurn && launchDraft?.trim()) { - return - } - seededLaunchDraftByKeyRef.current.set(draftKey, '') - setDrafts((previous) => - (previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous - ) - }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) + const { readSeededLaunchDraft } = useMobileNativeChatLaunchDraftSeed({ + draftKey, + messages, + launchDraft, + chatActive, + transcriptLoading, + setDrafts + }) const setComposerText: Dispatch> = useCallback( (value) => { @@ -358,6 +315,7 @@ export function useMobileNativeChatDrafts(args: { setComposerText, pending, captureSendOrigin, + readSeededLaunchDraft, clearDraftForSend, restoreRejectedDraft, acceptSend, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 68ae3e2dc05..be2f9a3730e 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -1,5 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' @@ -69,6 +70,7 @@ function baseArgs(overrides: Partial & Pick): Hook showToast: vi.fn(), onSendError: vi.fn(), baseSend: vi.fn().mockResolvedValue('accepted'), + readSeededLaunchDraft: () => null, sleep: async () => {}, ...overrides } @@ -201,6 +203,40 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.attachments).toEqual([]) }) + it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => { + // A single Ctrl+U kills only the last line, so the draft's earlier lines + // would survive the clear and ride along with the image as prompt body. + pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + const client = makeClient([ + methodNotFound('start'), + ok('save', '/tmp/a.png'), + sendResult(true), + sendResult(true) + ]) + const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + mount( + baseArgs({ + client: client as unknown as RpcClient, + deviceTokenRef: { current: 'device-1' }, + readSeededLaunchDraft: () => draft + }) + ) + + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('look at this') + }) + + const firstSend = client.calls.find((c) => c.method === 'terminal.send') + expect(firstSend?.params).toMatchObject({ + text: buildAgentTuiClearInputForText(draft), + enter: false + }) + expect(firstSend?.params.text).not.toBe('\x15') + }) + it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 7ce1ad8978b..7cd1f437b29 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react' import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { @@ -56,6 +57,10 @@ type Args = { imagePreviewUris?: string[], deadline?: number ) => Promise + /** Launch-context text parked on the agent's TUI input line, or null. The + * paste's leading clear must cover every line of it, or the draft's earlier + * lines survive and ride along with the image. */ + readonly readSeededLaunchDraft: () => string | null readonly onAttachSuccess?: () => void readonly onError?: () => void // Injected so the settle between image paste and submit is instant in tests. @@ -106,6 +111,7 @@ export function useMobileNativeChatImageAttachments({ showToast, onSendError, baseSend, + readSeededLaunchDraft, onAttachSuccess, onError, sleep = defaultSleep @@ -269,12 +275,16 @@ export function useMobileNativeChatImageAttachments({ return false } try { + const seededLaunchDraft = readSeededLaunchDraft() const pasted = await pasteMobileNativeChatImagePaths({ client, terminal: handle, deviceToken: deviceTokenRef.current, imagePaths: pendingImages.map((attachment) => attachment.path), - deadline + deadline, + ...(seededLaunchDraft + ? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) } + : {}) }) if (!pasted) { // Keep the chips so the user can retry; the failed paste never submitted. diff --git a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts new file mode 100644 index 00000000000..f8bef4ebf06 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { normalizedUserText } from './mobile-native-chat-draft-reconcile' + +/** + * Adopting the host's launch-context prefill as the mobile composer draft, and + * retiring it again once it is resolved elsewhere. Split out of the drafts hook + * so the general draft/pending accounting stays separate from this one concern. + */ +export function useMobileNativeChatLaunchDraftSeed(args: { + draftKey: string | null + messages: readonly NativeChatMessage[] + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string | null + chatActive: boolean + transcriptLoading?: boolean + setDrafts: Dispatch>> +}): { + /** Text still believed to be parked on the agent's TUI input line, or null + * once declined or retired. Send paths size their pre-clear from it, since + * one Ctrl+U clears only one logical line. */ + readSeededLaunchDraft: () => string | null +} { + const { draftKey, messages, launchDraft, chatActive, transcriptLoading, setDrafts } = args + + // Seeded launch-context text per tab; '' marks a permanent decline so a + // cleared composer never resurrects the prefill. + const seededLaunchDraftByKeyRef = useRef(new Map()) + + // Why: launch context delivered as a TUI-input prefill is invisible in chat; + // adopt it once as the composer draft so mobile shows the same context. + useEffect(() => { + if ( + !draftKey || + !chatActive || + !launchDraft?.trim() || + seededLaunchDraftByKeyRef.current.has(draftKey) + ) { + return + } + // Why: `session.tabs` carries launchDraft before the transcript read settles, + // and an empty (or previous tab's) list would let the decline below misjudge + // an already-submitted prefill — long enough for a send to duplicate it. + if (transcriptLoading) { + return + } + // A user turn already in the transcript means the TUI prefill was submitted + // or deliberately cleared; decline instead of resurrecting it. + if (messages.some((message) => normalizedUserText(message) !== null)) { + seededLaunchDraftByKeyRef.current.set(draftKey, '') + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft) + setDrafts((previous) => + (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous + ) + }, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading]) + + // Drop an untouched adopted copy once the prefill is resolved elsewhere — a + // user turn landed (sent or cleared TUI-side) or the host stopped publishing + // it (desktop sent or reconciled it). User edits are always kept. + useEffect(() => { + // Same gates as the seed: off-chat there is no retraction to read (the tab + // publishes no draft to us), and an untrusted transcript would wipe an + // untouched copy on the strength of another tab's user turns. + if (!draftKey || !chatActive || transcriptLoading) { + return + } + const seeded = seededLaunchDraftByKeyRef.current.get(draftKey) + if (!seeded) { + return + } + const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + if (!hasUserTurn && launchDraft?.trim()) { + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, '') + setDrafts((previous) => + (previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous + ) + }, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading]) + + // '' in the map is a permanent decline, not a parked draft — both mean there is + // nothing of ours on the TUI line, so both read as null. + const readSeededLaunchDraft = useCallback( + () => (draftKey ? seededLaunchDraftByKeyRef.current.get(draftKey) || null : null), + [draftKey] + ) + + return { readSeededLaunchDraft } +} diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts new file mode 100644 index 00000000000..3b345e6d615 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -0,0 +1,108 @@ +// Covers the wiring the image-attachments suite structurally cannot: that hook +// injects its own baseSend stub, so it never observes the real send params. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sendWithOutcome = vi.fn() +vi.mock('./mobile-native-chat-send', () => ({ + sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + openMobileNativeChatSendBudget: () => Date.now() + 15_000, + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, + MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS: 2_000 +})) +vi.mock('./mobile-native-chat-stale-input', () => ({ + healMobileNativeChatStaleInput: () => Promise.resolve(true) +})) + +import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' + +type Send = ReturnType + +const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + +describe('useMobileNativeChatMessageSend', () => { + let renderer: ReactTestRenderer | null = null + let api: Send | null = null + + const mount = (readSeededLaunchDraft: () => string | null): void => { + function Probe(): null { + api = useMobileNativeChatMessageSend({ + client: { sendRequest: vi.fn() } as never, + enabled: true, + handleRef: { current: 'term' }, + deviceTokenRef: { current: 'device' }, + captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, + readSeededLaunchDraft, + clearDraftForSend: () => {}, + restoreRejectedDraft: () => {}, + acceptSend: () => {}, + holdUnconfirmedSend: () => {}, + onSendError: () => {} + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + } + + const sentArgs = (): { clearInput?: string; clearInputFirst?: boolean } => + sendWithOutcome.mock.calls[0]![0] as { clearInput?: string; clearInputFirst?: boolean } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + sendWithOutcome.mockReset() + sendWithOutcome.mockResolvedValue('accepted') + }) + afterEach(() => { + act(() => { + renderer?.unmount() + }) + renderer = null + api = null + }) + + it('sizes the pre-clear to every line of a parked launch draft', async () => { + mount(() => DRAFT) + await act(async () => { + await api!.send('hello') + }) + expect(sentArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT)) + }) + + it('sends no clearInput at all when nothing is parked on the line', async () => { + mount(() => null) + await act(async () => { + await api!.send('hello') + }) + expect(sentArgs().clearInput).toBeUndefined() + }) + + it('reads the draft at send time, so a retired seed stops widening the clear', async () => { + let parked: string | null = DRAFT + mount(() => parked) + await act(async () => { + await api!.send('first') + }) + parked = null + await act(async () => { + await api!.send('second') + }) + expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true }) + expect( + (sendWithOutcome.mock.calls[1]![0] as { clearInput?: string }).clearInput + ).toBeUndefined() + }) + + it('still skips the pre-clear for an image send, which pasted its own first', async () => { + // A second clear here would wipe the image that was just pasted. + mount(() => DRAFT) + await act(async () => { + await api!.send('caption', ['file:///a.png']) + }) + expect(sentArgs().clearInputFirst).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 366dd426570..c005cfd25b7 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -7,6 +7,7 @@ import { } from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' export type MobileNativeChatMessageSend = { /** Composer send that syncs the draft (clear on send, restore on rejection). */ @@ -32,6 +33,9 @@ export function useMobileNativeChatMessageSend(args: { handleRef: MutableRefObject deviceTokenRef: MutableRefObject captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + /** Launch-context text Orca parked on the agent's TUI input line, or null. Read + * at send time so the pre-clear can be sized to every line it occupies. */ + readSeededLaunchDraft: () => string | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void @@ -48,6 +52,7 @@ export function useMobileNativeChatMessageSend(args: { handleRef, deviceTokenRef, captureSendOrigin, + readSeededLaunchDraft, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -95,10 +100,20 @@ export function useMobileNativeChatMessageSend(args: { if (syncComposer) { clearDraftForSend(origin, text) } + // Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears + // only one logical line. Size the clear to the text Orca injected, with + // slack — the user can also have typed into the TUI line directly, so that + // line count is a lower bound. Mobile cannot read the agent's screen, so + // there is no empty-line observable to confirm against here; the upper + // bound plus the atomic clear+body write is what makes it safe. + const seededLaunchDraft = readSeededLaunchDraft() const outcome = await sendMobileNativeChatMessageWithOutcome({ client, terminal: handle, text, + ...(seededLaunchDraft + ? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) } + : {}), // Why: pre-clear only when nothing was deliberately pasted first. The heal // above fires only for terminals a mobile image paste marked, so a desktop // launch-draft prefill parked on the input line would otherwise glue onto @@ -141,6 +156,7 @@ export function useMobileNativeChatMessageSend(args: { handleRef, holdUnconfirmedSend, onSendError, + readSeededLaunchDraft, restoreRejectedDraft ] ) diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index ad4343ac87d..9b5a010df9f 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -31,6 +31,9 @@ type Args = { images?: string[], deadline?: number ) => Promise + /** Launch-context text parked on the agent's TUI input line, or null — sizes + * the image paste's leading clear so a multi-line draft cannot ride along. */ + readonly readSeededLaunchDraft: () => string | null readonly showToast: (message: string, durationMs?: number) => void /** Native-chat send failures — rendered in the composer's inline banner. */ readonly onNativeChatSendError: (message: string) => void @@ -54,6 +57,7 @@ export function useMobileSessionImageAttachments({ getActiveWorktreeConnectionId, beforeTerminalSend, nativeChatBaseSend, + readSeededLaunchDraft, showToast, onNativeChatSendError, onSuccess, @@ -86,6 +90,7 @@ export function useMobileSessionImageAttachments({ showToast, onSendError: onNativeChatSendError, baseSend: nativeChatBaseSend, + readSeededLaunchDraft, onAttachSuccess: onSuccess, onError }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d58cc2b4094..3e328f3784b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -5,9 +5,11 @@ import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { sendNativeChatMessage, sendNativeChatMessageWithImageAttachments, + submitNativeChatDraftInPlace, submitNativeChatPrompt } from './native-chat-runtime-send' import type { NativeChatSendHandle } from './native-chat-runtime-send' +import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { @@ -250,21 +252,37 @@ export const NativeChatComposer = forwardRef 0, + readScreen: () => readTerminalScreen?.() + }) let pendingHandle: NativeChatSendHandle | null = null // Why: image attachments take the attachment send path even for a // command/unknown send, otherwise `clearImageAttachments()` below drops // them silently when the text starts with the agent's slash/skill prefix. - if (classification !== 'chat' && imagePaths.length === 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) + if (launchDraftPlan.kind === 'submit-in-place') { + pendingHandle = submitNativeChatDraftInPlace(target.settings, target.ptyId) + } else if (classification !== 'chat' && imagePaths.length === 0) { + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) } else if (imagePaths.length > 0) { pendingHandle = sendNativeChatMessageWithImageAttachments( target.settings, target.ptyId, text, - imagePaths + imagePaths, + sendOptions ) } else if (text.trim().length > 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) } else { submitNativeChatPrompt(target.settings, target.ptyId) } @@ -296,8 +314,9 @@ export const NativeChatComposer = forwardRef[0]> = {}) => + planNativeChatLaunchDraftSend({ + seededText: SEEDED, + text: SEEDED, + hasImages: false, + readScreen: () => screenHoldingDraft, + ...over + }) + +describe('planNativeChatLaunchDraftSend', () => { + it('submits in place when the composer still holds exactly the injected draft', () => { + expect(plan()).toEqual({ kind: 'submit-in-place' }) + }) + + it('keeps the ordinary send path when nothing is parked on the line', () => { + expect(plan({ seededText: null })).toEqual({ kind: 'default' }) + expect(plan({ seededText: ' ' })).toEqual({ kind: 'default' }) + }) + + it('replaces the draft when the user edited it, sizing the clear from the INJECTED text', () => { + // The buffer holds what was injected, not what the composer now shows — a + // burst sized from the (possibly one-line) edit would leave earlier lines. + const result = plan({ text: 'just one line now' }) + expect(result).toEqual({ + kind: 'replace-draft', + clearInput: buildAgentTuiClearInputForText(SEEDED), + seededText: SEEDED + }) + }) + + it('never submits in place for an image send, but still clears every draft line', () => { + const result = plan({ hasImages: true }) + expect(result).toEqual({ + kind: 'replace-draft', + clearInput: buildAgentTuiClearInputForText(SEEDED), + seededText: SEEDED + }) + }) + + // The hazard: the user can clear or retype the TUI line directly in terminal + // view while the composer copy lives on. Pressing Enter then submits the wrong + // buffer — or an empty one — and the message is silently lost. + it('falls back to replace-draft when the input line no longer shows the draft', () => { + expect(plan({ readScreen: () => screenClear }).kind).toBe('replace-draft') + }) + + it('falls back to replace-draft when the screen cannot be read at all', () => { + expect(plan({ readScreen: () => null }).kind).toBe('replace-draft') + expect(plan({ readScreen: () => undefined }).kind).toBe('replace-draft') + }) + + it('sizes a multi-line clear well past a single Ctrl+U', () => { + const result = plan({ text: 'edited' }) + expect(result.kind === 'replace-draft' && result.clearInput.length).toBeGreaterThan( + AGENT_TUI_CLEAR_INPUT_LINE.length + ) + }) +}) + +describe('agentInputLineShowsSeededDraft', () => { + it('sees the draft still parked on the input line', () => { + expect(agentInputLineShowsSeededDraft(screenHoldingDraft, SEEDED)).toBe(true) + }) + + it('reports gone once the TUI redraws its placeholder', () => { + expect(agentInputLineShowsSeededDraft(screenClear, SEEDED)).toBe(false) + }) + + it('reads codex frames too', () => { + const codex = ['› Linked Linear issue: ABC-123', ' gpt-5.6 · ~/repo'].join('\n') + expect(agentInputLineShowsSeededDraft(codex, SEEDED)).toBe(true) + }) + + it('ignores the same text sitting ABOVE as an already-sent turn', () => { + // Otherwise a sent draft reads as residue forever and every send re-clears. + const afterSend = [ + '> Linked Linear issue: ABC-123', + ' https://linear.app/x/issue/ABC-123', + '────────────────────────────────────────', + '❯ Try "create a util logging.py that..."', + '────────────────────────────────────────' + ].join('\n') + expect(agentInputLineShowsSeededDraft(afterSend, SEEDED)).toBe(false) + }) + + it('still sees a draft whose first line WRAPPED across rows', () => { + // The serializer breaks a wrapped line with a newline. A fingerprint long + // enough to span that break would miss and report the draft gone — the + // unsafe direction, since the caller would then paste on top of it. + const wrapped = [ + '────────────────────', + '❯ Linked Linear issu', + ' e: ABC-123', + ' https://linear.app', + '────────────────────' + ].join('\n') + expect(agentInputLineShowsSeededDraft(wrapped, SEEDED)).toBe(true) + }) + + it('returns null — not a verdict — when nothing can be observed', () => { + expect(agentInputLineShowsSeededDraft(null, SEEDED)).toBeNull() + expect(agentInputLineShowsSeededDraft('no prompt glyph anywhere', SEEDED)).toBeNull() + expect(agentInputLineShowsSeededDraft(screenHoldingDraft, ' ')).toBeNull() + }) + + it('matches through the ANSI the serializer leaves in the frame', () => { + const ansi = `❯ Linked Linear issue: ABC-123` + expect(agentInputLineShowsSeededDraft(ansi, SEEDED)).toBe(true) + }) +}) + +describe('agentInputLineClearedSeededDraft', () => { + it('confirms cleared only on a positive observation that the draft is gone', () => { + expect(agentInputLineClearedSeededDraft(screenClear, SEEDED)).toBe(true) + }) + + it('treats an unreadable screen as NOT cleared so the caller re-clears', () => { + // Wrong in this direction costs one harmless extra burst; the other direction + // pastes on top of residue and concatenates. + expect(agentInputLineClearedSeededDraft(null, SEEDED)).toBe(false) + expect(agentInputLineClearedSeededDraft('unparseable', SEEDED)).toBe(false) + }) + + it('reports not-cleared while the draft is still on the line', () => { + expect(agentInputLineClearedSeededDraft(screenHoldingDraft, SEEDED)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts new file mode 100644 index 00000000000..3a242cf3b13 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts @@ -0,0 +1,154 @@ +// Choosing how a chat send lands when the agent's TUI input line still holds a +// launch-context draft that Orca itself injected. +// +// The point of the plan: Orca INJECTED that text, so when the input line still +// holds exactly what was injected, the buffer already IS the message. The send +// is then just the submit key — no clear, no paste, nothing that can +// concatenate, and multi-line submits as one turn with no buffer surgery. + +import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +export type NativeChatLaunchDraftSendPlan = + /** Input line already holds exactly this text — press Enter and nothing else. */ + | { kind: 'submit-in-place' } + /** Input line holds a stale injected draft — replace it, clearing every line. */ + | { kind: 'replace-draft'; clearInput: string; seededText: string } + /** No injected draft is parked on the line; keep the ordinary send path. */ + | { kind: 'default' } + +/** Prompt glyphs both supported agent TUIs draw at the start of the input line. */ +const COMPOSER_PROMPT_LINE = /^\s*[❯›]\s?/ + +/** + * A short prefix of the draft's first line. Deliberately SHORT: the input line + * wraps long text, and a fingerprint spanning a wrap would go unmatched — so a + * long one would report "gone" for a draft that is still sitting there. + */ +const DRAFT_FINGERPRINT_LENGTH = 12 + +function seededDraftFingerprint(seededText: string): string { + const firstLine = seededText.split(/\r\n|\r|\n/).find((line) => line.trim() !== '') ?? '' + return firstLine.trim().slice(0, DRAFT_FINGERPRINT_LENGTH) +} + +/** + * Whether the agent's rendered input line still shows the injected draft. + * `null` means it could not be observed at all (no screen, no locatable prompt + * line, no usable fingerprint) — callers must treat that as "unknown" and pick + * whichever branch is safe when wrong, never as evidence either way. + * + * Both TUIs redraw their placeholder once the buffer is genuinely empty, so a + * `false` here is a real observation rather than an assumption that a write landed. + */ +export function agentInputLineShowsSeededDraft( + screen: string | null | undefined, + seededText: string +): boolean | null { + if (!screen) { + return null + } + const fingerprint = seededDraftFingerprint(seededText) + if (fingerprint === '') { + return null + } + const lines = stripScrollbackAnsi(screen).split('\n') + let promptIndex = -1 + for (let index = lines.length - 1; index >= 0; index -= 1) { + if (COMPOSER_PROMPT_LINE.test(lines[index]!)) { + promptIndex = index + break + } + } + if (promptIndex < 0) { + return null + } + // Only the input line downward: the same text sitting in the transcript above + // is a sent turn, not residue, and must not be read as a still-parked draft. + return lines.slice(promptIndex).join('\n').includes(fingerprint) +} + +/** + * Submitting in place is only safe on POSITIVE evidence that the draft is still + * on the input line. The user may have cleared or edited that line directly in + * terminal view while the composer copy lived on, and pressing Enter on a buffer + * that no longer holds the draft would submit the wrong thing (or nothing at + * all) and silently lose the message. Anything short of "yes, it is still there" + * falls through to replace-draft, which is correct in every state. + */ +export function planNativeChatLaunchDraftSend(args: { + /** Text Orca injected into the TUI line, or null when nothing is parked there. */ + seededText: string | null | undefined + text: string + hasImages: boolean + readScreen: () => string | null | undefined +}): NativeChatLaunchDraftSendPlan { + const seededText = args.seededText + if (!seededText || seededText.trim() === '') { + return { kind: 'default' } + } + // Images must be pasted, so they can never submit in place — but they still + // need the multi-line clear, since the image path's own single Ctrl+U would + // leave every earlier line of the draft to glue onto the message. + if ( + !args.hasImages && + args.text === seededText && + agentInputLineShowsSeededDraft(args.readScreen(), seededText) === true + ) { + return { kind: 'submit-in-place' } + } + return { + kind: 'replace-draft', + clearInput: buildAgentTuiClearInputForText(seededText), + seededText + } +} + +/** + * Did the clear empty the input line? Only a positive observation that the draft + * is GONE counts; unknown stays uncleared so the caller widens to a maximal + * burst. A wasted burst is harmless (measured), a missed one concatenates. + */ +export function agentInputLineClearedSeededDraft( + screen: string | null | undefined, + seededText: string +): boolean { + return agentInputLineShowsSeededDraft(screen, seededText) === false +} + +/** What a composer send needs: which path to take, and the clear/confirm bytes + * the ordinary send paths should use when it is a draft replacement. */ +export function resolveNativeChatLaunchDraftSend(args: { + launchDraft: { agent: string; text: string } | null | undefined + launchDraftResolved: boolean + agent: string + text: string + hasImages: boolean + readScreen: () => string | null | undefined +}): { + plan: NativeChatLaunchDraftSendPlan + sendOptions: { clearInput: string; confirmCleared: () => boolean } | undefined +} { + const { launchDraft, launchDraftResolved, agent, readScreen } = args + // A resolved draft was already submitted or cleared TUI-side, so nothing of + // ours is on the line any more — treating it as parked would clear or submit + // a buffer that no longer holds it. + const seededText = + launchDraft && launchDraft.agent === agent && !launchDraftResolved ? launchDraft.text : null + const plan = planNativeChatLaunchDraftSend({ + seededText, + text: args.text, + hasImages: args.hasImages, + readScreen + }) + if (plan.kind !== 'replace-draft') { + return { plan, sendOptions: undefined } + } + return { + plan, + sendOptions: { + clearInput: plan.clearInput, + confirmCleared: () => agentInputLineClearedSeededDraft(readScreen(), plan.seededText) + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts new file mode 100644 index 00000000000..91165164096 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -0,0 +1,140 @@ +// Send-path behaviour when a launch-context draft is still parked on the agent's +// TUI input line: submit-in-place, the multi-line clear, and the confirm step. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sendRuntimePtyInput = vi.fn() +const sendRuntimePtyInputVerified = vi.fn() +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) +})) + +import { + NATIVE_CHAT_CLEAR_CONFIRM_MS, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + NATIVE_CHAT_SUBMIT_DELAY_MS, + resetNativeChatPtySendQueuesForTests, + sendNativeChatMessage, + sendNativeChatMessageWithImageAttachments, + submitNativeChatDraftInPlace +} from './native-chat-runtime-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { + AGENT_TUI_CLEAR_INPUT_MAX, + buildAgentTuiClearInputForText +} from '../../../../shared/agent-tui-input-clear' + +const SETTINGS = {} as Parameters[0] +const PTY = 'pty-launch-draft' +const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + +const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => call[2] as string) + +beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInput.mockClear() + sendRuntimePtyInput.mockReturnValue(true) + resetNativeChatPtySendQueuesForTests() +}) +afterEach(() => { + vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() +}) + +describe('submitNativeChatDraftInPlace', () => { + it('writes ONLY the submit key — no clear, no body', () => { + // The buffer already holds the message, so anything else would either wipe + // it or paste a second copy alongside it. + submitNativeChatDraftInPlace(SETTINGS, PTY) + expect(writes()).toEqual([NATIVE_CHAT_SUBMIT]) + }) + + it('does not write the clear byte at all', () => { + submitNativeChatDraftInPlace(SETTINGS, PTY) + expect(writes()).not.toContain(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + }) +}) + +describe('sendNativeChatMessage with a parked multi-line draft', () => { + it('leads with a clear sized to every line of the draft, not one Ctrl+U', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited text', { clearInput }) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited text')]) + expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + }) + + it('still defaults to a single Ctrl+U when no draft is parked', () => { + sendNativeChatMessage(SETTINGS, PTY, 'plain') + expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + }) + + it('holds the body until the clear is confirmed, then submits after the gap', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput, + confirmCleared: () => true + }) + // Body must NOT ride out with the clear — the confirm happens in between. + expect(writes()).toEqual([clearInput]) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited')]) + vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT]) + }) + + it('widens to a maximal burst when the draft is still observed on the line', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput, + confirmCleared: () => false + }) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS) + expect(writes()).toEqual([ + clearInput, + AGENT_TUI_CLEAR_INPUT_MAX, + buildNativeChatPasteBytes('edited') + ]) + }) + + it('re-clears before the body, never after it', () => { + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput: buildAgentTuiClearInputForText(DRAFT), + confirmCleared: () => false + }) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS) + const order = writes() + expect(order.indexOf(AGENT_TUI_CLEAR_INPUT_MAX)).toBeLessThan( + order.indexOf(buildNativeChatPasteBytes('edited')) + ) + }) + + it('charges the confirm gap to the handle so the send card outlives the Enter', () => { + const withConfirm = sendNativeChatMessage(SETTINGS, PTY, 'a', { + clearInput: '\x15', + confirmCleared: () => true + }) + expect(withConfirm.settleAfterMs).toBe( + NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_CLEAR_CONFIRM_MS + ) + }) +}) + +describe('image sends with a parked multi-line draft', () => { + it('clears every draft line before pasting, so no line rides along with the image', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + clearInput + }) + expect(writes()[0]).toBe(clearInput) + }) + + it('clears exactly once — a second Ctrl+U would wipe the just-pasted image', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + clearInput + }) + vi.advanceTimersByTime(10_000) + expect(writes().filter((write) => write === clearInput)).toHaveLength(1) + }) +}) 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 33e653d073a..a604ee6b21a 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 @@ -8,6 +8,7 @@ import { } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' +import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear' import { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, @@ -34,8 +35,28 @@ export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 // start from an empty line so a prior cancelled paste cannot glue onto the next // prompt. Not used on verified option commands — model-switch confirmation // observes the PTY and Ctrl+U can miss confirmation markers. +// +// One Ctrl+U only ever clears ONE logical line. When the line may hold an +// injected multi-line launch draft, callers pass `clearInput` built by +// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured +// 2N-1 law and the sequences that do NOT work. export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' +/** Gap before re-reading the agent's input line to confirm a clear landed. */ +export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 + +export type NativeChatSendOptions = { + /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ + clearInput?: string + /** + * Observed check that the input line is now free of the injected draft. + * Supplied only for launch-draft replacement; when it reports "not cleared" + * the send widens to a maximal burst before writing the body rather than + * pasting on top of residue. + */ + confirmCleared?: () => boolean +} + /** 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 = { @@ -46,8 +67,67 @@ export type NativeChatSendHandle = { type RuntimeSettings = ReturnType -function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) +function clearUnsubmittedAgentInput( + settings: RuntimeSettings, + ptyId: string, + options?: NativeChatSendOptions +): void { + sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) +} + +/** + * Run `writeBody` once the input line is clear. With no `confirmCleared` the + * clear is a plain in-order write on the same byte stream, so the TUI consumes + * it before the body and the body follows immediately. With one, we pause to + * actually look at the agent's input line, and widen to a maximal burst when the + * draft is still visible — the injected line count is only a lower bound on what + * the buffer holds, since the user can type into the TUI directly. + */ +function clearThenWrite( + settings: RuntimeSettings, + ptyId: string, + options: NativeChatSendOptions | undefined, + delay: (ms: number, fn: () => void) => void, + writeBody: () => void +): void { + clearUnsubmittedAgentInput(settings, ptyId, options) + const confirmCleared = options?.confirmCleared + if (!confirmCleared) { + writeBody() + return + } + delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => { + if (!confirmCleared()) { + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX) + } + writeBody() + }) +} + +/** Extra time a send needs when it stops to confirm the clear before the body. */ +function clearConfirmDurationMs(options?: NativeChatSendOptions): number { + return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 +} + +/** + * Submit what the agent's input line ALREADY holds — Enter and nothing else. + * + * Used when the composer still holds exactly the launch draft Orca injected, so + * the TUI buffer is already the message. No clear and no paste means nothing can + * concatenate and a multi-line draft submits as one turn for free (measured: a + * bare CR submits a multi-line buffer whole on both agents). + */ +export function submitNativeChatDraftInPlace( + settings: RuntimeSettings, + ptyId: string +): NativeChatSendHandle { + return enqueueNativeChatPtySend(ptyId, 0, ({ isCancelled, markSubmitted }) => { + if (isCancelled()) { + return + } + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) } /** @@ -61,27 +141,29 @@ function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): v export function sendNativeChatMessage( settings: RuntimeSettings, ptyId: string, - text: string + text: string, + options?: NativeChatSendOptions ): NativeChatSendHandle { return enqueueNativeChatPtySend( ptyId, - NATIVE_CHAT_SUBMIT_DELAY_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options), ({ isCancelled, delay, markSubmitted }) => { if (isCancelled()) { return } - clearUnsubmittedAgentInput(settings, ptyId) - if (isCancelled()) { - return - } - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() + clearThenWrite(settings, ptyId, options, delay, () => { + if (isCancelled()) { + return + } + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) }) }, { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId) + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } ) } @@ -146,16 +228,17 @@ export function sendNativeChatMessageWithImageAttachments( settings: RuntimeSettings, ptyId: string, text: string, - imagePaths: readonly string[] + imagePaths: readonly string[], + options?: NativeChatSendOptions ): NativeChatSendHandle { if (imagePaths.length === 0) { - return sendNativeChatMessage(settings, ptyId, text) + return sendNativeChatMessage(settings, ptyId, text, options) } const trimmedText = text.trim() const durationMs = - trimmedText.length > 0 + (trimmedText.length > 0 ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS - : NATIVE_CHAT_SUBMIT_DELAY_MS + : NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options) return enqueueNativeChatPtySend( ptyId, durationMs, @@ -163,30 +246,31 @@ export function sendNativeChatMessageWithImageAttachments( if (isCancelled()) { return } - clearUnsubmittedAgentInput(settings, ptyId) - if (isCancelled()) { - return - } - for (const imagePath of imagePaths) { - sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) - } - if (trimmedText.length > 0) { - delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - }) - delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + clearThenWrite(settings, ptyId, options, delay, () => { + if (isCancelled()) { + return + } + for (const imagePath of imagePaths) { + sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) + } + if (trimmedText.length > 0) { + delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + }) + delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + return + } + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) markSubmitted() }) - return - } - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() }) }, { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId) + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } ) } diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 8672c681941..142832d221e 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -31,17 +31,20 @@ describe('seedNativeChatLaunchDraftForAgentTab', () => { vi.clearAllMocks() }) - it('rejects multi-line text at the helper, not just at the delivery caller', () => { - // Worktree-create and work-item launches seed through this helper directly - // (a Linear draft is always `Linked Linear issue: …\n\n`), so the - // Ctrl+U kill-to-start-of-LINE constraint has to live here. - seedNativeChatLaunchDraftForAgentTab({ + it('mirrors multi-line text — the majority of real drafts', () => { + // A Linear draft is always `Linked Linear issue: …\n\n`, so rejecting + // newlines made EVERY Linear launch invisible in chat. The send path no + // longer pastes on top of the prefill (it submits the buffer in place, or + // clears every line first), so there is nothing left to glue. + const text = 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n' + seedNativeChatLaunchDraftForAgentTab({ tabId: 'linear-tab', agent: 'codex', text }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ tabId: 'linear-tab', agent: 'codex', - text: 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n' + text, + createdAt: expect.any(Number) }) - - expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() }) it('seeds single-line text', () => { @@ -138,19 +141,23 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() }) - it('does not seed a launch draft for multi-line content', async () => { - // The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a - // multi-line prefill (e.g. scraped session-fork context) would leave earlier - // lines behind to glue onto the next message. + it('seeds a launch draft for multi-line content', async () => { + // Note+URL launches join with a blank line, so this shape is common too. + const content = 'Forked from session\n\nhttps://example.test/context' await deliverLaunchPromptToAgentTab({ tabId: 'fork-tab', agent: 'codex', - content: 'Forked from session\n\nhttps://example.test/context', + content, submit: false, forcePaste: false }) - expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'fork-tab', + agent: 'codex', + text: content, + createdAt: expect.any(Number) + }) }) it('does not seed a launch draft for submitted, unsupported, or empty content', async () => { diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 2f89856bb6a..8dfdc2604c8 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -12,14 +12,11 @@ export function seedNativeChatLaunchDraftForAgentTab(args: { agent: TuiAgent text: string }): void { - // Single-line only: the chat send pre-clears the TUI with Ctrl+U - // (kill-to-start-of-LINE), so a multi-line prefill (e.g. a Linear issue block) - // would leave earlier lines to glue onto the next message. - if ( - args.text.trim().length === 0 || - args.text.includes('\n') || - !isNativeChatSupportedAgent(args.agent) - ) { + // Multi-line drafts mirror too: the chat send no longer pastes on top of the + // parked prefill. It submits the TUI buffer in place when the composer still + // holds exactly what was injected, and otherwise clears every line before its + // body (agent-tui-input-clear.ts), so earlier lines cannot glue onto the message. + if (args.text.trim().length === 0 || !isNativeChatSupportedAgent(args.agent)) { return } useAppStore.getState().seedNativeChatLaunchDraft({ diff --git a/src/shared/agent-tui-input-clear.test.ts b/src/shared/agent-tui-input-clear.test.ts new file mode 100644 index 00000000000..9f3fdc6a80d --- /dev/null +++ b/src/shared/agent-tui-input-clear.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_TUI_CLEAR_INPUT_LINE, + AGENT_TUI_CLEAR_INPUT_MAX, + AGENT_TUI_CLEAR_LINE_SLACK, + AGENT_TUI_CLEAR_MAX_LINES, + buildAgentTuiClearInput, + buildAgentTuiClearInputForText, + countAgentTuiInputLines +} from './agent-tui-input-clear' + +const countCtrlU = (bytes: string): number => + bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_LINE).length + +describe('buildAgentTuiClearInput', () => { + // The measured law: N kills + (N-1) joins. A constant here silently under-clears + // (5 clears 3 lines but leaves residue at 4), which is what glues onto the next + // message — so pin every small N, not just one. + it.each([ + [1, 1], + [2, 3], + [3, 5], + [4, 7], + [10, 19] + ])('clears %i logical lines with %i Ctrl+U', (lines, expected) => { + expect(countCtrlU(buildAgentTuiClearInput(lines))).toBe(expected) + }) + + it('emits nothing but Ctrl+U — Ctrl+A/Ctrl+K do not clear a multi-line buffer', () => { + expect(buildAgentTuiClearInput(4)).toBe(AGENT_TUI_CLEAR_INPUT_LINE.repeat(7)) + }) + + it('still clears one line for a zero or negative count', () => { + expect(countCtrlU(buildAgentTuiClearInput(0))).toBe(1) + expect(countCtrlU(buildAgentTuiClearInput(-5))).toBe(1) + }) + + it('caps the burst so a pathological draft cannot emit an unbounded write', () => { + expect(countCtrlU(buildAgentTuiClearInput(10_000))).toBe(2 * AGENT_TUI_CLEAR_MAX_LINES - 1) + expect(AGENT_TUI_CLEAR_INPUT_MAX).toBe(buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES)) + }) +}) + +describe('countAgentTuiInputLines', () => { + it.each([ + ['one line', 1], + ['a\nb', 2], + ['a\r\nb\r\nc', 3], + ['a\rb', 2], + ['trailing\n', 2] + ])('counts %j as %i logical lines', (text, expected) => { + expect(countAgentTuiInputLines(text)).toBe(expected) + }) + + it('ignores visual wrapping — only logical newlines cost a Ctrl+U', () => { + expect(countAgentTuiInputLines('x'.repeat(5_000))).toBe(1) + }) +}) + +describe('buildAgentTuiClearInputForText', () => { + it('sizes the burst from the text plus slack for TUI-side edits', () => { + // The injected text is a LOWER bound: the user can type into the TUI line too. + expect(countCtrlU(buildAgentTuiClearInputForText('a\nb'))).toBe( + 2 * (2 + AGENT_TUI_CLEAR_LINE_SLACK) - 1 + ) + }) + + it('clears strictly more than the draft needs, never less', () => { + const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123\n' + expect(countCtrlU(buildAgentTuiClearInputForText(draft))).toBeGreaterThan( + 2 * countAgentTuiInputLines(draft) - 1 + ) + }) + + it('a long wrapped single line does not inflate the burst', () => { + expect(buildAgentTuiClearInputForText('y'.repeat(5_000))).toBe( + buildAgentTuiClearInputForText('y') + ) + }) +}) diff --git a/src/shared/agent-tui-input-clear.ts b/src/shared/agent-tui-input-clear.ts new file mode 100644 index 00000000000..bbbb6c19ec5 --- /dev/null +++ b/src/shared/agent-tui-input-clear.ts @@ -0,0 +1,59 @@ +// Clearing an agent TUI's input buffer when it may hold MORE THAN ONE line. +// +// Shared by desktop native chat and mobile: the law below is a property of the +// agent TUIs (Claude Code, codex), not of either client. + +/** Ctrl+U — kills to the START OF THE CURRENT LOGICAL LINE in an agent TUI. */ +export const AGENT_TUI_CLEAR_INPUT_LINE = '\x15' + +/** + * How many Ctrl+U an N-line buffer costs: **2N-1**. + * + * Measured on real PTYs against Claude Code and codex; both agree exactly. Each + * Ctrl+U kills to the start of the current logical line; when that line is + * ALREADY EMPTY the next one deletes the newline and joins to the previous line. + * So N kills + (N-1) joins = 2N-1. (N=2 needs 3; N=4 with 5 leaves residue; + * N=4 with 7 is clean.) + * + * Rejected alternatives — all measured, do not "fix" this by reaching for them: + * - **Ctrl+A, Ctrl+K, Ctrl+U** (the sequence a comparable agent-TUI wrapper uses) + * does NOT clear a multi-line buffer. Ctrl+A/Ctrl+K act inside the CURRENT + * logical line only, so earlier lines survive AND the cursor is left at the end + * of the previous line — the next paste glues straight onto it, strictly worse + * than a single Ctrl+U. + * - **Esc** does nothing at all to the buffer in either TUI. + * - **A fixed repeat count** is silently wrong: 5 clears 3 lines but leaves + * residue at 4. The count must come from the line count. + * + * Only LOGICAL newlines cost anything: one 300-char line wrapping several visual + * rows still dies to a single Ctrl+U, so callers must not count wrapped rows. + */ +export function buildAgentTuiClearInput(lineCount: number): string { + const lines = Math.max(1, Math.min(AGENT_TUI_CLEAR_MAX_LINES, Math.floor(lineCount))) + return AGENT_TUI_CLEAR_INPUT_LINE.repeat(2 * lines - 1) +} + +/** + * Headroom over the line count Orca knows about. The text Orca injected is a + * LOWER BOUND on what the buffer holds — the user can also type straight into + * the TUI line — so the count is deliberately biased upward. Overshoot is free: + * 41 Ctrl+U against a 1-line buffer measured perfectly clean on both agents, + * and an undershoot is what leaves residue to glue onto the next message. + */ +export const AGENT_TUI_CLEAR_LINE_SLACK = 8 + +/** Bounds the burst so a pathological draft cannot emit an unbounded write. */ +export const AGENT_TUI_CLEAR_MAX_LINES = 40 + +/** Widest burst we ever send — the remedy when a clear is not observed to land. */ +export const AGENT_TUI_CLEAR_INPUT_MAX = buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES) + +/** Logical lines in `text`. Visual wrapping is irrelevant to the clear cost. */ +export function countAgentTuiInputLines(text: string): number { + return text.split(/\r\n|\r|\n/).length +} + +/** Clear bytes for a buffer believed to hold `text`, with slack for TUI-side edits. */ +export function buildAgentTuiClearInputForText(text: string): string { + return buildAgentTuiClearInput(countAgentTuiInputLines(text) + AGENT_TUI_CLEAR_LINE_SLACK) +}