@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.
@@ -15,6 +15,14 @@ import { hub, liveApprovalDetails } from "../web/hub.js";
15
15
  import { SessionCheckpoint } from "../persistence.js";
16
16
  import { writeCrashLog } from "../terminal/restore.js";
17
17
  const TOOLS = getToolDefinitions();
18
+ /**
19
+ * A finished turn only folds into a one-line summary when hiding it saves real
20
+ * space: four or more visible blocks, or roughly six wrapped lines of text.
21
+ * Short exchanges stay readable without a click. `web/turns.js` applies the
22
+ * same rule in the browser, so both UIs fold exactly the same turns.
23
+ */
24
+ export const TURN_FOLD_MIN_BLOCKS = 4;
25
+ export const TURN_FOLD_MIN_CHARS = 600;
18
26
  function parseArgs(s) {
19
27
  if (!s || !s.trim())
20
28
  return {};
@@ -80,6 +88,19 @@ export class ChatScreen extends Screen {
80
88
  toolLineIndex = [];
81
89
  /** Terminal row -> tool message index, rebuilt each render for hit-testing. */
82
90
  toolRows = new Map();
91
+ // ---- collapsible turns ----
92
+ /** First message index of every turn whose reply has finished. A transcript
93
+ * loaded from disk starts finished: reopening history reviews past work. */
94
+ finishedTurns = new Set();
95
+ /** Turns the reader opened; a finished turn stays folded unless listed here. */
96
+ expandedTurns = new Set();
97
+ /** First message index of the turn being answered right now, if any. */
98
+ activeTurnStart = null;
99
+ /** For each built line, the folded turn it belongs to (-1 = none). Only the
100
+ * header lines carry it, so clicks inside a tool block still target the tool. */
101
+ turnLineIndex = [];
102
+ /** Terminal row -> folded turn start, rebuilt each render for hit-testing. */
103
+ turnRows = new Map();
83
104
  // ---- web bridge ----
84
105
  liveKey = null;
85
106
  liveDir = process.cwd();
@@ -120,6 +141,9 @@ export class ChatScreen extends Screen {
120
141
  for (const m of this.loadedMessages ?? []) {
121
142
  this.messages.push(m);
122
143
  }
144
+ // Everything already on disk was answered, so history opens compact.
145
+ for (const start of this.userMessageIndexes())
146
+ this.finishedTurns.add(start);
123
147
  this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
124
148
  this.liveDir = this.loadedCwd ?? process.cwd();
125
149
  this.checkpoint.register();
@@ -429,6 +453,9 @@ export class ChatScreen extends Screen {
429
453
  const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
430
454
  this.messages = [{ role: "system", content: sysText }];
431
455
  this.expandedTools.clear();
456
+ this.finishedTurns.clear();
457
+ this.expandedTurns.clear();
458
+ this.activeTurnStart = null;
432
459
  this.streamingContent = null;
433
460
  this.scrollTop = 0;
434
461
  this.followBottom = true;
@@ -504,6 +531,11 @@ export class ChatScreen extends Screen {
504
531
  this.app.render();
505
532
  return;
506
533
  }
534
+ const turn = this.turnRows.get(ev.y);
535
+ if (turn !== undefined) {
536
+ this.toggleTurn(turn);
537
+ return;
538
+ }
507
539
  const tool = this.toolRows.get(ev.y);
508
540
  if (tool !== undefined) {
509
541
  this.toggleTool(tool);
@@ -582,6 +614,14 @@ export class ChatScreen extends Screen {
582
614
  this.startTurn();
583
615
  }
584
616
  startTurn() {
617
+ // Sending closes every earlier turn, including one left unfinished by
618
+ // ESC ESC: it is folded from here on, but never while it is still paused.
619
+ const starts = this.userMessageIndexes();
620
+ const active = starts.length > 0 ? starts[starts.length - 1] : null;
621
+ for (const start of starts)
622
+ if (start !== active)
623
+ this.finishedTurns.add(start);
624
+ this.activeTurnStart = active;
585
625
  const turnMessages = this.messages.slice();
586
626
  const approvalContext = captureApprovalContext(turnMessages);
587
627
  this.busy = true;
@@ -687,7 +727,7 @@ export class ChatScreen extends Screen {
687
727
  this.messages.push({ role: "assistant", content });
688
728
  this.streamingContent = null;
689
729
  this.app.render();
690
- this.onTurnFinished();
730
+ this.onTurnFinished(false);
691
731
  return;
692
732
  }
693
733
  const toolCalls = client.toolCalls;
@@ -769,7 +809,14 @@ export class ChatScreen extends Screen {
769
809
  this.onTurnFinished();
770
810
  }
771
811
  }
772
- onTurnFinished() {
812
+ /**
813
+ * Ends the turn. `finished` is false when the turn was cut short (ESC ESC,
814
+ * Ctrl+C): a paused turn is not a completed one and stays open for reading.
815
+ */
816
+ onTurnFinished(finished = true) {
817
+ if (finished && this.activeTurnStart !== null)
818
+ this.finishedTurns.add(this.activeTurnStart);
819
+ this.activeTurnStart = null;
773
820
  this.busy = false;
774
821
  this.paused = false;
775
822
  this.app.state.isThinking = false;
@@ -900,6 +947,111 @@ export class ChatScreen extends Screen {
900
947
  deny: { x: denyX, y: by, w: deny.length },
901
948
  };
902
949
  }
950
+ // ---- turn folding ----------------------------------------------------
951
+ /** First message index of every turn: a turn starts at a user message and
952
+ * ends where the next one begins. */
953
+ userMessageIndexes() {
954
+ const starts = [];
955
+ for (let i = 0; i < this.messages.length; i++) {
956
+ if (this.messages[i].role === "user")
957
+ starts.push(i);
958
+ }
959
+ return starts;
960
+ }
961
+ turnEnd(start) {
962
+ for (let i = start + 1; i < this.messages.length; i++) {
963
+ if (this.messages[i].role === "user")
964
+ return i;
965
+ }
966
+ return this.messages.length;
967
+ }
968
+ /**
969
+ * A finished turn is only worth folding when it hides something substantial.
970
+ * Whether it is folded right now is a separate question (`expandedTurns`).
971
+ */
972
+ turnHides(start, end) {
973
+ if (!this.finishedTurns.has(start))
974
+ return false;
975
+ let blocks = 0;
976
+ let chars = 0;
977
+ for (let i = start; i < end; i++) {
978
+ const m = this.messages[i];
979
+ if (m.role === "user" || m.role === "assistant") {
980
+ blocks++;
981
+ chars += m.content.length;
982
+ }
983
+ }
984
+ return blocks >= TURN_FOLD_MIN_BLOCKS || chars >= TURN_FOLD_MIN_CHARS;
985
+ }
986
+ /** For every message, the first index of the turn folding it (-1 = none). */
987
+ turnMap() {
988
+ const map = new Array(this.messages.length).fill(-1);
989
+ for (const start of this.userMessageIndexes()) {
990
+ const end = this.turnEnd(start);
991
+ if (!this.turnHides(start, end))
992
+ continue;
993
+ for (let i = start; i < end; i++)
994
+ map[i] = start;
995
+ }
996
+ return map;
997
+ }
998
+ /**
999
+ * The first line of the first message in `role` (the last one with `last`).
1000
+ * Only one line: the preview is a signpost, and the full text is a click away.
1001
+ */
1002
+ turnPreview(start, end, role, last = false) {
1003
+ const slice = this.messages.slice(start, end);
1004
+ for (const m of last ? slice.reverse() : slice) {
1005
+ if (m.role !== role)
1006
+ continue;
1007
+ const line = sanitizeText(m.content)
1008
+ .split("\n")
1009
+ .map((part) => part.trim())
1010
+ .find((part) => part.length > 0);
1011
+ if (line)
1012
+ return line;
1013
+ }
1014
+ return "";
1015
+ }
1016
+ /** The header standing in for a folded turn, or the way back into one. */
1017
+ pushTurnHeader(start, end, width, add) {
1018
+ const count = end - start;
1019
+ if (this.expandedTurns.has(start)) {
1020
+ // Open: the messages below say everything, so the line is only the way
1021
+ // back. Reserve the room the hint needs so a narrow terminal keeps it.
1022
+ const hint = " — click to collapse";
1023
+ const label = `▾ ${count} message${count === 1 ? "" : "s"}`;
1024
+ add({
1025
+ spans: [
1026
+ { text: ellipsize(label, Math.max(10, width - displayWidth(hint))), style: { fg: C.dim, bold: true } },
1027
+ { text: hint, style: { fg: C.dim } },
1028
+ ],
1029
+ }, -1, start);
1030
+ return;
1031
+ }
1032
+ const you = `You: ${this.turnPreview(start, end, "user") || "(no text)"}`;
1033
+ add({ spans: [{ text: ellipsize(`▸ ${you}`, width), style: { fg: C.amber, bold: true } }] }, -1, start);
1034
+ const reply = this.turnPreview(start, end, "assistant", true);
1035
+ const meta = `${count} message${count === 1 ? "" : "s"} folded`;
1036
+ add({
1037
+ spans: [
1038
+ {
1039
+ text: ellipsize(` ${meta}${reply ? ` · Neutrino: ${reply}` : ""} — click to expand`, width),
1040
+ style: { fg: C.dim },
1041
+ },
1042
+ ],
1043
+ }, -1, start);
1044
+ }
1045
+ /** Fold or unfold one turn. */
1046
+ toggleTurn(start) {
1047
+ if (this.expandedTurns.has(start))
1048
+ this.expandedTurns.delete(start);
1049
+ else
1050
+ this.expandedTurns.add(start);
1051
+ // Expanding pushes the rest of the log down, so do not snap to the bottom.
1052
+ this.followBottom = false;
1053
+ this.app.render();
1054
+ }
903
1055
  /** Expand/collapse the tool block belonging to message `index`. */
904
1056
  toggleTool(index) {
905
1057
  if (this.expandedTools.has(index))
@@ -1044,9 +1196,13 @@ export class ChatScreen extends Screen {
1044
1196
  // Parallel map from rendered line -> owning tool message index (-1 = none),
1045
1197
  // used to turn a click on any line of a tool block into an expand/collapse.
1046
1198
  const toolLineIndex = [];
1047
- const add = (line, tool = -1) => {
1199
+ // Same idea for a folded turn. Only its header lines point at the turn, so
1200
+ // the messages of an expanded turn keep their own click targets.
1201
+ const turnLineIndex = [];
1202
+ const add = (line, tool = -1, turn = -1) => {
1048
1203
  lines.push(line);
1049
1204
  toolLineIndex.push(tool);
1205
+ turnLineIndex.push(turn);
1050
1206
  };
1051
1207
  const pushText = (text, style, tool = -1) => {
1052
1208
  for (const ln of wrapText(text, width))
@@ -1057,10 +1213,13 @@ export class ChatScreen extends Screen {
1057
1213
  add(ln);
1058
1214
  };
1059
1215
  const context = normalizeContext(this.context, this.messages.filter((m) => m.role !== "system").length);
1216
+ // Which turn owns each message: [] -> first index of that turn, -1 = none.
1217
+ const turns = this.turnMap();
1060
1218
  this.contextLines.clear();
1061
1219
  let recordIndex = 0;
1062
1220
  for (let mi = 0; mi < this.messages.length; mi++) {
1063
1221
  const m = this.messages[mi];
1222
+ const turn = m.role === "system" ? -1 : turns[mi];
1064
1223
  let archived = false;
1065
1224
  if (m.role !== "system") {
1066
1225
  if (recordIndex === 0 && context) {
@@ -1069,12 +1228,20 @@ export class ChatScreen extends Screen {
1069
1228
  }
1070
1229
  archived = context !== null && recordIndex < context.end;
1071
1230
  recordIndex++;
1072
- if (archived && !this.expandedContext)
1073
- continue;
1231
+ }
1232
+ // A foldable turn always shows its control line first; its messages then
1233
+ // render normally when the reader has opened it.
1234
+ if (m.role !== "system" && turn === mi && !(archived && !this.expandedContext)) {
1235
+ this.pushTurnHeader(mi, this.turnEnd(mi), width, add);
1074
1236
  }
1075
1237
  if (m.role === "system") {
1076
1238
  pushText(sanitizeText(m.content), { fg: C.dim });
1077
1239
  }
1240
+ else if ((archived && !this.expandedContext) || (turn >= 0 && !this.expandedTurns.has(turn))) {
1241
+ // Hidden, by the collapsed "Earlier conversation" fold or by the turn's
1242
+ // own fold. Either way the message stays in the transcript, on disk and
1243
+ // in the request; the header above says how to get it back on screen.
1244
+ }
1078
1245
  else if (m.role === "user") {
1079
1246
  add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
1080
1247
  pushMarkdown(sanitizeText(m.content), { fg: C.user });
@@ -1141,6 +1308,7 @@ export class ChatScreen extends Screen {
1141
1308
  pushMarkdown(sanitizeText(this.streamingContent), { fg: C.assistant });
1142
1309
  }
1143
1310
  this.toolLineIndex = toolLineIndex;
1311
+ this.turnLineIndex = turnLineIndex;
1144
1312
  return lines;
1145
1313
  }
1146
1314
  render(buf) {
@@ -1166,7 +1334,7 @@ export class ChatScreen extends Screen {
1166
1334
  statusStyle = { fg: C.amber, bold: true };
1167
1335
  }
1168
1336
  else if (this.busy) {
1169
- status = "Streaming... press ESC ESC to pause";
1337
+ status = "Streaming... press Ctrl+C or ESC ESC to pause";
1170
1338
  statusStyle = { fg: C.dim };
1171
1339
  }
1172
1340
  if (status && this.approvalToggleY(h) !== 1)
@@ -1195,6 +1363,7 @@ export class ChatScreen extends Screen {
1195
1363
  // click can expand/collapse it (see the onMouse handler).
1196
1364
  this.toolRows.clear();
1197
1365
  this.contextRows.clear();
1366
+ this.turnRows.clear();
1198
1367
  for (let y = logTop; y <= logBottom; y++) {
1199
1368
  const li = this.scrollTop + (y - logTop);
1200
1369
  if (li >= lines.length)
@@ -1202,6 +1371,9 @@ export class ChatScreen extends Screen {
1202
1371
  const tool = this.toolLineIndex[li];
1203
1372
  if (tool !== undefined && tool >= 0)
1204
1373
  this.toolRows.set(y, tool);
1374
+ const turn = this.turnLineIndex[li];
1375
+ if (turn !== undefined && turn >= 0)
1376
+ this.turnRows.set(y, turn);
1205
1377
  if (this.contextLines.has(li))
1206
1378
  this.contextRows.add(y);
1207
1379
  drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
@@ -1222,7 +1394,7 @@ export class ChatScreen extends Screen {
1222
1394
  placeholder: "Type a message...",
1223
1395
  });
1224
1396
  const hintY = h - 1;
1225
- const hints = "Enter send · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
1397
+ const hints = "Enter send · Ctrl+C pause · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
1226
1398
  const hintW = displayWidth(hints);
1227
1399
  const hintX = Math.max(0, w - 1 - hintW);
1228
1400
  const pathAvail = Math.max(0, hintX - 2);
package/dist/selection.js CHANGED
@@ -6,6 +6,12 @@ export function normalizeSelection(sel) {
6
6
  return { start: a, end: b };
7
7
  return { start: b, end: a };
8
8
  }
9
+ /** Order the two ends of a line-space selection into reading order. */
10
+ export function orderLinePoints(a, b) {
11
+ if (a.line < b.line || (a.line === b.line && a.x <= b.x))
12
+ return { start: a, end: b };
13
+ return { start: b, end: a };
14
+ }
9
15
  /**
10
16
  * Index of the last cell on a row that holds real content, or -1 when the row
11
17
  * has none. UI chrome (a scrollbar) and blank cells are ignored, so a highlight
@@ -87,3 +93,57 @@ export function selectionText(buf, sel) {
87
93
  lines.pop();
88
94
  return lines.join("\n");
89
95
  }
96
+ /**
97
+ * One buffer row as column-indexed text: `cells[x]` is what column `x` holds,
98
+ * or `""` for a cell that never copies (UI chrome, and the tail of a
99
+ * double-width glyph).
100
+ *
101
+ * Keeping the column index is the point: it lets a row be sliced by column
102
+ * later, after the frame it came from is long gone. A drag that scrolls the log
103
+ * needs exactly that, because the rows it started on leave the screen while the
104
+ * selection keeps growing.
105
+ */
106
+ export function rowCells(buf, y) {
107
+ const row = buf.cells[y];
108
+ if (!row)
109
+ return [];
110
+ const cells = new Array(row.length);
111
+ for (let x = 0; x < row.length; x++) {
112
+ const cell = row[x];
113
+ cells[x] = !cell || cell.chrome || cell.ch === "" ? "" : cell.ch;
114
+ }
115
+ return cells;
116
+ }
117
+ /**
118
+ * Extract the text of a selection expressed in content lines (`rowSelectionText`
119
+ * is to `selectionText` what `LinePoint` is to `Point`).
120
+ *
121
+ * `getRow` supplies the cells of a line — from the current frame while the line
122
+ * is on screen, from the snapshot taken during the drag once it has scrolled
123
+ * away, or `null` for a line that was never captured. Trailing blanks and edge
124
+ * blank lines are trimmed exactly like `selectionText`, so both paths produce
125
+ * the same text for a selection that fits on one screen.
126
+ */
127
+ export function rowSelectionText(getRow, start, end) {
128
+ const lines = [];
129
+ for (let line = start.line; line <= end.line; line++) {
130
+ const row = getRow(line);
131
+ if (!row) {
132
+ // Never captured (a line that scrolled past without being drawn). Keep
133
+ // the line count honest instead of glueing its neighbours together.
134
+ lines.push("");
135
+ continue;
136
+ }
137
+ const x0 = line === start.line ? Math.max(0, start.x) : 0;
138
+ const x1 = line === end.line ? Math.min(row.length - 1, end.x) : row.length - 1;
139
+ let text = "";
140
+ for (let x = x0; x <= x1; x++)
141
+ text += row[x] ?? "";
142
+ lines.push(text.replace(/[ \t]+$/, ""));
143
+ }
144
+ while (lines.length > 0 && lines[0] === "")
145
+ lines.shift();
146
+ while (lines.length > 0 && lines[lines.length - 1] === "")
147
+ lines.pop();
148
+ return lines.join("\n");
149
+ }
@@ -194,6 +194,24 @@ export class InputReader {
194
194
  finishCsi() {
195
195
  const s = this.csiBuf;
196
196
  const final = s[s.length - 1];
197
+ // CSI-u (kitty) and xterm modifyOtherKeys preserve Ctrl+Shift separately.
198
+ const extended = final === "u"
199
+ ? /^(\d+)(?::[\d:]*)?(?:;(\d+)(?::(\d+))?)?(?:;[\d:]*)?u$/.exec(s)
200
+ : /^27;(\d+);(\d+)~$/.exec(s);
201
+ if (extended) {
202
+ const code = Number(extended[final === "u" ? 1 : 2]);
203
+ const modifiers = Number(extended[final === "u" ? 2 : 1] ?? 1) - 1;
204
+ // Ignore key-up reports: releasing C must not count as another press.
205
+ if (final === "u" && extended[3] === "3")
206
+ return;
207
+ if (code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
208
+ return;
209
+ const char = String.fromCodePoint(code);
210
+ const special = { 13: "return", 9: "tab", 27: "escape", 127: "backspace" };
211
+ const ctrl = (modifiers & 4) !== 0;
212
+ this.emitKey(special[code] ?? (ctrl ? char.toLowerCase() : char), ctrl, (modifiers & 2) !== 0, (modifiers & 1) !== 0, char, "\x1b[" + s);
213
+ return;
214
+ }
197
215
  if (final === "A")
198
216
  this.emitKey("up", false, false, false, "\x1b[A");
199
217
  else if (final === "B")
@@ -272,7 +290,7 @@ export class InputReader {
272
290
  this.emitMouse({ type: "click", x, y, button });
273
291
  }
274
292
  }
275
- emitKey(name, ctrl, meta, shift, char) {
293
+ emitKey(name, ctrl, meta, shift, char, sequence = char) {
276
294
  if (!this.handler)
277
295
  return;
278
296
  this.handler({
@@ -283,7 +301,7 @@ export class InputReader {
283
301
  meta,
284
302
  shift,
285
303
  char,
286
- sequence: char,
304
+ sequence,
287
305
  },
288
306
  });
289
307
  }
@@ -50,13 +50,20 @@ export class Renderer {
50
50
  prevH = 0;
51
51
  out = process.stdout;
52
52
  /**
53
- * The most recent frame and the rows that were force-repainted for it. Kept
54
- * so `repaint()` can re-emit the exact same frame — without re-running the
53
+ * The most recent frame, kept so `repaint()` can re-emit it without re-running the
55
54
  * screen's (potentially expensive) render — to heal any drift the terminal
56
55
  * introduced behind our back.
57
56
  */
58
57
  lastBuf = null;
59
- lastForce = [];
58
+ blocked = false;
59
+ pendingForce = new Set();
60
+ drainHandler = () => {
61
+ this.blocked = false;
62
+ const forceRows = [...this.pendingForce];
63
+ this.pendingForce.clear();
64
+ if (!this.disposed && this.lastBuf)
65
+ this.draw(this.lastBuf, forceRows);
66
+ };
60
67
  init() {
61
68
  // Tell the crash guard the terminal is ours *before* touching it, so a
62
69
  // crash that skips dispose() can still undo every switch below.
@@ -65,11 +72,14 @@ export class Renderer {
65
72
  this.write("\x1b[?25l"); // hide cursor
66
73
  this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
67
74
  this.write("\x1b[?2004h"); // bracketed paste (so pastes can be told apart from typing)
75
+ this.write("\x1b[>4;2m\x1b[>1u"); // xterm/kitty modified keys, including Ctrl+Shift+C
68
76
  }
69
77
  dispose() {
70
78
  if (this.disposed)
71
79
  return;
72
80
  this.disposed = true;
81
+ this.out.removeListener?.("drain", this.drainHandler);
82
+ this.pendingForce.clear();
73
83
  // stdout.write may still be queued when App.quit calls process.exit.
74
84
  // Use the same synchronous teardown as the crash/exit guard.
75
85
  restoreTerminalSync();
@@ -82,7 +92,6 @@ export class Renderer {
82
92
  */
83
93
  render(buf, forceRows = []) {
84
94
  this.lastBuf = buf;
85
- this.lastForce = forceRows;
86
95
  this.draw(buf, forceRows);
87
96
  }
88
97
  /**
@@ -110,6 +119,13 @@ export class Renderer {
110
119
  this.write("\x07".repeat(count));
111
120
  }
112
121
  draw(buf, forceRows) {
122
+ if (this.disposed)
123
+ return;
124
+ if (this.blocked) {
125
+ for (const y of forceRows)
126
+ this.pendingForce.add(y);
127
+ return;
128
+ }
113
129
  const w = buf.width;
114
130
  const h = buf.height;
115
131
  if (w !== this.prevW || h !== this.prevH) {
@@ -127,8 +143,12 @@ export class Renderer {
127
143
  this.prevSig[y] = sig;
128
144
  }
129
145
  }
130
- if (out)
131
- this.write(out);
146
+ if (out && !this.write(out) && typeof this.out.once === "function") {
147
+ // The stream already accepted this frame. Wait for it to drain, then
148
+ // diff straight to the latest frame instead of queueing old mouse moves.
149
+ this.blocked = true;
150
+ this.out.once("drain", this.drainHandler);
151
+ }
132
152
  }
133
153
  renderRow(y, row) {
134
154
  // Move to the row, reset attributes and erase the whole line first. The
@@ -158,13 +178,14 @@ export class Renderer {
158
178
  }
159
179
  write(chunk) {
160
180
  if (this.disposed || this.out.destroyed || this.out.writableEnded)
161
- return;
181
+ return true;
162
182
  try {
163
- this.out.write(chunk);
183
+ return this.out.write(chunk) !== false;
164
184
  }
165
185
  catch {
166
186
  // A terminal that vanished (closed window, dying conhost) must not take
167
187
  // the whole session down with it.
188
+ return true;
168
189
  }
169
190
  }
170
191
  }
@@ -12,7 +12,8 @@ const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
12
12
  * half-configured terminal. Order matters: `?1049l` must come last or the
13
13
  * remaining writes would land on the main screen.
14
14
  */
15
- export const TUI_TEARDOWN = "\x1b[?2004l" + // bracketed paste off
15
+ export const TUI_TEARDOWN = "\x1b[<u\x1b[>4m" + // restore kitty keyboard flags and xterm modified keys
16
+ "\x1b[?2004l" + // bracketed paste off
16
17
  "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l" + // mouse tracking off
17
18
  "\x1b[?25h" + // cursor visible
18
19
  "\x1b[0m" + // default attributes
@@ -121,7 +122,7 @@ export function installCrashGuard() {
121
122
  }
122
123
  });
123
124
  // These signals otherwise bypass Node's exit hook, including Ctrl+Break on
124
- // Windows. SIGINT/SIGTERM are handled by App.quit (or the web server).
125
+ // Windows. The app handles SIGINT as an interrupt and SIGTERM as a quit.
125
126
  process.on("SIGHUP", () => process.exit(129));
126
127
  process.on("SIGBREAK", () => process.exit(149));
127
128
  const fatal = (kind, err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.json",
21
- "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-settings.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
21
+ "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-turn-fold.mjs && node scripts/test-web-settings.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
22
22
  "prepare": "npm run build",
23
23
  "web": "node dist/bin.js web"
24
24
  },
package/web/README.md CHANGED
@@ -5,7 +5,9 @@ a port you choose. It lets you:
5
5
 
6
6
  - browse every saved conversation grouped by working directory (like Codex),
7
7
  with only the five latest transcripts per path shown and the rest collapsed;
8
- - watch a running terminal conversation stream in real time;
8
+ - watch a running terminal conversation stream in real time, with every
9
+ finished turn folded into one summary line that opens on a click (the turn
10
+ being answered stays open, and a paused turn is not folded either);
9
11
  - switch the provider, model and intensity for the next turn;
10
12
  - edit defaults, context compression and approval settings in a Settings dialog;
11
13
  - drive the running conversation: pause / resume the current turn and send new