@xingjisoft/neutrino 0.1.6 → 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.
package/README.md CHANGED
@@ -15,7 +15,7 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
15
15
  - **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
16
16
  - **Intensity control** — tune `temperature`/`top_p` from `Low` to `Ultra`.
17
17
  - **Session history** — messages are saved before a turn starts, streamed replies are checkpointed every second, and tool calls/results are saved immediately. The history screen lists conversations started in the current working directory. Failed saves are shown in the terminal and browser UI.
18
- - **Auto-folding turns** — as soon as the model finishes a reply, that round collapses into one summary line (prompt, answer preview, message count) in both the terminal and the browser. Click it to read the complete round again; short exchanges stay open, because hiding two lines helps nobody. A turn stopped with `ESC ESC` is paused, not finished, so it stays open. Nothing is dropped: the transcript, the saved session and the model's context keep every message.
18
+ - **Auto-folding turns** — once a turn finishes, the work behind the reply (the thinking and the tool calls) folds into one summary line in both the terminal and the browser, while the answer itself always stays on screen. Click the line to read the whole round again; a turn with little to hide, or a long answer on its own, stays open. A turn stopped with `ESC ESC` is paused, not finished, so it stays open. Nothing is dropped: the transcript, the saved session and the model's context keep every message.
19
19
  - **Complete conversation history** — context compression keeps every original message and tool result on disk. The earlier portion starts collapsed in the terminal and browser; click **Earlier conversation** to expand it, including in reopened history. Model requests use a separate summary plus recent messages.
20
20
  - **Token usage stats** — click the `ⓘ` in the chat header to see prompt/completion/total token counts for the current session.
21
21
  - **Agent history browser** — view past sessions from other CLI agents (Claude Code, Codex, OpenCode) directly from the history screen.
@@ -212,8 +212,9 @@ Only sessions that were started in the current working directory are listed, so
212
212
 
213
213
  Neutrino can mount a small website on a port of your choice. It groups every
214
214
  saved conversation by working directory (showing the five newest per path with
215
- the rest collapsed), folds every finished turn into one summary line that opens
216
- on a click, streams a running conversation live, and lets you pause or
215
+ the rest collapsed), folds the work of every finished turn into one summary line
216
+ that opens on a click while the reply stays on screen, streams a running
217
+ conversation live, and lets you pause or
217
218
  resume it and send new messages from the browser.
218
219
 
219
220
  A saved transcript opens read-only, because nothing is running for it. Press
@@ -335,7 +336,7 @@ node scripts/test-fetch.mjs # verify streamed requests release aborts and r
335
336
  node scripts/test-compact.mjs # verify context-compression planning
336
337
  node scripts/test-history-fold.mjs # verify full-history retention, folding and resume
337
338
  node scripts/test-web-history.mjs # verify browser history disclosures and streaming state
338
- node scripts/test-turn-fold.mjs # verify finished turns fold in both UIs and keep every message
339
+ node scripts/test-turn-fold.mjs # verify folded turns keep their reply and every message
339
340
  node scripts/test-tools.mjs # verify tool-output folding
340
341
  node scripts/test-web.mjs # verify the web server, API, SSE stream and path-traversal guards
341
342
  node scripts/test-web-settings.mjs # verify model switching, persistence, browser controls and defaults
@@ -16,10 +16,12 @@ import { SessionCheckpoint } from "../persistence.js";
16
16
  import { writeCrashLog } from "../terminal/restore.js";
17
17
  const TOOLS = getToolDefinitions();
18
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.
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.
23
25
  */
24
26
  export const TURN_FOLD_MIN_BLOCKS = 4;
25
27
  export const TURN_FOLD_MIN_CHARS = 600;
@@ -966,15 +968,21 @@ export class ChatScreen extends Screen {
966
968
  return this.messages.length;
967
969
  }
968
970
  /**
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
+ * 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.
971
974
  */
972
975
  turnHides(start, end) {
973
976
  if (!this.finishedTurns.has(start))
974
977
  return false;
978
+ const answer = this.turnAnswer(start, end);
975
979
  let blocks = 0;
976
980
  let chars = 0;
977
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;
978
986
  const m = this.messages[i];
979
987
  if (m.role === "user" || m.role === "assistant") {
980
988
  blocks++;
@@ -983,6 +991,42 @@ export class ChatScreen extends Screen {
983
991
  }
984
992
  return blocks >= TURN_FOLD_MIN_BLOCKS || chars >= TURN_FOLD_MIN_CHARS;
985
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
+ }
986
1030
  /** For every message, the first index of the turn folding it (-1 = none). */
987
1031
  turnMap() {
988
1032
  const map = new Array(this.messages.length).fill(-1);
@@ -996,12 +1040,11 @@ export class ChatScreen extends Screen {
996
1040
  return map;
997
1041
  }
998
1042
  /**
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.
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.
1001
1045
  */
1002
- turnPreview(start, end, role, last = false) {
1003
- const slice = this.messages.slice(start, end);
1004
- for (const m of last ? slice.reverse() : slice) {
1046
+ turnPreview(start, end, role) {
1047
+ for (const m of this.messages.slice(start, end)) {
1005
1048
  if (m.role !== role)
1006
1049
  continue;
1007
1050
  const line = sanitizeText(m.content)
@@ -1031,16 +1074,11 @@ export class ChatScreen extends Screen {
1031
1074
  }
1032
1075
  const you = `You: ${this.turnPreview(start, end, "user") || "(no text)"}`;
1033
1076
  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);
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);
1044
1082
  }
1045
1083
  /** Fold or unfold one turn. */
1046
1084
  toggleTurn(start) {
@@ -1215,6 +1253,8 @@ export class ChatScreen extends Screen {
1215
1253
  const context = normalizeContext(this.context, this.messages.filter((m) => m.role !== "system").length);
1216
1254
  // Which turn owns each message: [] -> first index of that turn, -1 = none.
1217
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();
1218
1258
  this.contextLines.clear();
1219
1259
  let recordIndex = 0;
1220
1260
  for (let mi = 0; mi < this.messages.length; mi++) {
@@ -1229,18 +1269,21 @@ export class ChatScreen extends Screen {
1229
1269
  archived = context !== null && recordIndex < context.end;
1230
1270
  recordIndex++;
1231
1271
  }
1232
- // A foldable turn always shows its control line first; its messages then
1233
- // render normally when the reader has opened it.
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.
1234
1274
  if (m.role !== "system" && turn === mi && !(archived && !this.expandedContext)) {
1235
1275
  this.pushTurnHeader(mi, this.turnEnd(mi), width, add);
1236
1276
  }
1277
+ const folded = turn >= 0 && !this.expandedTurns.has(turn);
1278
+ const reply = folded && answers[mi] === mi;
1237
1279
  if (m.role === "system") {
1238
1280
  pushText(sanitizeText(m.content), { fg: C.dim });
1239
1281
  }
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.
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.
1244
1287
  }
1245
1288
  else if (m.role === "user") {
1246
1289
  add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.1.6",
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",
package/web/README.md CHANGED
@@ -5,9 +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, 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);
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);
11
11
  - switch the provider, model and intensity for the next turn;
12
12
  - edit defaults, context compression and approval settings in a Settings dialog;
13
13
  - drive the running conversation: pause / resume the current turn and send new
package/web/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createSettingsUI } from "./settings.js";
2
- import { turnFolds, turnPreview, turnRanges } from "./turns.js";
2
+ import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
3
3
 
4
4
  // Neutrino web UI — a dependency-free single page app.
5
5
  //
@@ -886,10 +886,10 @@ function renderMessageList(messages, streaming, options) {
886
886
  });
887
887
  el.messages.appendChild(details);
888
888
  }
889
- // Render the tail one turn at a time: a turn the agent has finished folds into
890
- // a summary line, a running one stays open. The turn in flight is the last one,
891
- // so `active` decides its state; a turn cut in half by the compressed prefix is
892
- // rendered as plain messages instead.
889
+ // Render the tail one turn at a time: a finished turn folds its work behind a
890
+ // summary line and keeps its reply; a running one stays fully open. The turn in
891
+ // flight is the last one, so `active` decides its state; a turn cut in half by
892
+ // the compressed prefix is rendered as plain messages instead.
893
893
  const turns = turnRanges(original);
894
894
  const running = options?.active && turns.length > 0 ? turns[turns.length - 1].start : -1;
895
895
  for (const turn of turns) {
@@ -958,17 +958,24 @@ function renderMessage(message, index) {
958
958
  }
959
959
 
960
960
  /**
961
- * A finished turn, folded to one summary line. The messages are rendered on
962
- * first open and dropped again on close, so a long transcript costs nothing
963
- * until the reader asks for it. Nothing is lost either way: this is presentation
964
- * only, and the saved transcript always holds every message.
961
+ * A finished turn: its work folded behind one control line, its reply always on
962
+ * screen below it. The folded messages are rendered on first open and dropped
963
+ * again on close, so a long transcript costs nothing until the reader asks for
964
+ * it. Nothing is lost either way: this is presentation only, and the saved
965
+ * transcript always holds every message.
965
966
  */
966
967
  function renderTurnFold(start, messages) {
968
+ // The reply is never folded away, so a turn renders as a group: the fold (the
969
+ // prompt line plus the work it hides) and, right below it, the answer itself.
970
+ const reply = turnAnswerIndex(messages);
971
+ const group = document.createElement("div");
972
+ group.className = "turn-group";
973
+
967
974
  const details = document.createElement("details");
968
975
  details.className = "turn";
969
976
  details.open = state.openTurns.has(start);
970
977
 
971
- const count = messages.length;
978
+ const hidden = reply < 0 ? messages.length : messages.length - 1;
972
979
  const summary = document.createElement("summary");
973
980
  const chevron = document.createElement("span");
974
981
  chevron.className = "turn-chevron";
@@ -978,9 +985,7 @@ function renderTurnFold(start, messages) {
978
985
  you.textContent = `You: ${truncate(turnPreview(messages, "user"), 110)}`;
979
986
  const meta = document.createElement("span");
980
987
  meta.className = "turn-meta";
981
- meta.textContent =
982
- `${count} message${count === 1 ? "" : "s"} · ` +
983
- truncate(turnPreview(messages, "assistant", true), 90);
988
+ meta.textContent = `${hidden} of ${messages.length} message${messages.length === 1 ? "" : "s"} folded`;
984
989
  // The hint cannot live in the static text: it says the opposite once open.
985
990
  const hint = document.createElement("span");
986
991
  hint.className = "turn-hint";
@@ -989,6 +994,7 @@ function renderTurnFold(start, messages) {
989
994
  summary.appendChild(you);
990
995
  summary.appendChild(meta);
991
996
  details.appendChild(summary);
997
+ group.appendChild(details);
992
998
 
993
999
  const syncHint = () => {
994
1000
  hint.textContent = details.open ? " — click to collapse" : " — click to expand";
@@ -1000,7 +1006,10 @@ function renderTurnFold(start, messages) {
1000
1006
  details.appendChild(body);
1001
1007
  const populate = () => {
1002
1008
  if (body.childNodes.length) return;
1003
- messages.forEach((message, offset) => body.appendChild(renderMessage(message, start + offset)));
1009
+ messages.forEach((message, offset) => {
1010
+ if (offset === reply) return; // the answer is never inside the fold
1011
+ body.appendChild(renderMessage(message, start + offset));
1012
+ });
1004
1013
  };
1005
1014
  if (details.open) populate();
1006
1015
  details.addEventListener("toggle", () => {
@@ -1015,7 +1024,10 @@ function renderTurnFold(start, messages) {
1015
1024
  }
1016
1025
  syncHint();
1017
1026
  });
1018
- return details;
1027
+ // The answer comes with the fold, so a finished exchange ends on its reply
1028
+ // even while the work that produced it stays hidden.
1029
+ if (reply >= 0) group.appendChild(renderMessage(messages[reply], start + reply));
1030
+ return group;
1019
1031
  }
1020
1032
 
1021
1033
  function firstUserMessage(messages) {
package/web/style.css CHANGED
@@ -597,12 +597,17 @@ button {
597
597
  margin-bottom: 18px;
598
598
  }
599
599
 
600
- /* A finished turn: one summary line, the whole round one click away. */
600
+ /* A finished turn: its work behind one control line, its reply always below. */
601
+ .turn-group {
602
+ display: flex;
603
+ flex-direction: column;
604
+ }
605
+
601
606
  .turn {
602
607
  border: 1px dashed var(--border-soft);
603
608
  border-radius: var(--radius);
604
609
  padding: 8px 12px;
605
- margin-bottom: 18px;
610
+ margin-bottom: 14px;
606
611
  }
607
612
 
608
613
  .turn > summary {
package/web/turns.js CHANGED
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // A conversation is a sequence of turns: each one starts at a user message and
4
4
  // runs until the next one. Once the agent has finished a turn, the browser folds
5
- // it into a single summary line so scrolling back stays manageable, while the
6
- // complete transcript is still rendered on demand (click the summary).
5
+ // away the work it took — the prompt body, the thinking and the tool calls — and
6
+ // keeps the final reply on screen, so a finished exchange always ends on its
7
+ // answer. The complete round is still one click away.
7
8
  //
8
9
  // The thresholds mirror TURN_FOLD_MIN_BLOCKS / TURN_FOLD_MIN_CHARS in
9
10
  // src/screens/chat.ts, so the terminal and the browser fold the same turns.
@@ -22,25 +23,38 @@ export function turnRanges(messages) {
22
23
  return turns;
23
24
  }
24
25
 
25
- /** True when folding this turn saves more room than it costs. */
26
+ /** Index of the turn's reply — its last assistant message (-1 when it has none). */
27
+ export function turnAnswerIndex(messages) {
28
+ for (let i = messages.length - 1; i >= 0; i--) {
29
+ if (messages[i].role === "assistant") return i;
30
+ }
31
+ return -1;
32
+ }
33
+
34
+ /**
35
+ * True when folding the work behind this turn saves more room than it costs.
36
+ * The reply is never folded away, so it is not counted: a long answer on its own
37
+ * must never collapse the turn that carries it.
38
+ */
26
39
  export function turnFolds(messages) {
40
+ const reply = turnAnswerIndex(messages);
27
41
  let blocks = 0;
28
42
  let chars = 0;
29
- for (const message of messages) {
30
- if (message.role !== "user" && message.role !== "assistant") continue;
43
+ messages.forEach((message, index) => {
44
+ if (index === reply) return;
45
+ if (message.role !== "user" && message.role !== "assistant") return;
31
46
  blocks++;
32
47
  chars += String(message.content ?? "").length;
33
- }
48
+ });
34
49
  return blocks >= TURN_FOLD_MIN_BLOCKS || chars >= TURN_FOLD_MIN_CHARS;
35
50
  }
36
51
 
37
52
  /**
38
- * The first line of the first (or, with `last`, the last) message in `role`.
39
- * One line only: the preview is a signpost, the full text is a click away.
53
+ * The first line of the first message in `role`. One line only: the header is a
54
+ * signpost, and the full text is one click away.
40
55
  */
41
- export function turnPreview(messages, role, last = false) {
42
- const ordered = last ? [...messages].reverse() : messages;
43
- for (const message of ordered) {
56
+ export function turnPreview(messages, role) {
57
+ for (const message of messages) {
44
58
  if (message.role !== role) continue;
45
59
  const line = String(message.content ?? "")
46
60
  .split(/\r?\n/)