@xingjisoft/neutrino 0.1.5 → 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 +19 -4
- package/dist/app.js +358 -28
- package/dist/screens/chat.js +179 -7
- package/dist/selection.js +60 -0
- package/dist/terminal/input.js +20 -2
- package/dist/terminal/renderer.js +29 -8
- package/dist/terminal/restore.js +3 -2
- package/package.json +2 -2
- package/web/README.md +3 -1
- package/web/app.js +106 -5
- package/web/index.html +4 -1
- package/web/style.css +163 -16
- package/web/turns.js +52 -0
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** — as soon as the model finishes a reply, that round collapses into one summary line (prompt, answer preview, message count) in both the terminal and the browser. Click it to read the complete round again; short exchanges stay open, because hiding two lines helps nobody. 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` |
|
|
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,8 @@ 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),
|
|
215
|
+
the rest collapsed), folds every finished turn into one summary line that opens
|
|
216
|
+
on a click, streams a running conversation live, and lets you pause or
|
|
204
217
|
resume it and send new messages from the browser.
|
|
205
218
|
|
|
206
219
|
A saved transcript opens read-only, because nothing is running for it. Press
|
|
@@ -309,6 +322,7 @@ node scripts/test-paste.mjs # verify pasted newlines are flattened to space
|
|
|
309
322
|
node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
|
|
310
323
|
node scripts/test-cursor.mjs # verify click-to-position-cursor math
|
|
311
324
|
node scripts/test-selection.mjs # verify selection highlighting & text extraction
|
|
325
|
+
node scripts/test-drag-scroll.mjs # verify drag-select auto-scroll: edge scrolling, cross-screen copy
|
|
312
326
|
node scripts/test-copy.mjs # verify a copy never includes the scrollbar or trailing padding
|
|
313
327
|
node scripts/test-scrollbar.mjs # verify the conversation scrollbar size/position and drag maths
|
|
314
328
|
node scripts/test-markdown.mjs # verify markdown/table rendering (incl. ragged rows)
|
|
@@ -321,6 +335,7 @@ node scripts/test-fetch.mjs # verify streamed requests release aborts and r
|
|
|
321
335
|
node scripts/test-compact.mjs # verify context-compression planning
|
|
322
336
|
node scripts/test-history-fold.mjs # verify full-history retention, folding and resume
|
|
323
337
|
node scripts/test-web-history.mjs # verify browser history disclosures and streaming state
|
|
338
|
+
node scripts/test-turn-fold.mjs # verify finished turns fold in both UIs and keep every message
|
|
324
339
|
node scripts/test-tools.mjs # verify tool-output folding
|
|
325
340
|
node scripts/test-web.mjs # verify the web server, API, SSE stream and path-traversal guards
|
|
326
341
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
183
|
-
|
|
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.
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
this.
|
|
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
|
-
|
|
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
|
|
326
|
-
this.
|
|
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 =
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
this.showToast(
|
|
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 {
|