mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
fix(native-chat): clear launch drafts from any cursor
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user