pi-better-btw-plus 1.0.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/LICENSE +23 -0
- package/README.md +251 -0
- package/README.zh-CN.md +252 -0
- package/banner.png +0 -0
- package/config.json +23 -0
- package/package.json +69 -0
- package/prompts/btw-focus-anchor.md +1 -0
- package/prompts/btw-framing.md +8 -0
- package/prompts/lane-failed-note.md +1 -0
- package/prompts/lane-preamble.md +1 -0
- package/prompts/lane-reminder-base.md +1 -0
- package/prompts/lane-reminder-escalated.md +1 -0
- package/srcs/clipboard-read.ts +339 -0
- package/srcs/config.ts +341 -0
- package/srcs/file-activity-tracker.ts +21 -0
- package/srcs/fork-surgery.ts +106 -0
- package/srcs/index.ts +381 -0
- package/srcs/model-switch.ts +60 -0
- package/srcs/prompt-pack.ts +145 -0
- package/srcs/retry.ts +360 -0
- package/srcs/shortcuts.ts +4 -0
- package/srcs/side-chat-export.ts +302 -0
- package/srcs/side-chat-messages.ts +479 -0
- package/srcs/side-chat-mouse.ts +108 -0
- package/srcs/side-chat-overlay.ts +1513 -0
- package/srcs/tool-wrapper.ts +225 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { Key, matchesKey, stripTerminalSequences, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A cell position in the visible chat area: index of the rendered line
|
|
7
|
+
* (as returned by {@link SideChatMessages.render}) and 0-based cell column.
|
|
8
|
+
* Columns count terminal cells (visible width), so wide (CJK) characters
|
|
9
|
+
* occupy two cells.
|
|
10
|
+
*/
|
|
11
|
+
export interface CellPos {
|
|
12
|
+
line: number;
|
|
13
|
+
col: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Map a terminal-cell column back to a character index in plain text. */
|
|
17
|
+
function colToCharIndex(plain: string, col: number): number {
|
|
18
|
+
let width = 0;
|
|
19
|
+
for (let i = 0; i < plain.length; ) {
|
|
20
|
+
const cp = plain.codePointAt(i)!;
|
|
21
|
+
const char = String.fromCodePoint(cp);
|
|
22
|
+
const charWidth = visibleWidth(char);
|
|
23
|
+
if (width + charWidth > col) return i;
|
|
24
|
+
width += charWidth;
|
|
25
|
+
i += char.length;
|
|
26
|
+
}
|
|
27
|
+
return plain.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Invert-video highlight applied to the selected cell range. */
|
|
31
|
+
const SELECTION_STYLE = "\x1b[7m";
|
|
32
|
+
const SELECTION_STYLE_END = "\x1b[27m";
|
|
33
|
+
|
|
34
|
+
// Marker for the framing block message (#9): it lives in the LLM context
|
|
35
|
+
// (user-role fallback placement) but must never render as a chat bubble.
|
|
36
|
+
const FRAMING_MARKER = Symbol("btw-framing-message");
|
|
37
|
+
|
|
38
|
+
/** Mark a message as the framing block so the render path skips it. */
|
|
39
|
+
export function markFramingMessage<T extends AgentMessage>(message: T): T {
|
|
40
|
+
(message as T & { [FRAMING_MARKER]?: boolean })[FRAMING_MARKER] = true;
|
|
41
|
+
return message;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True for framing-block messages (marked at construction); narrows to a string-content message. */
|
|
45
|
+
export function isFramingMessage(
|
|
46
|
+
message: AgentMessage,
|
|
47
|
+
): message is AgentMessage & { content: string } {
|
|
48
|
+
return (message as AgentMessage & { [FRAMING_MARKER]?: boolean })[FRAMING_MARKER] === true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class SideChatMessages implements Component {
|
|
52
|
+
private messages: AgentMessage[] = [];
|
|
53
|
+
private streamingContent = "";
|
|
54
|
+
private errorContent = "";
|
|
55
|
+
private toolStatus = "";
|
|
56
|
+
private scrollOffset = 0;
|
|
57
|
+
private totalLines = 0;
|
|
58
|
+
/**
|
|
59
|
+
* Active mouse selection over the visible chat lines ({@link CellPos} in
|
|
60
|
+
* rendered-line coordinates). Cleared on scroll / content changes and on
|
|
61
|
+
* the next press; kept after copy so Ctrl+C can copy again (B+C scheme).
|
|
62
|
+
*/
|
|
63
|
+
private selection: { anchor: CellPos; focus: CellPos } | null = null;
|
|
64
|
+
/** Plain (ANSI-stripped) text of the last rendered lines, for hit-testing and copy. */
|
|
65
|
+
private plainLines: string[] = [];
|
|
66
|
+
/** Slice start of the last render; appended status/stream lines shift it (window coords). */
|
|
67
|
+
private lastRenderStart = 0;
|
|
68
|
+
/**
|
|
69
|
+
* Number of leading messages injected at open time (fork context, reopened
|
|
70
|
+
* history, refork). They render as ONE collapsed, non-expandable cite line
|
|
71
|
+
* instead of full history. UI-only: the messages themselves stay in the
|
|
72
|
+
* agent's LLM context untouched.
|
|
73
|
+
*/
|
|
74
|
+
private injectedCount = 0;
|
|
75
|
+
/**
|
|
76
|
+
* While the user has scrolled away from the bottom, the content line index
|
|
77
|
+
* pinned to the viewport bottom. New lines appended below it (streamed
|
|
78
|
+
* output) grow the scroll offset instead of sliding the visible content.
|
|
79
|
+
* Null while following the bottom.
|
|
80
|
+
*/
|
|
81
|
+
private frozenAnchor: number | null = null;
|
|
82
|
+
|
|
83
|
+
// --- Render cache (perf) ---
|
|
84
|
+
// The conversation history is re-wrapped on EVERY render call (each mouse
|
|
85
|
+
// drag motion event, each streaming delta, each 80ms spinner tick), so an
|
|
86
|
+
// un-cached render that re-wraps the whole transcript cost ~21ms/frame at
|
|
87
|
+
// 20 turns and ~120ms/frame at 200 turns — enough to saturate the event
|
|
88
|
+
// loop and make the whole terminal stutter during selection drags and
|
|
89
|
+
// lane-blocked retry loops. Caching the wrapped lines per message (keyed
|
|
90
|
+
// by message identity + width) makes steady-state frames O(visible) again.
|
|
91
|
+
/** Rendered lines of the injected-context cite (depends on width + injected count). */
|
|
92
|
+
private prefixLines: string[] = [];
|
|
93
|
+
/** Cached wrapped lines per message index (keyed by message reference + width). */
|
|
94
|
+
private messageCache: string[][] = [];
|
|
95
|
+
/** Message reference each cache entry was rendered from (null = needs render). */
|
|
96
|
+
private messageRefs: (AgentMessage | null)[] = [];
|
|
97
|
+
/** Width the caches were built for; a resize invalidates everything. */
|
|
98
|
+
private cachedWidth = 0;
|
|
99
|
+
/** Injected count the prefix was built for. */
|
|
100
|
+
private cachedInject = -1;
|
|
101
|
+
|
|
102
|
+
constructor(private theme: Theme, private maxVisibleLines: number) {}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Mark the first `count` messages as injected context: they render as a
|
|
106
|
+
* single cite line (e.g. `[Context] 36 msgs`) rather than full history.
|
|
107
|
+
* Anything appended after the injected batch renders normally. Pass 0
|
|
108
|
+
* (Alt+N empty start) for no cite. UI-only — LLM context is unaffected.
|
|
109
|
+
*/
|
|
110
|
+
setInjectedMessageCount(count: number) {
|
|
111
|
+
this.injectedCount = Math.max(0, count);
|
|
112
|
+
// The cite label/count feeds the prefix; force a rebuild on next render.
|
|
113
|
+
this.cachedInject = -1;
|
|
114
|
+
this.prefixLines = [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setMessages(messages: AgentMessage[]) {
|
|
118
|
+
this.messages = messages;
|
|
119
|
+
// New content invalidates the mouse selection.
|
|
120
|
+
this.selection = null;
|
|
121
|
+
if (this.frozenAnchor === null) {
|
|
122
|
+
// Following: snap the viewport to the bottom.
|
|
123
|
+
this.scrollOffset = 0;
|
|
124
|
+
} else {
|
|
125
|
+
// Frozen: keep the anchored content line at the viewport bottom. The
|
|
126
|
+
// message list only grows, so the anchor stays valid across re-renders
|
|
127
|
+
// (streaming block -> completed message, tool status coming/going).
|
|
128
|
+
this.scrollOffset = Math.max(0, this.totalLines - this.frozenAnchor);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setStreamingContent(content: string) {
|
|
133
|
+
this.streamingContent = content;
|
|
134
|
+
if (content) this.errorContent = "";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setErrorContent(content: string) {
|
|
138
|
+
this.errorContent = content;
|
|
139
|
+
if (content) this.streamingContent = "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setToolStatus(status: string) {
|
|
143
|
+
this.toolStatus = status;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Set the mouse selection range in rendered-line coordinates. Positions are
|
|
148
|
+
* clamped to the currently rendered lines.
|
|
149
|
+
*/
|
|
150
|
+
setSelection(anchor: CellPos, focus: CellPos) {
|
|
151
|
+
this.selection = {
|
|
152
|
+
anchor: this.clampPos(anchor),
|
|
153
|
+
focus: this.clampPos(focus),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Drop the active mouse selection. */
|
|
158
|
+
clearSelection() {
|
|
159
|
+
this.selection = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The current selection anchor (window coordinates), or null when no selection. */
|
|
163
|
+
getSelectionAnchor(): CellPos | null {
|
|
164
|
+
return this.selection?.anchor ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when a non-empty selection is active (highlights are rendered). */
|
|
168
|
+
hasSelection(): boolean {
|
|
169
|
+
if (!this.selection) return false;
|
|
170
|
+
const { anchor, focus } = this.selection;
|
|
171
|
+
return anchor.line !== focus.line || anchor.col !== focus.col;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Extract the selected text (per rendered line, joined with newlines). */
|
|
175
|
+
getSelectedText(): string {
|
|
176
|
+
if (!this.selection) return "";
|
|
177
|
+
const { anchor, focus } = this.selection;
|
|
178
|
+
const start = this.orderedStart(anchor, focus);
|
|
179
|
+
const end = this.orderedEnd(anchor, focus);
|
|
180
|
+
const lines: string[] = [];
|
|
181
|
+
for (let line = start.line; line <= end.line; line++) {
|
|
182
|
+
const plain = this.plainLines[line] ?? "";
|
|
183
|
+
const s = line === start.line ? colToCharIndex(plain, start.col) : 0;
|
|
184
|
+
const e = line === end.line ? colToCharIndex(plain, end.col) : plain.length;
|
|
185
|
+
lines.push(plain.slice(s, e).trimEnd());
|
|
186
|
+
}
|
|
187
|
+
return lines.join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Clear the tool-status line, but only while it still shows the given text. */
|
|
191
|
+
clearToolStatusIf(expected: string) {
|
|
192
|
+
if (this.toolStatus === expected) this.toolStatus = "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
setMaxVisibleLines(max: number) {
|
|
196
|
+
this.maxVisibleLines = Math.max(1, max);
|
|
197
|
+
if (this.frozenAnchor === null) {
|
|
198
|
+
this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, this.totalLines - this.maxVisibleLines));
|
|
199
|
+
} else {
|
|
200
|
+
this.scrollOffset = Math.max(0, this.totalLines - this.frozenAnchor);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Scroll by a number of lines. Positive = toward older content (like PgUp),
|
|
206
|
+
* negative = toward the latest message. Clamped to the scrollable range.
|
|
207
|
+
*/
|
|
208
|
+
scrollBy(lines: number) {
|
|
209
|
+
if (lines === 0) return false;
|
|
210
|
+
// A scroll invalidates the mouse selection (rendered-line coordinates shift).
|
|
211
|
+
this.selection = null;
|
|
212
|
+
const maxOffset = Math.max(0, this.totalLines - this.maxVisibleLines);
|
|
213
|
+
const next = Math.max(0, Math.min(this.scrollOffset + lines, maxOffset));
|
|
214
|
+
if (next === this.scrollOffset) return false;
|
|
215
|
+
this.scrollOffset = next;
|
|
216
|
+
if (next === 0) {
|
|
217
|
+
// Scrolled back to the bottom: resume following.
|
|
218
|
+
this.frozenAnchor = null;
|
|
219
|
+
} else {
|
|
220
|
+
// Pin the new viewport position so arriving lines don't slide it.
|
|
221
|
+
this.frozenAnchor = Math.max(0, this.totalLines - next);
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Snap to the bottom and resume bottom-following. Called when the user
|
|
228
|
+
* sends a new message (the conversation restarts from the bottom).
|
|
229
|
+
*/
|
|
230
|
+
resumeFollowing() {
|
|
231
|
+
this.frozenAnchor = null;
|
|
232
|
+
this.scrollOffset = 0;
|
|
233
|
+
this.selection = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
getScrollOffset(): number {
|
|
237
|
+
return this.scrollOffset;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
isAtBottom(): boolean {
|
|
241
|
+
return this.scrollOffset <= 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* (Re)build the cached prefix + per-message wrapped lines when the messages,
|
|
246
|
+
* message count, or width changed. Per-message entries are re-rendered only
|
|
247
|
+
* when the message object reference differs; streaming replaces one message
|
|
248
|
+
* object per delta, so only that single message re-wraps per frame.
|
|
249
|
+
*/
|
|
250
|
+
private ensureCachedLines(width: number): void {
|
|
251
|
+
if (width !== this.cachedWidth) {
|
|
252
|
+
this.cachedWidth = width;
|
|
253
|
+
this.messageCache = [];
|
|
254
|
+
this.messageRefs = [];
|
|
255
|
+
this.prefixLines = [];
|
|
256
|
+
this.cachedInject = -1;
|
|
257
|
+
}
|
|
258
|
+
const n = this.messages.length;
|
|
259
|
+
const injected = Math.min(this.injectedCount, n);
|
|
260
|
+
if (this.messageRefs.length !== n) {
|
|
261
|
+
const prev = this.messageRefs.length;
|
|
262
|
+
if (prev > n) {
|
|
263
|
+
this.messageRefs.length = n;
|
|
264
|
+
this.messageCache.length = n;
|
|
265
|
+
} else {
|
|
266
|
+
this.messageRefs.length = n;
|
|
267
|
+
this.messageCache.length = n;
|
|
268
|
+
for (let i = prev; i < n; i++) this.messageRefs[i] = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
for (let i = 0; i < n; i++) {
|
|
272
|
+
if (i < injected) {
|
|
273
|
+
// Injected batch renders as one collapsed cite line, never as bubbles.
|
|
274
|
+
this.messageRefs[i] = this.messages[i];
|
|
275
|
+
this.messageCache[i] = [];
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// Framing-block messages are part of the LLM context but never render
|
|
279
|
+
// as chat bubbles (#9 fallback placement): cache an empty entry.
|
|
280
|
+
if (isFramingMessage(this.messages[i])) {
|
|
281
|
+
this.messageRefs[i] = this.messages[i];
|
|
282
|
+
this.messageCache[i] = [];
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (this.messageRefs[i] !== this.messages[i]) {
|
|
286
|
+
this.messageRefs[i] = this.messages[i];
|
|
287
|
+
this.messageCache[i] = this.renderMessage(this.messages[i], width);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (injected !== this.cachedInject) {
|
|
291
|
+
this.cachedInject = injected;
|
|
292
|
+
if (injected > 0) {
|
|
293
|
+
// One collapsed, non-expandable cite line for the injected batch
|
|
294
|
+
// (fresh fork context, reopened history, refork) instead of the full
|
|
295
|
+
// history. The visible conversation starts after it.
|
|
296
|
+
this.prefixLines = [
|
|
297
|
+
...wrapTextWithAnsi(this.theme.fg("muted", `[Context] ${injected} msg${injected === 1 ? "" : "s"}`), width),
|
|
298
|
+
"",
|
|
299
|
+
];
|
|
300
|
+
} else {
|
|
301
|
+
this.prefixLines = [];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
render(width: number): string[] {
|
|
307
|
+
this.ensureCachedLines(width);
|
|
308
|
+
const lines: string[] = [...this.prefixLines];
|
|
309
|
+
const n = this.messages.length;
|
|
310
|
+
for (let i = 0; i < n; i++) {
|
|
311
|
+
const messageLines = this.messageCache[i];
|
|
312
|
+
if (messageLines.length) {
|
|
313
|
+
lines.push(...messageLines, "");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Dynamic tail (error/streaming status/tool status) is re-wrapped every
|
|
318
|
+
// frame — bounded by the current chunk, orders of magnitude cheaper than
|
|
319
|
+
// the stored history.
|
|
320
|
+
if (this.errorContent) {
|
|
321
|
+
lines.push(...wrapTextWithAnsi(this.theme.fg("error", "[Error]: ") + this.errorContent, width));
|
|
322
|
+
} else if (this.streamingContent) {
|
|
323
|
+
lines.push(...wrapTextWithAnsi(this.theme.fg("text", "[Assistant]: ") + this.streamingContent + "▌", width));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (this.toolStatus) {
|
|
327
|
+
if (lines.length) lines.push("");
|
|
328
|
+
lines.push(...wrapTextWithAnsi(this.theme.fg("muted", `[Tool]: ${this.toolStatus}`), width));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
this.totalLines = lines.length;
|
|
332
|
+
// Freeze: while the user has scrolled away, grow the offset by the lines
|
|
333
|
+
// appended at the end, so the anchored content stays put instead of being
|
|
334
|
+
// pushed up by new streamed output.
|
|
335
|
+
if (this.frozenAnchor !== null) {
|
|
336
|
+
this.scrollOffset = Math.max(0, this.totalLines - this.frozenAnchor);
|
|
337
|
+
}
|
|
338
|
+
const start = Math.max(0, lines.length - this.maxVisibleLines - this.scrollOffset);
|
|
339
|
+
const end = Math.max(0, lines.length - this.scrollOffset);
|
|
340
|
+
const visible = lines.slice(start, end);
|
|
341
|
+
|
|
342
|
+
// Window-coordinate shift: appended status/stream lines (e.g. the "✓
|
|
343
|
+
// Copied" feedback after a copy) grow the slice start while bottom-
|
|
344
|
+
// following, moving the whole viewport down. The selection is stored in
|
|
345
|
+
// window coordinates, so translate it by the same delta to keep the
|
|
346
|
+
// highlight and Ctrl+C copy anchored to the same content. A selection
|
|
347
|
+
// scrolled out of the top of the window is dropped.
|
|
348
|
+
const delta = start - this.lastRenderStart;
|
|
349
|
+
this.lastRenderStart = start;
|
|
350
|
+
if (delta !== 0 && this.selection) {
|
|
351
|
+
const shift = (p: CellPos): CellPos => ({ line: p.line - delta, col: p.col });
|
|
352
|
+
const shifted = { anchor: shift(this.selection.anchor), focus: shift(this.selection.focus) };
|
|
353
|
+
if (shifted.anchor.line < 0 || shifted.focus.line < 0) {
|
|
354
|
+
this.selection = null;
|
|
355
|
+
} else {
|
|
356
|
+
this.selection = shifted;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Hit-testing and copy operate on the ANSI-stripped visible lines.
|
|
361
|
+
this.plainLines = visible.map(stripTerminalSequences);
|
|
362
|
+
if (this.selection && this.selection.focus.line >= visible.length) {
|
|
363
|
+
// Defensive: the rendered line count shrank under the selection.
|
|
364
|
+
this.selection = null;
|
|
365
|
+
}
|
|
366
|
+
if (this.selection) {
|
|
367
|
+
for (let i = 0; i < visible.length; i++) {
|
|
368
|
+
const highlighted = this.renderSelectionLine(i);
|
|
369
|
+
if (highlighted !== null) visible[i] = highlighted;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return visible;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Clamp a cell position to the currently rendered lines. */
|
|
376
|
+
private clampPos(pos: CellPos): CellPos {
|
|
377
|
+
const line = Math.max(0, Math.min(pos.line, Math.max(0, this.plainLines.length - 1)));
|
|
378
|
+
const maxCol = Math.max(0, visibleWidth(this.plainLines[line] ?? ""));
|
|
379
|
+
return { line, col: Math.max(0, Math.min(pos.col, maxCol)) };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Canonical (topmost-first) ordering helpers for a selection range. */
|
|
383
|
+
private orderedStart(a: CellPos, b: CellPos): CellPos {
|
|
384
|
+
return a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private orderedEnd(a: CellPos, b: CellPos): CellPos {
|
|
388
|
+
return a.line > b.line || (a.line === b.line && a.col > b.col) ? a : b;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Re-render line `lineIndex` with the selected cell range in inverse video.
|
|
393
|
+
* Returns null when the line is outside the selection. Selected lines lose
|
|
394
|
+
* their theme colors while highlighted (matching terminal-native selection).
|
|
395
|
+
*/
|
|
396
|
+
private renderSelectionLine(lineIndex: number): string | null {
|
|
397
|
+
if (!this.selection) return null;
|
|
398
|
+
const { anchor, focus } = this.selection;
|
|
399
|
+
const start = this.orderedStart(anchor, focus);
|
|
400
|
+
const end = this.orderedEnd(anchor, focus);
|
|
401
|
+
if (lineIndex < start.line || lineIndex > end.line) return null;
|
|
402
|
+
const plain = this.plainLines[lineIndex] ?? "";
|
|
403
|
+
const s = lineIndex === start.line ? colToCharIndex(plain, start.col) : 0;
|
|
404
|
+
const e = lineIndex === end.line ? colToCharIndex(plain, end.col) : plain.length;
|
|
405
|
+
if (s >= e) return null;
|
|
406
|
+
return plain.slice(0, s) + SELECTION_STYLE + plain.slice(s, e) + SELECTION_STYLE_END + plain.slice(e);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private renderMessage(msg: AgentMessage, width: number): string[] {
|
|
410
|
+
const { theme } = this;
|
|
411
|
+
|
|
412
|
+
if (msg.role === "user") {
|
|
413
|
+
const content = typeof msg.content === "string" ? msg.content : msg.content.map((b) => b.type === "text" ? b.text : "[image]").join("");
|
|
414
|
+
return wrapTextWithAnsi(theme.fg("accent", "[You]: ") + content, width);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (msg.role === "assistant") {
|
|
418
|
+
// Error stops surface the error text even when a partial reply streamed
|
|
419
|
+
// before the failure: the final error is the result to show (issue #8).
|
|
420
|
+
if (msg.stopReason === "error" && msg.errorMessage) {
|
|
421
|
+
return wrapTextWithAnsi(theme.fg("error", "[Error]: ") + String(msg.errorMessage), width);
|
|
422
|
+
}
|
|
423
|
+
const text = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
424
|
+
if (text) return wrapTextWithAnsi(theme.fg("text", "[Assistant]: ") + text, width);
|
|
425
|
+
if ("errorMessage" in msg && msg.errorMessage) {
|
|
426
|
+
return wrapTextWithAnsi(theme.fg("error", "[Error]: ") + String(msg.errorMessage), width);
|
|
427
|
+
}
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (msg.role === "toolResult") {
|
|
432
|
+
const fullText = msg.content[0]?.type === "text" ? msg.content[0].text : "";
|
|
433
|
+
const preview = fullText.slice(0, 100);
|
|
434
|
+
return wrapTextWithAnsi(theme.fg("muted", `[${msg.toolName}]: ${preview}${fullText.length > 100 ? "..." : ""}`), width);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (msg.role === "branchSummary" || msg.role === "compactionSummary") {
|
|
438
|
+
return wrapTextWithAnsi(theme.fg("muted", `[Summary]: ${msg.summary}`), width);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (msg.role === "bashExecution") {
|
|
442
|
+
return wrapTextWithAnsi(theme.fg("muted", `[Bash]: ${msg.command}`), width);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (msg.role === "custom" && msg.display) {
|
|
446
|
+
const content = typeof msg.content === "string" ? msg.content : msg.content.map((b) => b.type === "text" ? b.text : "[image]").join("");
|
|
447
|
+
return wrapTextWithAnsi(theme.fg("muted", "[Context]: ") + content, width);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
handleInput(data: string): boolean {
|
|
454
|
+
// PgUp / PgDn scroll by a full page; Shift+Up / Shift+Down by a few lines.
|
|
455
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
456
|
+
return this.scrollBy(this.maxVisibleLines);
|
|
457
|
+
}
|
|
458
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
459
|
+
return this.scrollBy(-this.maxVisibleLines);
|
|
460
|
+
}
|
|
461
|
+
if (matchesKey(data, Key.shift("up"))) {
|
|
462
|
+
return this.scrollBy(3);
|
|
463
|
+
}
|
|
464
|
+
if (matchesKey(data, Key.shift("down"))) {
|
|
465
|
+
return this.scrollBy(-3);
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
invalidate() {
|
|
471
|
+
// Terminal metrics (e.g. cell-size query) may have changed; drop the
|
|
472
|
+
// width-keyed caches so the next render re-wraps at the current width.
|
|
473
|
+
this.messageCache = [];
|
|
474
|
+
this.messageRefs = [];
|
|
475
|
+
this.prefixLines = [];
|
|
476
|
+
this.cachedWidth = 0;
|
|
477
|
+
this.cachedInject = -1;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Terminal } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal SGR mouse support for the side chat overlay.
|
|
5
|
+
*
|
|
6
|
+
* pi-tui has no built-in mouse handling in regular (non-fullscreen) mode,
|
|
7
|
+
* so the extension enables xterm mouse reporting (button-event tracking +
|
|
8
|
+
* button-event motion tracking + SGR extended coordinates) while the side
|
|
9
|
+
* chat is open and consumes the sequences before they could leak into the
|
|
10
|
+
* editor as garbage input.
|
|
11
|
+
*
|
|
12
|
+
* Wheel events arrive as button 64 (scroll up) / 65 (scroll down), drags as
|
|
13
|
+
* motion events (button | 32), all SGR encoded: ESC [ < B ; Cx ; Cy M (press)
|
|
14
|
+
* / m (release).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface SgrMouseEvent {
|
|
18
|
+
/** SGR button code (0 = left, 1 = middle, 2 = right, 64 = wheel up, 65 = wheel down, ...) */
|
|
19
|
+
button: number;
|
|
20
|
+
/** 1-based column */
|
|
21
|
+
col: number;
|
|
22
|
+
/** 1-based row */
|
|
23
|
+
row: number;
|
|
24
|
+
isRelease: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Button-event tracking + button-event motion tracking + SGR extended coordinates */
|
|
28
|
+
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
29
|
+
const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1002l\x1b[?1006l";
|
|
30
|
+
|
|
31
|
+
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
32
|
+
|
|
33
|
+
export const WHEEL_UP_BUTTON = 64;
|
|
34
|
+
export const WHEEL_DOWN_BUTTON = 65;
|
|
35
|
+
/** SGR motion flag: drag events are reported as (button | 32). */
|
|
36
|
+
const MOTION_FLAG = 32;
|
|
37
|
+
|
|
38
|
+
export function enableMouseReporting(terminal: Terminal): void {
|
|
39
|
+
terminal.write(MOUSE_ENABLE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function disableMouseReporting(terminal: Terminal): void {
|
|
43
|
+
terminal.write(MOUSE_DISABLE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse an SGR mouse sequence (complete, as emitted by StdinBuffer). */
|
|
47
|
+
export function parseSgrMouseEvent(data: string): SgrMouseEvent | null {
|
|
48
|
+
const match = SGR_MOUSE_RE.exec(data);
|
|
49
|
+
if (!match) return null;
|
|
50
|
+
return {
|
|
51
|
+
button: parseInt(match[1], 10),
|
|
52
|
+
col: parseInt(match[2], 10),
|
|
53
|
+
row: parseInt(match[3], 10),
|
|
54
|
+
isRelease: match[4] === "m",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True for wheel press events (button 64/65). Wheel releases (67/68) are ignored. */
|
|
59
|
+
export function isWheelEvent(event: SgrMouseEvent): boolean {
|
|
60
|
+
return !event.isRelease && (event.button === WHEEL_UP_BUTTON || event.button === WHEEL_DOWN_BUTTON);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Scroll direction of a wheel event: 1 = toward older content, -1 = toward latest. */
|
|
64
|
+
export function wheelDirection(event: SgrMouseEvent): 1 | -1 {
|
|
65
|
+
return event.button === WHEEL_UP_BUTTON ? 1 : -1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True for an unmodified left-button press (button 0). */
|
|
69
|
+
export function isLeftPress(event: SgrMouseEvent): boolean {
|
|
70
|
+
return !event.isRelease && (event.button & MOTION_FLAG) === 0 && (event.button & 3) === 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True for a left-button drag motion (button 32 with the left button held). */
|
|
74
|
+
export function isLeftDrag(event: SgrMouseEvent): boolean {
|
|
75
|
+
return !event.isRelease && (event.button & MOTION_FLAG) !== 0 && (event.button & 3) === 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* True for a left-button release. Terminals report the release either with
|
|
80
|
+
* the plain button code (0) or with the release bit set (3); wheel releases
|
|
81
|
+
* (67/68) are excluded.
|
|
82
|
+
*/
|
|
83
|
+
export function isLeftRelease(event: SgrMouseEvent): boolean {
|
|
84
|
+
if (!event.isRelease) return false;
|
|
85
|
+
if ((event.button & 64) !== 0) return false; // wheel release
|
|
86
|
+
const button = event.button & 3;
|
|
87
|
+
return button === 0 || button === 3;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True for an unmodified right-button press (button 2). */
|
|
91
|
+
export function isRightPress(event: SgrMouseEvent): boolean {
|
|
92
|
+
return (
|
|
93
|
+
!event.isRelease &&
|
|
94
|
+
(event.button & MOTION_FLAG) === 0 &&
|
|
95
|
+
(event.button & 3) === 2
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* True for a right-button release. SGR reports releases with the plain
|
|
101
|
+
* button code (2) and the 'm' suffix; modifier bits (shift/meta/ctrl) are
|
|
102
|
+
* masked out, wheel releases (67/68) are excluded.
|
|
103
|
+
*/
|
|
104
|
+
export function isRightRelease(event: SgrMouseEvent): boolean {
|
|
105
|
+
if (!event.isRelease) return false;
|
|
106
|
+
if ((event.button & 64) !== 0) return false; // wheel release
|
|
107
|
+
return (event.button & 3) === 2;
|
|
108
|
+
}
|