From 55b33920180efa188a19dfb7fac0fc993f10a922 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:01:47 -0700 Subject: [PATCH] fix(terminal): drop the agent gutter from copied selections (#19770) (#20545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../mobile-session-route-parity.test.ts | 8 +- .../use-mobile-session-accessory-selection.ts | 8 +- .../terminal-copy-gutter-preference.ts | 43 ++++++++ .../settings-read-operations.test.ts | 21 +++- .../src/transport/settings-read-operations.ts | 24 +++++ ...-settings-terminal-copy-projection.test.ts | 37 +++++++ src/main/runtime/runtime-client-settings.ts | 4 + src/main/runtime/runtime-store-contract.ts | 1 + .../dashboard-popout/AgentTerminalPreview.tsx | 13 +++ .../preview-terminal-key-handler.ts | 3 +- .../settings/TerminalInteractionSection.tsx | 101 +++++++----------- .../settings/TerminalScrollSpeedSlider.tsx | 61 +++++++++++ .../settings/terminal-clipboard-search.ts | 37 +++++++ .../terminal-clipboard-selection-text.test.ts | 60 +++++++++++ .../terminal-clipboard-selection-text.ts | 17 +++ .../terminal-native-copy-gutter.test.ts | 81 ++++++++++++++ .../terminal-native-copy-gutter.ts | 32 ++++++ .../terminal-pane-menu-copy-actions.ts | 3 +- .../terminal-pane-mount-cleanup.ts | 1 + .../terminal-pane-pane-closed.ts | 1 + .../terminal-pane/terminal-pane-pane-links.ts | 9 +- .../terminal-pane/terminal-selection-copy.ts | 3 +- .../use-terminal-pane-lifecycle-refs.ts | 1 + src/renderer/src/i18n/locales/en.json | 18 ++++ src/shared/default-global-settings.ts | 3 + src/shared/global-settings-types.ts | 2 + src/shared/terminal-selection-gutter.test.ts | 96 +++++++++++++++++ src/shared/terminal-selection-gutter.ts | 57 ++++++++++ 28 files changed, 675 insertions(+), 70 deletions(-) create mode 100644 mobile/src/terminal/terminal-copy-gutter-preference.ts create mode 100644 src/main/runtime/runtime-client-settings-terminal-copy-projection.test.ts create mode 100644 src/renderer/src/components/settings/TerminalScrollSpeedSlider.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.ts create mode 100644 src/shared/terminal-selection-gutter.test.ts create mode 100644 src/shared/terminal-selection-gutter.ts diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 76435561664..7d0c5c6de46 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,11 +62,11 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = 'c3e33699e3e3fa7e24408f3d4946fcc451e9b9419442d985c4ccde01782e5114' -const HEAD_HOOK_BINDING_SHA256 = '7f907e028893721d662eeee0aa9002ad1e00359948f39fb148d274596cd9b3c0' +const HEAD_MAIN_HOOK_SHA256 = '11cd92aec686a6e47b23114ec31da86152a850b064821578b165fabfbce53b27' +const HEAD_HOOK_BINDING_SHA256 = 'f8bce7101a26b4d794bb58dee54702424a4965cc81dec5c758ca56cd5a6f4ce8' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' -const HEAD_CALLBACK_BODY_SHA256 = 'af7f3c62954250d4be7ee432ecd10dc2689792aad8230fed2d1d68bbc892d776' +const HEAD_CALLBACK_BODY_SHA256 = '85c4f4605e66c45e2b6bc7de739cb3493d9e2d0db9c9242c379db8ed34a8cefe' const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = @@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(267) + expect(main.hooks).toHaveLength(268) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(77) diff --git a/mobile/src/session/use-mobile-session-accessory-selection.ts b/mobile/src/session/use-mobile-session-accessory-selection.ts index 356bf11952d..ad0116cb9c6 100644 --- a/mobile/src/session/use-mobile-session-accessory-selection.ts +++ b/mobile/src/session/use-mobile-session-accessory-selection.ts @@ -14,6 +14,8 @@ import type { } from '../terminal/terminal-webview-contract' import type { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input' import { clearTerminalLiveInputFocusTimer } from '../terminal/terminal-live-input' +import { stripTerminalSelectionGutter } from '../../../src/shared/terminal-selection-gutter' +import { useTerminalCopyTrimsGutter } from '../terminal/terminal-copy-gutter-preference' import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' import type { RuntimeRepoSummary } from './mobile-session-route-types' import type { MobileSessionTerminalInputModel } from './use-mobile-session-terminal-input' @@ -23,6 +25,7 @@ export function useMobileSessionAccessorySelection(scope: MobileSessionTerminalI worktreeId, isFloatingWorkspaceRoute, client, + connState, setTerminalKeyboardMetrics, setSelectModeActive, setCanPaste, @@ -41,6 +44,7 @@ export function useMobileSessionAccessorySelection(scope: MobileSessionTerminalI handleAccessoryKey, clearSessionTabActionSheetKeyboardListener } = scope + const trimsGutterRef = useTerminalCopyTrimsGutter(client, connState) // Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive). const repeatTimeoutRef = useRef | null>(null) const repeatIntervalRef = useRef | null>(null) @@ -115,7 +119,9 @@ export function useMobileSessionAccessorySelection(scope: MobileSessionTerminalI return } try { - await Clipboard.setStringAsync(text) + await Clipboard.setStringAsync( + trimsGutterRef.current ? stripTerminalSelectionGutter(text) : text + ) triggerSuccess() // Why: Android 13+ shows its own system copy toast; iOS shows none, so only iOS needs our in-app toast. if (Platform.OS === 'ios') { diff --git a/mobile/src/terminal/terminal-copy-gutter-preference.ts b/mobile/src/terminal/terminal-copy-gutter-preference.ts new file mode 100644 index 00000000000..b5e80d542d3 --- /dev/null +++ b/mobile/src/terminal/terminal-copy-gutter-preference.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef, type RefObject } from 'react' +import { terminalCopyTrimsGutterRead } from '../transport/settings-read-operations' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +/** + * Desktop owns "Trim Gutter on Copy" (GlobalSettings.terminalCopyTrimsGutter); + * mobile mirrors it so turning the setting off yields verbatim screen cells on + * every surface. Read once per connection — the mobile RPC has no + * settings-change stream — and default to on while the read is in flight. + */ +export function useTerminalCopyTrimsGutter( + client: RpcClient | null, + connState: ConnectionState +): RefObject { + const trimsGutterRef = useRef(true) + + useEffect(() => { + if (!client || connState !== 'connected') { + return + } + let stale = false + void terminalCopyTrimsGutterRead + .request(client) + .then((response) => { + if (stale) { + return + } + const preference = terminalCopyTrimsGutterRead.interpret(response) + if (preference.accepted) { + trimsGutterRef.current = preference.value + } + }) + .catch(() => { + // Best-effort: an unreachable host leaves the on-by-default trim in place. + }) + return () => { + stale = true + } + }, [client, connState]) + + return trimsGutterRef +} diff --git a/mobile/src/transport/settings-read-operations.test.ts b/mobile/src/transport/settings-read-operations.test.ts index 3a81da9942f..07f4ff771d4 100644 --- a/mobile/src/transport/settings-read-operations.test.ts +++ b/mobile/src/transport/settings-read-operations.test.ts @@ -8,7 +8,8 @@ import { settingsRead, optionalSettingsRead, botOverridesRead, - newTabSettingsRead + newTabSettingsRead, + terminalCopyTrimsGutterRead } from './settings-read-operations' import type { RpcResponse } from './types' @@ -91,6 +92,24 @@ describe('settings historical acceptance', () => { expect(botOverridesRead.interpret(empty)).toEqual({ accepted: true, value: [] }) }) + it('reads the gutter-trim preference, treating an older host as opted in', async () => { + const off = await terminalCopyTrimsGutterRead.request( + replyWith(success({ settings: { terminalCopyTrimsGutter: false } })) + ) + expect(terminalCopyTrimsGutterRead.interpret(off)).toEqual({ accepted: true, value: false }) + const on = await terminalCopyTrimsGutterRead.request( + replyWith(success({ settings: { terminalCopyTrimsGutter: true } })) + ) + expect(terminalCopyTrimsGutterRead.interpret(on)).toEqual({ accepted: true, value: true }) + // A host predating the setting sends no key; the desktop default is on. + const absent = await terminalCopyTrimsGutterRead.request(replyWith(success({ settings: {} }))) + expect(terminalCopyTrimsGutterRead.interpret(absent)).toEqual({ accepted: true, value: true }) + const empty = await terminalCopyTrimsGutterRead.request(replyWith(success(null))) + expect(terminalCopyTrimsGutterRead.interpret(empty)).toEqual({ accepted: true, value: true }) + const refused = await terminalCopyTrimsGutterRead.request(replyWith(refusal())) + expect(terminalCopyTrimsGutterRead.interpret(refused)).toEqual({ accepted: false }) + }) + it('does not read a stale payload until its caller permits interpretation', async () => { const read = vi.fn(() => ({})) const reply = await settingsRead.request( diff --git a/mobile/src/transport/settings-read-operations.ts b/mobile/src/transport/settings-read-operations.ts index f33471dcaf2..c98c87798b3 100644 --- a/mobile/src/transport/settings-read-operations.ts +++ b/mobile/src/transport/settings-read-operations.ts @@ -82,6 +82,30 @@ export const newTabSettingsRead = bindDeferredRpcOperation( }) ) +const copyTrimsGutterReader: RpcCompatibleReader = (raw) => { + const settings = raw == null ? undefined : settingsMember(raw) + const trims: unknown = + settings == null ? undefined : Reflect.get(Object(settings), 'terminalCopyTrimsGutter') + return { + compatible: true, + variant: 'copy-trims-gutter', + // Why `!== false`: a host predating the setting sends no key, and the + // desktop default is on, so absence must read as on. + value: trims !== false, + salvage: { droppedPaths: [], droppedCount: 0 } + } +} + +export const terminalCopyTrimsGutterRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.terminal-copy-trims-gutter-or-skip', + method: 'settings.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: copyTrimsGutterReader + }) +) + export const botOverridesRead = bindDeferredRpcOperation( defineRpcOperation({ name: 'settings.bot-logins-or-skip', diff --git a/src/main/runtime/runtime-client-settings-terminal-copy-projection.test.ts b/src/main/runtime/runtime-client-settings-terminal-copy-projection.test.ts new file mode 100644 index 00000000000..8a6fad5065c --- /dev/null +++ b/src/main/runtime/runtime-client-settings-terminal-copy-projection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientSettingsController } from './runtime-client-settings' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' + +// Why: `settings.get` is an explicit allowlist, not the whole settings object. +// Mobile's terminal Copy reads terminalCopyTrimsGutter from it (#19770), and a +// field missing here is indistinguishable on the client from an older host — +// so the opt-out would silently never arrive. +function projectionOf(settings: Partial) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: get() reads nothing but store.getSettings(); every other RuntimeStore member is unreachable from that path. + return new RuntimeClientSettingsController({ getSettings: () => settings } as never).get() +} + +function hostSettings(overrides: Partial): Partial { + return { ...createGlobalSettingsFixture({ workspaceDir: '/w' }), ...overrides } +} + +describe('RuntimeClientSettingsController terminal copy projection', () => { + it('publishes the gutter-trim opt-out to paired clients', () => { + expect( + projectionOf(hostSettings({ terminalCopyTrimsGutter: false })).terminalCopyTrimsGutter + ).toBe(false) + }) + + it('publishes the gutter-trim opt-in to paired clients', () => { + expect( + projectionOf(hostSettings({ terminalCopyTrimsGutter: true })).terminalCopyTrimsGutter + ).toBe(true) + }) + + it('reports on when the host has no persisted preference', () => { + const settings = hostSettings({}) + delete settings.terminalCopyTrimsGutter + expect(projectionOf(settings).terminalCopyTrimsGutter).toBe(true) + }) +}) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 900900700f3..4e36ee22024 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -27,6 +27,7 @@ export type RuntimeClientSettings = Pick< | 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentStatusHooksEnabled' + | 'terminalCopyTrimsGutter' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' @@ -97,6 +98,9 @@ export class RuntimeClientSettingsController { agentDefaultArgs: settings.agentDefaultArgs ?? {}, agentDefaultEnv: settings.agentDefaultEnv ?? {}, agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, + // Why projected: mobile's terminal Copy honours this, and a host predating + // the setting sends no key, which the client reads as on (#19770). + terminalCopyTrimsGutter: settings.terminalCopyTrimsGutter !== false, defaultTaskSource: settings.defaultTaskSource ?? 'github', defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS], diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index f3f5d5a8f51..b1471ff8efe 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -87,6 +87,7 @@ export type RuntimeStore = { terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] + terminalCopyTrimsGutter?: GlobalSettings['terminalCopyTrimsGutter'] experimentalNativeChat?: GlobalSettings['experimentalNativeChat'] openAgentTabsInChatByDefault?: GlobalSettings['openAgentTabsInChatByDefault'] experimentalStructuredNativeChat?: GlobalSettings['experimentalStructuredNativeChat'] diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index ec05a7105a5..f0360d2b795 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -26,6 +26,7 @@ import { createPreviewGridClaim } from './preview-grid-claim' import { createPreviewBoxFit } from './preview-terminal-box-fit' import { installPreviewTerminalAppMenuClipboard } from './preview-terminal-app-menu-clipboard' import { installPreviewTerminalRightClickPaste } from './preview-terminal-right-click-paste' +import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/terminal-native-copy-gutter' import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' @@ -107,6 +108,7 @@ export function AgentTerminalPreview({ let userInputDisposable: { dispose: () => void } | null = null let imeBridge: PreviewImeBridge | null = null let disposeKeyHandler: (() => void) | null = null + let disposeNativeCopyGutterTrim: (() => void) | null = null let disposeTerminalCompatibility: (() => void) | null = null // Why: mirrors the pane's tracker — the policy needs the flags the TUI // negotiated, and this preview parses the same output stream the pane does. @@ -215,6 +217,13 @@ export function AgentTerminalPreview({ }) } + const installNativeCopyGutterTrim = (): void => { + if (!terminal) { + return + } + disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose + } + const installTerminalCompatibility = (): void => { if (!terminal) { return @@ -273,6 +282,7 @@ export function AgentTerminalPreview({ } terminalRef.current = terminal installTerminalCompatibility() + installNativeCopyGutterTrim() installInputRouting() installImeNativeTextBridge() installKeyHandler() @@ -340,6 +350,8 @@ export function AgentTerminalPreview({ disposeTerminalCompatibility = null disposeKeyHandler?.() disposeKeyHandler = null + disposeNativeCopyGutterTrim?.() + disposeNativeCopyGutterTrim = null terminal?.dispose() terminal = null terminalRef.current = null @@ -395,6 +407,7 @@ export function AgentTerminalPreview({ disposeImeNativeTextBridge() disposeTerminalCompatibility?.() disposeKeyHandler?.() + disposeNativeCopyGutterTrim?.() void window.api.terminalPreview.unsubscribe(ptyId) terminal?.dispose() terminalRef.current = null diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts index 29a6b80688d..1b263144a53 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts @@ -13,6 +13,7 @@ import { resolvePreviewShortcutAction, type PreviewShortcutContext } from './preview-terminal-shortcuts' +import { readTerminalClipboardSelection } from '@/components/terminal-pane/terminal-clipboard-selection-text' /** * Installs the preview terminal's ONE custom key handler (xterm allows a single @@ -109,7 +110,7 @@ export function installPreviewTerminalKeyHandler(args: { nativeOnlyShortcutTracker.prepareKeyDown(event) const keybindings = useAppStore.getState().keybindings if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { - const selection = terminal.getSelection() + const selection = readTerminalClipboardSelection(terminal) if ( !selection && platform !== 'darwin' && diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index f7c92e998a4..fb174a12a0a 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -1,8 +1,8 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { RotateCcw } from 'lucide-react' -import { Slider } from '../ui/slider' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { ScrollSpeedSlider } from './TerminalScrollSpeedSlider' import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' @@ -27,65 +27,6 @@ type TerminalInteractionSectionProps = { searchQuery: string } -type ScrollSpeedSliderProps = { - label: string - description: string - value: number - min: number - max: number - step: number - suffix: string - onChange: (value: number) => void -} - -function formatScrollSpeedValue(value: number): string { - return Number.isInteger(value) - ? String(value) - : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') -} - -function ScrollSpeedSlider({ - label, - description, - value, - min, - max, - step, - suffix, - onChange -}: ScrollSpeedSliderProps): React.JSX.Element { - return ( -
-
-
- -

{description}

-
- - {formatScrollSpeedValue(value)} - {suffix} - -
- { - if (next !== undefined) { - onChange(next) - } - }} - /> -
- {formatScrollSpeedValue(min)} - {formatScrollSpeedValue(max)} -
-
- ) -} - export function TerminalInteractionSection({ settings, updateSettings, @@ -332,6 +273,46 @@ export function TerminalInteractionSection({ /> + + + updateSettings({ + terminalCopyTrimsGutter: !settings.terminalCopyTrimsGutter + }) + } + /> + + void +} + +function formatScrollSpeedValue(value: number): string { + return Number.isInteger(value) + ? String(value) + : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') +} + +export function ScrollSpeedSlider({ + label, + description, + value, + min, + max, + step, + suffix, + onChange +}: ScrollSpeedSliderProps): React.JSX.Element { + return ( +
+
+
+ +

{description}

+
+ + {formatScrollSpeedValue(value)} + {suffix} + +
+ { + if (next !== undefined) { + onChange(next) + } + }} + /> +
+ {formatScrollSpeedValue(min)} + {formatScrollSpeedValue(max)} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/terminal-clipboard-search.ts b/src/renderer/src/components/settings/terminal-clipboard-search.ts index 8cc00935d98..bb1a18e444c 100644 --- a/src/renderer/src/components/settings/terminal-clipboard-search.ts +++ b/src/renderer/src/components/settings/terminal-clipboard-search.ts @@ -119,5 +119,42 @@ export const getTerminalClipboardSearchEntries = createLocalizedCatalog(() => [ 'paste' ) ] + }, + { + title: translate( + 'components.settings.TerminalInteraction.copyTrimsGutter', + 'Trim Gutter on Copy' + ), + description: translate( + 'components.settings.TerminalInteraction.copyTrimsGutterDescription', + 'Drop the left gutter agent output is painted behind, so copied text is not indented. Only the indent every selected line shares is removed.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.terminal.clipboard.search.10d73e22d3', + 'clipboard' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.clipboard.search.a38508c419', + 'copy' + ), + ...translateSearchKeyword('components.settings.terminal.clipboard.search.gutter', 'gutter'), + ...translateSearchKeyword('components.settings.terminal.clipboard.search.indent', 'indent'), + ...translateSearchKeyword('components.settings.terminal.clipboard.search.margin', 'margin'), + ...translateSearchKeyword('components.settings.terminal.clipboard.search.leading', 'leading'), + ...translateSearchKeyword( + 'components.settings.terminal.clipboard.search.whitespace', + 'whitespace' + ), + ...translateSearchKeyword('components.settings.terminal.clipboard.search.spaces', 'spaces'), + ...translateSearchKeyword( + 'auto.components.settings.terminal.clipboard.search.c38c18be15', + 'selection' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.clipboard.search.5fb3512e8c', + 'paste' + ) + ] } ]) diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.test.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.test.ts new file mode 100644 index 00000000000..2307831bf59 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' + +const settings: { current: Partial } = { current: {} } +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ settings: settings.current }) } +})) + +const { readTerminalClipboardSelection } = await import('./terminal-clipboard-selection-text') +const { copyTerminalSelection } = await import('./terminal-selection-copy') + +// The gutter an agent CLI paints its message behind, as xterm reports it. +const GUTTERED = [' Retry limit is now 5.', ' Backoff starts at 2s.'].join('\n') +const UNGUTTERED = ['Retry limit is now 5.', 'Backoff starts at 2s.'].join('\n') + +describe('readTerminalClipboardSelection', () => { + beforeEach(() => { + settings.current = {} + }) + + it('strips the gutter by default', () => { + expect(readTerminalClipboardSelection({ getSelection: () => GUTTERED })).toBe(UNGUTTERED) + }) + + it('strips the gutter when the setting is explicitly on', () => { + settings.current = { terminalCopyTrimsGutter: true } + expect(readTerminalClipboardSelection({ getSelection: () => GUTTERED })).toBe(UNGUTTERED) + }) + + it('copies screen cells verbatim when the setting is off', () => { + settings.current = { terminalCopyTrimsGutter: false } + expect(readTerminalClipboardSelection({ getSelection: () => GUTTERED })).toBe(GUTTERED) + }) +}) + +describe('copyTerminalSelection gutter handling', () => { + beforeEach(() => { + settings.current = {} + }) + + it('writes the un-guttered text to the clipboard', async () => { + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue() + await copyTerminalSelection({ + terminal: { getSelection: () => GUTTERED, clearSelection: vi.fn() }, + writeClipboardText + }) + expect(writeClipboardText).toHaveBeenCalledWith(UNGUTTERED) + }) + + it('still reports no selection for an empty xterm selection', async () => { + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue() + await expect( + copyTerminalSelection({ + terminal: { getSelection: () => '', clearSelection: vi.fn() }, + writeClipboardText + }) + ).resolves.toBe(false) + expect(writeClipboardText).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.ts new file mode 100644 index 00000000000..5bc1b2b3c72 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.ts @@ -0,0 +1,17 @@ +import type { Terminal } from '@xterm/xterm' +import { useAppStore } from '@/store' +import { stripTerminalSelectionGutter } from '../../../../shared/terminal-selection-gutter' + +/** + * The selection text every terminal clipboard path should write: screen cells + * minus the left gutter the agent CLI painted them behind (#19770). + */ +export function readTerminalClipboardSelection(terminal: Pick): string { + const selection = terminal.getSelection() + // Why `=== false`: profiles saved before the setting existed have no key, and + // they should trim like every new profile does. + if (useAppStore.getState().settings?.terminalCopyTrimsGutter === false) { + return selection + } + return stripTerminalSelectionGutter(selection) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.test.ts b/src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.test.ts new file mode 100644 index 00000000000..f6e618e168c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' + +const settings: { current: Partial } = { current: {} } +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ settings: settings.current }) } +})) + +const { installTerminalNativeCopyGutterTrim } = await import('./terminal-native-copy-gutter') + +const GUTTERED = [' Retry limit is now 5.', ' Backoff starts at 2s.'].join('\n') +const UNGUTTERED = ['Retry limit is now 5.', 'Backoff starts at 2s.'].join('\n') + +function makeTerminal(selection: string) { + const element = document.createElement('div') + document.body.appendChild(element) + return { + element, + getSelection: () => selection, + hasSelection: () => selection.length > 0 + } +} + +function dispatchCopy(element: HTMLElement) { + const written = new Map() + const event = new Event('copy', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clipboardData', { + value: { setData: (type: string, data: string) => written.set(type, data) } + }) + element.dispatchEvent(event) + return { written, defaultPrevented: event.defaultPrevented } +} + +describe('installTerminalNativeCopyGutterTrim', () => { + beforeEach(() => { + settings.current = {} + document.body.innerHTML = '' + }) + + // Why: xterm's own `copy` listener writes raw screen cells, so a native copy + // Orca does not bind (Ctrl+Insert on Windows/Linux) would carry the gutter. + it('writes the un-guttered text for a native copy event', () => { + const terminal = makeTerminal(GUTTERED) + installTerminalNativeCopyGutterTrim(terminal) + const { written, defaultPrevented } = dispatchCopy(terminal.element) + expect(written.get('text/plain')).toBe(UNGUTTERED) + expect(defaultPrevented).toBe(true) + }) + + it('honours the opt-out', () => { + settings.current = { terminalCopyTrimsGutter: false } + const terminal = makeTerminal(GUTTERED) + installTerminalNativeCopyGutterTrim(terminal) + expect(dispatchCopy(terminal.element).written.get('text/plain')).toBe(GUTTERED) + }) + + it('leaves an empty selection to the default handler', () => { + const terminal = makeTerminal('') + installTerminalNativeCopyGutterTrim(terminal) + const { written, defaultPrevented } = dispatchCopy(terminal.element) + expect(written.size).toBe(0) + expect(defaultPrevented).toBe(false) + }) + + it('stops writing once disposed', () => { + const terminal = makeTerminal(GUTTERED) + installTerminalNativeCopyGutterTrim(terminal).dispose() + expect(dispatchCopy(terminal.element).written.size).toBe(0) + }) + + it('is inert before xterm has opened an element', () => { + expect(() => + installTerminalNativeCopyGutterTrim({ + element: undefined, + getSelection: () => GUTTERED, + hasSelection: () => true + }).dispose() + ).not.toThrow() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.ts b/src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.ts new file mode 100644 index 00000000000..1e12de1bc5f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.ts @@ -0,0 +1,32 @@ +import type { IDisposable, Terminal } from '@xterm/xterm' +import { readTerminalClipboardSelection } from './terminal-clipboard-selection-text' + +type NativeCopyTerminal = Pick & { + element?: HTMLElement +} + +/** + * 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 a native copy Orca does not bind still does: + * Ctrl+Insert is a Chromium copy accelerator on Windows/Linux and is not in + * `terminal.copySelection`'s bindings, so it would carry the gutter (#19770). + * Capture phase, so this wins when the event targets the helper textarea and + * xterm's element-level listener is next in line. + */ +export function installTerminalNativeCopyGutterTrim(terminal: NativeCopyTerminal): IDisposable { + const element = terminal.element + if (!element) { + return { dispose: () => {} } + } + const onCopy = (event: ClipboardEvent): void => { + if (!terminal.hasSelection() || !event.clipboardData) { + return + } + event.clipboardData.setData('text/plain', readTerminalClipboardSelection(terminal)) + event.preventDefault() + event.stopImmediatePropagation() + } + element.addEventListener('copy', onCopy, { capture: true }) + return { dispose: () => element.removeEventListener('copy', onCopy, { capture: true }) } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts index 417f920af5c..d8f5d8f0733 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts @@ -4,13 +4,14 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { translate } from '@/i18n/i18n' import { copyTerminalHandleForPane } from './terminal-handle-copy' import { runTerminalCopy, runTerminalIdentityCopy } from './terminal-copy-rejection-guards' +import { readTerminalClipboardSelection } from './terminal-clipboard-selection-text' export const copyTerminalPaneMenuSelection = async (pane: ManagedPane | null): Promise => { if (!pane) { return } await runTerminalCopy({ - selection: pane.terminal.getSelection(), + selection: readTerminalClipboardSelection(pane.terminal), writeClipboardText: window.api.ui.writeTerminalClipboardText, // Why: Radix returns focus to the menu trigger (the pane container) on // close, but xterm.js only accepts input when its own helper textarea is diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts index 69c7b71c380..d576576f900 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts @@ -61,6 +61,7 @@ export function cleanupTerminalPaneMount(args: { disposeAll(refs.fileLinkClickFallbackDisposablesRef.current) disposeAll(refs.httpLinkClickFallbackDisposablesRef.current) disposeAll(refs.selectionDisposablesRef.current) + disposeAll(refs.nativeCopyDisposablesRef.current) for (const timer of refs.selectionCaptureTimersRef.current.values()) { window.clearTimeout(timer) } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts index 7859c37c330..a70545c173c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts @@ -57,6 +57,7 @@ export function createTerminalPaneClosedHandler( disposeMapEntry(refs.fileLinkClickFallbackDisposablesRef.current, paneId) disposeMapEntry(refs.httpLinkClickFallbackDisposablesRef.current, paneId) disposeMapEntry(refs.selectionDisposablesRef.current, paneId) + disposeMapEntry(refs.nativeCopyDisposablesRef.current, paneId) disposeMapEntry(refs.imeCompositionDisposablesRef.current, paneId) disposeMapEntry(refs.imeNativeTextForwarderDisposablesRef.current, paneId) const timer = refs.selectionCaptureTimersRef.current.get(paneId) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-links.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-links.ts index f0eb3ae6c0e..3db5e48fdcf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-links.ts @@ -18,6 +18,8 @@ import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gestu import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing' import { handleOscLink } from './terminal-osc-link-routing' import { copyTerminalSelection } from './terminal-selection-copy' +import { readTerminalClipboardSelection } from './terminal-clipboard-selection-text' +import { installTerminalNativeCopyGutterTrim } from './terminal-native-copy-gutter' import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import { isPrimarySelectionEnabled, setPrimarySelectionText } from '@/lib/primary-selection' import { @@ -39,6 +41,7 @@ type PaneLinkContext = { | 'fileLinkClickFallbackDisposablesRef' | 'httpLinkClickFallbackDisposablesRef' | 'selectionDisposablesRef' + | 'nativeCopyDisposablesRef' | 'selectionCaptureTimersRef' | 'mouseHideDisposablesRef' > @@ -113,6 +116,10 @@ export function installTerminalPaneLinkHandling(context: PaneLinkContext): void ) seedStartupSessionRestoredBanner(ptyStartup, pane.id, onShowSessionRestoredBanner) + refs.nativeCopyDisposablesRef.current.set( + pane.id, + installTerminalNativeCopyGutterTrim(pane.terminal) + ) refs.selectionDisposablesRef.current.set( pane.id, pane.terminal.onSelectionChange(() => { @@ -144,7 +151,7 @@ export function installTerminalPaneLinkHandling(context: PaneLinkContext): void if (terminalSelectionExceedsPrimaryLimit(pane.terminal)) { return } - const selection = pane.terminal.getSelection() + const selection = readTerminalClipboardSelection(pane.terminal) if (selection) { setPrimarySelectionText(selection) } diff --git a/src/renderer/src/components/terminal-pane/terminal-selection-copy.ts b/src/renderer/src/components/terminal-pane/terminal-selection-copy.ts index 5123a80079d..7ea53cdb43e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-selection-copy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-selection-copy.ts @@ -1,4 +1,5 @@ import type { Terminal } from '@xterm/xterm' +import { readTerminalClipboardSelection } from './terminal-clipboard-selection-text' type TerminalSelectionCopyOptions = { terminal: Pick @@ -11,7 +12,7 @@ export async function copyTerminalSelection({ writeClipboardText, clearSelectionOnSuccess = false }: TerminalSelectionCopyOptions): Promise { - const selection = terminal.getSelection() + const selection = readTerminalClipboardSelection(terminal) if (!selection) { return false } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-refs.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-refs.ts index 53c8de6618f..01f13488486 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-refs.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-refs.ts @@ -21,6 +21,7 @@ export function useTerminalPaneLifecycleRefs() { new Map>() ), selectionDisposablesRef: useRef(new Map()), + nativeCopyDisposablesRef: useRef(new Map()), selectionCaptureTimersRef: useRef(new Map()), osc52DisposablesRef: useRef(new Map()), osc7DisposablesRef: useRef(new Map()), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f2c94ba41da..b0ba0c8f1a9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17607,6 +17607,24 @@ "copySessionIdSuccess": "Session ID copied", "copySessionIdError": "Unable to copy session ID" } + }, + "settings": { + "TerminalInteraction": { + "copyTrimsGutter": "Trim Gutter on Copy", + "copyTrimsGutterDescription": "Drop the left gutter agent output is painted behind, so copied text is not indented. Only the indent every selected line shares is removed." + }, + "terminal": { + "clipboard": { + "search": { + "gutter": "gutter", + "indent": "indent", + "margin": "margin", + "leading": "leading", + "whitespace": "whitespace", + "spaces": "spaces" + } + } + } } }, "dashboardPopout": { diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index c9b7a08928a..c11d05dc753 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -105,6 +105,9 @@ export function buildDefaultSettings(args: { // Why: default-on everywhere so it round-trips across platforms; only darwin acts on it. showMenuBarIcon: true, terminalClipboardOnSelect: false, + // Why: only the run of spaces shared by every selected line is dropped, so + // relative indentation survives and the clipboard loses only the gutter. + terminalCopyTrimsGutter: true, // Why: default on so Zellij/tmux/nvim copy works out of the box. Query // replies stay disabled and payload size is capped in the OSC 52 handler. // This default only covers new profiles; existing ones persisted `false` diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 5aac39c52ca..e65fec28c39 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -183,6 +183,8 @@ export type GlobalSettings = { terminalFocusFollowsMouse: boolean /** X11/gnome-terminal "copy on select": selecting text auto-copies to the clipboard; default off. */ terminalClipboardOnSelect: boolean + /** Drops the left gutter agent CLIs paint their output behind when copying a terminal selection; default on. */ + terminalCopyTrimsGutter: boolean /** Enables OSC 52 clipboard writes for TUIs (tmux/Zellij/nvim, incl. over SSH); default on. Clipboard *queries* stay blocked and payload size is capped, so this is write-only exposure. */ terminalAllowOsc52Clipboard: boolean /** One-shot stamp: profiles saved under the old off default get flipped on once, after which an explicit opt-out sticks. */ diff --git a/src/shared/terminal-selection-gutter.test.ts b/src/shared/terminal-selection-gutter.test.ts new file mode 100644 index 00000000000..9a70bc7013b --- /dev/null +++ b/src/shared/terminal-selection-gutter.test.ts @@ -0,0 +1,96 @@ +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) + }) +}) diff --git a/src/shared/terminal-selection-gutter.ts b/src/shared/terminal-selection-gutter.ts new file mode 100644 index 00000000000..ad30ce0a839 --- /dev/null +++ b/src/shared/terminal-selection-gutter.ts @@ -0,0 +1,57 @@ +// Why: an xterm selection is a rectangle of 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 (#19770). +// +// Only the run of spaces that *every* non-blank line shares is removed, so +// relative indentation — nested bullets, fenced code, YAML — survives. A +// selection that starts mid-line, or that covers any column-0 line, shares a +// run of zero and comes back untouched. + +// Spaces are the whole alphabet here: terminal cells never hold tabs (the +// emulator expands them), and xterm's selectionText getter already folds every +// NBSP cell to a plain space on its way out (SelectionService.ts, the +// ALL_NON_BREAKING_SPACE_REGEX replace) — that is the selection path, not the +// input path. +const LEADING_SPACES = /^ */ + +type SelectionLine = { indent: number; text: string; terminator: string } + +// xterm joins rows with CRLF on Windows, so split('\n') leaves the CR behind. +// It has to travel with the line: without it a blank CRLF row looks like a +// zero-indent content row and would cancel the gutter on Windows only. +function parseLine(rawLine: string): SelectionLine { + const carriageReturn = rawLine.endsWith('\r') + const text = carriageReturn ? rawLine.slice(0, -1) : rawLine + return { + indent: LEADING_SPACES.exec(text)?.[0].length ?? 0, + text, + terminator: carriageReturn ? '\r' : '' + } +} + +function measureGutter(lines: readonly SelectionLine[]): number { + let gutter = Number.POSITIVE_INFINITY + for (const { indent, text } of lines) { + // Blank and whitespace-only lines are evidence of nothing either way. + if (indent === text.length) { + continue + } + gutter = Math.min(gutter, indent) + if (gutter === 0) { + return 0 + } + } + return Number.isFinite(gutter) ? gutter : 0 +} + +export function stripTerminalSelectionGutter(selection: string): string { + const lines = selection.split('\n').map(parseLine) + const gutter = measureGutter(lines) + if (gutter === 0) { + return selection + } + return lines + .map(({ indent, text, terminator }) => text.slice(Math.min(indent, gutter)) + terminator) + .join('\n') +}