@xingjisoft/neutrino 0.1.4 → 0.1.6
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/README.md +413 -163
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +442 -30
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +19 -0
- package/dist/config.js +121 -28
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +14 -0
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +709 -49
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +131 -14
- package/dist/screens/start.js +50 -16
- package/dist/selection.js +60 -0
- package/dist/state.js +1 -0
- package/dist/terminal/input.js +34 -5
- package/dist/terminal/renderer.js +58 -12
- package/dist/terminal/restore.js +148 -0
- package/dist/ui.js +16 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +210 -0
- package/web/app.js +1100 -0
- package/web/index.html +90 -0
- package/web/settings.js +193 -0
- package/web/style.css +1088 -0
- package/web/turns.js +52 -0
package/dist/app.js
CHANGED
|
@@ -2,10 +2,11 @@ import process from "node:process";
|
|
|
2
2
|
import { Buffer } from "./terminal/buffer.js";
|
|
3
3
|
import { InputReader } from "./terminal/input.js";
|
|
4
4
|
import { Renderer } from "./terminal/renderer.js";
|
|
5
|
+
import { installCrashGuard, writeCrashLog } from "./terminal/restore.js";
|
|
5
6
|
import { defaultState } from "./state.js";
|
|
6
7
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
8
|
import { C } from "./colors.js";
|
|
8
|
-
import { applySelection, selectionText } from "./selection.js";
|
|
9
|
+
import { applySelection, orderLinePoints, rowCells, rowSelectionText, selectionText, } from "./selection.js";
|
|
9
10
|
import { offsetFromThumbTop, scrollbarGeometry } from "./ui.js";
|
|
10
11
|
import { copyToClipboard } from "./clipboard.js";
|
|
11
12
|
import { ChatScreen } from "./screens/chat.js";
|
|
@@ -14,12 +15,22 @@ import { AgentSelectScreen } from "./screens/agentSelect.js";
|
|
|
14
15
|
import { ModelSelectScreen } from "./screens/modelSelect.js";
|
|
15
16
|
import { SettingsScreen } from "./screens/settings.js";
|
|
16
17
|
import { StartScreen } from "./screens/start.js";
|
|
18
|
+
import { startWebServer } from "./web/server.js";
|
|
17
19
|
const CURSOR_BLINK_MS = 530;
|
|
18
20
|
const TOAST_MS = 1600;
|
|
21
|
+
const INTERRUPT_WINDOW_MS = 1500;
|
|
22
|
+
const SELECTION_FRAME_MS = 16;
|
|
23
|
+
const EXIT_HINT = "press ctrl+shift+c or press ctrl+c 3 times to exit";
|
|
19
24
|
// How often the whole frame is re-emitted as a safety net, so terminal-side
|
|
20
25
|
// drift (ConPTY reflow, stray scroll) can never leave any row stale for long.
|
|
21
26
|
// Re-emitting the cached frame is a no-op when nothing drifted.
|
|
22
27
|
const REPAINT_MS = 2000;
|
|
28
|
+
// Auto-scrolling while drag-selecting: parked on the first/last row of the log,
|
|
29
|
+
// the pointer scrolls it one line per tick, a few lines per tick once it has
|
|
30
|
+
// been held for a while.
|
|
31
|
+
const DRAG_SCROLL_MS = 60;
|
|
32
|
+
const DRAG_SCROLL_RAMP = 8;
|
|
33
|
+
const DRAG_SCROLL_MAX_ROWS = 3;
|
|
23
34
|
export class App {
|
|
24
35
|
state = defaultState();
|
|
25
36
|
width;
|
|
@@ -34,9 +45,18 @@ export class App {
|
|
|
34
45
|
toastTimer = null;
|
|
35
46
|
repaintTimer = null;
|
|
36
47
|
toast = "";
|
|
48
|
+
toastPosition = "bottom";
|
|
49
|
+
interruptCount = 0;
|
|
50
|
+
lastInterruptTime = 0;
|
|
51
|
+
// Consecutive failed renders. A screen that cannot draw itself is fatal;
|
|
52
|
+
// a single bad frame is not.
|
|
53
|
+
renderFailures = 0;
|
|
37
54
|
// Most recently rendered frame. Kept so a selection can be turned back into
|
|
38
55
|
// text without having to re-run the screen's render.
|
|
39
56
|
lastBuffer = null;
|
|
57
|
+
// Cache the screen without selection so dragging never reflows the history.
|
|
58
|
+
contentBuffer = null;
|
|
59
|
+
selectionTimer = null;
|
|
40
60
|
// Mouse selection bookkeeping. `pressed` is true between a left-button press
|
|
41
61
|
// and its release; `pressStart` is where the press landed, used as the anchor
|
|
42
62
|
// once the pointer starts moving (i.e. once it becomes a drag). A plain click
|
|
@@ -46,7 +66,30 @@ export class App {
|
|
|
46
66
|
// Active scrollbar drag: how many rows below the thumb's top the press
|
|
47
67
|
// landed, so the thumb stays under the pointer as it moves.
|
|
48
68
|
barDrag = null;
|
|
69
|
+
// Auto-scroll during a drag-select. `dragScrollDir` is where the pointer is
|
|
70
|
+
// parked (-1 top edge, 1 bottom edge, 0 neither) and `dragScrollTicks` counts
|
|
71
|
+
// how long it has been there, which speeds the scroll up.
|
|
72
|
+
dragScrollTimer = null;
|
|
73
|
+
dragScrollDir = 0;
|
|
74
|
+
dragScrollTicks = 0;
|
|
75
|
+
// Latest pointer position, so a scroll tick can keep the mark pinned to the
|
|
76
|
+
// edge the pointer is parked on.
|
|
77
|
+
pointer = { x: 0, y: 0 };
|
|
78
|
+
// True once a drag has scrolled the log. The anchor is then in *content*
|
|
79
|
+
// coordinates: it points at the line the drag started on, which may well be
|
|
80
|
+
// off screen by now.
|
|
81
|
+
dragScrolled = false;
|
|
82
|
+
// True when the drag started inside the scrollable log, i.e. when its anchor
|
|
83
|
+
// is log content that is allowed to scroll away.
|
|
84
|
+
anchorInLog = false;
|
|
85
|
+
// Rows covered by the current drag, keyed by content line, so text that
|
|
86
|
+
// scrolled out of the viewport still ends up in the copy.
|
|
87
|
+
selRows = new Map();
|
|
88
|
+
// Scroll offset the anchor was last reconciled with, so a log that moves on
|
|
89
|
+
// its own (streamed output, a resize) can still carry the anchor along.
|
|
90
|
+
selOffset = null;
|
|
49
91
|
resizeHandler = () => this.onResize();
|
|
92
|
+
webHandle = null;
|
|
50
93
|
constructor() {
|
|
51
94
|
this.width = process.stdout.columns || 80;
|
|
52
95
|
this.height = process.stdout.rows || 24;
|
|
@@ -76,11 +119,25 @@ export class App {
|
|
|
76
119
|
this.state.selectedModelApiKey = String(config.selected_model_api_key ?? this.state.selectedModelApiKey);
|
|
77
120
|
this.state.selectedModelStreaming = Boolean(config.selected_model_streaming ?? true);
|
|
78
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Mount the browser UI on the given port. Safe to call once; subsequent calls
|
|
124
|
+
* return the already-running server.
|
|
125
|
+
*/
|
|
126
|
+
async enableWeb(port, host, auth = true) {
|
|
127
|
+
if (this.webHandle)
|
|
128
|
+
return this.webHandle;
|
|
129
|
+
this.webHandle = await startWebServer({ port, host, auth });
|
|
130
|
+
return this.webHandle;
|
|
131
|
+
}
|
|
79
132
|
async start() {
|
|
133
|
+
// Installed before anything touches the terminal: from here on, a crash
|
|
134
|
+
// restores mouse tracking, raw mode and the alternate screen instead of
|
|
135
|
+
// handing the shell a terminal that types mouse reports.
|
|
136
|
+
installCrashGuard();
|
|
80
137
|
this.renderer.init();
|
|
81
138
|
this.input.start((ev) => this.handleEvent(ev));
|
|
82
139
|
process.stdout.on("resize", this.resizeHandler);
|
|
83
|
-
process.on("SIGINT", () => this.
|
|
140
|
+
process.on("SIGINT", () => this.handleInterrupt());
|
|
84
141
|
process.on("SIGTERM", () => this.quit());
|
|
85
142
|
this.sizeTimer = setInterval(() => {
|
|
86
143
|
if (this.syncSize())
|
|
@@ -111,7 +168,40 @@ export class App {
|
|
|
111
168
|
screen?.onDeactivate();
|
|
112
169
|
this.render();
|
|
113
170
|
}
|
|
114
|
-
render() {
|
|
171
|
+
render(selectionOnly = false) {
|
|
172
|
+
if (this.selectionTimer) {
|
|
173
|
+
clearTimeout(this.selectionTimer);
|
|
174
|
+
this.selectionTimer = null;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
if (selectionOnly && this.contentBuffer
|
|
178
|
+
&& this.contentBuffer.width === this.width && this.contentBuffer.height === this.height) {
|
|
179
|
+
this.renderBuffer(this.contentBuffer);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
this.renderFrame();
|
|
183
|
+
}
|
|
184
|
+
this.renderFailures = 0;
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
// Rendering runs from timers and stream callbacks too, so an escaping
|
|
188
|
+
// throw here is equally fatal.
|
|
189
|
+
this.renderFailures++;
|
|
190
|
+
if (this.renderFailures === 1)
|
|
191
|
+
this.noteInternalError("render", err);
|
|
192
|
+
// A screen that cannot be drawn at all means there is no UI left; stop
|
|
193
|
+
// instead of spinning on a broken frame every 60 ms.
|
|
194
|
+
if (this.renderFailures > 30)
|
|
195
|
+
this.quit(1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
noteInternalError(kind, err) {
|
|
199
|
+
const file = writeCrashLog(kind, err);
|
|
200
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
201
|
+
const where = file ? ` - details in ${file}` : "";
|
|
202
|
+
this.showToast(`internal error (${kind}): ${detail}${where}`);
|
|
203
|
+
}
|
|
204
|
+
renderFrame() {
|
|
115
205
|
// Start/stop the "thinking" blink animation. While the agent is thinking,
|
|
116
206
|
// a timer toggles the blink phase and re-renders so the top status bar
|
|
117
207
|
// flashes to draw the user's attention.
|
|
@@ -132,19 +222,240 @@ export class App {
|
|
|
132
222
|
this.updateCursorBlink();
|
|
133
223
|
const buf = new Buffer(this.width, this.height);
|
|
134
224
|
this.top?.render(buf);
|
|
135
|
-
this.
|
|
225
|
+
this.contentBuffer = buf;
|
|
226
|
+
this.renderBuffer(buf, this.top?.alwaysRepaintRows() ?? []);
|
|
227
|
+
}
|
|
228
|
+
renderBuffer(content, forceRows = []) {
|
|
229
|
+
// Snapshot the rows a drag has covered *before* painting: once a scroll
|
|
230
|
+
// pushes them off screen this is the only copy of their text left.
|
|
231
|
+
this.captureSelectionRows(content);
|
|
136
232
|
// The selection overlay is painted over the finished frame, so it works on
|
|
137
233
|
// every screen (chat log, input box, menus, …) without each having to know
|
|
138
234
|
// about selection at all.
|
|
139
|
-
|
|
140
|
-
|
|
235
|
+
let buf = content;
|
|
236
|
+
if (this.state.selection) {
|
|
237
|
+
buf = new Buffer(content.width, content.height);
|
|
238
|
+
buf.cells = content.cells.map((row) => row.map((cell) => ({ ...cell })));
|
|
239
|
+
applySelection(buf, this.displaySelection(this.state.selection, content.width));
|
|
240
|
+
}
|
|
141
241
|
this.drawToast(buf);
|
|
142
|
-
this.
|
|
242
|
+
this.lastBuffer = buf;
|
|
243
|
+
this.renderer.render(buf, forceRows);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The selection as it should be *painted*.
|
|
247
|
+
*
|
|
248
|
+
* An end that scrolled out of the log is drawn from the log's own edge: the
|
|
249
|
+
* rows it points at are off screen, and highlighting the header or the input
|
|
250
|
+
* box instead would claim those were selected.
|
|
251
|
+
*/
|
|
252
|
+
displaySelection(sel, width) {
|
|
253
|
+
const bar = this.top?.scrollbar();
|
|
254
|
+
if (!bar || !this.anchorInLog)
|
|
255
|
+
return sel;
|
|
256
|
+
if (sel.anchor.y < bar.top)
|
|
257
|
+
return { anchor: { x: 0, y: bar.top }, cursor: sel.cursor };
|
|
258
|
+
if (sel.anchor.y > bar.bottom) {
|
|
259
|
+
return { anchor: { x: width - 1, y: bar.bottom }, cursor: sel.cursor };
|
|
260
|
+
}
|
|
261
|
+
return sel;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Remember the log rows the current drag covers.
|
|
265
|
+
*
|
|
266
|
+
* Runs on every frame, because a row can only be captured while it is still
|
|
267
|
+
* on screen — after the scroll carries it away, its text is gone.
|
|
268
|
+
*/
|
|
269
|
+
captureSelectionRows(content) {
|
|
270
|
+
const sel = this.state.selection;
|
|
271
|
+
if (!sel || !this.pressed)
|
|
272
|
+
return;
|
|
273
|
+
const bar = this.top?.scrollbar();
|
|
274
|
+
if (!bar)
|
|
275
|
+
return;
|
|
276
|
+
// Anything else that moved the log — a streamed reply following the bottom,
|
|
277
|
+
// a resize — has to carry the anchor with it. Without this the anchor keeps
|
|
278
|
+
// its screen row while different text scrolls under it, and the copy
|
|
279
|
+
// silently covers the wrong lines.
|
|
280
|
+
if (this.selOffset === null) {
|
|
281
|
+
this.selOffset = bar.offset;
|
|
282
|
+
}
|
|
283
|
+
else if (bar.offset !== this.selOffset) {
|
|
284
|
+
const drift = bar.offset - this.selOffset;
|
|
285
|
+
this.selOffset = bar.offset;
|
|
286
|
+
if (this.anchorInLog) {
|
|
287
|
+
this.state.selection = {
|
|
288
|
+
anchor: { x: sel.anchor.x, y: sel.anchor.y - drift },
|
|
289
|
+
cursor: sel.cursor,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const ends = this.lineSelection(bar);
|
|
294
|
+
if (!ends)
|
|
295
|
+
return;
|
|
296
|
+
for (let y = bar.top; y <= bar.bottom && y < content.height; y++) {
|
|
297
|
+
const line = bar.offset + (y - bar.top);
|
|
298
|
+
if (line < ends.start.line || line > ends.end.line)
|
|
299
|
+
continue;
|
|
300
|
+
this.selRows.set(line, rowCells(content, y));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The current selection expressed in content lines instead of screen rows.
|
|
305
|
+
*
|
|
306
|
+
* Rows under the pointer map straight through the log's scroll offset, and
|
|
307
|
+
* rows outside the log get keys of their own (negative above it, past `total`
|
|
308
|
+
* below it) so a drag that starts on the header or runs into the input box
|
|
309
|
+
* still copies what is actually drawn there. The anchor is the exception: once
|
|
310
|
+
* it belongs to the log it follows its text out of the viewport, which is what
|
|
311
|
+
* lets a selection outlive the frame it started in.
|
|
312
|
+
*/
|
|
313
|
+
lineSelection(bar) {
|
|
314
|
+
const sel = this.state.selection;
|
|
315
|
+
if (!sel)
|
|
316
|
+
return null;
|
|
317
|
+
const screenLine = (y) => {
|
|
318
|
+
if (y < bar.top)
|
|
319
|
+
return y - bar.top;
|
|
320
|
+
if (y > bar.bottom)
|
|
321
|
+
return bar.total + (y - bar.bottom) - 1;
|
|
322
|
+
return bar.offset + (y - bar.top);
|
|
323
|
+
};
|
|
324
|
+
const anchorLine = this.anchorInLog
|
|
325
|
+
? bar.offset + (sel.anchor.y - bar.top)
|
|
326
|
+
: screenLine(sel.anchor.y);
|
|
327
|
+
return orderLinePoints({ line: anchorLine, x: sel.anchor.x }, { line: screenLine(sel.cursor.y), x: sel.cursor.x });
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Text covered by the current selection, including the rows a drag scrolled
|
|
331
|
+
* out of the viewport.
|
|
332
|
+
*/
|
|
333
|
+
selectionToText() {
|
|
334
|
+
const buf = this.lastBuffer;
|
|
335
|
+
const sel = this.state.selection;
|
|
336
|
+
if (!buf || !sel)
|
|
337
|
+
return "";
|
|
338
|
+
const bar = this.top?.scrollbar();
|
|
339
|
+
const ends = bar ? this.lineSelection(bar) : null;
|
|
340
|
+
// Nothing can scroll, so the frame holds the whole selection.
|
|
341
|
+
if (!bar || !ends)
|
|
342
|
+
return selectionText(buf, sel);
|
|
343
|
+
const getRow = (line) => {
|
|
344
|
+
// Rows above/below the log are drawn at a fixed screen row.
|
|
345
|
+
if (line < 0) {
|
|
346
|
+
const y = line + bar.top;
|
|
347
|
+
return y >= 0 && y < buf.height ? rowCells(buf, y) : null;
|
|
348
|
+
}
|
|
349
|
+
if (line >= bar.total) {
|
|
350
|
+
const y = bar.bottom + (line - bar.total) + 1;
|
|
351
|
+
return y >= 0 && y < buf.height ? rowCells(buf, y) : null;
|
|
352
|
+
}
|
|
353
|
+
// A log line: read the frame while it is on screen and the drag's snapshot
|
|
354
|
+
// once it has scrolled away.
|
|
355
|
+
const y = bar.top + (line - bar.offset);
|
|
356
|
+
if (y >= bar.top && y <= bar.bottom && y < buf.height)
|
|
357
|
+
return rowCells(buf, y);
|
|
358
|
+
return this.selRows.get(line) ?? null;
|
|
359
|
+
};
|
|
360
|
+
return rowSelectionText(getRow, ends.start, ends.end);
|
|
361
|
+
}
|
|
362
|
+
/** Where the mark sits, clamped into the log while a drag is scrolling it. */
|
|
363
|
+
cursorAt(x, y) {
|
|
364
|
+
const bar = this.top?.scrollbar();
|
|
365
|
+
if (!bar || !this.dragScrolled)
|
|
366
|
+
return { x, y };
|
|
367
|
+
return { x, y: Math.max(bar.top, Math.min(y, bar.bottom)) };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Start or stop the auto-scroll that runs while a drag sits on an edge of the
|
|
371
|
+
* log. Only a real drag scrolls: a press that never moved selects nothing, so
|
|
372
|
+
* clicking on the last row stays a plain click.
|
|
373
|
+
*/
|
|
374
|
+
updateDragScroll() {
|
|
375
|
+
const bar = this.top?.scrollbar();
|
|
376
|
+
const dir = !bar || !this.state.selection
|
|
377
|
+
? 0
|
|
378
|
+
: this.pointer.y <= bar.top
|
|
379
|
+
? -1
|
|
380
|
+
: this.pointer.y >= bar.bottom
|
|
381
|
+
? 1
|
|
382
|
+
: 0;
|
|
383
|
+
if (dir === this.dragScrollDir)
|
|
384
|
+
return;
|
|
385
|
+
this.stopDragScroll();
|
|
386
|
+
if (dir === 0)
|
|
387
|
+
return;
|
|
388
|
+
this.dragScrollDir = dir;
|
|
389
|
+
this.dragScrollTicks = 0;
|
|
390
|
+
// Reveal the first line at once: waiting a whole tick for it would make the
|
|
391
|
+
// edge feel dead. The tick schedules the ones that follow.
|
|
392
|
+
this.dragScrollTick();
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* One auto-scroll step: move the log a line (a few lines, the longer the
|
|
396
|
+
* pointer is held there) and grow the selection with it.
|
|
397
|
+
*/
|
|
398
|
+
dragScrollTick() {
|
|
399
|
+
const sel = this.state.selection;
|
|
400
|
+
const bar = this.top?.scrollbar();
|
|
401
|
+
if (!this.pressed || !sel || !bar || this.dragScrollDir === 0) {
|
|
402
|
+
this.stopDragScroll();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const maxOffset = Math.max(0, bar.total - bar.viewport);
|
|
406
|
+
const rows = Math.min(DRAG_SCROLL_MAX_ROWS, 1 + Math.floor(this.dragScrollTicks / DRAG_SCROLL_RAMP));
|
|
407
|
+
const target = Math.max(0, Math.min(bar.offset + this.dragScrollDir * rows, maxOffset));
|
|
408
|
+
const delta = target - bar.offset;
|
|
409
|
+
if (delta === 0) {
|
|
410
|
+
// The end of the log: nothing left to reveal in that direction.
|
|
411
|
+
this.stopDragScroll();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.dragScrollTicks++;
|
|
415
|
+
this.dragScrolled = true;
|
|
416
|
+
// The content moved by `delta` rows, so the anchor moves with it to stay on
|
|
417
|
+
// the line the drag started from. The mark stays under the pointer — that
|
|
418
|
+
// difference is what makes the selection grow instead of sliding along.
|
|
419
|
+
const anchor = this.anchorInLog ? { x: sel.anchor.x, y: sel.anchor.y - delta } : sel.anchor;
|
|
420
|
+
this.state.selection = { anchor, cursor: this.cursorAt(this.pointer.x, this.pointer.y) };
|
|
421
|
+
// This scroll is already accounted for; the frame that follows must not
|
|
422
|
+
// treat it as drift and move the anchor a second time.
|
|
423
|
+
this.selOffset = target;
|
|
424
|
+
// scrollTo re-renders, so the new offset, the highlight and the snapshot of
|
|
425
|
+
// the rows leaving the screen all land in a single paint.
|
|
426
|
+
bar.scrollTo(target);
|
|
427
|
+
// The next step is scheduled only now that this frame is drawn: laying out a
|
|
428
|
+
// very long log can take longer than one tick, and a fixed interval would
|
|
429
|
+
// queue ticks faster than they can be served.
|
|
430
|
+
if (this.dragScrollTimer)
|
|
431
|
+
clearTimeout(this.dragScrollTimer);
|
|
432
|
+
this.dragScrollTimer = setTimeout(() => this.dragScrollTick(), DRAG_SCROLL_MS);
|
|
433
|
+
}
|
|
434
|
+
stopDragScroll() {
|
|
435
|
+
if (this.dragScrollTimer) {
|
|
436
|
+
clearTimeout(this.dragScrollTimer);
|
|
437
|
+
this.dragScrollTimer = null;
|
|
438
|
+
}
|
|
439
|
+
this.dragScrollDir = 0;
|
|
440
|
+
this.dragScrollTicks = 0;
|
|
441
|
+
}
|
|
442
|
+
/** Drop everything remembered about a drag; a new gesture starts clean. */
|
|
443
|
+
resetDragTracking() {
|
|
444
|
+
this.stopDragScroll();
|
|
445
|
+
this.dragScrolled = false;
|
|
446
|
+
this.anchorInLog = false;
|
|
447
|
+
this.selOffset = null;
|
|
448
|
+
this.selRows.clear();
|
|
449
|
+
}
|
|
450
|
+
scheduleSelectionRender() {
|
|
451
|
+
if (!this.selectionTimer) {
|
|
452
|
+
this.selectionTimer = setTimeout(() => this.render(true), SELECTION_FRAME_MS);
|
|
453
|
+
}
|
|
143
454
|
}
|
|
144
455
|
drawToast(buf) {
|
|
145
456
|
if (!this.toast)
|
|
146
457
|
return;
|
|
147
|
-
const y = buf.height - 1;
|
|
458
|
+
const y = this.toastPosition === "top" ? 0 : buf.height - 1;
|
|
148
459
|
buf.fillRect(0, y, buf.width, 1, { bg: C.black });
|
|
149
460
|
buf.centerText(y, ` ${this.toast} `, { fg: C.black, bg: C.accent, bold: true });
|
|
150
461
|
}
|
|
@@ -167,6 +478,20 @@ export class App {
|
|
|
167
478
|
this.state.cursorOn = true;
|
|
168
479
|
}
|
|
169
480
|
handleEvent(ev) {
|
|
481
|
+
try {
|
|
482
|
+
this.dispatchEvent(ev);
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
// This runs inside a stdin 'data' callback, where a throw is an uncaught
|
|
486
|
+
// exception that would kill the app mid-keystroke.
|
|
487
|
+
this.noteInternalError("input handler", err);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
dispatchEvent(ev) {
|
|
491
|
+
const interrupt = ev.type === "key" && ev.key.ctrl && ev.key.name === "c";
|
|
492
|
+
if (!interrupt && (ev.type !== "mouse" || ev.event.type === "click" || ev.event.type === "wheel")) {
|
|
493
|
+
this.interruptCount = 0;
|
|
494
|
+
}
|
|
170
495
|
if (ev.type === "paste") {
|
|
171
496
|
// Pasted text arrives with its newlines already flattened to spaces, so
|
|
172
497
|
// it is inserted into the input instead of being submitted.
|
|
@@ -177,6 +502,11 @@ export class App {
|
|
|
177
502
|
}
|
|
178
503
|
if (ev.type === "key") {
|
|
179
504
|
const k = ev.key;
|
|
505
|
+
// Leave an active drag/selection untouched by the terminal copy shortcut.
|
|
506
|
+
if (k.ctrl && k.name === "c" && !k.shift && this.state.selection) {
|
|
507
|
+
this.interruptCount = 0;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
180
510
|
// Any keystroke ends a pending mouse gesture (in case a release was lost).
|
|
181
511
|
this.resetMouse();
|
|
182
512
|
if (k.ctrl && k.name === "q") {
|
|
@@ -184,13 +514,10 @@ export class App {
|
|
|
184
514
|
return;
|
|
185
515
|
}
|
|
186
516
|
if (k.ctrl && k.name === "c") {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
this.
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
this.quit();
|
|
517
|
+
if (k.shift)
|
|
518
|
+
this.quit();
|
|
519
|
+
else
|
|
520
|
+
this.handleInterrupt();
|
|
194
521
|
return;
|
|
195
522
|
}
|
|
196
523
|
if (k.name === "escape" && this.state.selection) {
|
|
@@ -212,6 +539,9 @@ export class App {
|
|
|
212
539
|
handleMouse(ev) {
|
|
213
540
|
if (ev.type === "click") {
|
|
214
541
|
this.wakeCursor();
|
|
542
|
+
// A new gesture starts clean: no auto-scroll left over from the previous
|
|
543
|
+
// drag, and no rows remembered from it.
|
|
544
|
+
this.resetDragTracking();
|
|
215
545
|
// A left-press on the scrollbar starts a thumb drag rather than a text
|
|
216
546
|
// selection or a click on the screen behind it.
|
|
217
547
|
if (ev.button === 0) {
|
|
@@ -236,6 +566,13 @@ export class App {
|
|
|
236
566
|
}
|
|
237
567
|
this.pressed = true;
|
|
238
568
|
this.pressStart = { x: ev.x, y: ev.y };
|
|
569
|
+
this.pointer = { x: ev.x, y: ev.y };
|
|
570
|
+
// A press inside the log starts a selection whose top is log content, so
|
|
571
|
+
// it may scroll away with the text (see dragScrollTick). Its offset is the
|
|
572
|
+
// baseline the anchor is reconciled against on every later frame.
|
|
573
|
+
const bar = this.top?.scrollbar();
|
|
574
|
+
this.anchorInLog = !!bar && ev.y >= bar.top && ev.y <= bar.bottom;
|
|
575
|
+
this.selOffset = bar ? bar.offset : null;
|
|
239
576
|
if (this.state.selection) {
|
|
240
577
|
this.state.selection = null;
|
|
241
578
|
this.render();
|
|
@@ -246,6 +583,7 @@ export class App {
|
|
|
246
583
|
return;
|
|
247
584
|
}
|
|
248
585
|
if (ev.type === "move") {
|
|
586
|
+
this.pointer = { x: ev.x, y: ev.y };
|
|
249
587
|
// A release can be lost (the pointer left the window, the terminal did
|
|
250
588
|
// not report button-up, …). Motion with no button held means the gesture
|
|
251
589
|
// is over, so drop any stale press/drag instead of letting a bare hover
|
|
@@ -254,6 +592,7 @@ export class App {
|
|
|
254
592
|
this.pressed = false;
|
|
255
593
|
this.pressStart = null;
|
|
256
594
|
this.barDrag = null;
|
|
595
|
+
this.resetDragTracking();
|
|
257
596
|
}
|
|
258
597
|
if (this.barDrag) {
|
|
259
598
|
const bar = this.top?.scrollbar();
|
|
@@ -266,28 +605,50 @@ export class App {
|
|
|
266
605
|
}
|
|
267
606
|
if (this.pressed && this.pressStart) {
|
|
268
607
|
const s = this.pressStart;
|
|
269
|
-
|
|
608
|
+
const sel = this.state.selection;
|
|
609
|
+
// A drag that already scrolled the log keeps the anchor it has: it has
|
|
610
|
+
// moved into content coordinates and no longer equals the press cell.
|
|
611
|
+
const anchor = this.dragScrolled && sel ? sel.anchor : s;
|
|
612
|
+
const cursor = this.cursorAt(ev.x, ev.y);
|
|
613
|
+
if ((cursor.x !== anchor.x || cursor.y !== anchor.y || sel)
|
|
614
|
+
&& (!sel || cursor.x !== sel.cursor.x || cursor.y !== sel.cursor.y)) {
|
|
270
615
|
// The pointer left the press cell, so this is a drag: grow/shrink the
|
|
271
616
|
// selection instead of forwarding a hover to the screen.
|
|
272
|
-
this.state.selection = { anchor
|
|
273
|
-
this.
|
|
617
|
+
this.state.selection = { anchor, cursor };
|
|
618
|
+
this.scheduleSelectionRender();
|
|
274
619
|
}
|
|
620
|
+
// Sitting on the first/last row of the log scrolls it, so a selection
|
|
621
|
+
// can run past the visible screen.
|
|
622
|
+
this.updateDragScroll();
|
|
275
623
|
return;
|
|
276
624
|
}
|
|
277
625
|
this.top?.onMouse(ev);
|
|
278
626
|
return;
|
|
279
627
|
}
|
|
280
628
|
if (ev.type === "release") {
|
|
629
|
+
this.pointer = { x: ev.x, y: ev.y };
|
|
630
|
+
this.stopDragScroll();
|
|
631
|
+
// A release can arrive before the queued frame (or the final move).
|
|
632
|
+
if (this.pressed && this.pressStart && this.state.selection) {
|
|
633
|
+
// The anchor goes back to the press cell — unless the drag scrolled the
|
|
634
|
+
// log, in which case it belongs to the line it was dropped on and has
|
|
635
|
+
// drifted away from that cell on purpose.
|
|
636
|
+
const anchor = this.dragScrolled ? this.state.selection.anchor : this.pressStart;
|
|
637
|
+
this.state.selection = { anchor, cursor: this.cursorAt(ev.x, ev.y) };
|
|
638
|
+
}
|
|
639
|
+
if (this.selectionTimer)
|
|
640
|
+
this.render(true);
|
|
281
641
|
const sel = this.state.selection;
|
|
282
642
|
this.pressed = false;
|
|
283
643
|
this.pressStart = null;
|
|
284
644
|
this.barDrag = null;
|
|
285
645
|
if (sel) {
|
|
286
646
|
if (this.lastBuffer) {
|
|
287
|
-
const text =
|
|
647
|
+
const text = this.selectionToText();
|
|
288
648
|
if (text.trim().length > 0) {
|
|
289
649
|
copyToClipboard(text);
|
|
290
650
|
this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
|
|
651
|
+
this.selRows.clear();
|
|
291
652
|
return;
|
|
292
653
|
}
|
|
293
654
|
}
|
|
@@ -296,6 +657,7 @@ export class App {
|
|
|
296
657
|
return;
|
|
297
658
|
}
|
|
298
659
|
if (ev.type === "wheel") {
|
|
660
|
+
this.resetDragTracking();
|
|
299
661
|
if (this.state.selection)
|
|
300
662
|
this.state.selection = null;
|
|
301
663
|
this.top?.onMouse(ev);
|
|
@@ -305,19 +667,51 @@ export class App {
|
|
|
305
667
|
this.pressed = false;
|
|
306
668
|
this.pressStart = null;
|
|
307
669
|
this.barDrag = null;
|
|
670
|
+
this.resetDragTracking();
|
|
308
671
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
672
|
+
handleInterrupt() {
|
|
673
|
+
if (this.state.selection) {
|
|
674
|
+
this.interruptCount = 0;
|
|
312
675
|
return;
|
|
313
|
-
|
|
314
|
-
|
|
676
|
+
}
|
|
677
|
+
const now = Date.now();
|
|
678
|
+
this.interruptCount = now - this.lastInterruptTime <= INTERRUPT_WINDOW_MS
|
|
679
|
+
? this.interruptCount + 1 : 1;
|
|
680
|
+
this.lastInterruptTime = now;
|
|
681
|
+
if (this.interruptCount >= 3) {
|
|
682
|
+
this.quit();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
// A model/settings overlay can sit above the current conversation.
|
|
686
|
+
const chat = [...this.screens].reverse().find((screen) => screen instanceof ChatScreen);
|
|
687
|
+
const meta = chat instanceof ChatScreen ? chat.liveMeta() : null;
|
|
688
|
+
if (chat instanceof ChatScreen && meta?.busy && !meta.paused) {
|
|
689
|
+
chat.livePause();
|
|
315
690
|
return;
|
|
316
|
-
|
|
317
|
-
this.showToast(
|
|
691
|
+
}
|
|
692
|
+
this.showToast(EXIT_HINT, "top");
|
|
318
693
|
}
|
|
319
|
-
|
|
694
|
+
// Public so screens can surface transient status (e.g. "reviewing 3/3").
|
|
695
|
+
/**
|
|
696
|
+
* Audible cue for something the user is waiting on.
|
|
697
|
+
*
|
|
698
|
+
* Silent when `sound_alerts` is off, so the bell stays a preference rather
|
|
699
|
+
* than unavoidable noise. A pending approval rings twice: it is the one event
|
|
700
|
+
* that actually blocks the agent, and a single blip is easy to miss.
|
|
701
|
+
*/
|
|
702
|
+
bell(kind = "turn") {
|
|
703
|
+
try {
|
|
704
|
+
if (loadConfig().sound_alerts === false)
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
// A config we cannot read must not swallow the cue.
|
|
709
|
+
}
|
|
710
|
+
this.renderer.bell(kind === "approval" ? 2 : 1);
|
|
711
|
+
}
|
|
712
|
+
showToast(text, position = "bottom") {
|
|
320
713
|
this.toast = text;
|
|
714
|
+
this.toastPosition = position;
|
|
321
715
|
if (this.toastTimer)
|
|
322
716
|
clearTimeout(this.toastTimer);
|
|
323
717
|
this.toastTimer = setTimeout(() => {
|
|
@@ -344,16 +738,20 @@ export class App {
|
|
|
344
738
|
saveConfig(config);
|
|
345
739
|
}
|
|
346
740
|
startNewChat(text) {
|
|
741
|
+
this.restoreSelection();
|
|
347
742
|
this.pushScreen(new ChatScreen(text));
|
|
348
743
|
}
|
|
349
744
|
loadSession(data) {
|
|
350
745
|
const screen = new ChatScreen("");
|
|
351
746
|
screen.sessionId = data.id;
|
|
352
747
|
screen.loadedMessages = data.messages ?? [];
|
|
748
|
+
screen.loadedCwd = data.cwd ?? null;
|
|
353
749
|
screen.loadedProvider = data.provider || null;
|
|
354
750
|
screen.loadedModel = data.model || null;
|
|
355
751
|
screen.loadedIntensity = data.intensity || null;
|
|
356
752
|
screen.loadedTokens = data.tokens ?? null;
|
|
753
|
+
screen.loadedContext = data.context ?? null;
|
|
754
|
+
screen.loadedApprovalMode = data.approval_mode ?? null;
|
|
357
755
|
if (data.provider)
|
|
358
756
|
this.state.selectedProvider = data.provider;
|
|
359
757
|
if (data.model)
|
|
@@ -382,7 +780,7 @@ export class App {
|
|
|
382
780
|
return;
|
|
383
781
|
this.pushScreen(new SettingsScreen());
|
|
384
782
|
}
|
|
385
|
-
quit() {
|
|
783
|
+
quit(exitCode = 0) {
|
|
386
784
|
if (this.running)
|
|
387
785
|
return;
|
|
388
786
|
this.running = true;
|
|
@@ -404,8 +802,22 @@ export class App {
|
|
|
404
802
|
clearTimeout(this.toastTimer);
|
|
405
803
|
this.toastTimer = null;
|
|
406
804
|
}
|
|
407
|
-
this.
|
|
805
|
+
if (this.selectionTimer) {
|
|
806
|
+
clearTimeout(this.selectionTimer);
|
|
807
|
+
this.selectionTimer = null;
|
|
808
|
+
}
|
|
809
|
+
// An auto-scroll timer outliving the app would keep firing into a disposed
|
|
810
|
+
// renderer.
|
|
811
|
+
this.stopDragScroll();
|
|
812
|
+
// Disable mouse reporting before input returns to cooked/echo mode.
|
|
408
813
|
this.renderer.dispose();
|
|
409
|
-
|
|
814
|
+
try {
|
|
815
|
+
this.input.stop();
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
// Terminal restoration has already run, even if stdin is unusable.
|
|
819
|
+
}
|
|
820
|
+
void this.webHandle?.close();
|
|
821
|
+
process.exit(exitCode);
|
|
410
822
|
}
|
|
411
823
|
}
|