@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.
- package/README.md +19 -4
- package/dist/app.js +358 -28
- package/dist/screens/chat.js +179 -7
- package/dist/selection.js +60 -0
- package/dist/terminal/input.js +20 -2
- package/dist/terminal/renderer.js +29 -8
- package/dist/terminal/restore.js +3 -2
- package/package.json +2 -2
- package/web/README.md +3 -1
- package/web/app.js +106 -5
- package/web/index.html +4 -1
- package/web/style.css +163 -16
- package/web/turns.js +52 -0
package/dist/screens/chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
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
|
+
}
|
package/dist/terminal/input.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/dist/terminal/restore.js
CHANGED
|
@@ -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[
|
|
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
|
|
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.
|
|
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
|