diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts index f346727da94..e04b273c97d 100644 --- a/src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts @@ -85,6 +85,16 @@ describe('agentInputLineCleared', () => { expect(agentInputLineCleared(edited)).toBe(false) }) + it('does not ignore nonempty continuation rows after an empty prompt row', () => { + const residue = [ + '────────────────────────────────────────', + '❯ ', + ' suffix after a cursor-middle clear', + '────────────────────────────────────────' + ].join('\n') + expect(agentInputLineCleared(residue)).toBe(false) + }) + it('treats placeholders and parked drafts as unconfirmed', () => { expect(agentInputLineCleared(screenPlaceholder)).toBe(false) expect(agentInputLineCleared(screenHoldingDraft)).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 index ff8f73aecd1..481540b035f 100644 --- 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 @@ -11,7 +11,25 @@ export type NativeChatLaunchDraftSendPlan = | { kind: 'default' } /** Prompt glyphs both supported agent TUIs draw at the start of the input line. */ -const COMPOSER_PROMPT_LINE = /^\s*[❯›]\s?(.*)$/ +const COMPOSER_PROMPT_LINE = /^\s*([❯›])\s?(.*)$/ +const CLAUDE_FRAME_LINE = /^\s*─{3,}\s*$/ +const CODEX_FOOTER_LINE = /^\s*\S.*\s[·•]\s.*$/ + +function composerContinuationIsEmpty(lines: string[], promptIndex: number, glyph: string): boolean { + for (let index = promptIndex + 1; index < lines.length; index += 1) { + const line = lines[index]! + if ( + (glyph === '❯' && CLAUDE_FRAME_LINE.test(line)) || + (glyph === '›' && CODEX_FOOTER_LINE.test(line)) + ) { + return true + } + if (line.trim() !== '') { + return false + } + } + return true +} /** * Whether the rendered composer prompt is observably empty. Placeholder text, @@ -25,7 +43,7 @@ export function agentInputLineCleared(screen: string | null | undefined): boolea for (let index = lines.length - 1; index >= 0; index -= 1) { const match = COMPOSER_PROMPT_LINE.exec(lines[index]!) if (match) { - return match[1]!.trim() === '' + return match[2]!.trim() === '' && composerContinuationIsEmpty(lines, index, match[1]!) } } return false diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts index 6d1a66e33ee..5cdc89ca936 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts @@ -13,6 +13,10 @@ vi.mock('@/store', () => ({ import { seedNativeChatLaunchDraftForAgentTab } from './agent-launch-prompt-delivery' import { canMirrorLaunchDraftToNativeChat } from './native-chat-launch-draft-mirrorability' import { decideInitialAgentTabViewMode } from './native-chat-initial-view-mode' +import { AGENT_TUI_CLEAR_MAX_LINES } from '../../../shared/agent-tui-input-clear' + +const maxLineDraft = Array.from({ length: AGENT_TUI_CLEAR_MAX_LINES }, () => 'line').join('\n') +const overMaxLineDraft = `${maxLineDraft}\nline` /** * Every shape a launch draft takes today. `formatDraftContextBlock` appends a @@ -28,6 +32,8 @@ const DRAFT_TEXTS = [ 'ORC-123: Restore linked quick-create\nhttps://linear.app/o/issue/ORC-123', 'ORC-123 https://linear.app/o/issue/ORC-123\n', ' spaced but single line ', + maxLineDraft, + overMaxLineDraft, '', ' ', '\n' @@ -79,6 +85,11 @@ describe('launch draft mirrorability', () => { expect(canMirrorLaunchDraftToNativeChat(' ')).toBe(false) }) + it('rejects drafts beyond the bounded TUI-clear budget', () => { + expect(canMirrorLaunchDraftToNativeChat(maxLineDraft)).toBe(true) + expect(canMirrorLaunchDraftToNativeChat(overMaxLineDraft)).toBe(false) + }) + it('withholds the mirror from agents without a native-chat renderer', () => { // The view mode already returns undefined for these, so the sets still // agree — but only the seeding side enforces it. diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts index b622be933c5..23e7395c853 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts @@ -1,3 +1,8 @@ +import { + AGENT_TUI_CLEAR_MAX_LINES, + countAgentTuiInputLines +} from '../../../shared/agent-tui-input-clear' + /** * Single source of truth for whether unsent launch context can be mirrored from * the agent's TUI input into the native-chat composer. @@ -7,9 +12,13 @@ * predicate, so a draft launch can never open in chat with a composer that * chat then refuses to fill. * - * CR/LF drafts are safe because chat sizes its TUI pre-clear from their logical - * line count. Unicode line separators remain unsupported by that clear contract. + * CR/LF drafts are safe within the bounded TUI-clear budget. Unicode line + * separators and drafts beyond that budget remain terminal-only. */ export function canMirrorLaunchDraftToNativeChat(text: string): boolean { - return text.trim().length > 0 && !/[\u2028\u2029]/.test(text) + return ( + text.trim().length > 0 && + !/[\u2028\u2029]/.test(text) && + countAgentTuiInputLines(text) <= AGENT_TUI_CLEAR_MAX_LINES + ) } diff --git a/src/shared/agent-tui-input-clear.test.ts b/src/shared/agent-tui-input-clear.test.ts index 9f3fdc6a80d..67715ce2df2 100644 --- a/src/shared/agent-tui-input-clear.test.ts +++ b/src/shared/agent-tui-input-clear.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + AGENT_TUI_CLEAR_INPUT_FORWARD, AGENT_TUI_CLEAR_INPUT_LINE, AGENT_TUI_CLEAR_INPUT_MAX, AGENT_TUI_CLEAR_LINE_SLACK, @@ -11,6 +12,8 @@ import { const countCtrlU = (bytes: string): number => bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_LINE).length +const countCtrlK = (bytes: string): number => + bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_FORWARD).length describe('buildAgentTuiClearInput', () => { // The measured law: N kills + (N-1) joins. A constant here silently under-clears @@ -23,11 +26,15 @@ describe('buildAgentTuiClearInput', () => { [4, 7], [10, 19] ])('clears %i logical lines with %i Ctrl+U', (lines, expected) => { - expect(countCtrlU(buildAgentTuiClearInput(lines))).toBe(expected) + const clearInput = buildAgentTuiClearInput(lines) + expect(countCtrlU(clearInput)).toBe(expected) + expect(countCtrlK(clearInput)).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('clears before the cursor before clearing the suffix after it', () => { + expect(buildAgentTuiClearInput(4)).toBe( + AGENT_TUI_CLEAR_INPUT_LINE.repeat(7) + AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(7) + ) }) it('still clears one line for a zero or negative count', () => { diff --git a/src/shared/agent-tui-input-clear.ts b/src/shared/agent-tui-input-clear.ts index bbbb6c19ec5..6d3bfc88e09 100644 --- a/src/shared/agent-tui-input-clear.ts +++ b/src/shared/agent-tui-input-clear.ts @@ -3,34 +3,20 @@ // 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. */ +/** Ctrl+U — clears toward the start of the input buffer. */ 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. - */ +/** Ctrl+K — clears toward the end of the input buffer. */ +export const AGENT_TUI_CLEAR_INPUT_FORWARD = '\x0b' + +/** Clear up to `lineCount` logical lines from any cursor position. */ 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) + const repetitions = 2 * lines - 1 + return ( + AGENT_TUI_CLEAR_INPUT_LINE.repeat(repetitions) + + AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(repetitions) + ) } /**