@xingjisoft/neutrino 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,16 @@ 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 folds its *work* away — the prompt body, the thinking and the
20
+ * tool calls — and never its final reply, which always stays on screen. Doing
21
+ * that is only worth a click when the work is substantial: four or more blocks,
22
+ * or roughly six wrapped lines of text. Short exchanges stay readable without a
23
+ * click. `web/turns.js` applies the same rule in the browser, so both UIs fold
24
+ * exactly the same turns.
25
+ */
26
+ export const TURN_FOLD_MIN_BLOCKS = 4;
27
+ export const TURN_FOLD_MIN_CHARS = 600;
18
28
  function parseArgs(s) {
19
29
  if (!s || !s.trim())
20
30
  return {};
@@ -80,6 +90,19 @@ export class ChatScreen extends Screen {
80
90
  toolLineIndex = [];
81
91
  /** Terminal row -> tool message index, rebuilt each render for hit-testing. */
82
92
  toolRows = new Map();
93
+ // ---- collapsible turns ----
94
+ /** First message index of every turn whose reply has finished. A transcript
95
+ * loaded from disk starts finished: reopening history reviews past work. */
96
+ finishedTurns = new Set();
97
+ /** Turns the reader opened; a finished turn stays folded unless listed here. */
98
+ expandedTurns = new Set();
99
+ /** First message index of the turn being answered right now, if any. */
100
+ activeTurnStart = null;
101
+ /** For each built line, the folded turn it belongs to (-1 = none). Only the
102
+ * header lines carry it, so clicks inside a tool block still target the tool. */
103
+ turnLineIndex = [];
104
+ /** Terminal row -> folded turn start, rebuilt each render for hit-testing. */
105
+ turnRows = new Map();
83
106
  // ---- web bridge ----
84
107
  liveKey = null;
85
108
  liveDir = process.cwd();
@@ -120,6 +143,9 @@ export class ChatScreen extends Screen {
120
143
  for (const m of this.loadedMessages ?? []) {
121
144
  this.messages.push(m);
122
145
  }
146
+ // Everything already on disk was answered, so history opens compact.
147
+ for (const start of this.userMessageIndexes())
148
+ this.finishedTurns.add(start);
123
149
  this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
124
150
  this.liveDir = this.loadedCwd ?? process.cwd();
125
151
  this.checkpoint.register();
@@ -429,6 +455,9 @@ export class ChatScreen extends Screen {
429
455
  const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
430
456
  this.messages = [{ role: "system", content: sysText }];
431
457
  this.expandedTools.clear();
458
+ this.finishedTurns.clear();
459
+ this.expandedTurns.clear();
460
+ this.activeTurnStart = null;
432
461
  this.streamingContent = null;
433
462
  this.scrollTop = 0;
434
463
  this.followBottom = true;
@@ -504,6 +533,11 @@ export class ChatScreen extends Screen {
504
533
  this.app.render();
505
534
  return;
506
535
  }
536
+ const turn = this.turnRows.get(ev.y);
537
+ if (turn !== undefined) {
538
+ this.toggleTurn(turn);
539
+ return;
540
+ }
507
541
  const tool = this.toolRows.get(ev.y);
508
542
  if (tool !== undefined) {
509
543
  this.toggleTool(tool);
@@ -582,6 +616,14 @@ export class ChatScreen extends Screen {
582
616
  this.startTurn();
583
617
  }
584
618
  startTurn() {
619
+ // Sending closes every earlier turn, including one left unfinished by
620
+ // ESC ESC: it is folded from here on, but never while it is still paused.
621
+ const starts = this.userMessageIndexes();
622
+ const active = starts.length > 0 ? starts[starts.length - 1] : null;
623
+ for (const start of starts)
624
+ if (start !== active)
625
+ this.finishedTurns.add(start);
626
+ this.activeTurnStart = active;
585
627
  const turnMessages = this.messages.slice();
586
628
  const approvalContext = captureApprovalContext(turnMessages);
587
629
  this.busy = true;
@@ -687,7 +729,7 @@ export class ChatScreen extends Screen {
687
729
  this.messages.push({ role: "assistant", content });
688
730
  this.streamingContent = null;
689
731
  this.app.render();
690
- this.onTurnFinished();
732
+ this.onTurnFinished(false);
691
733
  return;
692
734
  }
693
735
  const toolCalls = client.toolCalls;
@@ -769,7 +811,14 @@ export class ChatScreen extends Screen {
769
811
  this.onTurnFinished();
770
812
  }
771
813
  }
772
- onTurnFinished() {
814
+ /**
815
+ * Ends the turn. `finished` is false when the turn was cut short (ESC ESC,
816
+ * Ctrl+C): a paused turn is not a completed one and stays open for reading.
817
+ */
818
+ onTurnFinished(finished = true) {
819
+ if (finished && this.activeTurnStart !== null)
820
+ this.finishedTurns.add(this.activeTurnStart);
821
+ this.activeTurnStart = null;
773
822
  this.busy = false;
774
823
  this.paused = false;
775
824
  this.app.state.isThinking = false;
@@ -900,6 +949,147 @@ export class ChatScreen extends Screen {
900
949
  deny: { x: denyX, y: by, w: deny.length },
901
950
  };
902
951
  }
952
+ // ---- turn folding ----------------------------------------------------
953
+ /** First message index of every turn: a turn starts at a user message and
954
+ * ends where the next one begins. */
955
+ userMessageIndexes() {
956
+ const starts = [];
957
+ for (let i = 0; i < this.messages.length; i++) {
958
+ if (this.messages[i].role === "user")
959
+ starts.push(i);
960
+ }
961
+ return starts;
962
+ }
963
+ turnEnd(start) {
964
+ for (let i = start + 1; i < this.messages.length; i++) {
965
+ if (this.messages[i].role === "user")
966
+ return i;
967
+ }
968
+ return this.messages.length;
969
+ }
970
+ /**
971
+ * A finished turn is only worth folding when the work it hides is substantial.
972
+ * Whether it is folded right now is a separate question (`expandedTurns`), and
973
+ * its reply is never part of what it hides.
974
+ */
975
+ turnHides(start, end) {
976
+ if (!this.finishedTurns.has(start))
977
+ return false;
978
+ const answer = this.turnAnswer(start, end);
979
+ let blocks = 0;
980
+ let chars = 0;
981
+ for (let i = start; i < end; i++) {
982
+ // The reply stays on screen, so it is neither hidden content nor a reason
983
+ // to fold: a long answer alone must never collapse its own turn.
984
+ if (i === answer)
985
+ continue;
986
+ const m = this.messages[i];
987
+ if (m.role === "user" || m.role === "assistant") {
988
+ blocks++;
989
+ chars += m.content.length;
990
+ }
991
+ }
992
+ return blocks >= TURN_FOLD_MIN_BLOCKS || chars >= TURN_FOLD_MIN_CHARS;
993
+ }
994
+ /**
995
+ * The index of the turn's reply: its last assistant message. A fold hides the
996
+ * work that led to the answer, so this message is always rendered.
997
+ */
998
+ turnAnswer(start, end) {
999
+ for (let i = end - 1; i > start; i--) {
1000
+ if (this.messages[i].role === "assistant")
1001
+ return i;
1002
+ }
1003
+ return -1;
1004
+ }
1005
+ /** For every message, the reply of the turn it belongs to (-1 = none). */
1006
+ turnAnswers() {
1007
+ const answers = new Array(this.messages.length).fill(-1);
1008
+ let start = -1;
1009
+ let answer = -1;
1010
+ const close = (end) => {
1011
+ if (start < 0 || answer < 0)
1012
+ return;
1013
+ for (let i = start; i < end; i++)
1014
+ answers[i] = answer;
1015
+ };
1016
+ for (let i = 0; i < this.messages.length; i++) {
1017
+ const role = this.messages[i].role;
1018
+ if (role === "user") {
1019
+ close(i);
1020
+ start = i;
1021
+ answer = -1;
1022
+ }
1023
+ else if (role === "assistant") {
1024
+ answer = i;
1025
+ }
1026
+ }
1027
+ close(this.messages.length);
1028
+ return answers;
1029
+ }
1030
+ /** For every message, the first index of the turn folding it (-1 = none). */
1031
+ turnMap() {
1032
+ const map = new Array(this.messages.length).fill(-1);
1033
+ for (const start of this.userMessageIndexes()) {
1034
+ const end = this.turnEnd(start);
1035
+ if (!this.turnHides(start, end))
1036
+ continue;
1037
+ for (let i = start; i < end; i++)
1038
+ map[i] = start;
1039
+ }
1040
+ return map;
1041
+ }
1042
+ /**
1043
+ * The first line of the first message in `role`. Only one line: the header is
1044
+ * a signpost, and the full text is one click away.
1045
+ */
1046
+ turnPreview(start, end, role) {
1047
+ for (const m of this.messages.slice(start, end)) {
1048
+ if (m.role !== role)
1049
+ continue;
1050
+ const line = sanitizeText(m.content)
1051
+ .split("\n")
1052
+ .map((part) => part.trim())
1053
+ .find((part) => part.length > 0);
1054
+ if (line)
1055
+ return line;
1056
+ }
1057
+ return "";
1058
+ }
1059
+ /** The header standing in for a folded turn, or the way back into one. */
1060
+ pushTurnHeader(start, end, width, add) {
1061
+ const count = end - start;
1062
+ if (this.expandedTurns.has(start)) {
1063
+ // Open: the messages below say everything, so the line is only the way
1064
+ // back. Reserve the room the hint needs so a narrow terminal keeps it.
1065
+ const hint = " — click to collapse";
1066
+ const label = `▾ ${count} message${count === 1 ? "" : "s"}`;
1067
+ add({
1068
+ spans: [
1069
+ { text: ellipsize(label, Math.max(10, width - displayWidth(hint))), style: { fg: C.dim, bold: true } },
1070
+ { text: hint, style: { fg: C.dim } },
1071
+ ],
1072
+ }, -1, start);
1073
+ return;
1074
+ }
1075
+ const you = `You: ${this.turnPreview(start, end, "user") || "(no text)"}`;
1076
+ add({ spans: [{ text: ellipsize(`▸ ${you}`, width), style: { fg: C.amber, bold: true } }] }, -1, start);
1077
+ // The prompt and the reply sit either side of the work this fold hides, so
1078
+ // the control line goes between them and the answer stays on screen below.
1079
+ const hidden = this.turnAnswer(start, end) < 0 ? count : count - 1;
1080
+ const meta = `${hidden} of ${count} message${count === 1 ? "" : "s"} folded`;
1081
+ add({ spans: [{ text: ellipsize(` ${meta} — click to expand`, width), style: { fg: C.dim } }] }, -1, start);
1082
+ }
1083
+ /** Fold or unfold one turn. */
1084
+ toggleTurn(start) {
1085
+ if (this.expandedTurns.has(start))
1086
+ this.expandedTurns.delete(start);
1087
+ else
1088
+ this.expandedTurns.add(start);
1089
+ // Expanding pushes the rest of the log down, so do not snap to the bottom.
1090
+ this.followBottom = false;
1091
+ this.app.render();
1092
+ }
903
1093
  /** Expand/collapse the tool block belonging to message `index`. */
904
1094
  toggleTool(index) {
905
1095
  if (this.expandedTools.has(index))
@@ -1044,9 +1234,13 @@ export class ChatScreen extends Screen {
1044
1234
  // Parallel map from rendered line -> owning tool message index (-1 = none),
1045
1235
  // used to turn a click on any line of a tool block into an expand/collapse.
1046
1236
  const toolLineIndex = [];
1047
- const add = (line, tool = -1) => {
1237
+ // Same idea for a folded turn. Only its header lines point at the turn, so
1238
+ // the messages of an expanded turn keep their own click targets.
1239
+ const turnLineIndex = [];
1240
+ const add = (line, tool = -1, turn = -1) => {
1048
1241
  lines.push(line);
1049
1242
  toolLineIndex.push(tool);
1243
+ turnLineIndex.push(turn);
1050
1244
  };
1051
1245
  const pushText = (text, style, tool = -1) => {
1052
1246
  for (const ln of wrapText(text, width))
@@ -1057,10 +1251,15 @@ export class ChatScreen extends Screen {
1057
1251
  add(ln);
1058
1252
  };
1059
1253
  const context = normalizeContext(this.context, this.messages.filter((m) => m.role !== "system").length);
1254
+ // Which turn owns each message: [] -> first index of that turn, -1 = none.
1255
+ const turns = this.turnMap();
1256
+ // Per message, the reply of its turn: a folded turn always renders that one.
1257
+ const answers = this.turnAnswers();
1060
1258
  this.contextLines.clear();
1061
1259
  let recordIndex = 0;
1062
1260
  for (let mi = 0; mi < this.messages.length; mi++) {
1063
1261
  const m = this.messages[mi];
1262
+ const turn = m.role === "system" ? -1 : turns[mi];
1064
1263
  let archived = false;
1065
1264
  if (m.role !== "system") {
1066
1265
  if (recordIndex === 0 && context) {
@@ -1069,12 +1268,23 @@ export class ChatScreen extends Screen {
1069
1268
  }
1070
1269
  archived = context !== null && recordIndex < context.end;
1071
1270
  recordIndex++;
1072
- if (archived && !this.expandedContext)
1073
- continue;
1074
1271
  }
1272
+ // A foldable turn always shows its control lines first; its work then
1273
+ // renders when the reader has opened it, and its reply always does.
1274
+ if (m.role !== "system" && turn === mi && !(archived && !this.expandedContext)) {
1275
+ this.pushTurnHeader(mi, this.turnEnd(mi), width, add);
1276
+ }
1277
+ const folded = turn >= 0 && !this.expandedTurns.has(turn);
1278
+ const reply = folded && answers[mi] === mi;
1075
1279
  if (m.role === "system") {
1076
1280
  pushText(sanitizeText(m.content), { fg: C.dim });
1077
1281
  }
1282
+ else if ((archived && !this.expandedContext) || (folded && !reply)) {
1283
+ // Hidden: by the collapsed "Earlier conversation" fold, or by the turn's
1284
+ // own fold, which hides the prompt body and the work but never the
1285
+ // reply. Either way the message stays in the transcript, on disk and in
1286
+ // the request; the header above says how to get it back on screen.
1287
+ }
1078
1288
  else if (m.role === "user") {
1079
1289
  add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
1080
1290
  pushMarkdown(sanitizeText(m.content), { fg: C.user });
@@ -1141,6 +1351,7 @@ export class ChatScreen extends Screen {
1141
1351
  pushMarkdown(sanitizeText(this.streamingContent), { fg: C.assistant });
1142
1352
  }
1143
1353
  this.toolLineIndex = toolLineIndex;
1354
+ this.turnLineIndex = turnLineIndex;
1144
1355
  return lines;
1145
1356
  }
1146
1357
  render(buf) {
@@ -1166,7 +1377,7 @@ export class ChatScreen extends Screen {
1166
1377
  statusStyle = { fg: C.amber, bold: true };
1167
1378
  }
1168
1379
  else if (this.busy) {
1169
- status = "Streaming... press ESC ESC to pause";
1380
+ status = "Streaming... press Ctrl+C or ESC ESC to pause";
1170
1381
  statusStyle = { fg: C.dim };
1171
1382
  }
1172
1383
  if (status && this.approvalToggleY(h) !== 1)
@@ -1195,6 +1406,7 @@ export class ChatScreen extends Screen {
1195
1406
  // click can expand/collapse it (see the onMouse handler).
1196
1407
  this.toolRows.clear();
1197
1408
  this.contextRows.clear();
1409
+ this.turnRows.clear();
1198
1410
  for (let y = logTop; y <= logBottom; y++) {
1199
1411
  const li = this.scrollTop + (y - logTop);
1200
1412
  if (li >= lines.length)
@@ -1202,6 +1414,9 @@ export class ChatScreen extends Screen {
1202
1414
  const tool = this.toolLineIndex[li];
1203
1415
  if (tool !== undefined && tool >= 0)
1204
1416
  this.toolRows.set(y, tool);
1417
+ const turn = this.turnLineIndex[li];
1418
+ if (turn !== undefined && turn >= 0)
1419
+ this.turnRows.set(y, turn);
1205
1420
  if (this.contextLines.has(li))
1206
1421
  this.contextRows.add(y);
1207
1422
  drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
@@ -1222,7 +1437,7 @@ export class ChatScreen extends Screen {
1222
1437
  placeholder: "Type a message...",
1223
1438
  });
1224
1439
  const hintY = h - 1;
1225
- const hints = "Enter send · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
1440
+ const hints = "Enter send · Ctrl+C pause · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
1226
1441
  const hintW = displayWidth(hints);
1227
1442
  const hintX = Math.max(0, w - 1 - hintW);
1228
1443
  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.7",
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: the work behind every
9
+ finished turn folds into one line that opens on a click, while the reply itself
10
+ stays on screen (the turn being answered is open, and a pause is not a fold);
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