@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 +5 -4
- package/dist/screens/chat.js +70 -27
- package/package.json +1 -1
- package/web/README.md +3 -3
- package/web/app.js +27 -15
- package/web/style.css +7 -2
- package/web/turns.js +25 -11
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** —
|
|
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
|
|
216
|
-
on a click
|
|
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
|
|
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
|
package/dist/screens/chat.js
CHANGED
|
@@ -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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
1000
|
-
*
|
|
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
|
|
1003
|
-
const
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
|
1233
|
-
//
|
|
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) || (
|
|
1241
|
-
// Hidden
|
|
1242
|
-
// own fold
|
|
1243
|
-
//
|
|
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
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
|
|
9
|
-
finished turn
|
|
10
|
-
being answered
|
|
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
|
|
890
|
-
//
|
|
891
|
-
// so `active` decides its state; a turn cut in half by
|
|
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
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
* only, and the saved
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
6
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
30
|
-
if (
|
|
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
|
|
39
|
-
*
|
|
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
|
|
42
|
-
const
|
|
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/)
|