diff --git a/.changeset/terminal-copy-mode.md b/.changeset/terminal-copy-mode.md new file mode 100644 index 0000000..2c99cd8 --- /dev/null +++ b/.changeset/terminal-copy-mode.md @@ -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. diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 45acf2b..7218189 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -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 { + 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` + + `; + } + + private renderCopyModeToolbar() { + const snapshot = this.copySnapshot; + if (snapshot === undefined) return null; + return html` + + `; + } + + private renderCopyMode() { + const snapshot = this.copySnapshot; + if (snapshot === undefined) return null; + return html` +
+ ${this.copyToolbarReplacesSoftKeys() ? null : this.renderCopyModeToolbar()} +
+ + +
+
+ `; + } + 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`
+ ${this.renderCopyModeToggle()} ${this.renderSoftKeysToggle()} ${this.terminals.map((terminal) => html`
${this.error === undefined ? null : html`

${this.error}

`} ${this.renderCommandRunNotice()} - ${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null} + ${this.renderTerminalAccessoryBar()} ${this.loading ? html`

Loading terminals…

` : null} -
+
+
+ ${this.renderCopyMode()} +
`; } @@ -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"), diff --git a/src/client/src/terminalCopySnapshot.test.ts b/src/client/src/terminalCopySnapshot.test.ts new file mode 100644 index 0000000..ec09383 --- /dev/null +++ b/src/client/src/terminalCopySnapshot.test.ts @@ -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; +} diff --git a/src/client/src/terminalCopySnapshot.ts b/src/client/src/terminalCopySnapshot.ts new file mode 100644 index 0000000..24c4f46 --- /dev/null +++ b/src/client/src/terminalCopySnapshot.ts @@ -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")}`; +}