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.
This commit is contained in:
Neil
2026-09-13 22:35:28 -07:00
parent 9cc4be9881
commit 0dbe84f6bd
6 changed files with 48 additions and 4 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ allowBuilds:
node-pty: true
sherpa-onnx: true
ssh2: false
"@orca/windows-registry": false
'@orca/windows-registry': false
overrides:
monaco-editor>dompurify: 3.4.14
@@ -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<GlobalSettings>) {
// 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<GlobalSettings>): Partial<GlobalSettings> {
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)
})
})
@@ -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],
@@ -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']
@@ -108,7 +108,6 @@ describe('scanSourceTree filesystem traversal', () => {
expect(statSync).toHaveBeenCalledExactlyOnceWith(join(root, 'alias'))
})
it('still reports a broken link instead of silently dropping it', () => {
const target = join(root, '.target')
mkdirSync(target)
+5 -2
View File
@@ -8,8 +8,11 @@
// selection that starts mid-line, or that covers any column-0 line, shares a
// run of zero and comes back untouched.
// Terminal cells never hold tabs (the emulator expands them) and xterm folds
// non-breaking spaces into plain ones, so spaces are the whole alphabet here.
// 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 }