feat(terminal): add touch-friendly copy mode

This commit is contained in:
Federico Jaramillo Martinez
2026-07-11 22:50:25 +02:00
parent 6fbeed345e
commit 02f34c495c
4 changed files with 766 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add a terminal copy mode with a touch-selectable, color-preserving output snapshot and a Copy all action for mobile browsers.
+193 -6
View File
@@ -1,10 +1,13 @@
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { styleMap, type StyleInfo } from "lit/directives/style-map.js";
import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm";
import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
import { writeClipboardText } from "../clipboard";
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
import { createTerminalCopySnapshot, DEFAULT_TERMINAL_ANSI_THEME, type TerminalCopyRunStyle, type TerminalCopySnapshot } from "../terminalCopySnapshot";
import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference";
import "./TerminalSoftKeys";
import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys";
@@ -27,6 +30,8 @@ export class TerminalPanel extends LitElement {
@property({ type: Boolean }) autoStart = false;
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
@query(".terminal-host") private terminalHost?: HTMLDivElement | null;
@query(".terminal-copy-content") private terminalCopyContent?: HTMLPreElement | null;
@query(".terminal-copy-selector") private terminalCopySelector?: HTMLTextAreaElement | null;
@state() private terminals: TerminalInfo[] = [];
@state() private commandRuns: TerminalCommandRun[] = [];
@state() private selectedId: string | undefined;
@@ -37,6 +42,8 @@ export class TerminalPanel extends LitElement {
@state() private continuingTerminalIds: string[] = [];
@state() private defaultSoftKeysEnvironment = false;
@state() private softKeysEnabled = initialTerminalSoftKeysEnabled();
@state() private copySnapshot: TerminalCopySnapshot | undefined;
@state() private copyStatus: string | undefined;
private terminal: Terminal | undefined;
private fitAddon: FitAddon | undefined;
@@ -311,7 +318,7 @@ export class TerminalPanel extends LitElement {
this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); });
this.resizeObserver.observe(terminalHost);
terminal.onData((data) => {
if (this.suppressTerminalInput) return;
if (this.suppressTerminalInput || this.copySnapshot !== undefined) return;
this.sendTerminalInput(data);
});
const initialSize = this.fitTerminal();
@@ -406,6 +413,7 @@ export class TerminalPanel extends LitElement {
}
private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void {
if (this.copySnapshot !== undefined) return;
this.sendTerminalInput(data);
if (options.refocus) this.focusTerminal();
}
@@ -429,6 +437,8 @@ export class TerminalPanel extends LitElement {
this.terminal?.dispose();
this.terminal = undefined;
this.fitAddon = undefined;
this.copySnapshot = undefined;
this.copyStatus = undefined;
}
private renderCommandRunNotice() {
@@ -464,20 +474,147 @@ export class TerminalPanel extends LitElement {
return null;
}
private enterCopyMode(): void {
if (this.copySnapshot !== undefined) return;
this.captureCopySnapshot();
}
private refreshCopyMode(): void {
if (this.copySnapshot === undefined) return;
this.captureCopySnapshot();
}
private captureCopySnapshot(): void {
const terminal = this.terminal;
if (terminal === undefined) return;
const snapshot = createTerminalCopySnapshot(terminal.buffer.active, terminal.cols, {
theme: terminal.options.theme,
drawBoldTextInBrightColors: terminal.options.drawBoldTextInBrightColors,
});
this.copySnapshot = snapshot;
this.copyStatus = undefined;
terminal.blur();
void this.updateComplete.then(() => {
const selector = this.terminalCopySelector;
if (selector === null || selector === undefined) return;
const sourceScrollRange = Math.max(0, snapshot.physicalLineCount - terminal.rows);
const sourceScrollTop = Math.min(sourceScrollRange, snapshot.viewportLine);
const scrollRatio = sourceScrollRange === 0 ? 0 : sourceScrollTop / sourceScrollRange;
selector.scrollTop = scrollRatio * Math.max(0, selector.scrollHeight - selector.clientHeight);
this.syncCopySnapshotScroll();
});
}
private exitCopyMode(): void {
if (this.copySnapshot === undefined) return;
this.copySnapshot = undefined;
this.copyStatus = undefined;
}
// iOS WebKit offsets native selection hit-testing in a scrolled generic
// overflow container. A textarea owns selection and scrolling while the
// synchronized, noninteractive pre preserves the terminal's ANSI styling.
// Keep its caret visible: iOS hides native selection handles with the caret.
private syncCopySnapshotScroll(): void {
const selector = this.terminalCopySelector;
const content = this.terminalCopyContent;
if (selector === null || selector === undefined || content === null || content === undefined) return;
const selectorVerticalRange = Math.max(0, selector.scrollHeight - selector.clientHeight);
const contentVerticalRange = Math.max(0, content.scrollHeight - content.clientHeight);
const selectorHorizontalRange = Math.max(0, selector.scrollWidth - selector.clientWidth);
const contentHorizontalRange = Math.max(0, content.scrollWidth - content.clientWidth);
content.scrollTop = normalizedScrollOffset(selector.scrollTop, selectorVerticalRange, contentVerticalRange);
content.scrollLeft = normalizedScrollOffset(selector.scrollLeft, selectorHorizontalRange, contentHorizontalRange);
}
private async copyAllSnapshotText(): Promise<void> {
const text = this.copySnapshot?.text ?? "";
if (text === "") {
this.copyStatus = "No terminal output to copy.";
return;
}
this.copyStatus = await writeClipboardText(text) ? "Copied all terminal output." : "Unable to copy terminal output.";
}
private renderCopyModeToggle() {
if (this.selectedId === undefined) return null;
const active = this.copySnapshot !== undefined;
return html`
<button
type="button"
class=${active ? "copy-mode-toggle selected" : "copy-mode-toggle"}
title=${active ? "Return to the interactive terminal" : "Select and copy terminal output"}
aria-label=${active ? "Close terminal copy mode" : "Open terminal copy mode"}
aria-pressed=${String(active)}
@click=${() => { if (active) this.exitCopyMode(); else this.enterCopyMode(); }}
>
<span>${active ? "Done" : "Select"}</span>
</button>
`;
}
private renderCopyModeToolbar() {
const snapshot = this.copySnapshot;
if (snapshot === undefined) return null;
return html`
<div class="terminal-copy-toolbar" role="toolbar" aria-label="Terminal copy controls">
<span aria-live="polite">${this.copyStatus ?? "Snapshot · long-press and select text"}</span>
<small>${snapshot.physicalLineCount} ${snapshot.physicalLineCount === 1 ? "row" : "rows"}</small>
<button type="button" @click=${() => { this.refreshCopyMode(); }}>Refresh</button>
<button type="button" @click=${() => { void this.copyAllSnapshotText(); }}>Copy all</button>
</div>
`;
}
private renderCopyMode() {
const snapshot = this.copySnapshot;
if (snapshot === undefined) return null;
return html`
<section class="terminal-copy-view" aria-label="Terminal copy mode">
${this.copyToolbarReplacesSoftKeys() ? null : this.renderCopyModeToolbar()}
<div class="terminal-copy-layers">
<pre class="terminal-copy-content" aria-hidden="true">${snapshot.lines.map((line, index) => html`${index === 0 ? null : "\n"}${line.runs.map((run) => html`<span style=${styleMap(terminalCopyRunStyle(run.style))}>${run.text}</span>`)}`)}</pre>
<textarea
class="terminal-copy-selector"
readonly
inputmode="none"
wrap="soft"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
aria-label="Selectable terminal output"
.value=${snapshot.text}
@scroll=${() => { this.syncCopySnapshotScroll(); }}
></textarea>
</div>
</section>
`;
}
private selectedTerminalAcceptsInput(): boolean {
const terminal = this.selectedTerminalInfo();
return terminal !== undefined && !terminal.exited;
}
private copyToolbarReplacesSoftKeys(): boolean {
return this.copySnapshot !== undefined && this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
}
private renderTerminalAccessoryBar() {
if (this.copySnapshot !== undefined) return this.copyToolbarReplacesSoftKeys() ? this.renderCopyModeToolbar() : null;
return this.shouldShowSoftKeys() ? this.renderSoftKeys() : null;
}
private shouldShowSoftKeys(): boolean {
return this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
}
private shouldShowSoftKeysToggle(): boolean {
return this.selectedTerminalAcceptsInput();
return this.copySnapshot === undefined && this.selectedTerminalAcceptsInput();
}
private toggleSoftKeys(): void {
if (this.copySnapshot !== undefined) return;
this.softKeysEnabled = !this.softKeysEnabled;
this.softKeysPreferenceStored = true;
writeTerminalSoftKeysPreference(this.softKeysEnabled);
@@ -518,6 +655,7 @@ export class TerminalPanel extends LitElement {
return html`
<section class="terminal-shell">
<div class="terminal-tabs">
${this.renderCopyModeToggle()}
${this.renderSoftKeysToggle()}
${this.terminals.map((terminal) => html`
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
@@ -529,9 +667,12 @@ export class TerminalPanel extends LitElement {
</div>
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
${this.renderCommandRunNotice()}
${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null}
${this.renderTerminalAccessoryBar()}
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
<div class="terminal-host"></div>
<div class="terminal-stage">
<div class=${this.copySnapshot === undefined ? "terminal-host" : "terminal-host copying"} ?inert=${this.copySnapshot !== undefined}></div>
${this.renderCopyMode()}
</div>
</section>
`;
}
@@ -540,11 +681,19 @@ export class TerminalPanel extends LitElement {
:host { flex: 1 1 auto; min-height: 0; display: flex; }
.terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--pi-terminal-bg); }
.terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow: auto; }
.terminal-tabs > button { box-sizing: border-box; height: 30px; line-height: 16px; }
/* Desktop xterm already has mouse selection and hardware keys; keep touch controls to touch/narrow layouts. */
.copy-mode-toggle, .soft-keys-toggle, terminal-soft-keys { display: none; }
.copy-mode-toggle.selected { display: inline-flex; }
@media (pointer: coarse), (max-width: 760px) {
.copy-mode-toggle, .soft-keys-toggle { display: inline-flex; }
terminal-soft-keys { display: block; }
}
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
button.new { flex: 0 0 auto; color: var(--pi-muted); }
.soft-keys-toggle { flex: 0 0 auto; }
.soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.soft-keys-toggle .keyboard-icon { display: block; flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
button small:hover { color: var(--pi-danger); }
@@ -558,7 +707,20 @@ export class TerminalPanel extends LitElement {
.command-run-notice code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.command-run-notice kbd { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 0 4px; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.command-run-notice button { justify-self: end; max-width: none; }
.terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
.terminal-stage { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; background: var(--pi-terminal-bg); }
.terminal-host { position: absolute; inset: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
.terminal-host.copying { visibility: hidden; pointer-events: none; }
.terminal-copy-view { position: absolute; inset: 0; display: flex; flex-direction: column; min-height: 0; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); }
.terminal-copy-toolbar { box-sizing: border-box; flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 47px; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); font: 12px system-ui, sans-serif; }
.terminal-copy-toolbar > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.terminal-copy-toolbar small { margin-left: auto; white-space: nowrap; color: var(--pi-dim); }
.terminal-copy-toolbar button { flex: 0 0 auto; width: auto; min-height: 34px; padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.terminal-copy-layers { flex: 1 1 auto; min-height: 0; display: grid; overflow: hidden; background: var(--pi-terminal-bg); }
/* xterm renders the configured 13px terminal font in 17px-high cells. */
.terminal-copy-content, .terminal-copy-selector { grid-area: 1 / 1; box-sizing: border-box; min-width: 0; min-height: 0; width: 100%; height: 100%; margin: 0; padding: 6px; border: 0; border-radius: 0; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 17px; letter-spacing: normal; font-variant-ligatures: none; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; }
.terminal-copy-content { overflow: auto; pointer-events: none; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); -webkit-user-select: none; user-select: none; }
.terminal-copy-selector { z-index: 1; overflow: auto; resize: none; outline: none; appearance: none; background: transparent; color: transparent; caret-color: var(--pi-accent); -webkit-text-fill-color: transparent; cursor: text; -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; touch-action: auto; }
.terminal-copy-selector::selection { background: var(--pi-terminal-selection); color: transparent; -webkit-text-fill-color: transparent; }
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
.terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; }
@@ -582,6 +744,30 @@ export class TerminalPanel extends LitElement {
`;
}
function normalizedScrollOffset(sourceOffset: number, sourceRange: number, targetRange: number): number {
if (sourceRange <= 0 || targetRange <= 0) return 0;
return Math.min(1, Math.max(0, sourceOffset / sourceRange)) * targetRange;
}
function dimTerminalCopyColor(color: string): string {
return /^#[\da-f]{6}$/i.test(color) ? `${color}80` : `color-mix(in srgb, ${color} 50%, transparent)`;
}
function terminalCopyRunStyle(style: TerminalCopyRunStyle): StyleInfo {
const decorations = [
style.underline ? "underline" : undefined,
style.strikethrough ? "line-through" : undefined,
style.overline ? "overline" : undefined,
].filter((decoration): decoration is string => decoration !== undefined).join(" ");
return {
color: style.invisible ? "transparent" : style.dim ? dimTerminalCopyColor(style.foreground) : style.foreground,
backgroundColor: style.background,
fontWeight: style.bold ? "700" : undefined,
fontStyle: style.italic ? "italic" : undefined,
textDecorationLine: decorations === "" ? undefined : decorations,
};
}
interface TerminalSize {
cols: number;
rows: number;
@@ -631,6 +817,7 @@ function terminalOptions(element: HTMLElement): ITerminalOptions {
function terminalTheme(element: HTMLElement): ITheme {
return {
...DEFAULT_TERMINAL_ANSI_THEME,
background: themeColor(element, "--pi-terminal-bg", "#05070a"),
foreground: themeColor(element, "--pi-terminal-text", "#e6edf3"),
cursor: themeColor(element, "--pi-accent", "#58a6ff"),
+249
View File
@@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest";
import {
createTerminalCopySnapshot,
type TerminalCopyBufferCellSource,
type TerminalCopyBufferLineSource,
type TerminalCopyBufferSource,
} from "./terminalCopySnapshot";
type CellColor = { mode: "default" } | { mode: "palette"; value: number } | { mode: "rgb"; value: number };
interface CellOptions {
width?: number;
foreground?: CellColor;
background?: CellColor;
bold?: boolean;
italic?: boolean;
dim?: boolean;
underline?: boolean;
inverse?: boolean;
invisible?: boolean;
strikethrough?: boolean;
overline?: boolean;
}
class TestCell implements TerminalCopyBufferCellSource {
constructor(private readonly chars: string, private readonly options: CellOptions = {}) {}
getWidth(): number { return this.options.width ?? 1; }
getChars(): string { return this.chars; }
getCode(): number { return this.chars.codePointAt(0) ?? 0; }
getFgColorMode(): number { return 0; }
getBgColorMode(): number { return 0; }
getFgColor(): number { return colorValue(this.options.foreground); }
getBgColor(): number { return colorValue(this.options.background); }
isBold(): number { return Number(this.options.bold === true); }
isItalic(): number { return Number(this.options.italic === true); }
isDim(): number { return Number(this.options.dim === true); }
isUnderline(): number { return Number(this.options.underline === true); }
isInverse(): number { return Number(this.options.inverse === true); }
isInvisible(): number { return Number(this.options.invisible === true); }
isStrikethrough(): number { return Number(this.options.strikethrough === true); }
isOverline(): number { return Number(this.options.overline === true); }
isFgRGB(): boolean { return this.options.foreground?.mode === "rgb"; }
isBgRGB(): boolean { return this.options.background?.mode === "rgb"; }
isFgPalette(): boolean { return this.options.foreground?.mode === "palette"; }
isBgPalette(): boolean { return this.options.background?.mode === "palette"; }
isFgDefault(): boolean { return this.options.foreground === undefined || this.options.foreground.mode === "default"; }
isBgDefault(): boolean { return this.options.background === undefined || this.options.background.mode === "default"; }
isAttributeDefault(): boolean {
return this.options.foreground === undefined
&& this.options.background === undefined
&& this.options.bold !== true
&& this.options.italic !== true
&& this.options.dim !== true
&& this.options.underline !== true
&& this.options.inverse !== true
&& this.options.invisible !== true
&& this.options.strikethrough !== true
&& this.options.overline !== true;
}
}
class TestLine implements TerminalCopyBufferLineSource {
readonly length: number;
constructor(private readonly cells: (TestCell | undefined)[], readonly isWrapped = false) {
this.length = cells.length;
}
getCell(column: number, cell?: TerminalCopyBufferCellSource): TerminalCopyBufferCellSource | undefined {
void cell;
return this.cells[column];
}
}
class TestBuffer implements TerminalCopyBufferSource {
readonly length: number;
constructor(
private readonly lines: (TestLine | undefined)[],
readonly baseY = 0,
readonly cursorY = Math.max(0, lines.length - 1),
readonly viewportY = baseY,
) {
this.length = lines.length;
}
getLine(index: number): TerminalCopyBufferLineSource | undefined {
return this.lines[index];
}
getNullCell(): TerminalCopyBufferCellSource {
return new TestCell("");
}
}
describe("createTerminalCopySnapshot", () => {
it("joins wrapped physical rows into selectable logical lines", () => {
const buffer = new TestBuffer([
line("abc"),
line("def", true),
line("next"),
]);
const snapshot = createTerminalCopySnapshot(buffer, 20);
expect(snapshot.lines.map((item) => item.text)).toEqual(["abcdef", "next"]);
expect(snapshot.text).toBe("abcdef\nnext");
expect(snapshot.physicalLineCount).toBe(3);
});
it("preserves palette, RGB, inverse, and text-decoration styles", () => {
const styled = new TestLine([
new TestCell("A", { foreground: { mode: "palette", value: 1 }, bold: true }),
new TestCell("B", { foreground: { mode: "palette", value: 1 }, bold: true }),
new TestCell("C", {
foreground: { mode: "rgb", value: 0x123456 },
background: { mode: "palette", value: 4 },
italic: true,
underline: true,
strikethrough: true,
overline: true,
}),
new TestCell("D", {
foreground: { mode: "palette", value: 2 },
background: { mode: "rgb", value: 0x010203 },
inverse: true,
}),
]);
const snapshot = createTerminalCopySnapshot(new TestBuffer([styled]), 20);
expect(snapshot.lines[0]?.runs).toHaveLength(3);
expect(snapshot.lines[0]?.runs[0]).toMatchObject({
text: "AB",
style: { foreground: "#ef2929", background: "#000000", bold: true },
});
expect(snapshot.lines[0]?.runs[1]).toMatchObject({
text: "C",
style: {
foreground: "#123456",
background: "#3465a4",
italic: true,
underline: true,
strikethrough: true,
overline: true,
},
});
expect(snapshot.lines[0]?.runs[2]).toMatchObject({
text: "D",
style: { foreground: "#010203", background: "#4e9a06" },
});
});
it("uses terminal theme colors and extended ANSI overrides", () => {
const source = new TestLine([
new TestCell("A"),
new TestCell("B", { foreground: { mode: "palette", value: 1 } }),
new TestCell("C", { foreground: { mode: "palette", value: 16 } }),
]);
const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 20, {
theme: {
foreground: "#eeeeee",
background: "#111111",
red: "#aa0000",
extendedAnsi: ["#abcdef"],
},
});
expect(snapshot.lines[0]?.runs.map((run) => [run.text, run.style.foreground, run.style.background])).toEqual([
["A", "#eeeeee", "#111111"],
["B", "#aa0000", "#111111"],
["C", "#abcdef", "#111111"],
]);
});
it("keeps interior blanks while trimming unused cells on the right", () => {
const source = new TestLine([
new TestCell("A"),
new TestCell(""),
new TestCell("B"),
new TestCell(""),
new TestCell(""),
]);
const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 5);
expect(snapshot.text).toBe("A B");
});
it("includes the cursor line but omits unused rows below it", () => {
const buffer = new TestBuffer([
line("output"),
new TestLine([new TestCell("")]),
new TestLine([new TestCell("")]),
], 0, 1, 1);
const snapshot = createTerminalCopySnapshot(buffer, 20);
expect(snapshot.lines.map((item) => item.text)).toEqual(["output", ""]);
expect(snapshot.text).toBe("output\n");
expect(snapshot.physicalLineCount).toBe(2);
expect(snapshot.viewportLine).toBe(1);
});
it("preserves wide and combined cells while skipping continuation cells", () => {
const source = new TestLine([
new TestCell("👩‍💻", { width: 2 }),
new TestCell("", { width: 0 }),
new TestCell("é"),
new TestCell("!"),
]);
const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 10);
expect(snapshot.text).toBe("👩‍💻é!");
});
it("respects disabled bold-to-bright color promotion and terminal column bounds", () => {
const source = new TestLine([
new TestCell("A", { foreground: { mode: "palette", value: 1 }, bold: true }),
new TestCell("B"),
new TestCell("C"),
]);
const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 2, { drawBoldTextInBrightColors: false });
expect(snapshot.text).toBe("AB");
expect(snapshot.lines[0]?.runs[0]?.style.foreground).toBe("#cc0000");
});
it("returns an empty snapshot when there are no usable columns", () => {
expect(createTerminalCopySnapshot(new TestBuffer([line("output")]), 0)).toEqual({
text: "",
lines: [],
physicalLineCount: 0,
viewportLine: 0,
});
});
});
function line(text: string, isWrapped = false): TestLine {
return new TestLine(Array.from(text, (character) => new TestCell(character)), isWrapped);
}
function colorValue(color: CellColor | undefined): number {
return color?.mode === "default" || color === undefined ? 0 : color.value;
}
+319
View File
@@ -0,0 +1,319 @@
import type { ITheme } from "@xterm/xterm";
export interface TerminalCopyBufferSource {
readonly baseY: number;
readonly cursorY: number;
readonly viewportY: number;
readonly length: number;
getLine(index: number): TerminalCopyBufferLineSource | undefined;
getNullCell(): TerminalCopyBufferCellSource;
}
export interface TerminalCopyBufferLineSource {
readonly isWrapped: boolean;
readonly length: number;
getCell(column: number, cell?: TerminalCopyBufferCellSource): TerminalCopyBufferCellSource | undefined;
}
export interface TerminalCopyBufferCellSource {
getWidth(): number;
getChars(): string;
getCode(): number;
getFgColorMode(): number;
getBgColorMode(): number;
getFgColor(): number;
getBgColor(): number;
isBold(): number;
isItalic(): number;
isDim(): number;
isUnderline(): number;
isInverse(): number;
isInvisible(): number;
isStrikethrough(): number;
isOverline(): number;
isFgRGB(): boolean;
isBgRGB(): boolean;
isFgPalette(): boolean;
isBgPalette(): boolean;
isFgDefault(): boolean;
isBgDefault(): boolean;
isAttributeDefault(): boolean;
}
export interface TerminalCopyRunStyle {
foreground: string;
background: string;
bold: boolean;
italic: boolean;
dim: boolean;
underline: boolean;
invisible: boolean;
strikethrough: boolean;
overline: boolean;
}
export interface TerminalCopyRun {
text: string;
style: TerminalCopyRunStyle;
}
export interface TerminalCopyLine {
text: string;
runs: TerminalCopyRun[];
}
export interface TerminalCopySnapshot {
text: string;
lines: TerminalCopyLine[];
physicalLineCount: number;
viewportLine: number;
}
export interface TerminalCopySnapshotOptions {
theme?: ITheme | undefined;
drawBoldTextInBrightColors?: boolean | undefined;
}
interface CapturedPhysicalLine extends TerminalCopyLine {
wrapped: boolean;
}
const DEFAULT_FOREGROUND = "#ffffff";
const DEFAULT_BACKGROUND = "#000000";
const DEFAULT_ANSI_COLORS = [
"#2e3436", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
"#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
] as const;
const ANSI_THEME_KEYS = [
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
] as const satisfies readonly (keyof ITheme)[];
// Pin the palette used by both xterm and its copy snapshot so a dependency
// upgrade cannot make the interactive and selectable views drift apart.
export const DEFAULT_TERMINAL_ANSI_THEME: ITheme = {
black: DEFAULT_ANSI_COLORS[0],
red: DEFAULT_ANSI_COLORS[1],
green: DEFAULT_ANSI_COLORS[2],
yellow: DEFAULT_ANSI_COLORS[3],
blue: DEFAULT_ANSI_COLORS[4],
magenta: DEFAULT_ANSI_COLORS[5],
cyan: DEFAULT_ANSI_COLORS[6],
white: DEFAULT_ANSI_COLORS[7],
brightBlack: DEFAULT_ANSI_COLORS[8],
brightRed: DEFAULT_ANSI_COLORS[9],
brightGreen: DEFAULT_ANSI_COLORS[10],
brightYellow: DEFAULT_ANSI_COLORS[11],
brightBlue: DEFAULT_ANSI_COLORS[12],
brightMagenta: DEFAULT_ANSI_COLORS[13],
brightCyan: DEFAULT_ANSI_COLORS[14],
brightWhite: DEFAULT_ANSI_COLORS[15],
};
export function createTerminalCopySnapshot(
buffer: TerminalCopyBufferSource,
columns: number,
options: TerminalCopySnapshotOptions = {},
): TerminalCopySnapshot {
const columnCount = Math.max(0, Math.floor(columns));
if (buffer.length <= 0 || columnCount === 0) return { text: "", lines: [], physicalLineCount: 0, viewportLine: 0 };
const palette = terminalAnsiPalette(options.theme);
const foreground = options.theme?.foreground ?? DEFAULT_FOREGROUND;
const background = options.theme?.background ?? DEFAULT_BACKGROUND;
const physicalLines: CapturedPhysicalLine[] = [];
const reusableCell = buffer.getNullCell();
let lastMeaningfulLine = -1;
for (let index = 0; index < buffer.length; index += 1) {
const sourceLine = buffer.getLine(index);
if (sourceLine === undefined) {
physicalLines.push({ text: "", runs: [], wrapped: false });
continue;
}
const line = capturePhysicalLine(sourceLine, columnCount, reusableCell, {
palette,
foreground,
background,
drawBoldTextInBrightColors: options.drawBoldTextInBrightColors !== false,
});
physicalLines.push(line);
if (line.text !== "" || line.runs.some((run) => run.style.background !== background)) lastMeaningfulLine = index;
}
const cursorLine = Math.min(buffer.length - 1, Math.max(0, buffer.baseY + buffer.cursorY));
const endLine = Math.max(lastMeaningfulLine, cursorLine);
const includedPhysicalLines = physicalLines.slice(0, endLine + 1);
const lines: TerminalCopyLine[] = [];
for (const physicalLine of includedPhysicalLines) {
const currentLine = lines.at(-1);
if (physicalLine.wrapped && currentLine !== undefined) {
currentLine.text += physicalLine.text;
appendRuns(currentLine.runs, physicalLine.runs);
continue;
}
lines.push({ text: physicalLine.text, runs: physicalLine.runs.map((run) => ({ text: run.text, style: run.style })) });
}
return {
text: lines.map((line) => line.text).join("\n"),
lines,
physicalLineCount: includedPhysicalLines.length,
viewportLine: Math.min(endLine, Math.max(0, buffer.viewportY)),
};
}
interface CaptureColors {
palette: readonly string[];
foreground: string;
background: string;
drawBoldTextInBrightColors: boolean;
}
function capturePhysicalLine(sourceLine: TerminalCopyBufferLineSource, columns: number, reusableCell: TerminalCopyBufferCellSource, colors: CaptureColors): CapturedPhysicalLine {
const cells: { text: string; meaningful: boolean; style: TerminalCopyRunStyle }[] = [];
const cellCount = Math.min(columns, sourceLine.length);
for (let column = 0; column < cellCount; column += 1) {
const cell = sourceLine.getCell(column, reusableCell);
if (cell === undefined) {
cells.push({ text: " ", meaningful: false, style: defaultRunStyle(colors) });
continue;
}
const width = cell.getWidth();
if (width === 0) continue;
const chars = cell.getChars();
cells.push({
text: chars === "" ? " ".repeat(Math.max(1, width)) : chars,
meaningful: chars !== "" || !cell.isAttributeDefault(),
style: copyRunStyle(cell, colors),
});
}
let lastMeaningfulCell = cells.length - 1;
while (lastMeaningfulCell >= 0 && cells[lastMeaningfulCell]?.meaningful !== true) lastMeaningfulCell -= 1;
const runs: TerminalCopyRun[] = [];
let text = "";
for (let index = 0; index <= lastMeaningfulCell; index += 1) {
const cell = cells[index];
if (cell === undefined) continue;
text += cell.text;
appendRun(runs, { text: cell.text, style: cell.style });
}
return { text, runs, wrapped: sourceLine.isWrapped };
}
function copyRunStyle(cell: TerminalCopyBufferCellSource, colors: CaptureColors): TerminalCopyRunStyle {
const inverse = cell.isInverse() !== 0;
let foreground = resolveCellColor(cell, "foreground", colors);
let background = resolveCellColor(cell, "background", colors);
if (inverse) [foreground, background] = [background, foreground];
if (cell.isBold() !== 0 && colors.drawBoldTextInBrightColors) {
const foregroundPaletteIndex = inverse
? cell.isBgPalette() ? cell.getBgColor() : undefined
: cell.isFgPalette() ? cell.getFgColor() : undefined;
if (foregroundPaletteIndex !== undefined && foregroundPaletteIndex >= 0 && foregroundPaletteIndex < 8) {
foreground = colors.palette[foregroundPaletteIndex + 8] ?? foreground;
}
}
return {
foreground,
background,
bold: cell.isBold() !== 0,
italic: cell.isItalic() !== 0,
dim: cell.isDim() !== 0,
underline: cell.isUnderline() !== 0,
invisible: cell.isInvisible() !== 0,
strikethrough: cell.isStrikethrough() !== 0,
overline: cell.isOverline() !== 0,
};
}
function resolveCellColor(cell: TerminalCopyBufferCellSource, target: "foreground" | "background", colors: CaptureColors): string {
const rgb = target === "foreground" ? cell.isFgRGB() : cell.isBgRGB();
const palette = target === "foreground" ? cell.isFgPalette() : cell.isBgPalette();
const value = target === "foreground" ? cell.getFgColor() : cell.getBgColor();
if (rgb) return rgbColor(value);
if (palette) return colors.palette[value] ?? (target === "foreground" ? colors.foreground : colors.background);
return target === "foreground" ? colors.foreground : colors.background;
}
function defaultRunStyle(colors: CaptureColors): TerminalCopyRunStyle {
return {
foreground: colors.foreground,
background: colors.background,
bold: false,
italic: false,
dim: false,
underline: false,
invisible: false,
strikethrough: false,
overline: false,
};
}
function appendRuns(target: TerminalCopyRun[], incoming: readonly TerminalCopyRun[]): void {
for (const run of incoming) appendRun(target, run);
}
function appendRun(runs: TerminalCopyRun[], run: TerminalCopyRun): void {
if (run.text === "") return;
const previous = runs.at(-1);
if (previous !== undefined && sameRunStyle(previous.style, run.style)) {
previous.text += run.text;
return;
}
runs.push({ text: run.text, style: run.style });
}
function sameRunStyle(left: TerminalCopyRunStyle, right: TerminalCopyRunStyle): boolean {
return left.foreground === right.foreground
&& left.background === right.background
&& left.bold === right.bold
&& left.italic === right.italic
&& left.dim === right.dim
&& left.underline === right.underline
&& left.invisible === right.invisible
&& left.strikethrough === right.strikethrough
&& left.overline === right.overline;
}
function terminalAnsiPalette(theme: ITheme | undefined): string[] {
const colors: string[] = [...DEFAULT_ANSI_COLORS];
for (let index = 0; index < ANSI_THEME_KEYS.length; index += 1) {
const key = ANSI_THEME_KEYS[index];
if (key === undefined) continue;
const themedColor = theme?.[key];
if (typeof themedColor === "string") colors[index] = themedColor;
}
const levels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];
for (let index = 0; index < 216; index += 1) {
const red = levels[Math.floor(index / 36) % 6] ?? 0;
const green = levels[Math.floor(index / 6) % 6] ?? 0;
const blue = levels[index % 6] ?? 0;
colors.push(rgbChannels(red, green, blue));
}
for (let index = 0; index < 24; index += 1) {
const channel = 8 + index * 10;
colors.push(rgbChannels(channel, channel, channel));
}
for (let index = 0; index < Math.min(theme?.extendedAnsi?.length ?? 0, 240); index += 1) {
const themedColor = theme?.extendedAnsi?.[index];
if (themedColor !== undefined) colors[index + 16] = themedColor;
}
return colors;
}
function rgbColor(value: number): string {
return `#${(value & 0xFFFFFF).toString(16).padStart(6, "0")}`;
}
function rgbChannels(red: number, green: number, blue: number): string {
return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`;
}