@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/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.quit());
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.lastBuffer = buf;
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
- if (this.state.selection)
140
- applySelection(buf, this.state.selection);
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.renderer.render(buf, this.top?.alwaysRepaintRows() ?? []);
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
- // Ctrl+C copies the selection when there is one, otherwise it quits —
188
- // the usual terminal convention.
189
- if (this.state.selection) {
190
- this.copySelection();
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
- if (ev.x !== s.x || ev.y !== s.y) {
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: s, cursor: { x: ev.x, y: ev.y } };
273
- this.render();
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 = selectionText(this.lastBuffer, sel);
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
- copySelection() {
310
- const sel = this.state.selection;
311
- if (!sel || !this.lastBuffer)
672
+ handleInterrupt() {
673
+ if (this.state.selection) {
674
+ this.interruptCount = 0;
312
675
  return;
313
- const text = selectionText(this.lastBuffer, sel);
314
- if (text.trim().length === 0)
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
- copyToClipboard(text);
317
- this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
691
+ }
692
+ this.showToast(EXIT_HINT, "top");
318
693
  }
319
- showToast(text) {
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.input.stop();
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
- process.exit(0);
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
  }