@north-light/crouter 0.3.177 → 0.3.179

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.
@@ -0,0 +1,84 @@
1
+ // Mouse drag-select copies out of the PHYSICAL frame rows, which carry SGR
2
+ // color, OSC-8 link boundaries, pi-tui's APC cursor marker, and wide glyphs. A
3
+ // column is a terminal cell, never a string index — get that wrong and the copy
4
+ // silently slices mid-escape or off-by-N, with the highlight still looking right
5
+ // on screen.
6
+ import assert from 'node:assert/strict';
7
+ import test from 'node:test';
8
+ import { highlightCells, rowCells, sliceCells } from '../ansi-cells.js';
9
+ import { createFrameSelection } from '../session/selection.js';
10
+ const CURSOR_MARKER = '\x1b_pi-cursor\x1b\\';
11
+ const LINK = (uri, text) => `\x1b]8;;${uri}\x07${text}\x1b]8;;\x07`;
12
+ test('cells are counted as the terminal counts them', () => {
13
+ assert.equal(rowCells('plain'), 5);
14
+ assert.equal(rowCells('\x1b[31mred\x1b[0m'), 3);
15
+ assert.equal(rowCells(LINK('file:///tmp/a.ts', 'a.ts')), 4);
16
+ assert.equal(rowCells(`ab${CURSOR_MARKER}cd`), 4);
17
+ assert.equal(rowCells('日本'), 4);
18
+ });
19
+ test('a slice returns the plain text under those cells', () => {
20
+ assert.equal(sliceCells('\x1b[31mhello\x1b[0m world', 0, 5), 'hello');
21
+ assert.equal(sliceCells('\x1b[31mhello\x1b[0m world', 6, 11), 'world');
22
+ assert.equal(sliceCells(`src/${LINK('file:///tmp/a.ts', 'a.ts')}:12`, 0, 8), 'src/a.ts');
23
+ assert.equal(sliceCells(`ab${CURSOR_MARKER}cd`, 0, 4), 'abcd');
24
+ // A wide glyph belongs to the cell it starts at and is never split in half.
25
+ assert.equal(sliceCells('日本語', 2, 4), '本');
26
+ });
27
+ test('highlighting restyles without changing what the row says', () => {
28
+ const row = '\x1b[31mhello\x1b[0m world';
29
+ const painted = highlightCells(row, 0, 5, 80);
30
+ assert.equal(sliceCells(painted, 0, rowCells(row)), 'hello world');
31
+ assert.equal(rowCells(painted), rowCells(row));
32
+ assert.ok(painted.includes('\x1b[7m'));
33
+ // A reset inside the span would drop the highlight, so inverse is re-asserted.
34
+ assert.equal(highlightCells(row, 0, 11, 80).split('\x1b[7m').length - 1, 2);
35
+ });
36
+ test('the cursor marker survives a highlight that starts on its cell', () => {
37
+ const painted = highlightCells(`ab${CURSOR_MARKER}cd`, 2, 4, 80);
38
+ assert.ok(painted.includes(CURSOR_MARKER));
39
+ assert.equal(sliceCells(painted, 0, 4), 'abcd');
40
+ });
41
+ test('a short row pads so a multi-row selection reads as one block, never past the pane', () => {
42
+ assert.equal(rowCells(highlightCells('ab', 0, 6, 80)), 6);
43
+ // Padding past the pane width would wrap the row and break the frame's row count.
44
+ assert.equal(rowCells(highlightCells('ab', 0, 6, 4)), 4);
45
+ });
46
+ test('a press that never moves stays a click, so file links still open', () => {
47
+ const selection = createFrameSelection({ frame: () => ['src/a.ts'], width: () => 40 });
48
+ selection.begin(3, 1);
49
+ assert.deepEqual(selection.release(), { kind: 'click', column: 3, row: 1 });
50
+ assert.equal(selection.active(), false);
51
+ });
52
+ test('a drag across rows selects terminal-style: to the edge, then whole rows', () => {
53
+ const frame = ['first row', '\x1b[32msecond row\x1b[0m', 'third row'];
54
+ const selection = createFrameSelection({ frame: () => frame, width: () => 40 });
55
+ selection.begin(7, 1);
56
+ assert.equal(selection.extend(5, 3), true);
57
+ // Same cell twice is not a change — no wasted render.
58
+ assert.equal(selection.extend(5, 3), false);
59
+ const painted = selection.apply(frame);
60
+ assert.equal(painted.length, 3);
61
+ assert.ok(painted[0].includes('\x1b[7m'));
62
+ assert.equal(sliceCells(painted[1], 0, rowCells(frame[1])), 'second row');
63
+ assert.equal(selection.active(), true);
64
+ assert.equal(selection.clear(), true);
65
+ assert.equal(selection.active(), false);
66
+ });
67
+ test('a backwards drag selects the same span as a forwards one', () => {
68
+ const frame = ['abcdef', 'ghijkl'];
69
+ const forwards = createFrameSelection({ frame: () => frame, width: () => 6 });
70
+ forwards.begin(3, 1);
71
+ forwards.extend(4, 2);
72
+ const backwards = createFrameSelection({ frame: () => frame, width: () => 6 });
73
+ backwards.begin(4, 2);
74
+ backwards.extend(3, 1);
75
+ assert.deepEqual(backwards.apply(frame), forwards.apply(frame));
76
+ });
77
+ test('an empty drag over blank rows reports nothing to copy', () => {
78
+ // Releasing over content would shell out to the clipboard; blank rows settle
79
+ // the geometry without leaving the process.
80
+ const selection = createFrameSelection({ frame: () => ['', '', ''], width: () => 40 });
81
+ selection.begin(2, 1);
82
+ selection.extend(5, 3);
83
+ assert.deepEqual(selection.release(), { kind: 'selection', notice: 'Nothing to copy — selection is empty' });
84
+ });
@@ -0,0 +1,31 @@
1
+ /** Read one terminal control sequence so non-printing ANSI never takes a cell.
2
+ * `undefined` means the sequence runs off the end of the row. */
3
+ export declare function readEscape(line: string, start: number): {
4
+ text: string;
5
+ end: number;
6
+ } | undefined;
7
+ /** One printable grapheme, with the escape sequences that preceded it. */
8
+ export type RowGlyph = {
9
+ /** The grapheme itself, with no ANSI attached. */
10
+ text: string;
11
+ /** 0-indexed terminal cell this grapheme starts at. */
12
+ cell: number;
13
+ /** Cells occupied — 2 for a wide grapheme, 0 for a zero-width one. */
14
+ width: number;
15
+ };
16
+ /** Walk `line` as the terminal does, calling `onGlyph` per printable grapheme
17
+ * and `onEscape` per control sequence, in row order. Returns the cell count. */
18
+ export declare function walkRow(line: string, visit: {
19
+ onGlyph?: (glyph: RowGlyph) => void;
20
+ onEscape?: (escape: string) => void;
21
+ }): number;
22
+ /** Cells the row occupies on screen. */
23
+ export declare function rowCells(line: string): number;
24
+ /** Plain text of cells `[from, to)` — every escape, link boundary, and cursor
25
+ * marker dropped, exactly what a terminal copies out of that span. */
26
+ export declare function sliceCells(line: string, from: number, to: number): string;
27
+ /** Reverse-video cells `[from, to)`, re-asserting inverse after any SGR inside
28
+ * the span (a reset there would otherwise drop the highlight mid-selection).
29
+ * A short row is padded with spaces so a multi-row selection reads as one
30
+ * block, never past `maxCells`. */
31
+ export declare function highlightCells(line: string, from: number, to: number, maxCells: number): string;
@@ -0,0 +1,120 @@
1
+ // ansi-cells.ts — cell arithmetic over a rendered terminal row.
2
+ //
3
+ // A composed frame row mixes printable graphemes with SGR colors, OSC-8 link
4
+ // boundaries, and pi-tui's APC cursor marker. Anything that addresses a row by
5
+ // terminal column — link hit tests, mouse selection — has to walk it the way the
6
+ // terminal does: escapes take no cell, wide graphemes take two. Both the attach
7
+ // session and the renderers need that walk, so it lives at the client root
8
+ // rather than inside either peer.
9
+ import stringWidth from 'string-width';
10
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
11
+ const INVERSE_ON = '\x1b[7m';
12
+ const INVERSE_OFF = '\x1b[27m';
13
+ /** Read one terminal control sequence so non-printing ANSI never takes a cell.
14
+ * `undefined` means the sequence runs off the end of the row. */
15
+ export function readEscape(line, start) {
16
+ const kind = line[start + 1];
17
+ if (kind === undefined)
18
+ return undefined;
19
+ // OSC, APC, DCS, and PM are string controls terminated by BEL or ST. pi-tui's
20
+ // cursor marker uses APC, so it must consume no display cells here.
21
+ if (kind === ']' || kind === '_' || kind === 'P' || kind === '^') {
22
+ const bel = line.indexOf('\x07', start + 2);
23
+ const st = line.indexOf('\x1b\\', start + 2);
24
+ const end = bel === -1 ? st : st === -1 ? bel : Math.min(bel, st);
25
+ if (end === -1)
26
+ return undefined;
27
+ return { text: line.slice(start, end + (end === bel ? 1 : 2)), end: end + (end === bel ? 1 : 2) };
28
+ }
29
+ if (kind === '[') {
30
+ let end = start + 2;
31
+ while (end < line.length && (line.charCodeAt(end) < 0x40 || line.charCodeAt(end) > 0x7e))
32
+ end++;
33
+ if (end >= line.length)
34
+ return undefined;
35
+ return { text: line.slice(start, end + 1), end: end + 1 };
36
+ }
37
+ return { text: line.slice(start, start + 2), end: start + 2 };
38
+ }
39
+ /** Walk `line` as the terminal does, calling `onGlyph` per printable grapheme
40
+ * and `onEscape` per control sequence, in row order. Returns the cell count. */
41
+ export function walkRow(line, visit) {
42
+ let cell = 0;
43
+ let index = 0;
44
+ while (index < line.length) {
45
+ if (line[index] === '\x1b') {
46
+ const escape = readEscape(line, index);
47
+ if (escape === undefined)
48
+ break;
49
+ visit.onEscape?.(escape.text);
50
+ index = escape.end;
51
+ continue;
52
+ }
53
+ const nextEscape = line.indexOf('\x1b', index);
54
+ const text = line.slice(index, nextEscape === -1 ? undefined : nextEscape);
55
+ for (const segment of GRAPHEMES.segment(text)) {
56
+ const width = stringWidth(segment.segment);
57
+ visit.onGlyph?.({ text: segment.segment, cell, width });
58
+ cell += width;
59
+ }
60
+ index = nextEscape === -1 ? line.length : nextEscape;
61
+ }
62
+ return cell;
63
+ }
64
+ /** Cells the row occupies on screen. */
65
+ export function rowCells(line) {
66
+ return walkRow(line, {});
67
+ }
68
+ /** Plain text of cells `[from, to)` — every escape, link boundary, and cursor
69
+ * marker dropped, exactly what a terminal copies out of that span. */
70
+ export function sliceCells(line, from, to) {
71
+ let text = '';
72
+ walkRow(line, {
73
+ onGlyph: (glyph) => {
74
+ if (glyph.cell >= from && glyph.cell < to)
75
+ text += glyph.text;
76
+ },
77
+ });
78
+ return text;
79
+ }
80
+ /** Reverse-video cells `[from, to)`, re-asserting inverse after any SGR inside
81
+ * the span (a reset there would otherwise drop the highlight mid-selection).
82
+ * A short row is padded with spaces so a multi-row selection reads as one
83
+ * block, never past `maxCells`. */
84
+ export function highlightCells(line, from, to, maxCells) {
85
+ const end = Math.min(to, maxCells);
86
+ if (end <= from || from < 0)
87
+ return line;
88
+ let out = '';
89
+ let inside = false;
90
+ const open = () => { if (!inside) {
91
+ out += INVERSE_ON;
92
+ inside = true;
93
+ } };
94
+ const close = () => { if (inside) {
95
+ out += INVERSE_OFF;
96
+ inside = false;
97
+ } };
98
+ const cells = walkRow(line, {
99
+ onEscape: (escape) => {
100
+ out += escape;
101
+ // An SGR inside the span may have reset every attribute, including ours.
102
+ if (inside && escape.endsWith('m'))
103
+ out += INVERSE_ON;
104
+ },
105
+ onGlyph: (glyph) => {
106
+ if (glyph.cell >= from && glyph.cell < end)
107
+ open();
108
+ else
109
+ close();
110
+ out += glyph.text;
111
+ },
112
+ });
113
+ close();
114
+ if (cells < end) {
115
+ const gap = Math.max(0, from - cells);
116
+ const highlighted = end - Math.max(from, cells);
117
+ out += ' '.repeat(gap) + INVERSE_ON + ' '.repeat(highlighted) + INVERSE_OFF;
118
+ }
119
+ return out;
120
+ }
@@ -135,6 +135,7 @@ export class HelpOverlay {
135
135
  muted('mouse wheel (when reporting is on)'),
136
136
  blank,
137
137
  heading('Selection and copy'),
138
+ { text: 'Drag with the mouse to select; releasing copies the selection to the clipboard (and the tmux buffer). Any key clears the highlight.', style: 'plain' },
138
139
  { text: 'prefix + [ tmux copy-mode selects and copies the visible screenful; it works even while mouse reporting is on.', style: 'plain' },
139
140
  { text: 'Option-drag (iTerm2, Terminal.app) or Shift-drag (Ghostty, Alacritty, xterm) makes a true terminal selection that bypasses mouse reporting.', style: 'plain' },
140
141
  { text: 'Block-copy commands copy transcript content and are not capped at one screenful.', style: 'plain' },
@@ -146,7 +147,7 @@ export class HelpOverlay {
146
147
  { text: "/copy-transcript every copyable block's text, oldest to newest, joined by blank lines.", style: 'plain' },
147
148
  blank,
148
149
  heading('Mouse'),
149
- { text: `/mouse or ${this.binding('crtr.attach.mouse.toggle')} toggle mouse wheel scrolling. Turning it off restores tmux drag-select and copy-mode mouse.`, style: 'plain' },
150
+ { text: `/mouse or ${this.binding('crtr.attach.mouse.toggle')} toggle mouse reporting — wheel scrolling and viewer drag-select. Turning it off restores tmux drag-select and copy-mode mouse.`, style: 'plain' },
150
151
  blank,
151
152
  heading('Other attach keys'),
152
153
  ...otherKeys,
@@ -5,9 +5,8 @@
5
5
  // and OSC-8 boundaries have all already been composed there.
6
6
  import { existsSync } from 'node:fs';
7
7
  import { fileURLToPath } from 'node:url';
8
- import stringWidth from 'string-width';
8
+ import { walkRow } from '../ansi-cells.js';
9
9
  import { respawnPaneInBackground, shellQuote, viewerSplitEnv } from '../../../core/runtime/placement-tmux.js';
10
- const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
11
10
  /** URI of the OSC-8 hyperlink occupying the given 1-indexed terminal cell. */
12
11
  export function osc8UriAt(frame, row, column) {
13
12
  if (!Number.isInteger(row) || !Number.isInteger(column) || row < 1 || column < 1)
@@ -24,56 +23,22 @@ export function osc8UriAt(frame, row, column) {
24
23
  }
25
24
  function scanOsc8Row(line, initialUri, hitColumn) {
26
25
  let activeUri = initialUri;
27
- let cell = 1;
28
- let index = 0;
29
- while (index < line.length) {
30
- if (line[index] === '\x1b') {
31
- const escape = readEscape(line, index);
32
- if (escape === undefined)
33
- break;
34
- const osc8 = parseOsc8(escape.text);
26
+ let uri;
27
+ walkRow(line, {
28
+ onEscape: (escape) => {
29
+ const osc8 = parseOsc8(escape);
35
30
  if (osc8 !== null)
36
31
  activeUri = osc8 === '' ? undefined : osc8;
37
- index = escape.end;
38
- continue;
39
- }
40
- const nextEscape = line.indexOf('\x1b', index);
41
- const text = line.slice(index, nextEscape === -1 ? undefined : nextEscape);
42
- for (const segment of GRAPHEMES.segment(text)) {
43
- const width = stringWidth(segment.segment);
44
- if (hitColumn !== undefined && width > 0 && hitColumn >= cell && hitColumn < cell + width && activeUri !== undefined) {
45
- return { activeUri, uri: activeUri };
46
- }
47
- cell += width;
48
- }
49
- index = nextEscape === -1 ? line.length : nextEscape;
50
- }
51
- return { activeUri, uri: undefined };
52
- }
53
- /** Read one terminal control sequence so non-printing ANSI never takes a cell. */
54
- function readEscape(line, start) {
55
- const kind = line[start + 1];
56
- if (kind === undefined)
57
- return undefined;
58
- // OSC, APC, DCS, and PM are string controls terminated by BEL or ST. pi-tui's
59
- // cursor marker uses APC, so it must consume no display cells here.
60
- if (kind === ']' || kind === '_' || kind === 'P' || kind === '^') {
61
- const bel = line.indexOf('\x07', start + 2);
62
- const st = line.indexOf('\x1b\\', start + 2);
63
- const end = bel === -1 ? st : st === -1 ? bel : Math.min(bel, st);
64
- if (end === -1)
65
- return undefined;
66
- return { text: line.slice(start, end + (end === bel ? 1 : 2)), end: end + (end === bel ? 1 : 2) };
67
- }
68
- if (kind === '[') {
69
- let end = start + 2;
70
- while (end < line.length && (line.charCodeAt(end) < 0x40 || line.charCodeAt(end) > 0x7e))
71
- end++;
72
- if (end >= line.length)
73
- return undefined;
74
- return { text: line.slice(start, end + 1), end: end + 1 };
75
- }
76
- return { text: line.slice(start, start + 2), end: start + 2 };
32
+ },
33
+ onGlyph: (glyph) => {
34
+ if (uri !== undefined || hitColumn === undefined || activeUri === undefined || glyph.width === 0)
35
+ return;
36
+ const cell = hitColumn - 1;
37
+ if (cell >= glyph.cell && cell < glyph.cell + glyph.width)
38
+ uri = activeUri;
39
+ },
40
+ });
41
+ return { activeUri, uri };
77
42
  }
78
43
  /** `null` means another escape sequence; an empty URI closes the active link. */
79
44
  function parseOsc8(sequence) {
@@ -25,9 +25,13 @@ export declare class ViewerFrame implements Component {
25
25
  private readonly parts;
26
26
  private viewportRows;
27
27
  private lastFrame;
28
+ private overlay;
28
29
  constructor(terminal: Terminal, parts: ViewerFrameParts);
29
30
  render(width: number): string[];
30
- /** The physical rows returned by the most recent render. */
31
+ /** Paint attributes over the composed frame — mouse selection highlight. The
32
+ * overlay may restyle rows but must return the same number of them. */
33
+ setOverlay(overlay: (frame: readonly string[]) => string[]): void;
34
+ /** The physical rows returned by the most recent render, before any overlay. */
31
35
  lastRenderedFrame(): readonly string[];
32
36
  invalidate(): void;
33
37
  /** Rows the transcript viewport got in the last frame — the page size. */
@@ -56,6 +56,7 @@ export class ViewerFrame {
56
56
  parts;
57
57
  viewportRows = 0;
58
58
  lastFrame = [];
59
+ overlay;
59
60
  constructor(terminal, parts) {
60
61
  this.terminal = terminal;
61
62
  this.parts = parts;
@@ -117,12 +118,26 @@ export class ViewerFrame {
117
118
  if (frame.length !== rows)
118
119
  reportInvariant(`frame correction rendered ${frame.length} rows; expected ${rows}`);
119
120
  const finalFrame = this.ensureCursorMarker(frame, editorLines, editorSlice, editorBottomOffset, bottomLines.length, rows);
120
- // This is the exact physical screen content used for OSC-8 mouse hit tests.
121
- // Keep a copy because terminal rendering is allowed to retain the returned array.
121
+ // This is the exact physical screen content used for OSC-8 mouse hit tests
122
+ // and for the text a mouse selection copies, so it is kept BEFORE the
123
+ // overlay paints attributes over it. Keep a copy because terminal rendering
124
+ // is allowed to retain the returned array.
122
125
  this.lastFrame = finalFrame.slice();
123
- return finalFrame;
126
+ if (this.overlay === undefined)
127
+ return finalFrame;
128
+ const painted = this.overlay(finalFrame);
129
+ if (painted.length !== finalFrame.length) {
130
+ reportInvariant(`frame overlay returned ${painted.length} rows; expected ${finalFrame.length}`);
131
+ return finalFrame;
132
+ }
133
+ return painted;
134
+ }
135
+ /** Paint attributes over the composed frame — mouse selection highlight. The
136
+ * overlay may restyle rows but must return the same number of them. */
137
+ setOverlay(overlay) {
138
+ this.overlay = overlay;
124
139
  }
125
- /** The physical rows returned by the most recent render. */
140
+ /** The physical rows returned by the most recent render, before any overlay. */
126
141
  lastRenderedFrame() {
127
142
  return this.lastFrame;
128
143
  }
@@ -70,7 +70,7 @@ export function createInputController(ctx, km, capabilities, hooks) {
70
70
  onCopyTarget: hooks.copyTarget,
71
71
  onToggleMouse: () => {
72
72
  const on = hooks.toggleMouse();
73
- setNotice(on ? 'Mouse: wheel scrolling on' : 'Mouse: off — tmux drag-select and copy-mode restored');
73
+ setNotice(on ? 'Mouse: wheel scrolling on, drag to select and copy' : 'Mouse: off — tmux drag-select and copy-mode restored');
74
74
  },
75
75
  onColor: hooks.setChipColor,
76
76
  onPromptSent: hooks.onPromptSent,
@@ -18,6 +18,10 @@ export interface MouseHandlers {
18
18
  onWheel(direction: 'up' | 'down', lines: number, column: number): void;
19
19
  /** A primary-button press with no Shift, Alt, or Control modifier. */
20
20
  onPrimaryPress(column: number, row: number): void;
21
+ /** Pointer motion with the primary button held. */
22
+ onPrimaryDrag(column: number, row: number): void;
23
+ /** The primary button came back up, wherever the pointer ended. */
24
+ onPrimaryRelease(column: number, row: number): void;
21
25
  }
22
26
  /** Create per-viewer mouse reporting from the user's saved starting mode. */
23
27
  export declare function createMouseReporting(write: (data: string) => void, defaultMode: MouseModeDefault): MouseReporting;
@@ -3,7 +3,12 @@
3
3
  // Mouse reporting starts from the user's configured default. Follow-tmux mode
4
4
  // leaves native terminal selection alone when tmux mouse is off; inside a
5
5
  // reporting viewer, every mouse sequence is consumed so clicks and drags can
6
- // never become editor input.
6
+ // never become editor input — which is why the viewer, not the terminal, owns
7
+ // drag-select while reporting is on (session/selection.ts).
8
+ //
9
+ // Mode 1002 is button-event tracking: press, motion while a button is held, and
10
+ // release. Those three are exactly the gesture parts a selection needs, so the
11
+ // drag is reconstructed here and reported as press · drag · release.
7
12
  import { spawnSync } from 'node:child_process';
8
13
  import { inTmux } from '../../../core/runtime/placement-tmux.js';
9
14
  const MOUSE_REPORTING_ON = '\x1b[?1002h\x1b[?1006h';
@@ -46,12 +51,18 @@ export function createMouseReporting(write, defaultMode) {
46
51
  const withoutSgr = data.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (_match, buttons, column, row, action) => {
47
52
  consumed = true;
48
53
  const code = Number(buttons);
54
+ // Bits 4/8/16 are Shift/Meta/Control; bit 32 is the motion flag, which
55
+ // must survive the mask so a drag is distinguishable from a press.
49
56
  const button = code & ~(4 | 8 | 16);
50
57
  if (button === 64)
51
58
  handlers.onWheel('up', WHEEL_LINES, Number(column));
52
- if (button === 65)
59
+ else if (button === 65)
53
60
  handlers.onWheel('down', WHEEL_LINES, Number(column));
54
- if (code === 0 && action === 'M')
61
+ else if (button === 32 && action === 'M')
62
+ handlers.onPrimaryDrag(Number(column), Number(row));
63
+ else if (button === 0 && action === 'm')
64
+ handlers.onPrimaryRelease(Number(column), Number(row));
65
+ else if (code === 0 && action === 'M')
55
66
  handlers.onPrimaryPress(Number(column), Number(row));
56
67
  return '';
57
68
  });
@@ -0,0 +1,28 @@
1
+ export type SelectionRelease = {
2
+ kind: 'click';
3
+ column: number;
4
+ row: number;
5
+ } | {
6
+ kind: 'selection';
7
+ notice: string;
8
+ };
9
+ export interface FrameSelection {
10
+ /** Primary button pressed: anchor here and drop any previous highlight. */
11
+ begin(column: number, row: number): void;
12
+ /** Drag to a new cell. Returns true when the highlight changed. */
13
+ extend(column: number, row: number): boolean;
14
+ /** Primary button released. `click` means the press never moved, so the
15
+ * caller's click handling (file links) still owns the gesture. */
16
+ release(): SelectionRelease | undefined;
17
+ /** Drop the highlight. Returns true when something was showing. */
18
+ clear(): boolean;
19
+ /** A highlight is on screen. */
20
+ active(): boolean;
21
+ /** Paint the highlight onto a composed frame; identity when nothing is selected. */
22
+ apply(frame: readonly string[]): string[];
23
+ }
24
+ export declare function createFrameSelection(deps: {
25
+ /** The physical rows of the last render, unhighlighted. */
26
+ frame: () => readonly string[];
27
+ width: () => number;
28
+ }): FrameSelection;
@@ -0,0 +1,108 @@
1
+ // session/selection.ts — mouse drag-select and copy over the rendered frame.
2
+ //
3
+ // While the viewer reports mouse events, the terminal hands presses and drags to
4
+ // this process, so the terminal's own drag-select is unavailable: the viewer has
5
+ // to do the selecting. It selects the way a terminal does — over the physical
6
+ // rows last handed to the screen, not over transcript records — so wrapping,
7
+ // folds, the pinned header, and the cockpit all select exactly as they look.
8
+ // Releasing the button copies the selection to the clipboard (and, in tmux, the
9
+ // paste buffer); the highlight stays up as confirmation until the next keypress,
10
+ // wheel notch, or press.
11
+ import { highlightCells, sliceCells } from '../ansi-cells.js';
12
+ import { writeClipboardTextEverywhere } from '../../../core/clipboard-text.js';
13
+ function before(a, b) {
14
+ return a.row < b.row || (a.row === b.row && a.column <= b.column);
15
+ }
16
+ /** The per-row cell ranges covered by a drag, in terminal selection geometry:
17
+ * the first row runs to the pane edge, whole rows in between, the last row up
18
+ * to and including the cell under the pointer. */
19
+ function spans(anchor, head, width) {
20
+ const [start, end] = before(anchor, head) ? [anchor, head] : [head, anchor];
21
+ const rows = [];
22
+ for (let row = start.row; row <= end.row; row++) {
23
+ const from = row === start.row ? start.column - 1 : 0;
24
+ const to = row === end.row ? end.column : width;
25
+ if (to > from)
26
+ rows.push({ row, from, to });
27
+ }
28
+ return rows;
29
+ }
30
+ export function createFrameSelection(deps) {
31
+ let anchor;
32
+ let head;
33
+ const selected = () => anchor !== undefined && head !== undefined ? { anchor, head } : undefined;
34
+ const selectionText = () => {
35
+ const current = selected();
36
+ if (!current)
37
+ return '';
38
+ const frame = deps.frame();
39
+ const rows = spans(current.anchor, current.head, deps.width())
40
+ .map((span) => sliceCells(frame[span.row - 1] ?? '', span.from, span.to).trimEnd());
41
+ // A trailing blank row is an artifact of where the button came up; leading
42
+ // blanks inside the span are real content the user dragged across.
43
+ while (rows.length > 0 && rows[rows.length - 1] === '')
44
+ rows.pop();
45
+ return rows.join('\n');
46
+ };
47
+ return {
48
+ begin(column, row) {
49
+ anchor = { row, column };
50
+ head = undefined;
51
+ },
52
+ extend(column, row) {
53
+ if (anchor === undefined)
54
+ return false;
55
+ if (head !== undefined && head.row === row && head.column === column)
56
+ return false;
57
+ head = { row, column };
58
+ return true;
59
+ },
60
+ release() {
61
+ const press = anchor;
62
+ if (press === undefined)
63
+ return undefined;
64
+ if (head === undefined) {
65
+ anchor = undefined;
66
+ return { kind: 'click', column: press.column, row: press.row };
67
+ }
68
+ const text = selectionText();
69
+ if (text === '') {
70
+ anchor = undefined;
71
+ head = undefined;
72
+ return { kind: 'selection', notice: 'Nothing to copy — selection is empty' };
73
+ }
74
+ const { system, tmux } = writeClipboardTextEverywhere(text);
75
+ const destination = system && tmux ? 'clipboard + tmux buffer' : system ? 'clipboard' : tmux ? 'tmux buffer' : undefined;
76
+ const lines = text.split('\n').length;
77
+ return {
78
+ kind: 'selection',
79
+ notice: destination === undefined
80
+ ? 'Copy failed — no clipboard tool found'
81
+ : `Copied selection (${lines} ${lines === 1 ? 'line' : 'lines'}) — ${destination}`,
82
+ };
83
+ },
84
+ clear() {
85
+ const showing = head !== undefined;
86
+ anchor = undefined;
87
+ head = undefined;
88
+ return showing;
89
+ },
90
+ active() {
91
+ return head !== undefined;
92
+ },
93
+ apply(frame) {
94
+ const current = selected();
95
+ if (!current)
96
+ return frame.slice();
97
+ const width = deps.width();
98
+ const painted = frame.slice();
99
+ for (const span of spans(current.anchor, current.head, width)) {
100
+ const index = span.row - 1;
101
+ if (index < 0 || index >= painted.length)
102
+ continue;
103
+ painted[index] = highlightCells(painted[index], span.from, span.to, width);
104
+ }
105
+ return painted;
106
+ },
107
+ };
108
+ }
@@ -44,7 +44,7 @@ export const CANVAS_SLASH_COMMANDS = [
44
44
  { name: 'mcp', description: 'MCP servers — status from the adapter’s config/cache; Enter (or /mcp <server>) connects, reconnects, or re-auths' },
45
45
  { name: 'rename', description: 'Rename this node everywhere — node row, viewer window, session chip, tmux pane: /rename <name>' },
46
46
  { name: 'color', description: 'Color this node — tmux pane outline + name chip: /color <color> (red, blue, #rrggbb, …) or /color none' },
47
- { name: 'mouse', description: 'Toggle mouse wheel scrolling — off restores tmux drag-select and copy-mode mouse' },
47
+ { name: 'mouse', description: 'Toggle mouse wheel scrolling and drag-select — off restores tmux drag-select and copy-mode mouse' },
48
48
  { name: 'copy-message', description: 'Copy the newest message visible in the transcript viewport' },
49
49
  { name: 'copy-tool', description: 'Copy the newest tool call output' },
50
50
  { name: 'copy-transcript', description: 'Copy every copyable transcript block' },