Files
orca/src/shared/terminal-composer-draft.ts
T
Brennan Benson 419e3b4496 Fix terminal reads that flatten composer drafts into output (#16711)
* fix(terminal): separate composer drafts from read output

Rendered screen reads treated cursor-line suggestion overlays as PTY output. Detect composer-owned text from cell attributes and cursor context, remove it from tail, and expose it as structured draft metadata.

* fix(terminal): handle wrapped composer overlays

* fix(terminal): preserve draft wrapping and tail alignment

* fix(terminal): preserve composer wrap boundaries

* fix(terminal): preserve draft continuations with middle dots

* fix(terminal): recognize configurable Codex status lines
2026-08-27 15:28:09 -07:00

174 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type TerminalCursorContext = {
rows: string[]
typedRows: string[]
promptGlyphBoldRows: boolean[]
rowsWrapped?: boolean[]
rowsBelow: string[]
typedRowsBelow: string[]
rowsBelowWrapped?: boolean[]
rowsBelowCustomForeground?: boolean[]
beforeCursor: string
afterCursor: string
rawAfterCursor: string
cursorHidden: boolean
cursorViewportRow: number
}
export type TerminalComposerDraft = {
text: string
promptRow: number
cursorRow: number
endRow: number
promptGlyph: '❯' | '›' | '»'
}
const COMPOSER_FRAME_LINE = /^[─━-]{8,}\s*$/
const CODEX_FOOTER_LINE = /^\s*(?:gpt-\S+|o\d\S*)\s+[·•]\s+\S.*$/i
function composerContinuationRows(
context: TerminalCursorContext,
afterCursor: string,
codexFooterIndex: number
): { text: string; wrapped: boolean }[] {
if (!afterCursor.trim() && !context.typedRowsBelow.some((row) => row.trim())) {
return []
}
const continuation: { text: string; wrapped: boolean }[] = []
const hasTypedContinuationAfter = context.rowsBelow.map(() => false)
let hasFollowingTyped = false
for (let index = context.rowsBelow.length - 1; index >= 0; index -= 1) {
const raw = context.rowsBelow[index] ?? ''
if (COMPOSER_FRAME_LINE.test(raw) || index === codexFooterIndex) {
hasFollowingTyped = false
continue
}
hasTypedContinuationAfter[index] = hasFollowingTyped
if ((context.typedRowsBelow[index] ?? '').trim()) {
hasFollowingTyped = true
}
}
for (let index = 0; index < context.rowsBelow.length; index += 1) {
const raw = context.rowsBelow[index] ?? ''
if (COMPOSER_FRAME_LINE.test(raw) || index === codexFooterIndex) {
break
}
if (!raw.trim() && !hasTypedContinuationAfter[index]) {
break
}
const typed = context.typedRowsBelow[index] ?? ''
continuation.push({
text: typed.trim() ? typed : raw,
wrapped: context.rowsBelowWrapped?.[index] ?? false
})
}
return continuation
}
function findCodexFooterIndex(context: TerminalCursorContext): number {
for (let index = context.rowsBelow.length - 1; index >= 0; index -= 1) {
const row = context.rowsBelow[index] ?? ''
if (!row.trim()) {
continue
}
const undimmed = context.typedRowsBelow[index] ?? ''
const hasFooterGap = index > 0 && !(context.rowsBelow[index - 1] ?? '').trim()
const isDimmedFooter =
hasFooterGap && !undimmed.trim() && context.rowsBelowWrapped?.[index] === false
const isColoredFooter =
hasFooterGap &&
context.rowsBelowCustomForeground?.[index] === true &&
context.rowsBelowWrapped?.[index] === false
return isDimmedFooter || isColoredFooter || CODEX_FOOTER_LINE.test(row) ? index : -1
}
return -1
}
function isStockPlaceholder(
afterCursor: string,
continuationRows: { text: string; wrapped: boolean }[]
): boolean {
const text = [afterCursor, ...continuationRows.map((row) => row.text)]
.join(' ')
.replace(/\s+/g, ' ')
.trim()
return (
/^Try\s+["“]/.test(text) ||
text === 'Ask Codex to do anything' ||
text === 'Ask a follow-up question'
)
}
export function detectTerminalComposerDraft(
context: TerminalCursorContext | null | undefined
): TerminalComposerDraft | null {
if (!context || context.cursorHidden || context.rows.length === 0) {
return null
}
const cursorIndex = context.rows.length - 1
const codexFooterIndex = findCodexFooterIndex(context)
let afterCursor = context.afterCursor || context.rawAfterCursor
let continuationRows = composerContinuationRows(context, afterCursor, codexFooterIndex)
if (isStockPlaceholder(afterCursor, continuationRows)) {
afterCursor = ''
continuationRows = []
}
const cursorText = `${context.beforeCursor}${afterCursor}`
for (let index = cursorIndex; index >= 0; index -= 1) {
const row = context.rows[index] ?? ''
const glyph = row.match(/^\s*([❯›»])/)?.[1] as '❯' | '›' | '»' | undefined
if (glyph) {
if (glyph === '❯' && !COMPOSER_FRAME_LINE.test(context.rows[index - 1] ?? '')) {
return null
}
if (
(glyph === '›' || glyph === '»') &&
(context.promptGlyphBoldRows[index] !== true || codexFooterIndex === -1)
) {
return null
}
const lines: { text: string; wrapped: boolean }[] =
index === cursorIndex
? [{ text: cursorText.replace(/^\s*[❯›»]\s?/, ''), wrapped: false }, ...continuationRows]
: [
{
text: (context.typedRows[index] ?? row).replace(/^\s*[❯›»]\s?/, ''),
wrapped: false
},
...context.typedRows.slice(index + 1, cursorIndex).map((text, offset) => ({
text,
wrapped: context.rowsWrapped?.[index + 1 + offset] ?? false
})),
{
text: cursorText,
wrapped: context.rowsWrapped?.[cursorIndex] ?? false
},
...continuationRows
]
const text = lines
.map((line, lineIndex) => {
const continuesPrevious = lineIndex > 0 && line.wrapped
const continuesNext = lines[lineIndex + 1]?.wrapped ?? false
const start = continuesPrevious ? '' : lineIndex > 0 ? '\n' : ''
const content = continuesPrevious ? line.text : line.text.trimStart()
return `${start}${continuesNext ? content : content.trimEnd()}`
})
.join('')
.trim()
if (!text) {
return null
}
return {
text,
promptRow: context.cursorViewportRow - (cursorIndex - index),
cursorRow: context.cursorViewportRow,
endRow: context.cursorViewportRow + continuationRows.length,
promptGlyph: glyph
}
}
if (row.length > 0 && context.rowsWrapped?.[index] !== true && !/^\s/.test(row)) {
return null
}
}
return null
}