mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
* 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.
97 lines
3.5 KiB
TypeScript
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)
|
|
})
|
|
})
|