@xingjisoft/neutrino 0.1.2 → 0.1.4

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
@@ -7,6 +7,9 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
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
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.
10
13
  - **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
11
14
  - **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
12
15
  - **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
@@ -48,7 +51,7 @@ Type a message on the start screen and press `Enter` to begin a new chat.
48
51
 
49
52
  | Keys | Action |
50
53
  | --- | --- |
51
- | `Enter` | Send message / confirm |
54
+ | `Enter` | Send message / confirm (only a manually typed Enter sends) |
52
55
  | `←`/`→`, `↑`/`↓` | Move focus / navigate |
53
56
  | `Ctrl+E` | Open model config |
54
57
  | `Ctrl+B` | Back to menu (from chat) |
@@ -69,10 +72,11 @@ Type a message on the start screen and press `Enter` to begin a new chat.
69
72
  | Click and drag | Select text under the rectangle; releasing copies it to the clipboard |
70
73
  | Click the header (left of `ⓘ`) | Open the model selector |
71
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 |
72
76
  | Scroll wheel | Scroll the chat/log, or move through lists |
73
77
  | Right-click | Go back (like `Esc`) |
74
78
 
75
- 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.
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. 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.
76
80
 
77
81
  ## Configuration
78
82
 
@@ -113,17 +117,47 @@ npm test # build, then run every test below
113
117
  Individual tests:
114
118
 
115
119
  ```sh
120
+ node scripts/test-paste.mjs # verify pasted newlines are flattened to spaces
116
121
  node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
117
122
  node scripts/test-cursor.mjs # verify click-to-position-cursor math
118
123
  node scripts/test-selection.mjs # verify selection highlighting & text extraction
124
+ node scripts/test-copy.mjs # verify a copy never includes the scrollbar or trailing padding
125
+ node scripts/test-scrollbar.mjs # verify the conversation scrollbar size/position and drag maths
126
+ node scripts/test-markdown.mjs # verify markdown/table rendering (incl. ragged rows)
127
+ node scripts/test-wrap.mjs # verify indentation & spacing are preserved when wrapping
119
128
  node scripts/test-render.mjs # fuzz the diff renderer + render the chat screen,
120
129
  # asserting no stale/residual cells are left behind
130
+ node scripts/test-sanitize.mjs # verify ANSI/control codes never reach the screen
131
+ node scripts/test-drift.mjs # verify the input area self-heals after terminal drift
132
+ node scripts/test-fetch.mjs # verify streamed requests release aborts and retry drops
133
+ node scripts/test-compact.mjs # verify context-compression planning
134
+ node scripts/test-tools.mjs # verify tool-output folding
121
135
  ```
122
136
 
123
137
  ### Rendering model
124
138
 
125
139
  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.
126
140
 
141
+ Because only *changed* rows are emitted, a row whose contents happen to be identical from one frame to the next is never rewritten — so if the terminal shifts its own contents behind our back (ConPTY reflowing the buffer mid-resize, a size-race repaint, a stray scroll) that row would stay stale. Two defences cover this: the persistent chrome (input box, tabs, key hints) is repainted on every frame via `Screen.alwaysRepaintRows()`, and the app periodically re-emits the whole cached frame (`Renderer.repaint()`) as a safety net. Both are no-ops when nothing drifted.
142
+
143
+ ### Text hygiene
144
+
145
+ A cell's glyph is copied straight into the output stream, so a cell holding a *control* character becomes a command the terminal executes — an ESC sequence can move the cursor, clear the screen or leave the alternate buffer, and a TAB shifts the rest of the row. Tool output (ANSI colours from `ls --color`, `git`, `npm`, progress bars, …), file contents and pasted text all carry such bytes, so they are neutralised in three places:
146
+
147
+ 1. `src/sanitize.ts` — `sanitizeText()` removes whole escape sequences (not just the ESC byte, which would leave `[31m` behind as literal text), expands tabs so measured widths match what the terminal draws, and drops any remaining control code. It is applied where untrusted text enters the display (chat messages, tool results, pasted input).
148
+ 2. `Buffer` — writing a control character stores a space instead, preserving the column width the caller measured, so no cell can ever hold one.
149
+ 3. `Renderer` — a last-resort check when emitting a row, so the byte stream cannot carry a control code even if a cell were populated another way.
150
+
151
+ ### Network resilience
152
+
153
+ Model responses arrive over a streamed connection that can drop at any point. A single blip should not end the session, so three cases are handled:
154
+
155
+ - **The request never goes out** (`fetch` rejects) — retried a couple of times with a short backoff, bounded by a connect timeout.
156
+ - **The stream dies before the first byte** — the server or a proxy closed the connection mid-flight (`TypeError: terminated`, `other side closed`, …). Nothing has reached the user, so the request is simply re-issued.
157
+ - **The stream dies after output has started** — retrying would duplicate text, so the error is surfaced instead, with the real reason expanded out of the `error.cause` chain (`fetch failed` alone says nothing useful).
158
+
159
+ Any stream the consumer walks away from (pause, or a newer turn taking over) always has its body cancelled, so sockets are not leaked.
160
+
127
161
  ## License
128
162
 
129
163
  Apache-2.0. See [LICENSE](./LICENSE).
package/dist/app.js CHANGED
@@ -6,6 +6,7 @@ import { defaultState } from "./state.js";
6
6
  import { loadConfig, saveConfig } from "./config.js";
7
7
  import { C } from "./colors.js";
8
8
  import { applySelection, selectionText } from "./selection.js";
9
+ import { offsetFromThumbTop, scrollbarGeometry } from "./ui.js";
9
10
  import { copyToClipboard } from "./clipboard.js";
10
11
  import { ChatScreen } from "./screens/chat.js";
11
12
  import { HistoryScreen } from "./screens/history.js";
@@ -15,6 +16,10 @@ import { SettingsScreen } from "./screens/settings.js";
15
16
  import { StartScreen } from "./screens/start.js";
16
17
  const CURSOR_BLINK_MS = 530;
17
18
  const TOAST_MS = 1600;
19
+ // How often the whole frame is re-emitted as a safety net, so terminal-side
20
+ // drift (ConPTY reflow, stray scroll) can never leave any row stale for long.
21
+ // Re-emitting the cached frame is a no-op when nothing drifted.
22
+ const REPAINT_MS = 2000;
18
23
  export class App {
19
24
  state = defaultState();
20
25
  width;
@@ -27,6 +32,7 @@ export class App {
27
32
  blinkTimer = null;
28
33
  cursorTimer = null;
29
34
  toastTimer = null;
35
+ repaintTimer = null;
30
36
  toast = "";
31
37
  // Most recently rendered frame. Kept so a selection can be turned back into
32
38
  // text without having to re-run the screen's render.
@@ -37,6 +43,9 @@ export class App {
37
43
  // never moves, so it is forwarded to the screen and leaves no selection.
38
44
  pressed = false;
39
45
  pressStart = null;
46
+ // Active scrollbar drag: how many rows below the thumb's top the press
47
+ // landed, so the thumb stays under the pointer as it moves.
48
+ barDrag = null;
40
49
  resizeHandler = () => this.onResize();
41
50
  constructor() {
42
51
  this.width = process.stdout.columns || 80;
@@ -77,6 +86,7 @@ export class App {
77
86
  if (this.syncSize())
78
87
  this.render();
79
88
  }, 200);
89
+ this.repaintTimer = setInterval(() => this.renderer.repaint(), REPAINT_MS);
80
90
  this.pushScreen(new StartScreen());
81
91
  }
82
92
  onResize() {
@@ -129,7 +139,7 @@ export class App {
129
139
  if (this.state.selection)
130
140
  applySelection(buf, this.state.selection);
131
141
  this.drawToast(buf);
132
- this.renderer.render(buf);
142
+ this.renderer.render(buf, this.top?.alwaysRepaintRows() ?? []);
133
143
  }
134
144
  drawToast(buf) {
135
145
  if (!this.toast)
@@ -157,6 +167,14 @@ export class App {
157
167
  this.state.cursorOn = true;
158
168
  }
159
169
  handleEvent(ev) {
170
+ if (ev.type === "paste") {
171
+ // Pasted text arrives with its newlines already flattened to spaces, so
172
+ // it is inserted into the input instead of being submitted.
173
+ this.resetMouse();
174
+ this.wakeCursor();
175
+ this.top?.onPaste(ev.text);
176
+ return;
177
+ }
160
178
  if (ev.type === "key") {
161
179
  const k = ev.key;
162
180
  // Any keystroke ends a pending mouse gesture (in case a release was lost).
@@ -194,6 +212,28 @@ export class App {
194
212
  handleMouse(ev) {
195
213
  if (ev.type === "click") {
196
214
  this.wakeCursor();
215
+ // A left-press on the scrollbar starts a thumb drag rather than a text
216
+ // selection or a click on the screen behind it.
217
+ if (ev.button === 0) {
218
+ const bar = this.top?.scrollbar();
219
+ if (bar && ev.x >= bar.x && ev.y >= bar.top && ev.y <= bar.bottom) {
220
+ const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
221
+ let grab = ev.y - bar.top - g.thumbTop;
222
+ if (grab < 0 || grab >= g.thumbH) {
223
+ // Pressed the groove: centre the thumb under the pointer.
224
+ grab = Math.floor(g.thumbH / 2);
225
+ const thumbTop = Math.max(0, Math.min(ev.y - bar.top - grab, g.maxThumbTop));
226
+ bar.scrollTo(offsetFromThumbTop(g, thumbTop));
227
+ }
228
+ this.barDrag = { grab };
229
+ this.pressed = false;
230
+ this.pressStart = null;
231
+ if (this.state.selection)
232
+ this.state.selection = null;
233
+ this.render();
234
+ return;
235
+ }
236
+ }
197
237
  this.pressed = true;
198
238
  this.pressStart = { x: ev.x, y: ev.y };
199
239
  if (this.state.selection) {
@@ -206,6 +246,24 @@ export class App {
206
246
  return;
207
247
  }
208
248
  if (ev.type === "move") {
249
+ // A release can be lost (the pointer left the window, the terminal did
250
+ // not report button-up, …). Motion with no button held means the gesture
251
+ // is over, so drop any stale press/drag instead of letting a bare hover
252
+ // keep scrolling the log or grow a phantom selection.
253
+ if (!ev.drag) {
254
+ this.pressed = false;
255
+ this.pressStart = null;
256
+ this.barDrag = null;
257
+ }
258
+ if (this.barDrag) {
259
+ const bar = this.top?.scrollbar();
260
+ if (bar) {
261
+ const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
262
+ const thumbTop = Math.max(0, Math.min(ev.y - bar.top - this.barDrag.grab, g.maxThumbTop));
263
+ bar.scrollTo(offsetFromThumbTop(g, thumbTop));
264
+ }
265
+ return;
266
+ }
209
267
  if (this.pressed && this.pressStart) {
210
268
  const s = this.pressStart;
211
269
  if (ev.x !== s.x || ev.y !== s.y) {
@@ -223,6 +281,7 @@ export class App {
223
281
  const sel = this.state.selection;
224
282
  this.pressed = false;
225
283
  this.pressStart = null;
284
+ this.barDrag = null;
226
285
  if (sel) {
227
286
  if (this.lastBuffer) {
228
287
  const text = selectionText(this.lastBuffer, sel);
@@ -245,6 +304,7 @@ export class App {
245
304
  resetMouse() {
246
305
  this.pressed = false;
247
306
  this.pressStart = null;
307
+ this.barDrag = null;
248
308
  }
249
309
  copySelection() {
250
310
  const sel = this.state.selection;
@@ -328,6 +388,10 @@ export class App {
328
388
  this.running = true;
329
389
  if (this.sizeTimer)
330
390
  clearInterval(this.sizeTimer);
391
+ if (this.repaintTimer) {
392
+ clearInterval(this.repaintTimer);
393
+ this.repaintTimer = null;
394
+ }
331
395
  if (this.blinkTimer) {
332
396
  clearInterval(this.blinkTimer);
333
397
  this.blinkTimer = null;
package/dist/colors.js CHANGED
@@ -12,5 +12,7 @@ export const C = {
12
12
  red: [247, 118, 142],
13
13
  codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
14
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
15
17
  };
16
18
  export const EMPTY_STYLE = {};
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Helpers for compressing a long conversation so it fits back inside the
3
+ * model's context window.
4
+ *
5
+ * Everything here is pure: deciding *what* to summarise, and flattening that
6
+ * part into a plain transcript. The actual "ask the model to summarise" step
7
+ * lives in `LLMClient.summarize`, so this module stays trivially unit-testable.
8
+ */
9
+ /** How many trailing messages are always kept verbatim when compressing. */
10
+ export const COMPACT_KEEP = 4;
11
+ /** Per-message character budget fed to the summariser, to bound its own input. */
12
+ export const COMPACT_MAX_CHARS_PER_MESSAGE = 4000;
13
+ /**
14
+ * Split `messages` into an older prefix to summarise and a recent suffix to
15
+ * keep. The kept suffix never begins with a bare tool result — that would be an
16
+ * orphan without the assistant tool-call that produced it — so such messages
17
+ * are folded into the prefix instead.
18
+ *
19
+ * Returns null when there is nothing worth compressing (the conversation is
20
+ * already short enough).
21
+ */
22
+ export function planCompaction(messages, keep = COMPACT_KEEP) {
23
+ if (keep < 1)
24
+ keep = 1;
25
+ const head = [];
26
+ const tail = [];
27
+ for (let i = messages.length - 1; i >= 0; i--) {
28
+ if (tail.length < keep)
29
+ tail.unshift(messages[i]);
30
+ else
31
+ head.push(messages[i]);
32
+ }
33
+ head.reverse();
34
+ // Never open the kept suffix with an orphan tool result.
35
+ while (tail.length > 1 && tail[0].role === "tool") {
36
+ head.push(tail.shift());
37
+ }
38
+ if (head.length === 0 || tail.length === 0)
39
+ return null;
40
+ return { head, tail };
41
+ }
42
+ /**
43
+ * Flatten messages into a plain transcript for the summariser, clipping each
44
+ * message so a single huge tool result cannot dominate the summary request.
45
+ */
46
+ export function transcriptFor(messages, maxCharsPerMessage = COMPACT_MAX_CHARS_PER_MESSAGE) {
47
+ const clip = (s) => {
48
+ const t = s.replace(/\r\n?/g, "\n").trim();
49
+ return t.length > maxCharsPerMessage ? t.slice(0, maxCharsPerMessage) + "\n…[truncated]" : t;
50
+ };
51
+ const parts = [];
52
+ for (const m of messages) {
53
+ const body = clip(m.content ?? "");
54
+ if (m.role === "tool") {
55
+ parts.push(`TOOL ${m.name ?? ""} RESULT:\n${body}`);
56
+ }
57
+ else if (m.role === "assistant") {
58
+ parts.push(`ASSISTANT:\n${body}`);
59
+ }
60
+ else if (m.role === "user") {
61
+ parts.push(`USER:\n${body}`);
62
+ }
63
+ else {
64
+ parts.push(`${m.role.toUpperCase()}:\n${body}`);
65
+ }
66
+ }
67
+ return parts.join("\n\n");
68
+ }
package/dist/config.js CHANGED
@@ -36,6 +36,9 @@ export const DEFAULT_CONFIG = {
36
36
  language: "en",
37
37
  theme: "default",
38
38
  default_intensity: "High",
39
+ // Estimated prompt tokens above which the conversation is auto-compressed.
40
+ // 0 disables auto-compression.
41
+ context_compress_threshold: 32000,
39
42
  };
40
43
  export const INTENSITY_LEVELS = ["Low", "Medium", "High", "Max", "Ultra"];
41
44
  export const INTENSITY_PARAMS = {
@@ -45,6 +48,18 @@ export const INTENSITY_PARAMS = {
45
48
  Max: { temperature: 1.0, top_p: 1.0 },
46
49
  Ultra: { temperature: 1.5, top_p: 1.0 },
47
50
  };
51
+ /**
52
+ * Selectable auto-compression thresholds, in estimated prompt tokens.
53
+ * `0` turns the feature off. Rendered as-is in the settings screen.
54
+ */
55
+ export const COMPRESS_THRESHOLD_OPTIONS = [
56
+ { value: 0, label: "Off" },
57
+ { value: 8000, label: "8K tokens" },
58
+ { value: 16000, label: "16K tokens" },
59
+ { value: 32000, label: "32K tokens" },
60
+ { value: 64000, label: "64K tokens" },
61
+ { value: 128000, label: "128K tokens" },
62
+ ];
48
63
  export function ensureDirs() {
49
64
  fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
50
65
  fs.mkdirSync(SESSION_DIR, { recursive: true });