@xingjisoft/neutrino 0.1.5 → 0.1.7

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 CHANGED
@@ -6,7 +6,7 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
6
6
 
7
7
  - **Interactive terminal UI** — logo screen, chat, model select, history, and settings screens.
8
8
  - **Full mouse support** — hover to highlight, click to select/open/position the cursor, wheel to scroll, right-click to go back (like OpenCode).
9
- - **Select & copy** — drag the mouse over any text (agent output, the input box, menus, …) to select it; releasing the button copies the selection to the clipboard (via OSC 52, with a native fallback).
9
+ - **Select & copy** — drag the mouse over any text (agent output, the input box, menus, …) to select it; releasing the button copies the selection to the clipboard (via OSC 52, with a native fallback). Holding the drag against the top or bottom edge of the log scrolls it, so one selection can run for as long as the whole conversation.
10
10
  - **Paste-friendly input** — like OpenCode, a pasted block has its newlines turned into spaces so it stays one line, and only a manually typed `Enter` submits the message.
11
11
  - **Faithful log rendering** — text in the chat log keeps its indentation, blank lines and column alignment; when a long tool-output line wraps, continuation lines stay indented instead of snapping back to the left margin.
12
12
  - **Scrollable context log** — a vertical scrollbar is drawn on the right edge of the chat (and the agent-conversation viewer) whenever the conversation is taller than the view. Scroll with the wheel, `PgUp`/`PgDn` or `↑`/`↓`, or grab the scrollbar with the mouse and drag it like in a GUI.
@@ -15,6 +15,7 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
15
15
  - **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
16
16
  - **Intensity control** — tune `temperature`/`top_p` from `Low` to `Ultra`.
17
17
  - **Session history** — messages are saved before a turn starts, streamed replies are checkpointed every second, and tool calls/results are saved immediately. The history screen lists conversations started in the current working directory. Failed saves are shown in the terminal and browser UI.
18
+ - **Auto-folding turns** — once a turn finishes, the work behind the reply (the thinking and the tool calls) folds into one summary line in both the terminal and the browser, while the answer itself always stays on screen. Click the line to read the whole round again; a turn with little to hide, or a long answer on its own, stays open. A turn stopped with `ESC ESC` is paused, not finished, so it stays open. Nothing is dropped: the transcript, the saved session and the model's context keep every message.
18
19
  - **Complete conversation history** — context compression keeps every original message and tool result on disk. The earlier portion starts collapsed in the terminal and browser; click **Earlier conversation** to expand it, including in reopened history. Model requests use a separate summary plus recent messages.
19
20
  - **Token usage stats** — click the `ⓘ` in the chat header to see prompt/completion/total token counts for the current session.
20
21
  - **Agent history browser** — view past sessions from other CLI agents (Claude Code, Codex, OpenCode) directly from the history screen.
@@ -59,10 +60,12 @@ Type a message on the start screen and press `Enter` to begin a new chat.
59
60
  | `Ctrl+E` | Open model config |
60
61
  | `Ctrl+B` | Back to menu (from chat) |
61
62
  | `Ctrl+L` | Clear conversation (from chat) |
62
- | `Esc Esc` | Pause / resume streaming |
63
+ | `Esc Esc` | Pause / resume streaming (a paused turn stays open: only a finished reply folds) |
63
64
  | `Esc` | Clear an active selection |
64
65
  | `PgUp` / `PgDn` | Scroll chat history |
65
- | `Ctrl+C` | Copy the selection, or quit if nothing is selected |
66
+ | `Ctrl+C` | Do nothing while text is selected; otherwise pause the running conversation or show the exit hint at the top |
67
+ | `Ctrl+C` × 3 | Quit after three consecutive presses, no more than 1.5 seconds apart (another key, paste, click or scroll resets the count) |
68
+ | `Ctrl+Shift+C` | Quit immediately (requires the terminal to forward the shortcut) |
66
69
  | `Ctrl+A` | Toggle Request Approval / Auto Approval in a conversation |
67
70
  | `Y` | Approve the pending request (while the approval card is up) |
68
71
  | `N` / `Esc` | Deny the pending request |
@@ -75,7 +78,10 @@ Type a message on the start screen and press `Enter` to begin a new chat.
75
78
  | Hover over an item | Highlight / select it in lists and menus |
76
79
  | Click an item | Select and confirm (open a screen, load a session, apply a setting…) |
77
80
  | Click the input box | Focus it and place the cursor at the clicked position |
81
+ | Click a folded turn | Expand the complete round, or fold it again |
82
+ | Click `⚙ tool` | Expand or collapse a tool call's output |
78
83
  | Click and drag | Select text under the rectangle; releasing copies it to the clipboard |
84
+ | Drag onto the first/last row of the log | Scroll the log a line at a time (faster the longer you hold it) and keep growing the selection past the screen |
79
85
  | Click the header (left of `ⓘ`) | Open the model selector |
80
86
  | Click `ⓘ` (chat header) | Toggle token usage stats |
81
87
  | Drag the scrollbar | Grab the thumb on the right edge of the chat/log and drag it to scroll |
@@ -84,6 +90,12 @@ Type a message on the start screen and press `Enter` to begin a new chat.
84
90
 
85
91
  The selection highlight is drawn over whatever is on screen, so it works uniformly on the chat log, the input box and every menu. Selected text is copied with trailing whitespace trimmed per line (shell-style), so it can be pasted straight back into a terminal. UI chrome (such as the scrollbar) is skipped when copying and highlighting, so a copy never picks up the bar or the padding in front of it.
86
92
 
93
+ A drag that scrolls keeps its starting point anchored to the line it began on,
94
+ not to the row it began on: the mark stays on the edge that is scrolling, the
95
+ highlight stops at the log's edge instead of spilling into the header or the
96
+ input box, and the copy includes the lines that scrolled out of view. Scroll back
97
+ and the selection shrinks again; press `Esc` to drop it.
98
+
87
99
  ## Configuration
88
100
 
89
101
  Settings are stored under `~/.neutrino/`:
@@ -200,7 +212,9 @@ Only sessions that were started in the current working directory are listed, so
200
212
 
201
213
  Neutrino can mount a small website on a port of your choice. It groups every
202
214
  saved conversation by working directory (showing the five newest per path with
203
- the rest collapsed), streams a running conversation live, and lets you pause or
215
+ the rest collapsed), folds the work of every finished turn into one summary line
216
+ that opens on a click while the reply stays on screen, streams a running
217
+ conversation live, and lets you pause or
204
218
  resume it and send new messages from the browser.
205
219
 
206
220
  A saved transcript opens read-only, because nothing is running for it. Press
@@ -309,6 +323,7 @@ node scripts/test-paste.mjs # verify pasted newlines are flattened to space
309
323
  node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
310
324
  node scripts/test-cursor.mjs # verify click-to-position-cursor math
311
325
  node scripts/test-selection.mjs # verify selection highlighting & text extraction
326
+ node scripts/test-drag-scroll.mjs # verify drag-select auto-scroll: edge scrolling, cross-screen copy
312
327
  node scripts/test-copy.mjs # verify a copy never includes the scrollbar or trailing padding
313
328
  node scripts/test-scrollbar.mjs # verify the conversation scrollbar size/position and drag maths
314
329
  node scripts/test-markdown.mjs # verify markdown/table rendering (incl. ragged rows)
@@ -321,6 +336,7 @@ node scripts/test-fetch.mjs # verify streamed requests release aborts and r
321
336
  node scripts/test-compact.mjs # verify context-compression planning
322
337
  node scripts/test-history-fold.mjs # verify full-history retention, folding and resume
323
338
  node scripts/test-web-history.mjs # verify browser history disclosures and streaming state
339
+ node scripts/test-turn-fold.mjs # verify folded turns keep their reply and every message
324
340
  node scripts/test-tools.mjs # verify tool-output folding
325
341
  node scripts/test-web.mjs # verify the web server, API, SSE stream and path-traversal guards
326
342
  node scripts/test-web-settings.mjs # verify model switching, persistence, browser controls and defaults
package/dist/app.js CHANGED
@@ -6,7 +6,7 @@ import { installCrashGuard, writeCrashLog } from "./terminal/restore.js";
6
6
  import { defaultState } from "./state.js";
7
7
  import { loadConfig, saveConfig } from "./config.js";
8
8
  import { C } from "./colors.js";
9
- import { applySelection, selectionText } from "./selection.js";
9
+ import { applySelection, orderLinePoints, rowCells, rowSelectionText, selectionText, } from "./selection.js";
10
10
  import { offsetFromThumbTop, scrollbarGeometry } from "./ui.js";
11
11
  import { copyToClipboard } from "./clipboard.js";
12
12
  import { ChatScreen } from "./screens/chat.js";
@@ -18,10 +18,19 @@ import { StartScreen } from "./screens/start.js";
18
18
  import { startWebServer } from "./web/server.js";
19
19
  const CURSOR_BLINK_MS = 530;
20
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";
21
24
  // How often the whole frame is re-emitted as a safety net, so terminal-side
22
25
  // drift (ConPTY reflow, stray scroll) can never leave any row stale for long.
23
26
  // Re-emitting the cached frame is a no-op when nothing drifted.
24
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;
25
34
  export class App {
26
35
  state = defaultState();
27
36
  width;
@@ -36,12 +45,18 @@ export class App {
36
45
  toastTimer = null;
37
46
  repaintTimer = null;
38
47
  toast = "";
48
+ toastPosition = "bottom";
49
+ interruptCount = 0;
50
+ lastInterruptTime = 0;
39
51
  // Consecutive failed renders. A screen that cannot draw itself is fatal;
40
52
  // a single bad frame is not.
41
53
  renderFailures = 0;
42
54
  // Most recently rendered frame. Kept so a selection can be turned back into
43
55
  // text without having to re-run the screen's render.
44
56
  lastBuffer = null;
57
+ // Cache the screen without selection so dragging never reflows the history.
58
+ contentBuffer = null;
59
+ selectionTimer = null;
45
60
  // Mouse selection bookkeeping. `pressed` is true between a left-button press
46
61
  // and its release; `pressStart` is where the press landed, used as the anchor
47
62
  // once the pointer starts moving (i.e. once it becomes a drag). A plain click
@@ -51,6 +66,28 @@ export class App {
51
66
  // Active scrollbar drag: how many rows below the thumb's top the press
52
67
  // landed, so the thumb stays under the pointer as it moves.
53
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;
54
91
  resizeHandler = () => this.onResize();
55
92
  webHandle = null;
56
93
  constructor() {
@@ -100,7 +137,7 @@ export class App {
100
137
  this.renderer.init();
101
138
  this.input.start((ev) => this.handleEvent(ev));
102
139
  process.stdout.on("resize", this.resizeHandler);
103
- process.on("SIGINT", () => this.quit());
140
+ process.on("SIGINT", () => this.handleInterrupt());
104
141
  process.on("SIGTERM", () => this.quit());
105
142
  this.sizeTimer = setInterval(() => {
106
143
  if (this.syncSize())
@@ -131,9 +168,19 @@ export class App {
131
168
  screen?.onDeactivate();
132
169
  this.render();
133
170
  }
134
- render() {
171
+ render(selectionOnly = false) {
172
+ if (this.selectionTimer) {
173
+ clearTimeout(this.selectionTimer);
174
+ this.selectionTimer = null;
175
+ }
135
176
  try {
136
- this.renderFrame();
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
+ }
137
184
  this.renderFailures = 0;
138
185
  }
139
186
  catch (err) {
@@ -175,19 +222,240 @@ export class App {
175
222
  this.updateCursorBlink();
176
223
  const buf = new Buffer(this.width, this.height);
177
224
  this.top?.render(buf);
178
- 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);
179
232
  // The selection overlay is painted over the finished frame, so it works on
180
233
  // every screen (chat log, input box, menus, …) without each having to know
181
234
  // about selection at all.
182
- if (this.state.selection)
183
- 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
+ }
184
241
  this.drawToast(buf);
185
- 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
+ }
186
454
  }
187
455
  drawToast(buf) {
188
456
  if (!this.toast)
189
457
  return;
190
- const y = buf.height - 1;
458
+ const y = this.toastPosition === "top" ? 0 : buf.height - 1;
191
459
  buf.fillRect(0, y, buf.width, 1, { bg: C.black });
192
460
  buf.centerText(y, ` ${this.toast} `, { fg: C.black, bg: C.accent, bold: true });
193
461
  }
@@ -220,6 +488,10 @@ export class App {
220
488
  }
221
489
  }
222
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
+ }
223
495
  if (ev.type === "paste") {
224
496
  // Pasted text arrives with its newlines already flattened to spaces, so
225
497
  // it is inserted into the input instead of being submitted.
@@ -230,6 +502,11 @@ export class App {
230
502
  }
231
503
  if (ev.type === "key") {
232
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
+ }
233
510
  // Any keystroke ends a pending mouse gesture (in case a release was lost).
234
511
  this.resetMouse();
235
512
  if (k.ctrl && k.name === "q") {
@@ -237,13 +514,10 @@ export class App {
237
514
  return;
238
515
  }
239
516
  if (k.ctrl && k.name === "c") {
240
- // Ctrl+C copies the selection when there is one, otherwise it quits —
241
- // the usual terminal convention.
242
- if (this.state.selection) {
243
- this.copySelection();
244
- return;
245
- }
246
- this.quit();
517
+ if (k.shift)
518
+ this.quit();
519
+ else
520
+ this.handleInterrupt();
247
521
  return;
248
522
  }
249
523
  if (k.name === "escape" && this.state.selection) {
@@ -265,6 +539,9 @@ export class App {
265
539
  handleMouse(ev) {
266
540
  if (ev.type === "click") {
267
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();
268
545
  // A left-press on the scrollbar starts a thumb drag rather than a text
269
546
  // selection or a click on the screen behind it.
270
547
  if (ev.button === 0) {
@@ -289,6 +566,13 @@ export class App {
289
566
  }
290
567
  this.pressed = true;
291
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;
292
576
  if (this.state.selection) {
293
577
  this.state.selection = null;
294
578
  this.render();
@@ -299,6 +583,7 @@ export class App {
299
583
  return;
300
584
  }
301
585
  if (ev.type === "move") {
586
+ this.pointer = { x: ev.x, y: ev.y };
302
587
  // A release can be lost (the pointer left the window, the terminal did
303
588
  // not report button-up, …). Motion with no button held means the gesture
304
589
  // is over, so drop any stale press/drag instead of letting a bare hover
@@ -307,6 +592,7 @@ export class App {
307
592
  this.pressed = false;
308
593
  this.pressStart = null;
309
594
  this.barDrag = null;
595
+ this.resetDragTracking();
310
596
  }
311
597
  if (this.barDrag) {
312
598
  const bar = this.top?.scrollbar();
@@ -319,28 +605,50 @@ export class App {
319
605
  }
320
606
  if (this.pressed && this.pressStart) {
321
607
  const s = this.pressStart;
322
- 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)) {
323
615
  // The pointer left the press cell, so this is a drag: grow/shrink the
324
616
  // selection instead of forwarding a hover to the screen.
325
- this.state.selection = { anchor: s, cursor: { x: ev.x, y: ev.y } };
326
- this.render();
617
+ this.state.selection = { anchor, cursor };
618
+ this.scheduleSelectionRender();
327
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();
328
623
  return;
329
624
  }
330
625
  this.top?.onMouse(ev);
331
626
  return;
332
627
  }
333
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);
334
641
  const sel = this.state.selection;
335
642
  this.pressed = false;
336
643
  this.pressStart = null;
337
644
  this.barDrag = null;
338
645
  if (sel) {
339
646
  if (this.lastBuffer) {
340
- const text = selectionText(this.lastBuffer, sel);
647
+ const text = this.selectionToText();
341
648
  if (text.trim().length > 0) {
342
649
  copyToClipboard(text);
343
650
  this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
651
+ this.selRows.clear();
344
652
  return;
345
653
  }
346
654
  }
@@ -349,6 +657,7 @@ export class App {
349
657
  return;
350
658
  }
351
659
  if (ev.type === "wheel") {
660
+ this.resetDragTracking();
352
661
  if (this.state.selection)
353
662
  this.state.selection = null;
354
663
  this.top?.onMouse(ev);
@@ -358,16 +667,29 @@ export class App {
358
667
  this.pressed = false;
359
668
  this.pressStart = null;
360
669
  this.barDrag = null;
670
+ this.resetDragTracking();
361
671
  }
362
- copySelection() {
363
- const sel = this.state.selection;
364
- if (!sel || !this.lastBuffer)
672
+ handleInterrupt() {
673
+ if (this.state.selection) {
674
+ this.interruptCount = 0;
675
+ return;
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();
365
683
  return;
366
- const text = selectionText(this.lastBuffer, sel);
367
- if (text.trim().length === 0)
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();
368
690
  return;
369
- copyToClipboard(text);
370
- this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
691
+ }
692
+ this.showToast(EXIT_HINT, "top");
371
693
  }
372
694
  // Public so screens can surface transient status (e.g. "reviewing 3/3").
373
695
  /**
@@ -387,8 +709,9 @@ export class App {
387
709
  }
388
710
  this.renderer.bell(kind === "approval" ? 2 : 1);
389
711
  }
390
- showToast(text) {
712
+ showToast(text, position = "bottom") {
391
713
  this.toast = text;
714
+ this.toastPosition = position;
392
715
  if (this.toastTimer)
393
716
  clearTimeout(this.toastTimer);
394
717
  this.toastTimer = setTimeout(() => {
@@ -479,6 +802,13 @@ export class App {
479
802
  clearTimeout(this.toastTimer);
480
803
  this.toastTimer = null;
481
804
  }
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();
482
812
  // Disable mouse reporting before input returns to cooked/echo mode.
483
813
  this.renderer.dispose();
484
814
  try {