pi-better-btw-plus 1.3.0 → 1.4.0

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.
package/config.json CHANGED
@@ -18,6 +18,7 @@
18
18
  "features": {
19
19
  "rightClickCopyPaste": true,
20
20
  "modelSwitch": true,
21
- "retry": true
21
+ "retry": true,
22
+ "editorSelection": true
22
23
  }
23
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-btw-plus",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "pi extension: /btw side-chat overlay — maintained fork of @yceachan/pi-better-btw (+ right-click copy/paste, fork model switch, turn-level retry)",
5
5
  "type": "module",
6
6
  "keywords": [
package/srcs/config.ts CHANGED
@@ -42,6 +42,8 @@ export interface SideChatFeatures {
42
42
  modelSwitch: boolean;
43
43
  /** Turn-level auto-retry of transient provider errors. Default: true. */
44
44
  retry: boolean;
45
+ /** Left-drag selection on the input editor (spec #24). Default: true. */
46
+ editorSelection: boolean;
45
47
  }
46
48
 
47
49
  export interface SideChatConfig {
@@ -204,6 +206,7 @@ function parseConfigLayer(raw: unknown, dir: string): ConfigLayer {
204
206
  rightClickCopyPaste: parseBoolean(featureRec.rightClickCopyPaste),
205
207
  modelSwitch: parseBoolean(featureRec.modelSwitch),
206
208
  retry: parseBoolean(featureRec.retry),
209
+ editorSelection: parseBoolean(featureRec.editorSelection),
207
210
  }
208
211
  : undefined,
209
212
  };
@@ -287,12 +290,14 @@ const FEATURE_KEYS = [
287
290
  "rightClickCopyPaste",
288
291
  "modelSwitch",
289
292
  "retry",
293
+ "editorSelection",
290
294
  ] as const;
291
295
  function mergeFeatures(layers: ConfigLayer[]): SideChatFeatures {
292
296
  const features: SideChatFeatures = {
293
297
  rightClickCopyPaste: true,
294
298
  modelSwitch: true,
295
299
  retry: true,
300
+ editorSelection: true,
296
301
  };
297
302
  for (const layer of layers) {
298
303
  const layerFeatures = layer.features;
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Editor selection: a zero-TUI, visual-space selection model for the side chat's
3
+ * input editor (pi-tui `Editor`), spec #24 / T1 (#25).
4
+ *
5
+ * The `Editor` widget exposes no selection or highlight API and keeps its
6
+ * word-wrap map and scroll offset private, so selection lives in *visual/screen
7
+ * space*: an anchor/focus pair of `{line, col}` positions relative to the
8
+ * editor's content band — the lines of `Editor.render(width)` between the top
9
+ * and bottom border rows (top border excluded, so band line 0 is `rendered[1]`).
10
+ * Columns are cell columns (visible width), grapheme-aligned so a wide/CJK
11
+ * grapheme is never split, and every range is clipped so it never includes a
12
+ * paste-marker literal (`[paste #N …]`).
13
+ *
14
+ * Everything here is pure and unit-testable; the overlay (T3/T4) wires the
15
+ * `EditorSelectionState` holder and `decorateEditorSelection` into `render()`.
16
+ */
17
+ import {
18
+ findWordBackward,
19
+ findWordForward,
20
+ } from "@earendil-works/pi-tui/dist/word-navigation.js";
21
+ import {
22
+ sliceByColumn,
23
+ stripTerminalSequences,
24
+ visibleWidth,
25
+ } from "@earendil-works/pi-tui";
26
+
27
+ /** A position in editor visual space. */
28
+ export interface EditorPos {
29
+ /** Visual line index within the editor content band (0-based, top border excluded). */
30
+ line: number;
31
+ /** Cell column (visible width, 0-based), grapheme-aligned. */
32
+ col: number;
33
+ }
34
+
35
+ /** A visual-space selection over the editor content band. */
36
+ export interface EditorSelection {
37
+ anchor: EditorPos;
38
+ focus: EditorPos;
39
+ }
40
+
41
+ /** One visual line's inverse-video highlight range (content-band coordinates). */
42
+ export interface EditorHighlightRange {
43
+ /** Content-band line index. */
44
+ line: number;
45
+ /** Start cell column (inclusive). */
46
+ start: number;
47
+ /** End cell column (exclusive). */
48
+ end: number;
49
+ }
50
+
51
+ // Paste markers the editor inserts for large pastes (mirrors the editor's own
52
+ // `PASTE_MARKER_REGEX`). Selection must never include their literal text.
53
+ const PASTE_MARKER_GLOBAL = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
54
+ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
55
+
56
+ const SELECTION_ON = "\x1b[7m";
57
+ const SELECTION_OFF = "\x1b[27m";
58
+
59
+ const graphemeSegmenter = new Intl.Segmenter(undefined, {
60
+ granularity: "grapheme",
61
+ });
62
+ const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
63
+
64
+ function isPasteMarkerText(segment: string): boolean {
65
+ return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
66
+ }
67
+
68
+ /** Cell-column spans of every paste-marker literal in a plain line. */
69
+ function pasteMarkerSpans(
70
+ plain: string,
71
+ ): Array<{ start: number; end: number }> {
72
+ const spans: Array<{ start: number; end: number }> = [];
73
+ for (const m of plain.matchAll(PASTE_MARKER_GLOBAL)) {
74
+ const start = m.index ?? 0;
75
+ const end = start + m[0].length;
76
+ spans.push({
77
+ start: visibleWidth(plain.slice(0, start)),
78
+ end: visibleWidth(plain.slice(0, end)),
79
+ });
80
+ }
81
+ return spans;
82
+ }
83
+
84
+ /** Map a cell column to the code-unit index of the grapheme containing it. */
85
+ function colToCharIndex(plain: string, col: number): number {
86
+ let width = 0;
87
+ for (const seg of graphemeSegmenter.segment(plain)) {
88
+ const w = visibleWidth(seg.segment);
89
+ if (width + w > col) return seg.index;
90
+ width += w;
91
+ }
92
+ return plain.length;
93
+ }
94
+
95
+ /** Snap a cell column to a grapheme boundary ("start" floors, "end" ceils). */
96
+ function snapCol(plain: string, col: number, which: "start" | "end"): number {
97
+ let width = 0;
98
+ for (const { segment } of graphemeSegmenter.segment(plain)) {
99
+ const w = visibleWidth(segment);
100
+ const gStart = width;
101
+ const gEnd = width + w;
102
+ if (col > gStart && col < gEnd) return which === "start" ? gStart : gEnd;
103
+ width = gEnd;
104
+ }
105
+ return col;
106
+ }
107
+
108
+ /** Clamp a cell column into [0, lineWidth]. */
109
+ function clampCol(col: number, width: number): number {
110
+ return Math.max(0, Math.min(col, width));
111
+ }
112
+
113
+ /** Clip a cell range so it never includes a paste-marker literal. */
114
+ function clipRangeToMarkers(
115
+ start: number,
116
+ end: number,
117
+ plain: string,
118
+ ): { start: number; end: number } {
119
+ let s = start;
120
+ let e = end;
121
+ for (const span of pasteMarkerSpans(plain)) {
122
+ if (span.end <= s) continue; // marker entirely before the range
123
+ if (span.start >= e) break; // markers are sorted; the rest are after
124
+ if (span.start <= s && span.end >= e) return { start: s, end: s }; // fully inside
125
+ if (span.start <= s) s = span.end; // marker covers the start
126
+ else if (span.end >= e) e = span.start; // marker covers the end
127
+ else e = span.start; // marker strictly inside → keep the left part
128
+ if (s >= e) return { start: s, end: s };
129
+ }
130
+ return { start: s, end: e };
131
+ }
132
+
133
+ /** Topmost/leftmost endpoint of a selection. */
134
+ export function orderedStart(sel: EditorSelection): EditorPos {
135
+ const { anchor, focus } = sel;
136
+ if (
137
+ anchor.line < focus.line ||
138
+ (anchor.line === focus.line && anchor.col <= focus.col)
139
+ ) {
140
+ return anchor;
141
+ }
142
+ return focus;
143
+ }
144
+
145
+ /** Bottommost/rightmost endpoint of a selection. */
146
+ export function orderedEnd(sel: EditorSelection): EditorPos {
147
+ const { anchor, focus } = sel;
148
+ if (
149
+ anchor.line > focus.line ||
150
+ (anchor.line === focus.line && anchor.col > focus.col)
151
+ ) {
152
+ return anchor;
153
+ }
154
+ return focus;
155
+ }
156
+
157
+ /**
158
+ * The editor content band: `Editor.render()` output with the top/bottom border
159
+ * rows dropped, ANSI stripped, and trailing padding trimmed. Assumes the
160
+ * overlay's editor config (`paddingX: 0`), so padding is trailing only.
161
+ */
162
+ export function contentBandPlainLines(rendered: string[]): string[] {
163
+ return rendered.slice(1, -1).map((l) => stripTerminalSequences(l).trimEnd());
164
+ }
165
+
166
+ /**
167
+ * Per-visual-line inverse-video highlight ranges for a selection. Columns are
168
+ * clamped to each line's visible width, snapped to grapheme boundaries, and
169
+ * clipped so the range never crosses a paste marker.
170
+ */
171
+ export function highlightRanges(
172
+ plainLines: string[],
173
+ sel: EditorSelection,
174
+ ): EditorHighlightRange[] {
175
+ const n = plainLines.length;
176
+ if (n === 0) return [];
177
+ const start = orderedStart(sel);
178
+ const end = orderedEnd(sel);
179
+ const first = Math.max(0, Math.min(start.line, n - 1));
180
+ const last = Math.max(0, Math.min(end.line, n - 1));
181
+ const ranges: EditorHighlightRange[] = [];
182
+ for (let line = first; line <= last; line++) {
183
+ const text = plainLines[line] ?? "";
184
+ const width = visibleWidth(text);
185
+ let s = line === start.line ? clampCol(start.col, width) : 0;
186
+ let e = line === end.line ? clampCol(end.col, width) : width;
187
+ if (s >= e) continue;
188
+ s = snapCol(text, s, "start");
189
+ e = snapCol(text, e, "end");
190
+ const clipped = clipRangeToMarkers(s, e, text);
191
+ if (clipped.end > clipped.start) {
192
+ ranges.push({ line, start: clipped.start, end: clipped.end });
193
+ }
194
+ }
195
+ return ranges;
196
+ }
197
+
198
+ /**
199
+ * Copy text for a selection: each selected line is stripped to plain text and
200
+ * sliced by cell column, then lines are joined with `\n` in top-to-bottom
201
+ * reading order (soft wraps become hard newlines — accepted v1 behavior).
202
+ */
203
+ export function selectedText(plainLines: string[], sel: EditorSelection): string {
204
+ return highlightRanges(plainLines, sel)
205
+ .map((r) => sliceByColumn(plainLines[r.line] ?? "", r.start, r.end - r.start))
206
+ .join("\n");
207
+ }
208
+
209
+ // Word navigation mirroring the editor's own word-jump semantics: paste markers
210
+ // are merged into atomic segments (the editor's `segmentWithMarkers`), so
211
+ // findWordBackward/Forward treat a marker as a single unit and never land
212
+ // mid-marker.
213
+ function wordSegments(text: string): Intl.SegmentData[] {
214
+ if (!text.includes("[paste #")) return [...wordSegmenter.segment(text)];
215
+ const markers: Array<{ start: number; end: number }> = [];
216
+ for (const m of text.matchAll(PASTE_MARKER_GLOBAL)) {
217
+ const start = m.index ?? 0;
218
+ markers.push({ start, end: start + m[0].length });
219
+ }
220
+ if (markers.length === 0) return [...wordSegmenter.segment(text)];
221
+ const merged: Intl.SegmentData[] = [];
222
+ let markerIdx = 0;
223
+ for (const seg of wordSegmenter.segment(text)) {
224
+ while (markerIdx < markers.length && markers[markerIdx].end <= seg.index) {
225
+ markerIdx++;
226
+ }
227
+ const marker = markerIdx < markers.length ? markers[markerIdx] : null;
228
+ if (marker && seg.index >= marker.start && seg.index < marker.end) {
229
+ if (seg.index === marker.start) {
230
+ merged.push({
231
+ segment: text.slice(marker.start, marker.end),
232
+ index: marker.start,
233
+ input: text,
234
+ });
235
+ }
236
+ } else {
237
+ merged.push(seg);
238
+ }
239
+ }
240
+ return merged;
241
+ }
242
+
243
+ const WORD_NAV_OPTIONS = {
244
+ segment: (text: string) => wordSegments(text),
245
+ isAtomicSegment: isPasteMarkerText,
246
+ };
247
+
248
+ /**
249
+ * Double-click word selection around `(line, col)`: resolve the word bounds with
250
+ * the editor's own `findWordBackward`/`findWordForward` on that visual line's
251
+ * visible text, then clip the result at paste markers (so a double-click on a
252
+ * marker selects nothing).
253
+ */
254
+ export function wordSelection(
255
+ plainLines: string[],
256
+ line: number,
257
+ col: number,
258
+ ): EditorSelection {
259
+ const text = plainLines[line] ?? "";
260
+ const c = clampCol(col, visibleWidth(text));
261
+ const idx = colToCharIndex(text, c);
262
+ const back = findWordBackward(text, idx, WORD_NAV_OPTIONS);
263
+ const fwd = findWordForward(text, idx, WORD_NAV_OPTIONS);
264
+ const clipped = clipRangeToMarkers(
265
+ visibleWidth(text.slice(0, back)),
266
+ visibleWidth(text.slice(0, fwd)),
267
+ text,
268
+ );
269
+ return {
270
+ anchor: { line, col: clipped.start },
271
+ focus: { line, col: clipped.end },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Triple-click visual-line selection: the whole line, from column 0 to the
277
+ * line's visible width (paste-marker clipping still applies downstream).
278
+ */
279
+ export function lineSelection(
280
+ plainLines: string[],
281
+ line: number,
282
+ ): EditorSelection {
283
+ const text = plainLines[line] ?? "";
284
+ return {
285
+ anchor: { line, col: 0 },
286
+ focus: { line, col: visibleWidth(text) },
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Post-process `Editor.render()` output with inverse-video highlight for a
292
+ * selection. Only the content-band lines in the selection are rebuilt (from
293
+ * their plain text, dropping the cursor's own ANSI highlight); border rows and
294
+ * unselected lines pass through untouched. Rebuilt lines are re-padded to the
295
+ * original content width. Assumes no autocomplete rows are present (the overlay
296
+ * gates selection off while the popup is open — ADR 0006).
297
+ */
298
+ export function decorateEditorSelection(
299
+ rendered: string[],
300
+ sel: EditorSelection,
301
+ ): string[] {
302
+ const plain = contentBandPlainLines(rendered);
303
+ const ranges = highlightRanges(plain, sel);
304
+ if (ranges.length === 0) return rendered;
305
+ const out = rendered.slice();
306
+ for (const r of ranges) {
307
+ const text = plain[r.line];
308
+ if (text === undefined) continue;
309
+ const raw = out[r.line + 1];
310
+ if (raw === undefined) continue;
311
+ const contentWidth = visibleWidth(raw);
312
+ const s = colToCharIndex(text, r.start);
313
+ const e = colToCharIndex(text, r.end);
314
+ const rebuilt =
315
+ text.slice(0, s) + SELECTION_ON + text.slice(s, e) + SELECTION_OFF +
316
+ text.slice(e);
317
+ out[r.line + 1] =
318
+ rebuilt + " ".repeat(Math.max(0, contentWidth - visibleWidth(rebuilt)));
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /**
324
+ * Stateful holder for the overlay (T3): stores the anchor/focus pair and
325
+ * delegates all computation to the pure functions above. No window-shift
326
+ * translation is needed (the editor does not grow mid-drag like the message
327
+ * area does), and the lifecycle is transient — the overlay clears it on any
328
+ * non-drag editor input or a plain click.
329
+ */
330
+ export class EditorSelectionState {
331
+ private selection: EditorSelection | null = null;
332
+
333
+ setSelection(anchor: EditorPos, focus: EditorPos): void {
334
+ this.selection = { anchor, focus };
335
+ }
336
+
337
+ clear(): void {
338
+ this.selection = null;
339
+ }
340
+
341
+ get(): EditorSelection | null {
342
+ return this.selection;
343
+ }
344
+
345
+ hasSelection(): boolean {
346
+ const s = this.selection;
347
+ if (!s) return false;
348
+ return s.anchor.line !== s.focus.line || s.anchor.col !== s.focus.col;
349
+ }
350
+
351
+ getAnchor(): EditorPos | null {
352
+ return this.selection?.anchor ?? null;
353
+ }
354
+
355
+ highlightRanges(plainLines: string[]): EditorHighlightRange[] {
356
+ return this.selection ? highlightRanges(plainLines, this.selection) : [];
357
+ }
358
+
359
+ selectedText(plainLines: string[]): string {
360
+ return this.selection ? selectedText(plainLines, this.selection) : "";
361
+ }
362
+
363
+ decorate(rendered: string[]): string[] {
364
+ return this.selection ? decorateEditorSelection(rendered, this.selection) : rendered;
365
+ }
366
+ }
@@ -1,12 +1,15 @@
1
1
  /**
2
- * Pointer gesture state machine for the side chat overlay (spec #12/#13):
3
- * consumes raw SGR mouse events, produces `GestureAction[]` action objects.
2
+ * Pointer gesture state machine for the side chat overlay (spec #12/#13,
3
+ * #24/#26): consumes raw SGR mouse events, produces `GestureAction[]` action
4
+ * objects over two surfaces — the chat area and the input editor.
4
5
  *
5
6
  * The module is deliberately ignorant of the TUI, clipboard and editor: it
6
- * only classifies press/drag/double-click/wheel/right-click sequences and
7
- * reports WHAT happened; the overlay owns the "action → render" translation.
8
- * All coordinates handed to the injected hit queries are 0-based screen
9
- * coordinates (the SGR 1-based → 0-based conversion happens here).
7
+ * only classifies press/drag/double-/triple-click/wheel/right-click sequences
8
+ * and reports WHAT happened and on WHICH surface; the overlay owns the
9
+ * "action → render" translation. All coordinates handed to the injected hit
10
+ * queries are 0-based screen coordinates (the SGR 1-based → 0-based
11
+ * conversion happens here); positions the queries return live in each
12
+ * surface's own coordinate space (chat cells or editor visual cells).
10
13
  */
11
14
  import {
12
15
  isLeftDrag,
@@ -19,15 +22,19 @@ import {
19
22
  type SgrMouseEvent,
20
23
  } from "./side-chat-mouse.ts";
21
24
  import type { CellPos } from "./side-chat-messages.ts";
25
+ import type { EditorPos } from "./editor-selection.ts";
22
26
 
23
- /** Two quick presses within this window (same line) count as a double-click → select line. */
27
+ /**
28
+ * Two (or three) quick presses within this window count as a multi-click
29
+ * (double-click → word/line, triple-click → line), same surface only.
30
+ */
24
31
  export const DOUBLE_CLICK_INTERVAL_MS = 500;
25
32
 
26
33
  /**
27
34
  * True when a drag release ended within the double-click tolerance (same
28
35
  * line, within a couple of cells). Real terminals report motion even for
29
- * 1-cell hand shake during a double-click, so a selection this small is a
30
- * click, not a drag — it must not suppress the next press's double-click
36
+ * 1-cell hand shake during a multi-click, so a selection this small is a
37
+ * click, not a drag — it must not suppress the next press's multi-click
31
38
  * classification. Only the same line counts: a real cross-line drag of a
32
39
  * couple of cells stays a drag, never a click.
33
40
  */
@@ -44,14 +51,24 @@ export function selectionWithinClickTolerance(a: CellPos, b: CellPos): boolean {
44
51
  */
45
52
  export const DRAG_RENDER_INTERVAL_MS = 32;
46
53
 
54
+ /**
55
+ * The two selection surfaces the gesture module classifies over (spec #24).
56
+ * Positions on each surface live in that surface's own coordinate space:
57
+ * chat cells for "chat", editor visual cells for "editor".
58
+ */
59
+ export type Surface = "chat" | "editor";
60
+
47
61
  /**
48
62
  * One output of the gesture state machine. At most one action per event;
49
63
  * no-op situations (release outside the press area, right-click without a
50
- * selection, header/border hits) produce an empty array.
64
+ * selection, header/border hits) produce an empty array. Selection actions
65
+ * carry the surface they act on; the overlay resolves surface-local geometry
66
+ * (column bounds, word bounds) from its own state.
51
67
  */
52
68
  export type GestureAction =
53
69
  | {
54
70
  kind: "select";
71
+ surface: Surface;
55
72
  anchor: CellPos;
56
73
  focus: CellPos;
57
74
  /** False on throttled drag updates: the selection changed but the frame need not repaint. */
@@ -59,11 +76,19 @@ export type GestureAction =
59
76
  }
60
77
  | {
61
78
  kind: "selectLine";
79
+ surface: Surface;
62
80
  /** Rendered line to select; column bounds are filled in by the overlay from its own geometry. */
63
81
  line: number;
64
82
  }
83
+ | {
84
+ kind: "selectWord";
85
+ surface: Surface;
86
+ /** Visual cell under the double-click; word bounds are resolved by the overlay (editor surface only). */
87
+ line: number;
88
+ col: number;
89
+ }
65
90
  | { kind: "scroll"; lines: number }
66
- | { kind: "copy" }
91
+ | { kind: "copy"; surface: Surface }
67
92
  | { kind: "paste" };
68
93
 
69
94
  /**
@@ -80,19 +105,29 @@ export interface PointerHit {
80
105
  clampToChat(row: number, col: number): CellPos;
81
106
  /** True when a screen row falls inside the input editor widget band. */
82
107
  overEditor(row: number, col: number): boolean;
83
- /** True when a non-empty selection is active (right-click copy precondition). */
108
+ /** Map a screen position to an editor visual cell, or null off the editor content band. */
109
+ editorAt(row: number, col: number): EditorPos | null;
110
+ /** Like editorAt but clamps into the editor content band (drag overshoot). */
111
+ clampToEditor(row: number, col: number): EditorPos;
112
+ /** True when a non-empty chat selection is active (right-click copy precondition). */
84
113
  hasSelection(): boolean;
85
- /** The current selection anchor (window coordinates), or null when no selection. */
114
+ /** The current chat selection anchor (window coordinates), or null when no selection. */
86
115
  getSelectionAnchor(): CellPos | null;
116
+ /** True when a non-empty editor selection is active. */
117
+ hasEditorSelection(): boolean;
118
+ /** The current editor selection anchor, or null when no selection. */
119
+ getEditorSelectionAnchor(): EditorPos | null;
87
120
  }
88
121
 
89
122
  export interface PointerGestureOptions {
90
123
  hit: PointerHit;
91
124
  /** D11 gate: when false, right presses are not recorded and releases produce no actions. */
92
125
  rightClickEnabled?: boolean;
126
+ /** Editor-selection gate (spec #24): when false, left presses never classify onto the editor surface. */
127
+ editorSelectionEnabled?: boolean;
93
128
  /** Wheel scroll step in lines (default 3, matching the previous mouse handler). */
94
129
  wheelScrollLines?: number;
95
- /** Clock for the double-click window and drag throttle (test seam; defaults to Date.now). */
130
+ /** Clock for the click-count window and drag throttle (test seam; defaults to Date.now). */
96
131
  now?: () => number;
97
132
  }
98
133
 
@@ -102,10 +137,12 @@ export interface PointerGestureOptions {
102
137
  * lives in the overlay, the module knows nothing about the modal).
103
138
  */
104
139
  export class PointerGesture {
105
- /** Options with defaults already merged in (rightClickEnabled / wheelScrollLines / now). */
140
+ /** Options with defaults already merged in (rightClickEnabled / editorSelectionEnabled / wheelScrollLines / now). */
106
141
  private readonly options: Required<PointerGestureOptions>;
107
142
  /** Set while a left-button selection drag is in progress. */
108
143
  private dragging = false;
144
+ /** The surface the in-flight left drag is on (null when not dragging). */
145
+ private surface: Surface | null = null;
109
146
  /** Right-press landed in the chat area; the copy action fires on release there. */
110
147
  private rightPressInChat = false;
111
148
  /** Right-press landed in the input editor; the paste action fires on release there. */
@@ -113,8 +150,11 @@ export class PointerGesture {
113
150
  private mouseAnchor: CellPos = { line: 0, col: 0 };
114
151
  private lastPressTime = 0;
115
152
  private lastPressPos: CellPos | null = null;
116
- private pendingDoubleClick = false;
117
- /** The last release ended a drag; a quick follow-up click must not count as a double-click. */
153
+ /** The surface of the last press (a surface change resets the click count). */
154
+ private lastPressSurface: Surface | null = null;
155
+ /** Consecutive-click count of the current series (1–3), resolved at press time. */
156
+ private clickCount = 1;
157
+ /** The last release ended a drag; a quick follow-up click must not count as a multi-click. */
118
158
  private lastReleaseWasDrag = false;
119
159
  /** Timestamp of the last paint-triggering drag motion (coalescing). */
120
160
  private lastDragRenderAt = 0;
@@ -122,15 +162,51 @@ export class PointerGesture {
122
162
  constructor(options: PointerGestureOptions) {
123
163
  this.options = {
124
164
  rightClickEnabled: true,
165
+ editorSelectionEnabled: true,
125
166
  wheelScrollLines: 3,
126
167
  now: () => Date.now(),
127
168
  ...options,
128
169
  };
129
170
  }
130
171
 
172
+ /** Clamp a screen position into the active surface's coordinate space. */
173
+ private clampTo(surface: Surface, row: number, col: number): CellPos {
174
+ const { hit } = this.options;
175
+ return surface === "chat"
176
+ ? hit.clampToChat(row, col)
177
+ : hit.clampToEditor(row, col);
178
+ }
179
+
180
+ /**
181
+ * Read the selection anchor back from the active surface's store. The
182
+ * authoritative copy of the chat anchor lives in SideChatMessages
183
+ * (window-shift translation moves it mid-drag), so the module reads it back
184
+ * instead of hoarding a private copy; the editor anchor needs no shift.
185
+ */
186
+ private selectionAnchor(surface: Surface): CellPos {
187
+ const { hit } = this.options;
188
+ const anchor =
189
+ surface === "chat"
190
+ ? hit.getSelectionAnchor()
191
+ : hit.getEditorSelectionAnchor();
192
+ return anchor ?? this.mouseAnchor;
193
+ }
194
+
195
+ /** True when the active surface holds a non-empty selection. */
196
+ private hasSelection(surface: Surface): boolean {
197
+ return surface === "chat"
198
+ ? this.options.hit.hasSelection()
199
+ : this.options.hit.hasEditorSelection();
200
+ }
201
+
131
202
  /** Classify one SGR mouse event and return the actions it produces. */
132
203
  onEvent(event: SgrMouseEvent): GestureAction[] {
133
- const { hit, rightClickEnabled, wheelScrollLines } = this.options;
204
+ const {
205
+ hit,
206
+ rightClickEnabled,
207
+ editorSelectionEnabled,
208
+ wheelScrollLines,
209
+ } = this.options;
134
210
 
135
211
  if (isWheelEvent(event)) {
136
212
  return [
@@ -139,67 +215,103 @@ export class PointerGesture {
139
215
  }
140
216
 
141
217
  if (isLeftPress(event)) {
142
- const pos = hit.chatAt(event.row - 1, event.col - 1);
143
- if (!pos) return [];
218
+ const row = event.row - 1;
219
+ const col = event.col - 1;
220
+ const chatPos = hit.chatAt(row, col);
221
+ let surface: Surface;
222
+ let pos: CellPos;
223
+ if (chatPos) {
224
+ surface = "chat";
225
+ pos = chatPos;
226
+ } else if (editorSelectionEnabled) {
227
+ const editorPos = hit.editorAt(row, col);
228
+ if (!editorPos) return [];
229
+ surface = "editor";
230
+ pos = editorPos;
231
+ } else {
232
+ return [];
233
+ }
144
234
  const now = this.options.now();
145
- const doubleClick =
235
+ // Consecutive-click classification (spec #24/#26): the click count
236
+ // advances only when this press lands on the same surface, within the
237
+ // double-click window and tolerance, and the previous release was not a
238
+ // drag. Any of those failing resets the count to a fresh single click.
239
+ const isMultiClick =
146
240
  this.lastPressPos !== null &&
241
+ this.lastPressSurface === surface &&
147
242
  !this.lastReleaseWasDrag &&
148
243
  now - this.lastPressTime <= DOUBLE_CLICK_INTERVAL_MS &&
149
244
  Math.abs(pos.line - this.lastPressPos.line) <= 1 &&
150
245
  Math.abs(pos.col - this.lastPressPos.col) <= 2;
246
+ this.clickCount = isMultiClick ? Math.min(this.clickCount + 1, 3) : 1;
151
247
  this.dragging = true;
248
+ this.surface = surface;
152
249
  this.mouseAnchor = pos;
153
250
  this.lastPressPos = pos;
251
+ this.lastPressSurface = surface;
154
252
  this.lastPressTime = now;
155
- this.pendingDoubleClick = doubleClick;
156
253
  // Seed the selection with the anchor (empty range): the window-shift
157
254
  // translation in SideChatMessages.render then keeps the anchor aligned
158
255
  // with the same content when status/stream lines are appended mid-drag.
159
- return [{ kind: "select", anchor: pos, focus: pos, paint: true }];
256
+ return [{ kind: "select", surface, anchor: pos, focus: pos, paint: true }];
160
257
  }
161
258
 
162
259
  if (isLeftDrag(event)) {
163
260
  if (!this.dragging) return [];
164
- const pos = hit.clampToChat(event.row - 1, event.col - 1);
165
- const anchor = hit.getSelectionAnchor() ?? this.mouseAnchor;
261
+ const surface = this.surface ?? "chat";
262
+ const pos = this.clampTo(surface, event.row - 1, event.col - 1);
263
+ const anchor = this.selectionAnchor(surface);
166
264
  // Coalesce drag paints: the selection state is always current (the next
167
265
  // render picks it up), only the number of full-frame redraws is capped.
168
266
  const now = this.options.now();
169
267
  const paint = now - this.lastDragRenderAt >= DRAG_RENDER_INTERVAL_MS;
170
268
  if (paint) this.lastDragRenderAt = now;
171
- return [{ kind: "select", anchor, focus: pos, paint }];
269
+ return [{ kind: "select", surface, anchor, focus: pos, paint }];
172
270
  }
173
271
 
174
272
  if (isLeftRelease(event)) {
175
273
  if (!this.dragging) return [];
176
274
  this.dragging = false;
177
- if (this.pendingDoubleClick) {
178
- // Double-click: select the whole rendered line (no auto-copy; the
179
- // hotkey copies it).
180
- this.pendingDoubleClick = false;
275
+ const surface = this.surface ?? "chat";
276
+ const clickCount = this.clickCount;
277
+ const pos = this.clampTo(surface, event.row - 1, event.col - 1);
278
+
279
+ if (clickCount === 2) {
280
+ // Double-click (no auto-copy; the hotkey copies it): chat selects the
281
+ // whole rendered line, the editor selects the word under the click
282
+ // (word bounds resolved by the overlay from its own geometry).
181
283
  this.lastReleaseWasDrag = false;
182
- const pos = hit.clampToChat(event.row - 1, event.col - 1);
183
- return [{ kind: "selectLine", line: pos.line }];
284
+ if (surface === "editor") {
285
+ return [
286
+ { kind: "selectWord", surface, line: pos.line, col: pos.col },
287
+ ];
288
+ }
289
+ return [{ kind: "selectLine", surface, line: pos.line }];
184
290
  }
185
- this.pendingDoubleClick = false;
186
- const pos = hit.clampToChat(event.row - 1, event.col - 1);
187
- if (hit.hasSelection()) {
291
+
292
+ if (clickCount === 3) {
293
+ // Triple-click: select the whole rendered/visual line on both surfaces.
294
+ this.lastReleaseWasDrag = false;
295
+ return [{ kind: "selectLine", surface, line: pos.line }];
296
+ }
297
+
298
+ // clickCount === 1: a single press — either finalize a drag or end a click.
299
+ if (this.hasSelection(surface)) {
188
300
  // Drag-release: finalize the selection (the anchor may have been
189
301
  // window-shifted by appended status/stream lines mid-drag). The
190
302
  // selection stays highlighted so Ctrl+C copies it (hotkey-only copy).
191
- const anchor = hit.getSelectionAnchor() ?? this.mouseAnchor;
303
+ const anchor = this.selectionAnchor(surface);
192
304
  // A selection that ends within the double-click tolerance is a click
193
305
  // with hand shake, not a drag: real terminals report motion (button 32)
194
306
  // even for 1-cell moves, so without this the tiniest movement while
195
- // double-clicking marks the release as a drag and the second press is
196
- // never classified as a double-click (bug: line-select never fires).
307
+ // multi-clicking marks the release as a drag and the next press is
308
+ // never classified as a multi-click (bug: line/word-select never fires).
197
309
  this.lastReleaseWasDrag = !selectionWithinClickTolerance(anchor, pos);
198
- return [{ kind: "select", anchor, focus: pos, paint: true }];
310
+ return [{ kind: "select", surface, anchor, focus: pos, paint: true }];
199
311
  }
200
312
  // Plain click without drag: the press-seeded empty selection (anchor === focus)
201
- // is inert (hasSelection() === false, no highlight rendered), so no explicit
202
- // clear action is needed; left to be overwritten on the next press/scroll.
313
+ // is inert (no highlight rendered), so no explicit clear action is needed;
314
+ // left to be overwritten on the next press/scroll.
203
315
  this.lastReleaseWasDrag = false;
204
316
  return [];
205
317
  }
@@ -230,7 +342,7 @@ export class PointerGesture {
230
342
  if (pressInChat) {
231
343
  if (hit.chatAt(event.row - 1, event.col - 1) === null) return [];
232
344
  if (!hit.hasSelection()) return [];
233
- return [{ kind: "copy" }];
345
+ return [{ kind: "copy", surface: "chat" }];
234
346
  }
235
347
  if (pressInEditor) {
236
348
  if (!hit.overEditor(event.row - 1, event.col - 1)) return [];
@@ -254,8 +366,12 @@ export class PointerGesture {
254
366
  */
255
367
  cancel(): void {
256
368
  this.dragging = false;
369
+ this.surface = null;
257
370
  this.rightPressInChat = false;
258
371
  this.rightPressInEditor = false;
259
- this.pendingDoubleClick = false;
372
+ // The next press after an aborted drag is a fresh single click, never a
373
+ // continuation of a multi-click series.
374
+ this.clickCount = 1;
375
+ this.lastReleaseWasDrag = true;
260
376
  }
261
377
  }
package/srcs/shortcuts.ts CHANGED
@@ -6,6 +6,17 @@ export const SIDE_CHAT_SHORTCUT: KeyId = "alt+w";
6
6
  interface Keybinding {
7
7
  readonly keys: readonly KeyId[];
8
8
  readonly hint: string;
9
+ /**
10
+ * Raw terminal encodings that also trigger this action, besides the
11
+ * parser-recognizable `keys`. Needed because pi-tui's parser cannot express
12
+ * alt+shift+letter: its legacy branch only maps ESC+lowercase to alt+letter,
13
+ * and its modifyOtherKeys parse drops the shift bit. Terminals without the
14
+ * kitty keyboard protocol (Windows Terminal < 1.25) deliver Alt+Shift+C as
15
+ * the legacy ESC+'C' form (shift folded into case), which parseKey returns
16
+ * undefined for — so the binding must match the raw bytes too. Only
17
+ * copyInput needs this today.
18
+ */
19
+ readonly raw?: readonly string[];
9
20
  }
10
21
 
11
22
  /**
@@ -37,7 +48,7 @@ export const KEYBINDINGS = {
37
48
  /** Copy the last side-chat assistant message (pi `app.message.copy` parity). */
38
49
  copyLastMessage: { keys: ["ctrl+x"], hint: "C+x last" },
39
50
  /** Copy all input editor text (expanded paste markers — submit semantics). */
40
- copyInput: { keys: ["alt+shift+c"], hint: "A+⇧C all" },
51
+ copyInput: { keys: ["alt+shift+c"], raw: ["\x1bC", "\x1b[27;3;99~"], hint: "A+⇧C all" },
41
52
  /** Paste clipboard text (pi `app.clipboard.pasteImage` parity). */
42
53
  paste: { keys: ["ctrl+v", "alt+v"], hint: "C+v paste" },
43
54
  } as const satisfies Record<string, Keybinding>;
@@ -46,3 +57,9 @@ export const KEYBINDINGS = {
46
57
  export function matchesAnyKey(data: string, keys: readonly KeyId[]): boolean {
47
58
  return keys.some((key) => matchesKey(data, key));
48
59
  }
60
+
61
+ /** True when `data` matches a binding's keys or one of its raw encodings. */
62
+ export function matchesKeybinding(data: string, kb: Keybinding): boolean {
63
+ if (kb.raw?.includes(data)) return true;
64
+ return matchesAnyKey(data, kb.keys);
65
+ }
@@ -44,6 +44,13 @@ import {
44
44
  } from "./clipboard-read.ts";
45
45
  import { exportChatHistoryToFile } from "./side-chat-export.ts";
46
46
  import { type SgrMouseEvent } from "./side-chat-mouse.ts";
47
+ import {
48
+ contentBandPlainLines,
49
+ EditorSelectionState,
50
+ lineSelection,
51
+ wordSelection,
52
+ type EditorPos,
53
+ } from "./editor-selection.ts";
47
54
  import {
48
55
  PointerGesture,
49
56
  type GestureAction,
@@ -62,7 +69,7 @@ import {
62
69
  modelKey,
63
70
  type ModelChoice,
64
71
  } from "./model-switch.ts";
65
- import { KEYBINDINGS, matchesAnyKey } from "./shortcuts.ts";
72
+ import { KEYBINDINGS, matchesAnyKey, matchesKeybinding } from "./shortcuts.ts";
66
73
  import { wrapToolsWithOverlapDetection } from "./tool-wrapper.ts";
67
74
  import type { SideChatFeatures } from "./config.ts";
68
75
  import type { RetryPolicy } from "./retry.ts";
@@ -171,6 +178,10 @@ export class SideChatOverlay implements Component, Focusable {
171
178
  private geometry: ChatGeometry | null = null;
172
179
  /** Pointer gesture state machine (spec #13): press/drag/double-click/right-click classification. */
173
180
  private gesture: PointerGesture;
181
+ /** Visual-space selection over the input editor (spec #24, T3). */
182
+ private editorSelection = new EditorSelectionState();
183
+ /** Plain (ANSI-stripped) lines of the last editor render's content band. */
184
+ private editorPlainLines: string[] = [];
174
185
  /** Leading messages injected from the main lane at fork time (context cite). */
175
186
  private forkedMessageCount: number;
176
187
  /** Tool names allowed in the read-only lane (builtins + allowlist + peek_main). */
@@ -217,6 +228,7 @@ export class SideChatOverlay implements Component, Focusable {
217
228
  cancelMouseDrag(): void {
218
229
  this.gesture.cancel();
219
230
  this.messages.clearSelection();
231
+ this.editorSelection.clear();
220
232
  }
221
233
 
222
234
  /**
@@ -243,16 +255,36 @@ export class SideChatOverlay implements Component, Focusable {
243
255
  private applyGestureAction(action: GestureAction): void {
244
256
  switch (action.kind) {
245
257
  case "select":
246
- this.messages.setSelection(action.anchor, action.focus);
258
+ // Cross-surface exclusion (spec #24): starting a selection on one
259
+ // surface clears the other, so only one highlight is ever on screen.
260
+ if (action.surface === "editor") {
261
+ this.setEditorSelection(action.anchor, action.focus);
262
+ } else {
263
+ this.setChatSelection(action.anchor, action.focus);
264
+ }
247
265
  if (action.paint) this.options.tui.requestRender();
248
266
  break;
249
267
  case "selectLine":
250
- // Column bounds come from the overlay's own geometry; the module
251
- // only knows the rendered line.
252
- this.messages.setSelection(
253
- { line: action.line, col: 0 },
254
- { line: action.line, col: this.geometry?.innerWidth ?? 0 },
255
- );
268
+ if (action.surface === "editor") {
269
+ // Column bounds come from the editor's own content band; the module
270
+ // only knows the rendered line (spec #24/#26).
271
+ const sel = lineSelection(this.editorPlainLines, action.line);
272
+ this.setEditorSelection(sel.anchor, sel.focus);
273
+ } else {
274
+ // Column bounds come from the overlay's own geometry; the module
275
+ // only knows the rendered line.
276
+ this.setChatSelection(
277
+ { line: action.line, col: 0 },
278
+ { line: action.line, col: this.geometry?.innerWidth ?? 0 },
279
+ );
280
+ }
281
+ this.options.tui.requestRender();
282
+ break;
283
+ case "selectWord":
284
+ // Editor double-click: word bounds resolved by the overlay from the
285
+ // editor's own content band (findWordBackward/Forward semantics).
286
+ const word = wordSelection(this.editorPlainLines, action.line, action.col);
287
+ this.setEditorSelection(word.anchor, word.focus);
256
288
  this.options.tui.requestRender();
257
289
  break;
258
290
  case "scroll":
@@ -267,6 +299,18 @@ export class SideChatOverlay implements Component, Focusable {
267
299
  }
268
300
  }
269
301
 
302
+ /** Set the chat selection, clearing the editor selection (cross-surface). */
303
+ private setChatSelection(anchor: CellPos, focus: CellPos): void {
304
+ this.messages.setSelection(anchor, focus);
305
+ this.editorSelection.clear();
306
+ }
307
+
308
+ /** Set the editor selection, clearing the chat selection (cross-surface). */
309
+ private setEditorSelection(anchor: EditorPos, focus: EditorPos): void {
310
+ this.editorSelection.setSelection(anchor, focus);
311
+ this.messages.clearSelection();
312
+ }
313
+
270
314
  /**
271
315
  * Copy the current mouse selection to the clipboard (native → wl-copy /
272
316
  * xclip → OSC 52 cascade via {@link copyToClipboard}, matching the main
@@ -287,6 +331,23 @@ export class SideChatOverlay implements Component, Focusable {
287
331
  return ok;
288
332
  }
289
333
 
334
+ /**
335
+ * Copy the current editor selection (spec #24, hotkey-only): selected text
336
+ * from the editor content band, consumed on success so Ctrl+C then falls
337
+ * back to the clear-input lane; a failed (or empty) copy keeps it for retry.
338
+ */
339
+ async copyEditorSelectionToClipboard(): Promise<boolean> {
340
+ if (!this.editorSelection.hasSelection()) return false;
341
+ const text = this.editorSelection.selectedText(this.editorPlainLines);
342
+ if (!text) return false;
343
+ const ok = await this.copyTextWithFeedback(text);
344
+ if (ok) {
345
+ this.editorSelection.clear();
346
+ this.options.tui.requestRender();
347
+ }
348
+ return ok;
349
+ }
350
+
290
351
  /**
291
352
  * Copy text to the system clipboard and surface the outcome: a success
292
353
  * flash on the status line (labelled by `hint`) or an error line on
@@ -348,6 +409,7 @@ export class SideChatOverlay implements Component, Focusable {
348
409
  }
349
410
  // Bracketed paste is the only paste entry the Editor exposes (handlePaste
350
411
  // is private); the same sequences a native terminal paste produces.
412
+ this.editorSelection.clear();
351
413
  this.editor.handleInput(`\x1b[200~${outcome.text}\x1b[201~`);
352
414
  this.options.tui.requestRender();
353
415
  }
@@ -380,6 +442,32 @@ export class SideChatOverlay implements Component, Focusable {
380
442
  return { line, col: c };
381
443
  }
382
444
 
445
+ /**
446
+ * Map 0-based screen coords to an editor visual cell, or null off the
447
+ * editor content band (the `Editor.render()` lines between the top and
448
+ * bottom borders). The top border row is `editorTopRow`, so content-band
449
+ * line 0 is the next screen row down.
450
+ */
451
+ private screenToEditor(row: number, col: number): EditorPos | null {
452
+ const g = this.geometry;
453
+ if (!g) return null;
454
+ const line = row - (g.editorTopRow + 1);
455
+ const c = col - g.contentCol;
456
+ if (line < 0 || line >= g.editorHeight - 2 || c < 0 || c >= g.innerWidth)
457
+ return null;
458
+ return { line, col: c };
459
+ }
460
+
461
+ /** Like {@link screenToEditor} but clamps into the editor content band (drag overshoot). */
462
+ private clampScreenToEditor(row: number, col: number): EditorPos {
463
+ const g = this.geometry;
464
+ if (!g) return { line: 0, col: 0 };
465
+ const maxLine = Math.max(0, g.editorHeight - 3);
466
+ const line = Math.max(0, Math.min(row - (g.editorTopRow + 1), maxLine));
467
+ const c = Math.max(0, Math.min(col - g.contentCol, Math.max(0, g.innerWidth - 1)));
468
+ return { line, col: c };
469
+ }
470
+
383
471
  get focused() {
384
472
  return this._focused;
385
473
  }
@@ -464,11 +552,25 @@ export class SideChatOverlay implements Component, Focusable {
464
552
  chatAt: (row, col) => this.screenToChat(row, col),
465
553
  clampToChat: (row, col) => this.clampScreenToChat(row, col),
466
554
  overEditor: (row, _col) => this.isOverEditor(row),
555
+ // Editor surface (spec #24/#26): real hit-testing / selection store.
556
+ // While the autocomplete popup is open the editor-drag branch is
557
+ // disabled (row-index drift — ADR 0006), so editorAt reports null and
558
+ // left presses fall through to a no-op.
559
+ editorAt: (row, col) =>
560
+ this.editor.isShowingAutocomplete()
561
+ ? null
562
+ : this.screenToEditor(row, col),
563
+ clampToEditor: (row, col) => this.clampScreenToEditor(row, col),
564
+ hasEditorSelection: () => this.editorSelection.hasSelection(),
565
+ getEditorSelectionAnchor: () => this.editorSelection.getAnchor(),
467
566
  hasSelection: () => this.messages.hasSelection(),
468
567
  getSelectionAnchor: () => this.messages.getSelectionAnchor(),
469
568
  },
470
569
  // D11: the feature switch gates right-click copy/paste at the module.
471
570
  rightClickEnabled: this.options.features.rightClickCopyPaste,
571
+ // Editor-selection gate (spec #24): off → left-drag never classifies
572
+ // onto the editor surface; right-click paste is unaffected.
573
+ editorSelectionEnabled: this.options.features.editorSelection,
472
574
  });
473
575
 
474
576
  // The runner (fork-turn.ts, issue #9) owns the fork agent and the whole
@@ -678,6 +780,7 @@ export class SideChatOverlay implements Component, Focusable {
678
780
  this.refreshFramingModel();
679
781
 
680
782
  this.editor.setText("");
783
+ this.editorSelection.clear();
681
784
  this.streamingContent = "";
682
785
  // A new user message resumes bottom-following even if the view was frozen.
683
786
  this.messages.resumeFollowing();
@@ -848,6 +951,13 @@ export class SideChatOverlay implements Component, Focusable {
848
951
  for (let i = msgLines.length; i < maxLines; i++) msgLines.push("");
849
952
 
850
953
  const editorLines = this.editor.render(innerWidth);
954
+ // Editor selection (spec #24): hit-testing / copy resolve against the
955
+ // content band (top/bottom borders dropped, ANSI stripped), and the
956
+ // highlight is injected by post-processing the rendered lines. The plain
957
+ // lines are cached here so mouse actions between renders see the same
958
+ // band the frame was drawn from.
959
+ this.editorPlainLines = contentBandPlainLines(editorLines);
960
+ const decoratedEditorLines = this.editorSelection.decorate(editorLines);
851
961
  const lines = renderSideChatFrame({
852
962
  width,
853
963
  theme,
@@ -855,7 +965,7 @@ export class SideChatOverlay implements Component, Focusable {
855
965
  headerLeft: left,
856
966
  headerRight: status,
857
967
  msgLines,
858
- editorLines,
968
+ editorLines: decoratedEditorLines,
859
969
  hints: hintLines,
860
970
  });
861
971
  this.lastRenderHeight = lines.length;
@@ -863,7 +973,7 @@ export class SideChatOverlay implements Component, Focusable {
863
973
  this.options.tui.terminal.columns,
864
974
  this.options.tui.terminal.rows,
865
975
  msgLines.length,
866
- editorLines.length,
976
+ decoratedEditorLines.length,
867
977
  );
868
978
  return lines;
869
979
  }
@@ -910,11 +1020,13 @@ export class SideChatOverlay implements Component, Focusable {
910
1020
  this.openModelPicker();
911
1021
  return;
912
1022
  }
913
- if (matchesAnyKey(data, KEYBINDINGS.copyInput.keys)) {
1023
+ if (matchesKeybinding(data, KEYBINDINGS.copyInput)) {
914
1024
  // Copy the whole input editor text (issue #23): expanded paste
915
1025
  // markers — exactly what a submit would send. Read-only, unlike
916
1026
  // Ctrl+C's clear lane it never touches the draft; an empty input
917
- // flashes a hint instead of copying.
1027
+ // flashes a hint instead of copying. `matchesKeybinding` also accepts
1028
+ // the raw legacy ESC+C form that terminals without the kitty protocol
1029
+ // (Windows Terminal < 1.25) deliver for Alt+Shift+C.
918
1030
  const text = this.editor.getExpandedText();
919
1031
  if (!text) {
920
1032
  this.status.flash(INPUT_EMPTY_STATUS, COPIED_STATUS_CLEAR_MS);
@@ -924,15 +1036,23 @@ export class SideChatOverlay implements Component, Focusable {
924
1036
  return;
925
1037
  }
926
1038
  if (matchesAnyKey(data, KEYBINDINGS.copySelection.keys)) {
927
- // Hotkey copy: with an active mouse selection, Ctrl+C / Ctrl+Shift+C
928
- // copies it. Without one, Ctrl+C clears the input box (pi `app.clear`
929
- // parity, spec #22); Ctrl+Shift+C falls through pi binds no such
930
- // key, so it stays a forced copy (terminal habit).
1039
+ // Hotkey copy (spec #24): three-tier routing chat selection first,
1040
+ // then editor selection, then the legacy clear-input lane. Copying the
1041
+ // editor selection consumes it so Ctrl+C then returns to clearing the
1042
+ // input; a failed copy keeps it for retry. Bare Ctrl+C with no
1043
+ // selection clears the input box (pi `app.clear` parity, spec #22);
1044
+ // Ctrl+Shift+C falls through — pi binds no such key, so it stays a
1045
+ // forced copy (terminal habit).
931
1046
  if (this.messages.hasSelection()) {
932
1047
  void this.copySelectionToClipboard();
933
1048
  return;
934
1049
  }
1050
+ if (this.editorSelection.hasSelection()) {
1051
+ void this.copyEditorSelectionToClipboard();
1052
+ return;
1053
+ }
935
1054
  if (matchesKey(data, KEYBINDINGS.copySelection.keys[0])) {
1055
+ this.editorSelection.clear();
936
1056
  this.editor.setText("");
937
1057
  this.options.tui.requestRender();
938
1058
  return;
@@ -975,6 +1095,11 @@ export class SideChatOverlay implements Component, Focusable {
975
1095
  this.options.tui.requestRender();
976
1096
  return;
977
1097
  }
1098
+ // Any non-drag editor input (typing, cursor movement) clears the editor
1099
+ // selection — transient by design (spec #24). The clear happens before
1100
+ // the editor consumes the input so the next render never shows a stale
1101
+ // highlight next to the moved cursor.
1102
+ this.editorSelection.clear();
978
1103
  this.editor.handleInput(data);
979
1104
  this.options.tui.requestRender();
980
1105
  }
@@ -989,6 +1114,11 @@ export class SideChatOverlay implements Component, Focusable {
989
1114
  if (this.modelPicker) return;
990
1115
  // Feature switch (D11): Ctrl+L is inert when model switching is off.
991
1116
  if (!this.options.features.modelSwitch) return;
1117
+ // Editor selection is transient: opening the picker clears it and aborts
1118
+ // any in-flight editor drag, so the modal's mouse surface starts clean
1119
+ // (spec #24).
1120
+ this.gesture.cancel();
1121
+ this.editorSelection.clear();
992
1122
  if (this.runner.isRunning) {
993
1123
  this.status.setSteady("feedback", {
994
1124
  text: () => "Model switch unavailable while streaming",
@@ -1124,6 +1254,7 @@ export class SideChatOverlay implements Component, Focusable {
1124
1254
  // phase would do it, but handlePhase is a no-op once disposed).
1125
1255
  this.status.reset();
1126
1256
  this.runner.cancel();
1257
+ this.editorSelection.clear();
1127
1258
  const messages = [...this.runner.agent.state.messages];
1128
1259
  this.options.onClose(action, messages);
1129
1260
  }