fix(native-chat): clear launch drafts from any cursor

This commit is contained in:
Brennan Benson
2026-07-29 20:26:04 -07:00
parent 69d9085e0b
commit b0f2ccf9f7
6 changed files with 73 additions and 32 deletions
@@ -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)
@@ -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
@@ -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.
@@ -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
)
}
+10 -3
View File
@@ -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', () => {
+10 -24
View File
@@ -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)
)
}
/**