fix: trim trailing whitespace from terminal clipboard copies (#3167)

Fixes #2778.

## Problem

`xterm.js`'s `getSelection()` returns lines padded to the full terminal
column width. Every copy path passed this directly to
`navigator.clipboard.writeText()`, so pasting into Slack, editors, etc.
included hundreds of trailing spaces.

## Solution

Adds a `term:trimtrailingwhitespace` setting (default `true`) that
strips trailing whitespace from each line before writing to the
clipboard. Applied to all three copy paths:

- copy-on-select (`termwrap.ts`)
- `Ctrl+Shift+C` (`term-model.ts`)
- right-click Copy context menu (`term-model.ts`)

OSC 52 is intentionally excluded — that path copies program-provided
text, not grid content.

The trim itself uses the same per-line `trimEnd()` approach already
present in `bufferLinesToText` via `translateToString(true)`.

Setting to `false` restores the previous behaviour.

## Files changed

- `pkg/wconfig/settingsconfig.go` — new `TermTrimTrailingWhitespace
*bool` field
- `pkg/wconfig/defaultconfig/settings.json` — default `true`
- `frontend/app/view/term/termutil.ts` — `trimTerminalSelection` helper
- `frontend/app/view/term/termwrap.ts` — copy-on-select path
- `frontend/app/view/term/term-model.ts` — Ctrl+Shift+C and right-click
paths
- Generated: `pkg/wconfig/metaconsts.go`, `frontend/types/gotypes.d.ts`,
`schema/settings.json`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Drew Goddyn 2026-04-15 10:24:39 -07:00 committed by GitHub
parent 38e3c2e9b3
commit 1fe0fa236c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 32 additions and 6 deletions

View file

@ -40,7 +40,7 @@ import { boundNumber, fireAndForget, stringToBase64 } from "@/util/util";
import * as jotai from "jotai";
import * as React from "react";
import { getBlockingCommand } from "./shellblocking";
import { computeTheme, DefaultTermTheme } from "./termutil";
import { computeTheme, DefaultTermTheme, trimTerminalSelection } from "./termutil";
import { TermWrap, WebGLSupported } from "./termwrap";
export class TermViewModel implements ViewModel {
@ -750,10 +750,13 @@ export class TermViewModel implements ViewModel {
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
event.preventDefault();
event.stopPropagation();
const sel = this.termRef.current?.terminal.getSelection();
let sel = this.termRef.current?.terminal.getSelection();
if (!sel) {
return false;
}
if (globalStore.get(getSettingsKeyAtom("term:trimtrailingwhitespace")) !== false) {
sel = trimTerminalSelection(sel);
}
navigator.clipboard.writeText(sel);
return false;
} else if (keyutil.checkKeyPressed(waveEvent, "Cmd:k")) {
@ -829,7 +832,11 @@ export class TermViewModel implements ViewModel {
label: "Copy",
click: () => {
if (selection) {
navigator.clipboard.writeText(selection);
const text =
globalStore.get(getSettingsKeyAtom("term:trimtrailingwhitespace")) !== false
? trimTerminalSelection(selection)
: selection;
navigator.clipboard.writeText(text);
}
},
});

View file

@ -10,6 +10,13 @@ import { colord } from "colord";
export type GenClipboardItem = { text?: string; image?: Blob };
export function trimTerminalSelection(text: string): string {
return text
.split("\n")
.map((line) => line.trimEnd())
.join("\n");
}
export function normalizeCursorStyle(cursorStyle: string): TermTypes.Terminal["options"]["cursorStyle"] {
if (cursorStyle === "underline" || cursorStyle === "bar") {
return cursorStyle;

View file

@ -42,6 +42,7 @@ import {
extractAllClipboardData,
normalizeCursorStyle,
quoteForPosixShell,
trimTerminalSelection,
} from "./termutil";
const dlog = debug("wave:termwrap");
@ -380,6 +381,7 @@ export class TermWrap {
async initTerminal() {
const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect");
const trimTrailingWhitespaceAtom = getSettingsKeyAtom("term:trimtrailingwhitespace");
this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));
this.toDispose.push(
this.terminal.onSelectionChange(
@ -393,8 +395,11 @@ export class TermWrap {
if (active != null && active.closest(".search-container") != null) {
return;
}
const selectedText = this.terminal.getSelection();
let selectedText = this.terminal.getSelection();
if (selectedText.length > 0) {
if (globalStore.get(trimTrailingWhitespaceAtom) !== false) {
selectedText = trimTerminalSelection(selectedText);
}
navigator.clipboard.writeText(selectedText);
}
})

View file

@ -1425,6 +1425,7 @@ declare global {
"term:osc52"?: string;
"term:durable"?: boolean;
"term:showsplitbuttons"?: boolean;
"term:trimtrailingwhitespace"?: boolean;
"editor:minimapenabled"?: boolean;
"editor:stickyscrollenabled"?: boolean;
"editor:wordwrap"?: boolean;

View file

@ -36,6 +36,7 @@
"term:cursor": "block",
"term:cursorblink": false,
"term:copyonselect": true,
"term:trimtrailingwhitespace": true,
"term:durable": false,
"waveai:showcloudmodes": true,
"waveai:defaultmode": "waveai@balanced",

View file

@ -60,6 +60,7 @@ const (
ConfigKey_TermOsc52 = "term:osc52"
ConfigKey_TermDurable = "term:durable"
ConfigKey_TermShowSplitButtons = "term:showsplitbuttons"
ConfigKey_TermTrimTrailingWhitespace = "term:trimtrailingwhitespace"
ConfigKey_EditorMinimapEnabled = "editor:minimapenabled"
ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled"

View file

@ -109,8 +109,9 @@ type SettingsType struct {
TermBellSound *bool `json:"term:bellsound,omitempty"`
TermBellIndicator *bool `json:"term:bellindicator,omitempty"`
TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"`
TermDurable *bool `json:"term:durable,omitempty"`
TermShowSplitButtons bool `json:"term:showsplitbuttons,omitempty"`
TermDurable *bool `json:"term:durable,omitempty"`
TermShowSplitButtons bool `json:"term:showsplitbuttons,omitempty"`
TermTrimTrailingWhitespace *bool `json:"term:trimtrailingwhitespace,omitempty"`
EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"`
EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"`

View file

@ -171,6 +171,9 @@
"term:showsplitbuttons": {
"type": "boolean"
},
"term:trimtrailingwhitespace": {
"type": "boolean"
},
"editor:minimapenabled": {
"type": "boolean"
},