Files
orca/src/shared/terminal-selection-gutter.test.ts
T
Neil 55b3392018 fix(terminal): drop the agent gutter from copied selections (#19770) (#20545)
* fix(terminal): drop the agent gutter from copied selections (#19770)

xterm selections are screen cells, not logical text. Agent CLIs paint
their messages behind a fixed left gutter, so every copied line carried
that gutter into the clipboard and pasted replies came out indented.

Terminal clipboard writes now drop the run of spaces that *every*
selected line shares, so relative indentation (nested bullets, fenced
code, YAML) survives and only the gutter is lost. A selection that
starts mid-line, or that includes any column-0 line, has a shared run of
zero and is copied verbatim.

Applied at every terminal clipboard seam: the Cmd/Ctrl+C shortcut, the
pane context menu's Copy, right-click-to-copy, the app menu's Copy,
copy-on-select, the X11 primary selection, the dashboard popout's
preview terminal, and mobile's selection Copy button.

New "Trim Gutter on Copy" terminal setting (default on) restores the
old verbatim-cell behaviour.

* fix(terminal): honour the gutter-trim setting on mobile copy

Mobile stripped the gutter unconditionally, so turning "Trim Gutter on
Copy" off left one surface still rewriting the clipboard. Mobile now
mirrors the desktop preference through the existing settings.get RPC —
a host predating the setting sends no key, which reads as on, matching
the desktop default.

Also folds the single-use gutter helpers into their callers so the
shared module exposes one function.

* refactor(terminal): parse each selection line once in the gutter rule

Also locks the Windows subtlety with a test: a blank CRLF row is '\r',
which reads as a zero-indent content row and would cancel the gutter
unless the CR is split off first.

* fix(terminal): publish the gutter-trim setting to paired clients

settings.get is an explicit allowlist projection, not the whole settings
object, so terminalCopyTrimsGutter never reached mobile: the client read
the key as absent, which means "older host", which means on. Mobile
therefore always trimmed and the desktop opt-out was inert.

Adds the field to the projection and a test that fails if it is ever
dropped again — absence is indistinguishable on the client from an old
host, so a silent regression here has no other signal.

* chore: drop unrelated formatter drift from this branch

A repo-wide `pnpm format` swept a quote-style change in pnpm-workspace.yaml
and a blank line in source-tree-walk.test.ts into this branch; neither is
related to the gutter fix.

* fix(terminal): trim the gutter on native copy events too

xterm binds its own DOM `copy` listener that writes raw screen cells
(CoreBrowserTerminal `_initGlobal`). Orca's own chords never reach it —
they preventDefault in keydown — but Ctrl+Insert is a Chromium copy
accelerator on Windows/Linux and is not in `terminal.copySelection`'s
bindings, so it still copied the gutter. Orca binds Shift+Insert for
paste on those platforms, which makes the asymmetry worse.

A capture-phase listener on the xterm element now writes the trimmed
text, closing the class rather than the one chord: any native copy event
— assistive tech, execCommand — lands on the same path. Installed for
both terminal panes and the dashboard popout's preview terminal.
2026-09-13 23:01:47 -07:00

97 lines
3.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { stripTerminalSelectionGutter } from './terminal-selection-gutter'
// Shape agent CLIs paint: a marker column, then continuation lines behind a
// two-space gutter. Selecting the body is what users copy to paste elsewhere.
const AGENT_MESSAGE_BODY = [
' Thanks for flagging this. The retry limit is now 5, and the backoff',
' starts at 2s instead of 500ms.',
'',
' Let me know if anything still looks off.'
].join('\n')
describe('stripTerminalSelectionGutter', () => {
it('drops the gutter agent output is painted behind', () => {
expect(stripTerminalSelectionGutter(AGENT_MESSAGE_BODY)).toBe(
[
'Thanks for flagging this. The retry limit is now 5, and the backoff',
'starts at 2s instead of 500ms.',
'',
'Let me know if anything still looks off.'
].join('\n')
)
})
it('drops the gutter from a single wrapped line', () => {
expect(stripTerminalSelectionGutter(' one logical line, joined by xterm')).toBe(
'one logical line, joined by xterm'
)
})
it('keeps relative indentation inside the gutter', () => {
const nested = [' def run():', ' return 1', '', ' run()'].join('\n')
expect(stripTerminalSelectionGutter(nested)).toBe(
['def run():', ' return 1', '', 'run()'].join('\n')
)
})
it('leaves a selection that starts mid-line untouched', () => {
const midLine = ['answer starts here', ' and continues', ' and ends'].join('\n')
expect(stripTerminalSelectionGutter(midLine)).toBe(midLine)
})
it('leaves unindented output untouched', () => {
const shellOutput = ['$ git status', 'On branch main', 'nothing to commit'].join('\n')
expect(stripTerminalSelectionGutter(shellOutput)).toBe(shellOutput)
})
it('ignores blank lines when measuring the gutter', () => {
expect(stripTerminalSelectionGutter([' a', '', ' b'].join('\n'))).toBe(
['a', '', 'b'].join('\n')
)
})
it('ignores whitespace-only lines when measuring the gutter', () => {
expect(stripTerminalSelectionGutter([' a', ' ', ' b'].join('\n'))).toBe(
['a', '', 'b'].join('\n')
)
})
it('leaves an all-whitespace selection untouched', () => {
expect(stripTerminalSelectionGutter(' \n \n')).toBe(' \n \n')
})
it('preserves the CRLF joins xterm emits on Windows', () => {
expect(stripTerminalSelectionGutter(' first\r\n second\r\n')).toBe('first\r\nsecond\r\n')
})
// Regression: a blank CRLF row is '\r', which reads as a zero-indent content
// row unless the CR is split off first — that would cancel the gutter on
// Windows only.
it('still finds the gutter across a blank CRLF row', () => {
expect(stripTerminalSelectionGutter(' first\r\n\r\n second\r\n')).toBe(
'first\r\n\r\nsecond\r\n'
)
})
it('leaves wide characters and emoji in the content alone', () => {
expect(
stripTerminalSelectionGutter([' 変更を適用しました 🎉', ' お疲れさま'].join('\n'))
).toBe(['変更を適用しました 🎉', 'お疲れさま'].join('\n'))
})
it('does not strip past a shorter line', () => {
const uneven = [' deep', ' shallow'].join('\n')
expect(stripTerminalSelectionGutter(uneven)).toBe([' deep', 'shallow'].join('\n'))
})
it('is a no-op on an empty selection', () => {
expect(stripTerminalSelectionGutter('')).toBe('')
})
it('leaves tab-indented text alone (terminal cells never hold tabs)', () => {
const tabbed = ['\tone', '\ttwo'].join('\n')
expect(stripTerminalSelectionGutter(tabbed)).toBe(tabbed)
})
})