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 +2 -1
- package/package.json +1 -1
- package/srcs/config.ts +5 -0
- package/srcs/editor-selection.ts +366 -0
- package/srcs/pointer-gesture.ts +159 -43
- package/srcs/shortcuts.ts +18 -1
- package/srcs/side-chat-overlay.ts +147 -16
package/config.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-btw-plus",
|
|
3
|
-
"version": "1.
|
|
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
|
+
}
|
package/srcs/pointer-gesture.ts
CHANGED
|
@@ -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
|
|
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
|
|
7
|
-
* reports WHAT happened; the overlay owns the
|
|
8
|
-
* All coordinates handed to the injected hit
|
|
9
|
-
* coordinates (the SGR 1-based → 0-based
|
|
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
|
-
/**
|
|
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
|
|
30
|
-
* click, not a drag — it must not suppress the next press's
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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 {
|
|
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
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
const
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 =
|
|
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
|
-
//
|
|
196
|
-
// never classified as a
|
|
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 (
|
|
202
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
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
|
}
|