@code-yeongyu/senpi-tui 2026.11.12 → 2026.11.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { LAYOUT_NODE } from "../layout-node.js";
2
+ import { copyLineSources } from "../selection-source.js";
2
3
  import { Container } from "../tui.js";
3
4
  export class ScrollView extends Container {
4
5
  constructor(component, options = {}) {
@@ -175,7 +176,9 @@ export class ScrollView extends Container {
175
176
  render(width) {
176
177
  const contentWidth = this.getContentWidth(width);
177
178
  const lines = this.child.render(contentWidth);
178
- return contentWidth === width ? lines : lines.map((line) => `${line} `);
179
+ return contentWidth === width
180
+ ? lines
181
+ : copyLineSources(lines, lines.map((line) => `${line} `));
179
182
  }
180
183
  [LAYOUT_NODE]() {
181
184
  return { type: "scroll", component: this.child, state: this };
@@ -1,4 +1,5 @@
1
1
  import { nextRenderRevision } from "../tui.js";
2
+ import { setLineSource } from "../selection-source.js";
2
3
  /**
3
4
  * Spacer component that renders empty lines
4
5
  */
@@ -25,6 +26,7 @@ export class Spacer {
25
26
  const result = [];
26
27
  for (let i = 0; i < this.lines; i++) {
27
28
  result.push("");
29
+ setLineSource(result, i, null);
28
30
  }
29
31
  return result;
30
32
  }
@@ -1,4 +1,5 @@
1
1
  import { nextRenderRevision } from "../tui.js";
2
+ import { setLineSource } from "../selection-source.js";
2
3
  import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.js";
3
4
  /**
4
5
  * Text component - displays multi-line text with word wrapping
@@ -90,6 +91,10 @@ export class Text {
90
91
  emptyLines.push(line);
91
92
  }
92
93
  const result = [...emptyLines, ...contentLines, ...emptyLines];
94
+ for (let row = 0; row < emptyLines.length; row++) {
95
+ setLineSource(result, row, null);
96
+ setLineSource(result, result.length - row - 1, null);
97
+ }
93
98
  flattenLines(result);
94
99
  // Update cache
95
100
  this.cachedText = this.text;
@@ -1,3 +1,4 @@
1
+ import { copyLineSources, setLineSource } from "../selection-source.js";
1
2
  import { allocateStackSizes, Stack, visibleStackEntries } from "./stack.js";
2
3
  export class VStack extends Stack {
3
4
  constructor(children = [], options = {}) {
@@ -12,13 +13,20 @@ export class VStack extends Stack {
12
13
  const lines = [];
13
14
  for (let index = 0; index < entries.length; index++) {
14
15
  if (index > 0) {
15
- for (let gap = 0; gap < this.gap; gap++)
16
+ for (let gap = 0; gap < this.gap; gap++) {
16
17
  lines.push("");
18
+ setLineSource(lines, lines.length - 1, null);
19
+ }
17
20
  }
18
- const childLines = rendered[index].slice(0, sizes[index]);
21
+ const original = rendered[index];
22
+ const childLines = copyLineSources(original, original.slice(0, sizes[index]));
23
+ const offset = lines.length;
19
24
  lines.push(...childLines);
20
- for (let padding = childLines.length; padding < sizes[index]; padding++)
25
+ copyLineSources(childLines, lines, offset);
26
+ for (let padding = childLines.length; padding < sizes[index]; padding++) {
21
27
  lines.push("");
28
+ setLineSource(lines, lines.length - 1, null);
29
+ }
22
30
  }
23
31
  return lines;
24
32
  }
package/dist/index.d.ts CHANGED
@@ -34,6 +34,7 @@ export { parseTerminalColorSchemeReport, type RgbColor, type TerminalColorScheme
34
34
  export declare function calculateImageRows(imageDimensions: ImageDimensions, targetWidthCells: number, cellDimensions?: CellDimensions): number;
35
35
  export { allocateImageId, buildKittyPlaceholderRow, type CellDimensions, deleteAllKittyImages, deleteKittyImage, detectCapabilities, encodeITerm2, encodeKitty, getCapabilities, getCellDimensions, getGifDimensions, getImageDimensions, getJpegDimensions, getPngDimensions, getTerminalColorMode, getWebpDimensions, hyperlink, type ImageDimensions, type ImageProtocol, type ImageRenderOptions, imageFallback, KITTY_PLACEHOLDER_MAX, outerKittyGraphicsMode, renderImage, resetCapabilitiesCache, setCapabilities, setCapabilityOverrides, setCellDimensions, type TerminalCapabilities, type TmuxPassthroughState, wrapTmuxPassthrough, } from "./terminal-image.ts";
36
36
  export { sanitizeTerminalLabel, shortenImagePath } from "./terminal-text.ts";
37
+ export { copyLineSources } from "./selection-source.ts";
37
38
  export { encodeProgramStatus, type ProgramStatus, type ProgramStatusKind, type ProgramStatusState, } from "./program-status.ts";
38
39
  export { type Component, CompositeRevision, Container, CURSOR_MARKER, claimFrameRow, compositeTuiLine, currentRenderRevision, dispatchMouseEvent, type Focusable, type FrameLineBytesTotals, frameLineBytesTotals, frameMode, frameScrollbackRows, isFocusable, isViewportTUI, joinLineArrays, mainScreenHistoryLines, nextRenderRevision, type OverlayAnchor, type OverlayBounds, type OverlayHandle, type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, renderAtFrameRow, resetMainScreenHistoryLines, type SizeValue, TUI, type TuiInputListener, type TuiInputListenerResult, type TuiMode, type TuiMouseButton, type TuiMouseDispatchResult, type TuiMouseEvent, type TuiMouseEventResult, type TuiMouseEventType, type TuiStopOptions, type ViewportTUI, } from "./tui.ts";
39
40
  export { TuiAltScreen, type TuiAltScreenOptions } from "./tui-alt-screen.ts";
package/dist/index.js CHANGED
@@ -49,6 +49,7 @@ export function calculateImageRows(imageDimensions, targetWidthCells, cellDimens
49
49
  }
50
50
  export { allocateImageId, buildKittyPlaceholderRow, deleteAllKittyImages, deleteKittyImage, detectCapabilities, encodeITerm2, encodeKitty, getCapabilities, getCellDimensions, getGifDimensions, getImageDimensions, getJpegDimensions, getPngDimensions, getTerminalColorMode, getWebpDimensions, hyperlink, imageFallback, KITTY_PLACEHOLDER_MAX, outerKittyGraphicsMode, renderImage, resetCapabilitiesCache, setCapabilities, setCapabilityOverrides, setCellDimensions, wrapTmuxPassthrough, } from "./terminal-image.js";
51
51
  export { sanitizeTerminalLabel, shortenImagePath } from "./terminal-text.js";
52
+ export { copyLineSources } from "./selection-source.js";
52
53
  export { encodeProgramStatus, } from "./program-status.js";
53
54
  export { CompositeRevision, Container, CURSOR_MARKER, claimFrameRow, compositeTuiLine, currentRenderRevision, dispatchMouseEvent, frameLineBytesTotals, frameMode, frameScrollbackRows, isFocusable, isViewportTUI, joinLineArrays, mainScreenHistoryLines, nextRenderRevision, renderAtFrameRow, resetMainScreenHistoryLines, TUI, } from "./tui.js";
54
55
  export { TuiAltScreen } from "./tui-alt-screen.js";
@@ -0,0 +1,43 @@
1
+ import { type SourceRange, type SourceRanges } from "./source-ranges.ts";
2
+ export type { SourceRange } from "./source-ranges.ts";
3
+ export interface SourceDocument {
4
+ readonly text: string;
5
+ readonly enclosures: Array<SourceRange & {
6
+ readonly content: SourceRange;
7
+ }>;
8
+ readonly resolveLines?: () => readonly (SourceLine | null | undefined)[] | undefined;
9
+ }
10
+ export interface SourceLine {
11
+ readonly document: SourceDocument;
12
+ readonly ranges: SourceRanges;
13
+ readonly sourceRow?: number;
14
+ readonly columnOffset?: number;
15
+ readonly unavailable?: boolean;
16
+ readonly renderedFallback?: boolean;
17
+ }
18
+ export declare function setLineSources(lines: readonly string[], mapping: readonly (SourceLine | null | undefined)[]): void;
19
+ export declare function setLineSource(lines: readonly string[], row: number, source: SourceLine | null): void;
20
+ export declare function getLineSources(lines: readonly string[]): readonly (SourceLine | null | undefined)[] | undefined;
21
+ /**
22
+ * Preserve provenance when a component copies, pads, or decorates an existing rendered array.
23
+ * columnOffset is the number of inserted ASCII padding spaces, not the width of arbitrary text.
24
+ * Populate unpublished arrays only; retained frames must keep their original provenance.
25
+ */
26
+ export declare function copyLineSources(from: readonly string[], to: string[], rowOffset?: number, columnOffset?: number): string[];
27
+ type SourceSelection = {
28
+ readonly text: string;
29
+ } | {
30
+ readonly unavailable: true;
31
+ } | {
32
+ readonly renderedFallback: true;
33
+ };
34
+ /**
35
+ * Resolve the exact source interval of each selected Markdown document. Enclosures are included
36
+ * only when their complete visible contents were selected; an interior selection remains interior.
37
+ */
38
+ export declare function resolveSourceSelection(lines: readonly string[], rows: readonly {
39
+ readonly row: number;
40
+ readonly start: number;
41
+ readonly end: number;
42
+ }[]): SourceSelection | undefined;
43
+ //# sourceMappingURL=selection-source.d.ts.map
@@ -0,0 +1,200 @@
1
+ import { getGraphemeSegmenter, stripTerminalSequences, visibleWidth } from "./utils.js";
2
+ import { concatRanges, emptyRanges, repeatedRange, sliceRanges, } from "./source-ranges.js";
3
+ // Undefined rows retain rendered plain text; null rows are known layout padding.
4
+ const sources = new WeakMap();
5
+ export function setLineSources(lines, mapping) {
6
+ sources.set(lines, [...mapping]);
7
+ }
8
+ export function setLineSource(lines, row, source) {
9
+ const mapping = sources.get(lines) ?? [];
10
+ mapping[row] = source;
11
+ sources.set(lines, mapping);
12
+ }
13
+ export function getLineSources(lines) {
14
+ return sources.get(lines);
15
+ }
16
+ /**
17
+ * Preserve provenance when a component copies, pads, or decorates an existing rendered array.
18
+ * columnOffset is the number of inserted ASCII padding spaces, not the width of arbitrary text.
19
+ * Populate unpublished arrays only; retained frames must keep their original provenance.
20
+ */
21
+ export function copyLineSources(from, to, rowOffset = 0, columnOffset = 0) {
22
+ const mapping = sources.get(from);
23
+ if (mapping) {
24
+ const result = sources.get(to) ?? [];
25
+ for (let row = 0; row < mapping.length && row + rowOffset < to.length; row++) {
26
+ const line = mapping[row];
27
+ result[row + rowOffset] =
28
+ line && columnOffset
29
+ ? line.sourceRow === undefined
30
+ ? { ...line, ranges: concatRanges([repeatedRange(undefined, columnOffset), line.ranges]) }
31
+ : { ...line, columnOffset: (line.columnOffset ?? 0) + columnOffset }
32
+ : line;
33
+ }
34
+ sources.set(to, result);
35
+ }
36
+ return to;
37
+ }
38
+ /**
39
+ * Resolve the exact source interval of each selected Markdown document. Enclosures are included
40
+ * only when their complete visible contents were selected; an interior selection remains interior.
41
+ */
42
+ export function resolveSourceSelection(lines, rows) {
43
+ const mapping = sources.get(lines);
44
+ if (!mapping)
45
+ return undefined;
46
+ const resolvedDocuments = new Map();
47
+ for (const selection of rows) {
48
+ const document = mapping[selection.row]?.document;
49
+ if (!document?.resolveLines || resolvedDocuments.has(document))
50
+ continue;
51
+ const resolved = document.resolveLines();
52
+ resolvedDocuments.set(document, {
53
+ lines: resolved,
54
+ document: { ...(resolved?.find((line) => line)?.document ?? document) },
55
+ });
56
+ }
57
+ const resolveLine = (line) => {
58
+ if (!line)
59
+ return undefined;
60
+ if (line.sourceRow === undefined)
61
+ return line;
62
+ const resolved = resolvedDocuments.get(line.document);
63
+ if (!resolved)
64
+ return undefined;
65
+ if (!resolved.lines)
66
+ return { document: resolved.document, ranges: emptyRanges, unavailable: true };
67
+ const original = resolved.lines[line.sourceRow];
68
+ if (!original)
69
+ return undefined;
70
+ return {
71
+ ...original,
72
+ document: resolved.document,
73
+ ranges: line.columnOffset
74
+ ? concatRanges([repeatedRange(undefined, line.columnOffset), original.ranges])
75
+ : original.ranges,
76
+ };
77
+ };
78
+ const documentLines = new Map();
79
+ for (const source of mapping) {
80
+ const line = resolveLine(source);
81
+ if (!line)
82
+ continue;
83
+ const group = documentLines.get(line.document);
84
+ if (group)
85
+ group.push(line);
86
+ else
87
+ documentLines.set(line.document, [line]);
88
+ }
89
+ const pieces = [];
90
+ let active;
91
+ let usedSource = false;
92
+ let unavailable = false;
93
+ let renderedFallback = false;
94
+ const flush = () => {
95
+ if (!active)
96
+ return;
97
+ let { start, end } = active;
98
+ const covered = [];
99
+ for (const range of active.selected.sort((left, right) => left.start - right.start)) {
100
+ const previous = covered.at(-1);
101
+ if (previous && previous.end >= range.start)
102
+ previous.end = Math.max(previous.end, range.end);
103
+ else
104
+ covered.push({ ...range });
105
+ }
106
+ // A wrapped table can visit source cells out of order. Never fill an unselected hole.
107
+ for (const line of documentLines.get(active.document) ?? []) {
108
+ for (const span of line.ranges.spans) {
109
+ const range = span.source;
110
+ if (!range)
111
+ continue;
112
+ const left = span.linear ? Math.max(start, range.start) : range.start;
113
+ const right = span.linear ? Math.min(end, range.end) : range.end;
114
+ if (left < start || right > end || left >= right)
115
+ continue;
116
+ let low = 0;
117
+ let high = covered.length;
118
+ while (low < high) {
119
+ const middle = (low + high) >>> 1;
120
+ if (covered[middle].start <= left)
121
+ low = middle + 1;
122
+ else
123
+ high = middle;
124
+ }
125
+ if (low === 0 || covered[low - 1].end < right)
126
+ unavailable = true;
127
+ }
128
+ }
129
+ // Inner enclosures can make an outer enclosure complete.
130
+ for (const enclosure of active.document.enclosures) {
131
+ if (start <= enclosure.content.start && end >= enclosure.content.end) {
132
+ start = Math.min(start, enclosure.start);
133
+ end = Math.max(end, enclosure.end);
134
+ }
135
+ }
136
+ pieces.push(active.document.text.slice(start, end));
137
+ active = undefined;
138
+ };
139
+ for (const selection of rows) {
140
+ const source = mapping[selection.row];
141
+ if (source === null)
142
+ continue;
143
+ const line = resolveLine(source);
144
+ const text = stripTerminalSequences(lines[selection.row] ?? "");
145
+ if (!line) {
146
+ // Source-owned blank rows are represented by the source interval.
147
+ // Unmapped plain-text rows must retain their hard blank lines.
148
+ if (source && !text.trim())
149
+ continue;
150
+ flush();
151
+ let column = 0;
152
+ let selected = "";
153
+ for (const { segment } of getGraphemeSegmenter().segment(text)) {
154
+ const width = visibleWidth(segment);
155
+ if (column < selection.end && column + width > selection.start)
156
+ selected += segment;
157
+ column += width;
158
+ }
159
+ pieces.push(selected.trimEnd());
160
+ continue;
161
+ }
162
+ usedSource = true;
163
+ if (active && active.document !== line.document)
164
+ flush();
165
+ let column = 0;
166
+ let unitStart = text.length;
167
+ let unitEnd = 0;
168
+ for (const { segment, index } of getGraphemeSegmenter().segment(text)) {
169
+ const width = visibleWidth(segment);
170
+ if (column < selection.end && column + width > selection.start) {
171
+ if (line.unavailable && segment.trim())
172
+ return { unavailable: true };
173
+ if (line.renderedFallback && segment.trim())
174
+ renderedFallback = true;
175
+ unitStart = Math.min(unitStart, index);
176
+ unitEnd = index + segment.length;
177
+ }
178
+ column += width;
179
+ }
180
+ for (const span of sliceRanges(line.ranges, unitStart, unitEnd).spans) {
181
+ const range = span.source;
182
+ if (range === null)
183
+ return { unavailable: true };
184
+ if (!active)
185
+ active = { document: line.document, ...range, selected: [] };
186
+ else {
187
+ active.start = Math.min(active.start, range.start);
188
+ active.end = Math.max(active.end, range.end);
189
+ }
190
+ active.selected.push(range);
191
+ }
192
+ }
193
+ flush();
194
+ if (unavailable)
195
+ return { unavailable: true };
196
+ if (renderedFallback)
197
+ return { renderedFallback: true };
198
+ return usedSource ? { text: pieces.join("\n") } : undefined;
199
+ }
200
+ //# sourceMappingURL=selection-source.js.map
@@ -0,0 +1,25 @@
1
+ export interface SourceRange {
2
+ readonly start: number;
3
+ readonly end: number;
4
+ }
5
+ interface SourceSpan {
6
+ readonly offset: number;
7
+ readonly length: number;
8
+ /** null marks visible output whose original source cannot be recovered. */
9
+ readonly source: SourceRange | null;
10
+ /** Linear spans map UTF-16 units one-to-one; other spans repeat one source interval. */
11
+ readonly linear: boolean;
12
+ }
13
+ /** Display-only padding occupies length but has no span. */
14
+ export interface SourceRanges {
15
+ readonly length: number;
16
+ readonly spans: readonly SourceSpan[];
17
+ }
18
+ export declare const emptyRanges: SourceRanges;
19
+ export declare function linearRanges(start: number, length: number): SourceRanges;
20
+ export declare function repeatedRange(source: SourceRange | null | undefined, length: number): SourceRanges;
21
+ export declare function rangeAt(ranges: SourceRanges, offset: number): SourceRange | null | undefined;
22
+ export declare function sliceRanges(ranges: SourceRanges, from: number, to?: number): SourceRanges;
23
+ export declare function concatRanges(parts: readonly SourceRanges[]): SourceRanges;
24
+ export {};
25
+ //# sourceMappingURL=source-ranges.d.ts.map
@@ -0,0 +1,122 @@
1
+ export const emptyRanges = { length: 0, spans: [] };
2
+ /** Most token slices are contiguous; retain two numbers instead of a span object graph. */
3
+ class LinearRanges {
4
+ constructor(start, length) {
5
+ this.start = start;
6
+ this.length = length;
7
+ }
8
+ get spans() {
9
+ return [
10
+ {
11
+ offset: 0,
12
+ length: this.length,
13
+ source: { start: this.start, end: this.start + this.length },
14
+ linear: true,
15
+ },
16
+ ];
17
+ }
18
+ }
19
+ export function linearRanges(start, length) {
20
+ return length ? new LinearRanges(start, length) : emptyRanges;
21
+ }
22
+ export function repeatedRange(source, length) {
23
+ return length
24
+ ? { length, spans: source === undefined ? [] : [{ offset: 0, length, source, linear: false }] }
25
+ : emptyRanges;
26
+ }
27
+ function firstSpan(ranges, offset) {
28
+ let low = 0;
29
+ let high = ranges.spans.length;
30
+ while (low < high) {
31
+ const middle = (low + high) >>> 1;
32
+ const span = ranges.spans[middle];
33
+ if (span.offset + span.length <= offset)
34
+ low = middle + 1;
35
+ else
36
+ high = middle;
37
+ }
38
+ return low;
39
+ }
40
+ export function rangeAt(ranges, offset) {
41
+ if (ranges instanceof LinearRanges) {
42
+ if (offset < 0 || offset >= ranges.length)
43
+ return undefined;
44
+ const start = ranges.start + offset;
45
+ return { start, end: start + 1 };
46
+ }
47
+ const span = ranges.spans[firstSpan(ranges, offset)];
48
+ if (!span || offset < span.offset)
49
+ return undefined;
50
+ if (!span.linear || !span.source)
51
+ return span.source;
52
+ const start = span.source.start + offset - span.offset;
53
+ return { start, end: start + 1 };
54
+ }
55
+ export function sliceRanges(ranges, from, to = ranges.length) {
56
+ const start = Math.max(0, Math.min(ranges.length, from < 0 ? ranges.length + from : from));
57
+ const end = Math.max(start, Math.min(ranges.length, to < 0 ? ranges.length + to : to));
58
+ if (start === 0 && end === ranges.length)
59
+ return ranges;
60
+ if (ranges instanceof LinearRanges)
61
+ return linearRanges(ranges.start + start, end - start);
62
+ const spans = [];
63
+ for (let index = firstSpan(ranges, start); index < ranges.spans.length; index++) {
64
+ const span = ranges.spans[index];
65
+ if (span.offset >= end)
66
+ break;
67
+ const left = Math.max(start, span.offset);
68
+ const right = Math.min(end, span.offset + span.length);
69
+ spans.push({
70
+ offset: left - start,
71
+ length: right - left,
72
+ source: span.linear && span.source
73
+ ? {
74
+ start: span.source.start + left - span.offset,
75
+ end: span.source.start + right - span.offset,
76
+ }
77
+ : span.source,
78
+ linear: span.linear,
79
+ });
80
+ }
81
+ return compactRanges(end - start, spans);
82
+ }
83
+ export function concatRanges(parts) {
84
+ if (parts.length === 1)
85
+ return parts[0];
86
+ const spans = [];
87
+ let length = 0;
88
+ for (const part of parts) {
89
+ for (const span of part.spans) {
90
+ const offset = length + span.offset;
91
+ const previous = spans.at(-1);
92
+ if (previous &&
93
+ previous.offset + previous.length === offset &&
94
+ previous.linear === span.linear &&
95
+ (previous.linear
96
+ ? previous.source && span.source && previous.source.end === span.source.start
97
+ : previous.source?.start === span.source?.start && previous.source?.end === span.source?.end)) {
98
+ spans[spans.length - 1] = {
99
+ ...previous,
100
+ length: previous.length + span.length,
101
+ source: previous.linear && previous.source && span.source
102
+ ? { start: previous.source.start, end: span.source.end }
103
+ : previous.source,
104
+ };
105
+ }
106
+ else {
107
+ spans.push(length ? { ...span, offset } : span);
108
+ }
109
+ }
110
+ length += part.length;
111
+ }
112
+ return compactRanges(length, spans);
113
+ }
114
+ function compactRanges(length, spans) {
115
+ if (!length)
116
+ return emptyRanges;
117
+ const span = spans[0];
118
+ return spans.length === 1 && span.offset === 0 && span.length === length && span.linear && span.source
119
+ ? linearRanges(span.source.start, length)
120
+ : { length, spans };
121
+ }
122
+ //# sourceMappingURL=source-ranges.js.map
@@ -151,6 +151,7 @@ export declare class TuiAltScreen extends TuiBase implements ViewportTUI {
151
151
  private handleSelectionMouseEvent;
152
152
  private getSelectionBounds;
153
153
  private getSelectionColumns;
154
+ private selectionSourceUnavailable;
154
155
  private getActiveSelectionText;
155
156
  private copySelectionToClipboard;
156
157
  private copyTextToClipboard;
@@ -7,6 +7,7 @@ import { isKeyRelease } from "./keys.js";
7
7
  import { getLayoutBoxesAt, getScrollbarGeometry, getScrollViewBox, getScrollViewsAt, renderLayoutFrame, } from "./layout.js";
8
8
  import { getLayoutNode } from "./layout-node.js";
9
9
  import { decodeMouseButton, isMouseSequence, parseSgrMouseEvent, parseWheelEvent } from "./mouse-input.js";
10
+ import { resolveSourceSelection } from "./selection-source.js";
10
11
  import { deleteAllKittyImages, deleteAllKittyPlacements, deleteKittyImage, getCapabilities, getKittyImagePlacement, isImageLine, setCapabilities, } from "./terminal-image.js";
11
12
  import { Container, CURSOR_MARKER, compositeTuiLine, dispatchMouseEvent, retargetMouseEvent, TuiBase, VIEWPORT_TUI, } from "./tui.js";
12
13
  import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, } from "./utils.js";
@@ -54,6 +55,7 @@ export class TuiAltScreen extends TuiBase {
54
55
  this.selectionPressActive = false;
55
56
  this.selectionDragged = false;
56
57
  this.mousePressMoved = false;
58
+ this.selectionSourceUnavailable = false;
57
59
  this.implicitDocument = {
58
60
  render: (width) => super.render(width),
59
61
  handleMouse: (event) => super.handleMouse(event),
@@ -93,14 +95,11 @@ export class TuiAltScreen extends TuiBase {
93
95
  }
94
96
  /** Whether the fullscreen viewport has a non-empty active text selection. */
95
97
  hasActiveSelection() {
96
- return this.getActiveSelectionText() !== undefined;
98
+ return this.getActiveSelectionText() !== undefined || this.selectionSourceUnavailable;
97
99
  }
98
100
  /** Copy the active fullscreen text selection, if any, using the configured selection clipboard path. */
99
101
  async copyActiveSelectionToClipboard() {
100
- const text = this.getActiveSelectionText();
101
- if (!text)
102
- return false;
103
- return this.copyTextToClipboard(text);
102
+ return this.copySelectionToClipboard();
104
103
  }
105
104
  /** The lines of the last rendered frame, one per terminal row, as written to the terminal. */
106
105
  getScreenLines() {
@@ -1126,6 +1125,7 @@ export class TuiAltScreen extends TuiBase {
1126
1125
  return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
1127
1126
  }
1128
1127
  getActiveSelectionText() {
1128
+ this.selectionSourceUnavailable = false;
1129
1129
  const selection = this.getSelectionBounds();
1130
1130
  if (!selection)
1131
1131
  return undefined;
@@ -1138,6 +1138,17 @@ export class TuiAltScreen extends TuiBase {
1138
1138
  return undefined;
1139
1139
  sourceLines = box.scrollContentLines;
1140
1140
  }
1141
+ const rows = [];
1142
+ for (let row = selection.start.row; row <= selection.end.row; row++) {
1143
+ rows.push({ row, ...this.getSelectionColumns(sourceLines[row] ?? "", row, selection) });
1144
+ }
1145
+ const original = resolveSourceSelection(sourceLines, rows);
1146
+ if (original && "unavailable" in original) {
1147
+ this.selectionSourceUnavailable = true;
1148
+ return undefined;
1149
+ }
1150
+ if (original && "text" in original)
1151
+ return original.text || undefined;
1141
1152
  const lines = [];
1142
1153
  for (let row = selection.start.row; row <= selection.end.row; row++) {
1143
1154
  const line = sourceLines[row] ?? "";
@@ -1149,6 +1160,10 @@ export class TuiAltScreen extends TuiBase {
1149
1160
  }
1150
1161
  async copySelectionToClipboard() {
1151
1162
  const text = this.getActiveSelectionText();
1163
+ if (this.selectionSourceUnavailable) {
1164
+ this.flash("Original source unavailable for this selection", COPY_ERROR_FLASH_DURATION_MS);
1165
+ return false;
1166
+ }
1152
1167
  if (!text)
1153
1168
  return false;
1154
1169
  return this.copyTextToClipboard(text);
package/dist/tui.js CHANGED
@@ -9,6 +9,7 @@ import { performance } from "node:perf_hooks";
9
9
  import { isKeyRelease, matchesKey } from "./keys.js";
10
10
  import { buildKittyGraphicsQuery, KITTY_GRAPHICS_PROBE_TIMEOUT_MS, parseKittyGraphicsProbeResponse, } from "./kitty-graphics-probe.js";
11
11
  import { isMultiplexerSession, useLegacyMuxRender, viewportRenderEnabled } from "./mux.js";
12
+ import { copyLineSources } from "./selection-source.js";
12
13
  import { parseOscColorResponse, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
13
14
  import { allocateImageId, deleteKittyImage, getCapabilities, isImageLine, resetCapabilitiesCache, setCellDimensions, setProbedImageProtocol, } from "./terminal-image.js";
14
15
  import { consumeTmuxFocusEvent, DISABLE_FOCUS_REPORTING, ENABLE_FOCUS_REPORTING } from "./tmux-focus.js";
@@ -574,13 +575,20 @@ const JOIN_BATCH = 1024;
574
575
  * which keeps a frame over a long transcript from paying a per-line iterator and push.
575
576
  */
576
577
  export function joinLineArrays(chunks) {
577
- if (chunks.length <= JOIN_BATCH)
578
- return [].concat(...chunks);
578
+ if (chunks.length <= JOIN_BATCH) {
579
+ const result = [].concat(...chunks);
580
+ let offset = 0;
581
+ for (const chunk of chunks) {
582
+ copyLineSources(chunk, result, offset);
583
+ offset += chunk.length;
584
+ }
585
+ return result;
586
+ }
579
587
  const batches = [];
580
588
  for (let start = 0; start < chunks.length; start += JOIN_BATCH) {
581
- batches.push([].concat(...chunks.slice(start, start + JOIN_BATCH)));
589
+ batches.push(joinLineArrays(chunks.slice(start, start + JOIN_BATCH)));
582
590
  }
583
- return [].concat(...batches);
591
+ return joinLineArrays(batches);
584
592
  }
585
593
  /**
586
594
  * TUI - Main class for managing terminal UI with differential rendering
package/dist/utils.d.ts CHANGED
@@ -60,7 +60,8 @@ export declare function getActiveBackgroundAnsi(text: string): string;
60
60
  * reads it whole, so V8 flattens it in place; the strings' values do not change.
61
61
  */
62
62
  export declare function flattenLines(lines: readonly string[]): void;
63
- export declare function wrapTextWithAnsi(text: string, width: number): string[];
63
+ /** onLine receives each output line's consumed UTF-16 interval in the ANSI-stripped input. */
64
+ export declare function wrapTextWithAnsi(text: string, width: number, onLine?: (start: number, end: number) => void): string[];
64
65
  export declare const PUNCTUATION_REGEX: RegExp;
65
66
  /**
66
67
  * Check if a character is whitespace.