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') +}