From a6e54fc4a9d6514fae1ae4a2ed36e491faded611 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:23:32 -0700 Subject: [PATCH] fix(native-chat): mirror multi-line launch drafts into the chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline, so every Linear launch ("Linked Linear issue: X\n") and any GitHub launch with a typed note was invisible in chat. The rejection existed because the send path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with embedded newlines. Orca injects the draft itself, so when the composer still holds exactly what was injected the buffer already IS the message: the send becomes the submit key alone — no clear, no paste, nothing that can concatenate, and multi-line submits as one turn for free. Only the edited case needs real buffer replacement, and that now clears every line and verifies against the agent's rendered input line instead of firing blind. Measured on real PTYs against Claude Code and codex (both agree exactly): clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts for the law, the sequences that do NOT work, and why an upper bound is safe. --- .../app/h/[hostId]/session/[worktreeId].tsx | 1 + .../mobile-native-chat-image-send.test.ts | 50 ++++++ .../session/mobile-native-chat-image-send.ts | 9 +- .../session/mobile-native-chat-send.test.ts | 66 ++++++++ mobile/src/session/mobile-native-chat-send.ts | 14 +- .../use-mobile-native-chat-controller.test.ts | 1 + .../use-mobile-native-chat-controller.ts | 8 +- .../session/use-mobile-native-chat-drafts.ts | 70 ++------ ...bile-native-chat-image-attachments.test.ts | 36 ++++ ...se-mobile-native-chat-image-attachments.ts | 12 +- ...se-mobile-native-chat-launch-draft-seed.ts | 91 ++++++++++ ...se-mobile-native-chat-message-send.test.ts | 108 ++++++++++++ .../use-mobile-native-chat-message-send.ts | 16 ++ .../use-mobile-session-image-attachments.ts | 5 + .../native-chat/NativeChatComposer.tsx | 34 +++- .../native-chat-launch-draft-send.test.ts | 156 ++++++++++++++++++ .../native-chat-launch-draft-send.ts | 154 +++++++++++++++++ ...ive-chat-runtime-send-launch-draft.test.ts | 140 ++++++++++++++++ .../native-chat/native-chat-runtime-send.ts | 154 +++++++++++++---- .../lib/agent-launch-prompt-delivery.test.ts | 35 ++-- .../src/lib/agent-launch-prompt-delivery.ts | 13 +- src/shared/agent-tui-input-clear.test.ts | 80 +++++++++ src/shared/agent-tui-input-clear.ts | 59 +++++++ 23 files changed, 1188 insertions(+), 124 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts create mode 100644 mobile/src/session/use-mobile-native-chat-message-send.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts create mode 100644 src/shared/agent-tui-input-clear.test.ts create mode 100644 src/shared/agent-tui-input-clear.ts 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) +}