@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.
- package/README.md +20 -4
- package/dist/app.js +358 -28
- package/dist/screens/chat.js +222 -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 +118 -5
- package/web/index.html +4 -1
- package/web/style.css +168 -16
- package/web/turns.js +66 -0
package/dist/screens/chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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.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
|