@xingjisoft/neutrino 0.1.1 → 0.1.3

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,6 +6,10 @@ 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).
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
+ - **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
+ - **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.
9
13
  - **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
10
14
  - **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
11
15
  - **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
@@ -47,14 +51,16 @@ Type a message on the start screen and press `Enter` to begin a new chat.
47
51
 
48
52
  | Keys | Action |
49
53
  | --- | --- |
50
- | `Enter` | Send message / confirm |
54
+ | `Enter` | Send message / confirm (only a manually typed Enter sends) |
51
55
  | `←`/`→`, `↑`/`↓` | Move focus / navigate |
52
56
  | `Ctrl+E` | Open model config |
53
57
  | `Ctrl+B` | Back to menu (from chat) |
54
58
  | `Ctrl+L` | Clear conversation (from chat) |
55
59
  | `Esc Esc` | Pause / resume streaming |
60
+ | `Esc` | Clear an active selection |
56
61
  | `PgUp` / `PgDn` | Scroll chat history |
57
- | `Ctrl+C` / `Ctrl+Q` | Quit |
62
+ | `Ctrl+C` | Copy the selection, or quit if nothing is selected |
63
+ | `Ctrl+Q` | Quit |
58
64
 
59
65
  ### Mouse
60
66
 
@@ -63,11 +69,15 @@ Type a message on the start screen and press `Enter` to begin a new chat.
63
69
  | Hover over an item | Highlight / select it in lists and menus |
64
70
  | Click an item | Select and confirm (open a screen, load a session, apply a setting…) |
65
71
  | Click the input box | Focus it and place the cursor at the clicked position |
72
+ | Click and drag | Select text under the rectangle; releasing copies it to the clipboard |
66
73
  | Click the header (left of `ⓘ`) | Open the model selector |
67
74
  | Click `ⓘ` (chat header) | Toggle token usage stats |
75
+ | Drag the scrollbar | Grab the thumb on the right edge of the chat/log and drag it to scroll |
68
76
  | Scroll wheel | Scroll the chat/log, or move through lists |
69
77
  | Right-click | Go back (like `Esc`) |
70
78
 
79
+ 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.
80
+
71
81
  ## Configuration
72
82
 
73
83
  Settings are stored under `~/.neutrino/`:
@@ -101,10 +111,27 @@ The agent can call the following tools during a conversation:
101
111
 
102
112
  ```sh
103
113
  npm run build # compile TypeScript to dist/
104
- node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
105
- node scripts/test-cursor.mjs # verify click-to-position-cursor math
114
+ npm test # build, then run every test below
106
115
  ```
107
116
 
117
+ Individual tests:
118
+
119
+ ```sh
120
+ node scripts/test-paste.mjs # verify pasted newlines are flattened to spaces
121
+ node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
122
+ node scripts/test-cursor.mjs # verify click-to-position-cursor math
123
+ node scripts/test-selection.mjs # verify selection highlighting & text extraction
124
+ node scripts/test-scrollbar.mjs # verify the conversation scrollbar size/position and drag maths
125
+ node scripts/test-markdown.mjs # verify markdown/table rendering (incl. ragged rows)
126
+ node scripts/test-wrap.mjs # verify indentation & spacing are preserved when wrapping
127
+ node scripts/test-render.mjs # fuzz the diff renderer + render the chat screen,
128
+ # asserting no stale/residual cells are left behind
129
+ ```
130
+
131
+ ### Rendering model
132
+
133
+ The screen is drawn into a `Buffer` of cells which is diffed against the previous frame, so only changed rows are emitted. Rows are erased to the end of the line before being repainted, and the buffer keeps double-width glyphs consistent (writing over one half of a wide character blanks the other half). Together these guarantee that a repainted row can never leave a stale cell behind.
134
+
108
135
  ## License
109
136
 
110
137
  Apache-2.0. See [LICENSE](./LICENSE).
package/dist/app.js CHANGED
@@ -4,12 +4,18 @@ import { InputReader } from "./terminal/input.js";
4
4
  import { Renderer } from "./terminal/renderer.js";
5
5
  import { defaultState } from "./state.js";
6
6
  import { loadConfig, saveConfig } from "./config.js";
7
+ import { C } from "./colors.js";
8
+ import { applySelection, selectionText } from "./selection.js";
9
+ import { offsetFromThumbTop, scrollbarGeometry } from "./ui.js";
10
+ import { copyToClipboard } from "./clipboard.js";
7
11
  import { ChatScreen } from "./screens/chat.js";
8
12
  import { HistoryScreen } from "./screens/history.js";
9
13
  import { AgentSelectScreen } from "./screens/agentSelect.js";
10
14
  import { ModelSelectScreen } from "./screens/modelSelect.js";
11
15
  import { SettingsScreen } from "./screens/settings.js";
12
16
  import { StartScreen } from "./screens/start.js";
17
+ const CURSOR_BLINK_MS = 530;
18
+ const TOAST_MS = 1600;
13
19
  export class App {
14
20
  state = defaultState();
15
21
  width;
@@ -20,6 +26,21 @@ export class App {
20
26
  running = false;
21
27
  sizeTimer = null;
22
28
  blinkTimer = null;
29
+ cursorTimer = null;
30
+ toastTimer = null;
31
+ toast = "";
32
+ // Most recently rendered frame. Kept so a selection can be turned back into
33
+ // text without having to re-run the screen's render.
34
+ lastBuffer = null;
35
+ // Mouse selection bookkeeping. `pressed` is true between a left-button press
36
+ // and its release; `pressStart` is where the press landed, used as the anchor
37
+ // once the pointer starts moving (i.e. once it becomes a drag). A plain click
38
+ // never moves, so it is forwarded to the screen and leaves no selection.
39
+ pressed = false;
40
+ pressStart = null;
41
+ // Active scrollbar drag: how many rows below the thumb's top the press
42
+ // landed, so the thumb stays under the pointer as it moves.
43
+ barDrag = null;
23
44
  resizeHandler = () => this.onResize();
24
45
  constructor() {
25
46
  this.width = process.stdout.columns || 80;
@@ -72,11 +93,15 @@ export class App {
72
93
  pushScreen(screen) {
73
94
  screen.app = this;
74
95
  this.screens.push(screen);
96
+ this.resetMouse();
97
+ this.state.selection = null;
75
98
  screen.onActivate();
76
99
  this.render();
77
100
  }
78
101
  popScreen() {
79
102
  const screen = this.screens.pop();
103
+ this.resetMouse();
104
+ this.state.selection = null;
80
105
  screen?.onDeactivate();
81
106
  this.render();
82
107
  }
@@ -96,17 +121,78 @@ export class App {
96
121
  this.blinkTimer = null;
97
122
  this.state.blinkOn = false;
98
123
  }
124
+ // The text cursor blinks whenever the top screen shows one. The timer is
125
+ // only kept alive while needed so idle screens don't repaint needlessly.
126
+ this.updateCursorBlink();
99
127
  const buf = new Buffer(this.width, this.height);
100
128
  this.top?.render(buf);
129
+ this.lastBuffer = buf;
130
+ // The selection overlay is painted over the finished frame, so it works on
131
+ // every screen (chat log, input box, menus, …) without each having to know
132
+ // about selection at all.
133
+ if (this.state.selection)
134
+ applySelection(buf, this.state.selection);
135
+ this.drawToast(buf);
101
136
  this.renderer.render(buf);
102
137
  }
138
+ drawToast(buf) {
139
+ if (!this.toast)
140
+ return;
141
+ const y = buf.height - 1;
142
+ buf.fillRect(0, y, buf.width, 1, { bg: C.black });
143
+ buf.centerText(y, ` ${this.toast} `, { fg: C.black, bg: C.accent, bold: true });
144
+ }
145
+ updateCursorBlink() {
146
+ const want = this.top?.showsCursor() ?? false;
147
+ if (want && !this.cursorTimer) {
148
+ this.cursorTimer = setInterval(() => {
149
+ this.state.cursorOn = !this.state.cursorOn;
150
+ this.render();
151
+ }, CURSOR_BLINK_MS);
152
+ }
153
+ else if (!want && this.cursorTimer) {
154
+ clearInterval(this.cursorTimer);
155
+ this.cursorTimer = null;
156
+ this.state.cursorOn = true;
157
+ }
158
+ }
159
+ /** Reset the blink phase so the cursor is solid right after an interaction. */
160
+ wakeCursor() {
161
+ this.state.cursorOn = true;
162
+ }
103
163
  handleEvent(ev) {
164
+ if (ev.type === "paste") {
165
+ // Pasted text arrives with its newlines already flattened to spaces, so
166
+ // it is inserted into the input instead of being submitted.
167
+ this.resetMouse();
168
+ this.wakeCursor();
169
+ this.top?.onPaste(ev.text);
170
+ return;
171
+ }
104
172
  if (ev.type === "key") {
105
173
  const k = ev.key;
106
- if (k.ctrl && (k.name === "c" || k.name === "q")) {
174
+ // Any keystroke ends a pending mouse gesture (in case a release was lost).
175
+ this.resetMouse();
176
+ if (k.ctrl && k.name === "q") {
107
177
  this.quit();
108
178
  return;
109
179
  }
180
+ if (k.ctrl && k.name === "c") {
181
+ // Ctrl+C copies the selection when there is one, otherwise it quits —
182
+ // the usual terminal convention.
183
+ if (this.state.selection) {
184
+ this.copySelection();
185
+ return;
186
+ }
187
+ this.quit();
188
+ return;
189
+ }
190
+ if (k.name === "escape" && this.state.selection) {
191
+ this.state.selection = null;
192
+ this.render();
193
+ return;
194
+ }
195
+ this.wakeCursor();
110
196
  if (k.ctrl && (k.name === "m" || k.name === "e")) {
111
197
  this.openModelConfig();
112
198
  return;
@@ -114,9 +200,118 @@ export class App {
114
200
  this.top?.onKey(k);
115
201
  }
116
202
  else {
117
- this.top?.onMouse(ev.event);
203
+ this.handleMouse(ev.event);
118
204
  }
119
205
  }
206
+ handleMouse(ev) {
207
+ if (ev.type === "click") {
208
+ this.wakeCursor();
209
+ // A left-press on the scrollbar starts a thumb drag rather than a text
210
+ // selection or a click on the screen behind it.
211
+ if (ev.button === 0) {
212
+ const bar = this.top?.scrollbar();
213
+ if (bar && ev.x >= bar.x && ev.y >= bar.top && ev.y <= bar.bottom) {
214
+ const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
215
+ let grab = ev.y - bar.top - g.thumbTop;
216
+ if (grab < 0 || grab >= g.thumbH) {
217
+ // Pressed the groove: centre the thumb under the pointer.
218
+ grab = Math.floor(g.thumbH / 2);
219
+ const thumbTop = Math.max(0, Math.min(ev.y - bar.top - grab, g.maxThumbTop));
220
+ bar.scrollTo(offsetFromThumbTop(g, thumbTop));
221
+ }
222
+ this.barDrag = { grab };
223
+ this.pressed = false;
224
+ this.pressStart = null;
225
+ if (this.state.selection)
226
+ this.state.selection = null;
227
+ this.render();
228
+ return;
229
+ }
230
+ }
231
+ this.pressed = true;
232
+ this.pressStart = { x: ev.x, y: ev.y };
233
+ if (this.state.selection) {
234
+ this.state.selection = null;
235
+ this.render();
236
+ }
237
+ // Forward immediately so plain clicks (focus, tabs, header, …) keep
238
+ // working even in terminals that don't report button releases.
239
+ this.top?.onMouse(ev);
240
+ return;
241
+ }
242
+ if (ev.type === "move") {
243
+ if (this.barDrag) {
244
+ const bar = this.top?.scrollbar();
245
+ if (bar) {
246
+ const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
247
+ const thumbTop = Math.max(0, Math.min(ev.y - bar.top - this.barDrag.grab, g.maxThumbTop));
248
+ bar.scrollTo(offsetFromThumbTop(g, thumbTop));
249
+ }
250
+ return;
251
+ }
252
+ if (this.pressed && this.pressStart) {
253
+ const s = this.pressStart;
254
+ if (ev.x !== s.x || ev.y !== s.y) {
255
+ // The pointer left the press cell, so this is a drag: grow/shrink the
256
+ // selection instead of forwarding a hover to the screen.
257
+ this.state.selection = { anchor: s, cursor: { x: ev.x, y: ev.y } };
258
+ this.render();
259
+ }
260
+ return;
261
+ }
262
+ this.top?.onMouse(ev);
263
+ return;
264
+ }
265
+ if (ev.type === "release") {
266
+ const sel = this.state.selection;
267
+ this.pressed = false;
268
+ this.pressStart = null;
269
+ this.barDrag = null;
270
+ if (sel) {
271
+ if (this.lastBuffer) {
272
+ const text = selectionText(this.lastBuffer, sel);
273
+ if (text.trim().length > 0) {
274
+ copyToClipboard(text);
275
+ this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
276
+ return;
277
+ }
278
+ }
279
+ this.render();
280
+ }
281
+ return;
282
+ }
283
+ if (ev.type === "wheel") {
284
+ if (this.state.selection)
285
+ this.state.selection = null;
286
+ this.top?.onMouse(ev);
287
+ }
288
+ }
289
+ resetMouse() {
290
+ this.pressed = false;
291
+ this.pressStart = null;
292
+ this.barDrag = null;
293
+ }
294
+ copySelection() {
295
+ const sel = this.state.selection;
296
+ if (!sel || !this.lastBuffer)
297
+ return;
298
+ const text = selectionText(this.lastBuffer, sel);
299
+ if (text.trim().length === 0)
300
+ return;
301
+ copyToClipboard(text);
302
+ this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
303
+ }
304
+ showToast(text) {
305
+ this.toast = text;
306
+ if (this.toastTimer)
307
+ clearTimeout(this.toastTimer);
308
+ this.toastTimer = setTimeout(() => {
309
+ this.toastTimer = null;
310
+ this.toast = "";
311
+ this.render();
312
+ }, TOAST_MS);
313
+ this.render();
314
+ }
120
315
  setModelConfig(providerName, model, intensity) {
121
316
  this.state.selectedProvider = providerName;
122
317
  this.state.selectedModelName = model.name;
@@ -182,6 +377,14 @@ export class App {
182
377
  clearInterval(this.blinkTimer);
183
378
  this.blinkTimer = null;
184
379
  }
380
+ if (this.cursorTimer) {
381
+ clearInterval(this.cursorTimer);
382
+ this.cursorTimer = null;
383
+ }
384
+ if (this.toastTimer) {
385
+ clearTimeout(this.toastTimer);
386
+ this.toastTimer = null;
387
+ }
185
388
  this.input.stop();
186
389
  this.renderer.dispose();
187
390
  process.exit(0);
@@ -0,0 +1,113 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ /**
8
+ * Copy `text` to the system clipboard.
9
+ *
10
+ * Two independent, best-effort strategies are used so the copy works across as
11
+ * many terminals as possible:
12
+ *
13
+ * 1. OSC 52 — a terminal escape sequence that asks the host terminal to put
14
+ * the (base64-encoded) text on the clipboard. This is the only strategy
15
+ * that also works over SSH, and is supported by Windows Terminal, iTerm2,
16
+ * Kitty, WezTerm, Alacritty, …
17
+ * 2. A native helper process (`clip` / `pbcopy` / `wl-copy` / `xclip` / …),
18
+ * as a fallback for terminals that ignore OSC 52.
19
+ *
20
+ * Both are best-effort: any failure is silently ignored.
21
+ */
22
+ export function copyToClipboard(text) {
23
+ if (!text)
24
+ return;
25
+ copyViaOsc52(text);
26
+ copyViaNative(text);
27
+ }
28
+ function copyViaOsc52(text) {
29
+ try {
30
+ const payload = Buffer.from(text, "utf8").toString("base64");
31
+ // OSC 52 ; <selection> ; <base64> BEL
32
+ process.stdout.write(`\x1b]52;c;${payload}\x07`);
33
+ }
34
+ catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+ function copyViaNative(text) {
39
+ if (process.platform === "win32") {
40
+ copyWindows(text);
41
+ return;
42
+ }
43
+ const candidates = process.platform === "darwin"
44
+ ? [["pbcopy", []]]
45
+ : process.env.WAYLAND_DISPLAY
46
+ ? [
47
+ ["wl-copy", []],
48
+ ["xclip", ["-selection", "clipboard"]],
49
+ ["xsel", ["--clipboard", "--input"]],
50
+ ]
51
+ : [
52
+ ["xclip", ["-selection", "clipboard"]],
53
+ ["xsel", ["--clipboard", "--input"]],
54
+ ["wl-copy", []],
55
+ ];
56
+ pipeToFirstAvailable(candidates, text);
57
+ }
58
+ /** Spawn the first available helper and pipe the text into it. */
59
+ function pipeToFirstAvailable(candidates, text) {
60
+ let index = 0;
61
+ const tryNext = () => {
62
+ if (index >= candidates.length)
63
+ return;
64
+ const [cmd, args] = candidates[index++];
65
+ let child;
66
+ try {
67
+ child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
68
+ }
69
+ catch {
70
+ tryNext();
71
+ return;
72
+ }
73
+ child.once("error", () => tryNext());
74
+ child.once("spawn", () => {
75
+ child.stdin?.end(text);
76
+ });
77
+ child.stdin?.on("error", () => {
78
+ /* the helper closed early — ignore */
79
+ });
80
+ };
81
+ tryNext();
82
+ }
83
+ /**
84
+ * Windows: `clip.exe` decodes its input using the console code page, which
85
+ * mangles non-ASCII text. Instead we drop the text in a temp file and let
86
+ * PowerShell read it back as UTF-8 via the Unicode-aware Set-Clipboard cmdlet.
87
+ */
88
+ function copyWindows(text) {
89
+ let tmp = null;
90
+ try {
91
+ tmp = path.join(os.tmpdir(), `neutrino-clip-${process.pid}-${Date.now()}.txt`);
92
+ fs.writeFileSync(tmp, text, "utf8");
93
+ const script = `Set-Clipboard -Value ([IO.File]::ReadAllText(${psQuote(tmp)}, [Text.Encoding]::UTF8)); ` +
94
+ `Remove-Item -LiteralPath ${psQuote(tmp)} -ErrorAction SilentlyContinue`;
95
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { stdio: "ignore", windowsHide: true });
96
+ child.once("error", () => {
97
+ if (tmp) {
98
+ try {
99
+ fs.rmSync(tmp, { force: true });
100
+ }
101
+ catch {
102
+ /* ignore */
103
+ }
104
+ }
105
+ });
106
+ }
107
+ catch {
108
+ /* ignore */
109
+ }
110
+ }
111
+ function psQuote(value) {
112
+ return "'" + value.replace(/'/g, "''") + "'";
113
+ }
package/dist/colors.js CHANGED
@@ -11,5 +11,8 @@ export const C = {
11
11
  green: [115, 218, 202],
12
12
  red: [247, 118, 142],
13
13
  codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
14
+ selection: [56, 92, 138], // muted blue used to highlight selected text
15
+ scrollTrack: [45, 58, 62], // dim groove behind the scrollbar thumb
16
+ scrollThumb: [130, 142, 190], // draggable scrollbar thumb
14
17
  };
15
18
  export const EMPTY_STYLE = {};
package/dist/config.js CHANGED
@@ -194,3 +194,22 @@ export function loadPrompt() {
194
194
  return (readAsset(path.join(PKG_ROOT, "assets", "prompt.txt"), path.join(PKG_ROOT, "prompt.txt")) ??
195
195
  "You are Neutrino, a helpful AI coding assistant.");
196
196
  }
197
+ let cachedVersion = null;
198
+ /**
199
+ * Read the CLI version straight from the package's `package.json` so the
200
+ * displayed version can never drift out of sync with the published package.
201
+ * The result is cached after the first successful read.
202
+ */
203
+ export function loadVersion() {
204
+ if (cachedVersion !== null)
205
+ return cachedVersion;
206
+ try {
207
+ const raw = fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8");
208
+ const pkg = JSON.parse(raw);
209
+ cachedVersion = typeof pkg.version === "string" ? pkg.version : "";
210
+ }
211
+ catch {
212
+ cachedVersion = "";
213
+ }
214
+ return cachedVersion;
215
+ }
package/dist/markdown.js CHANGED
@@ -566,7 +566,7 @@ function renderTable(t, width) {
566
566
  for (let c = 0; c < nCols; c++) {
567
567
  if (c === 0)
568
568
  spans.push({ text: "│ ", style: borderStyle });
569
- const cellLine = wrapped[c][li]?.spans ?? [];
569
+ const cellLine = wrapped[c]?.[li]?.spans ?? [];
570
570
  for (const s of cellLine) {
571
571
  spans.push(isHeader ? { ...s, style: { ...s.style, bold: true } } : s);
572
572
  }
@@ -1,7 +1,7 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { drawHeader } from "../header.js";
4
- import { drawHint } from "../ui.js";
4
+ import { drawHint, drawScrollbar } from "../ui.js";
5
5
  import { drawSpans, renderMarkdownText } from "../markdown.js";
6
6
  import { ellipsize } from "../width.js";
7
7
  export class AgentConversationScreen extends Screen {
@@ -9,6 +9,7 @@ export class AgentConversationScreen extends Screen {
9
9
  messages;
10
10
  scrollTop = 0;
11
11
  followBottom = true;
12
+ scrollbarGeom = null;
12
13
  constructor(title, messages) {
13
14
  super();
14
15
  this.title = title;
@@ -52,6 +53,25 @@ export class AgentConversationScreen extends Screen {
52
53
  this.app.popScreen();
53
54
  }
54
55
  }
56
+ scrollbar() {
57
+ const g = this.scrollbarGeom;
58
+ if (!g || g.total <= g.viewport)
59
+ return null;
60
+ return {
61
+ x: g.x,
62
+ top: g.top,
63
+ bottom: g.bottom,
64
+ total: g.total,
65
+ viewport: g.viewport,
66
+ offset: this.scrollTop,
67
+ scrollTo: (offset) => {
68
+ const maxScroll = Math.max(0, g.total - g.viewport);
69
+ this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
70
+ this.followBottom = this.scrollTop >= maxScroll;
71
+ this.app.render();
72
+ },
73
+ };
74
+ }
55
75
  buildLines(width) {
56
76
  const lines = [];
57
77
  for (const m of this.messages) {
@@ -85,12 +105,19 @@ export class AgentConversationScreen extends Screen {
85
105
  }
86
106
  if (this.scrollTop < 0)
87
107
  this.scrollTop = 0;
108
+ // Expose the bar so the app can drag it (see the scrollbar() method).
109
+ this.scrollbarGeom =
110
+ lines.length > viewH
111
+ ? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
112
+ : null;
88
113
  for (let y = logTop; y <= logBottom; y++) {
89
114
  const li = this.scrollTop + (y - logTop);
90
115
  if (li >= lines.length)
91
116
  break;
92
117
  drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
93
118
  }
94
- drawHint(buf, h - 1, "↑/↓ scroll · wheel scroll · right-click back · Esc back");
119
+ // Scrollbar on the right edge, visible only when the log overflows.
120
+ drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
121
+ drawHint(buf, h - 1, "↑/↓ scroll · wheel or drag the bar · right-click back · Esc back");
95
122
  }
96
123
  }
@@ -2,7 +2,7 @@ import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { loadPrompt, saveSession, } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
- import { computeInputWindow, cursorFromColumn, drawInputBox, wrapText } from "../ui.js";
5
+ import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText } from "../ui.js";
6
6
  import { drawSpans, renderMarkdownText } from "../markdown.js";
7
7
  import { ellipsize, displayWidth } from "../width.js";
8
8
  import { LLMClient } from "../llm/client.js";
@@ -42,6 +42,7 @@ export class ChatScreen extends Screen {
42
42
  cursor = 0;
43
43
  scrollTop = 0;
44
44
  followBottom = true;
45
+ scrollbarGeom = null;
45
46
  lastEscTime = 0;
46
47
  initialized = false;
47
48
  constructor(initialMessage = "") {
@@ -78,6 +79,20 @@ export class ChatScreen extends Screen {
78
79
  this.app.render();
79
80
  }
80
81
  }
82
+ showsCursor() {
83
+ // The chat input is always focused; a cursor is drawn when it holds text.
84
+ return this.inputValue.length > 0;
85
+ }
86
+ onPaste(text) {
87
+ if (!text)
88
+ return;
89
+ if (this.showInfo)
90
+ this.showInfo = false;
91
+ this.inputValue =
92
+ this.inputValue.slice(0, this.cursor) + text + this.inputValue.slice(this.cursor);
93
+ this.cursor += text.length;
94
+ this.app.render();
95
+ }
81
96
  onKey(key) {
82
97
  if (this.showInfo) {
83
98
  this.showInfo = false;
@@ -233,6 +248,26 @@ export class ChatScreen extends Screen {
233
248
  this.app.render();
234
249
  }
235
250
  }
251
+ scrollbar() {
252
+ const g = this.scrollbarGeom;
253
+ if (!g || g.total <= g.viewport)
254
+ return null;
255
+ return {
256
+ x: g.x,
257
+ top: g.top,
258
+ bottom: g.bottom,
259
+ total: g.total,
260
+ viewport: g.viewport,
261
+ offset: this.scrollTop,
262
+ scrollTo: (offset) => {
263
+ const maxScroll = Math.max(0, g.total - g.viewport);
264
+ this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
265
+ // Dragging to the very bottom resumes auto-following new output.
266
+ this.followBottom = this.scrollTop >= maxScroll;
267
+ this.app.render();
268
+ },
269
+ };
270
+ }
236
271
  togglePause() {
237
272
  if (!this.busy && !this.queued)
238
273
  return;
@@ -459,20 +494,30 @@ export class ChatScreen extends Screen {
459
494
  }
460
495
  if (this.scrollTop < 0)
461
496
  this.scrollTop = 0;
497
+ // Remember the bar's geometry so a drag on the last column can scroll the
498
+ // log (see the scrollbar() method).
499
+ this.scrollbarGeom =
500
+ lines.length > viewH
501
+ ? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
502
+ : null;
462
503
  for (let y = logTop; y <= logBottom; y++) {
463
504
  const li = this.scrollTop + (y - logTop);
464
505
  if (li >= lines.length)
465
506
  break;
466
507
  drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
467
508
  }
509
+ // A scrollbar on the right edge of the log, shown only when the
510
+ // conversation overflows the visible area.
511
+ drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
468
512
  const inW = Math.max(12, w - 2);
469
513
  drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
470
514
  active: true,
471
515
  dimmed: this.busy,
516
+ cursorOn: this.app.state.cursorOn,
472
517
  placeholder: "Type a message...",
473
518
  });
474
519
  const hintY = h - 1;
475
- const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear";
520
+ const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear · drag to copy";
476
521
  const hintW = displayWidth(hints);
477
522
  const hintX = Math.max(0, w - 1 - hintW);
478
523
  const pathAvail = Math.max(0, hintX - 2);