@xingjisoft/neutrino 0.1.3 → 0.1.5
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 +398 -137
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +105 -4
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +87 -0
- package/dist/config.js +121 -13
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +323 -84
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/sanitize.js +61 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +749 -44
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +201 -45
- package/dist/screens/start.js +64 -16
- package/dist/selection.js +25 -11
- package/dist/state.js +1 -0
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/input.js +14 -3
- package/dist/terminal/renderer.js +72 -9
- package/dist/terminal/restore.js +147 -0
- package/dist/terminal/screen.js +15 -0
- package/dist/ui.js +19 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +208 -0
- package/web/app.js +999 -0
- package/web/index.html +87 -0
- package/web/settings.js +193 -0
- package/web/style.css +941 -0
package/dist/screens/chat.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { loadPrompt, saveSession, } from "../config.js";
|
|
3
|
+
import { findModel, contextCompressionThreshold, loadConfig, normalizeApprovalMode, loadPrompt, saveSession, } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
|
-
import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText } from "../ui.js";
|
|
5
|
+
import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText, approvalToggle, drawApprovalToggle, inRect } from "../ui.js";
|
|
6
6
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
7
7
|
import { ellipsize, displayWidth } from "../width.js";
|
|
8
|
-
import {
|
|
8
|
+
import { sanitizeText } from "../sanitize.js";
|
|
9
|
+
import { LLMClient, describeError } from "../llm/client.js";
|
|
9
10
|
import { executeTool, getToolDefinitions } from "../llm/tools.js";
|
|
11
|
+
import { assessToolCall } from "../llm/risk.js";
|
|
12
|
+
import { ApprovalGate, captureApprovalContext, describeApprovalIntent, statedReason } from "../llm/approval.js";
|
|
13
|
+
import { COMPACT_KEEP, COMPACT_MAX_CHARS_PER_MESSAGE, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
|
|
14
|
+
import { hub, liveApprovalDetails } from "../web/hub.js";
|
|
15
|
+
import { SessionCheckpoint } from "../persistence.js";
|
|
16
|
+
import { writeCrashLog } from "../terminal/restore.js";
|
|
10
17
|
const TOOLS = getToolDefinitions();
|
|
11
18
|
function parseArgs(s) {
|
|
12
19
|
if (!s || !s.trim())
|
|
@@ -22,11 +29,15 @@ function parseArgs(s) {
|
|
|
22
29
|
export class ChatScreen extends Screen {
|
|
23
30
|
initialMessage;
|
|
24
31
|
loadedMessages = null;
|
|
32
|
+
loadedCwd = null;
|
|
25
33
|
sessionId = null;
|
|
26
34
|
loadedProvider = null;
|
|
27
35
|
loadedModel = null;
|
|
28
36
|
loadedIntensity = null;
|
|
29
37
|
loadedTokens = null;
|
|
38
|
+
loadedContext = null;
|
|
39
|
+
loadedApprovalMode = null;
|
|
40
|
+
approvalMode = "request";
|
|
30
41
|
modelProvider = "DeepSeek";
|
|
31
42
|
model = { name: "deepseek-chat", url: "https://api.deepseek.com/v1", api_key: "", supports_streaming: true };
|
|
32
43
|
intensity = "High";
|
|
@@ -34,6 +45,12 @@ export class ChatScreen extends Screen {
|
|
|
34
45
|
showInfo = false;
|
|
35
46
|
messages = [];
|
|
36
47
|
streamingContent = null;
|
|
48
|
+
saveError = null;
|
|
49
|
+
checkpoint = new SessionCheckpoint(() => {
|
|
50
|
+
const saved = this.writeSession();
|
|
51
|
+
this.publishLive();
|
|
52
|
+
return saved;
|
|
53
|
+
});
|
|
37
54
|
busy = false;
|
|
38
55
|
paused = false;
|
|
39
56
|
queued = null;
|
|
@@ -45,6 +62,36 @@ export class ChatScreen extends Screen {
|
|
|
45
62
|
scrollbarGeom = null;
|
|
46
63
|
lastEscTime = 0;
|
|
47
64
|
initialized = false;
|
|
65
|
+
// ---- auto context compression ----
|
|
66
|
+
/** Prompt tokens of the most recent request — the best proxy for how large
|
|
67
|
+
* the current context has grown. Reset once the history is compressed. */
|
|
68
|
+
lastPromptTokens = 0;
|
|
69
|
+
compressing = false;
|
|
70
|
+
context = null;
|
|
71
|
+
expandedContext = false;
|
|
72
|
+
contextLines = new Set();
|
|
73
|
+
contextRows = new Set();
|
|
74
|
+
// ---- collapsible tool output ----
|
|
75
|
+
/** Message indices whose tool output is expanded (everything starts
|
|
76
|
+
* collapsed). */
|
|
77
|
+
expandedTools = new Set();
|
|
78
|
+
/** For each line produced by buildLines, the tool message index it belongs
|
|
79
|
+
* to, or -1 when it is not part of a tool block. */
|
|
80
|
+
toolLineIndex = [];
|
|
81
|
+
/** Terminal row -> tool message index, rebuilt each render for hit-testing. */
|
|
82
|
+
toolRows = new Map();
|
|
83
|
+
// ---- web bridge ----
|
|
84
|
+
liveKey = null;
|
|
85
|
+
liveDir = process.cwd();
|
|
86
|
+
// ---- approval gate ---------------------------------------------------
|
|
87
|
+
/** Non-null while a tool call is waiting for a human decision. */
|
|
88
|
+
pendingApproval = null;
|
|
89
|
+
reviewing = null;
|
|
90
|
+
/** Decisions taken by the gate, keyed by the tool message (survives
|
|
91
|
+
* context compression, which renumbers the array). */
|
|
92
|
+
approvals = new Map();
|
|
93
|
+
/** Hit boxes of the approval card, rebuilt on every render. */
|
|
94
|
+
approvalButtons = null;
|
|
48
95
|
constructor(initialMessage = "") {
|
|
49
96
|
super();
|
|
50
97
|
this.initialMessage = initialMessage;
|
|
@@ -53,6 +100,7 @@ export class ChatScreen extends Screen {
|
|
|
53
100
|
if (this.initialized)
|
|
54
101
|
return;
|
|
55
102
|
this.initialized = true;
|
|
103
|
+
this.approvalMode = normalizeApprovalMode(this.loadedApprovalMode ?? loadConfig().approval_mode);
|
|
56
104
|
const st = this.app.state;
|
|
57
105
|
this.modelProvider = this.loadedProvider ?? st.selectedProvider;
|
|
58
106
|
this.model = {
|
|
@@ -72,6 +120,11 @@ export class ChatScreen extends Screen {
|
|
|
72
120
|
for (const m of this.loadedMessages ?? []) {
|
|
73
121
|
this.messages.push(m);
|
|
74
122
|
}
|
|
123
|
+
this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
|
|
124
|
+
this.liveDir = this.loadedCwd ?? process.cwd();
|
|
125
|
+
this.checkpoint.register();
|
|
126
|
+
this.liveKey = hub.register(this);
|
|
127
|
+
this.publishLive();
|
|
75
128
|
if (this.initialMessage) {
|
|
76
129
|
this.send(this.initialMessage);
|
|
77
130
|
}
|
|
@@ -79,11 +132,171 @@ export class ChatScreen extends Screen {
|
|
|
79
132
|
this.app.render();
|
|
80
133
|
}
|
|
81
134
|
}
|
|
135
|
+
onDeactivate() {
|
|
136
|
+
// Never leave the agent awaiting a decision nobody can give.
|
|
137
|
+
if (this.pendingApproval)
|
|
138
|
+
this.settleApproval(false);
|
|
139
|
+
// A turn can continue after returning to the menu. Keep its exit checkpoint
|
|
140
|
+
// registered until it finishes, including while a tool is still running.
|
|
141
|
+
if (this.busy)
|
|
142
|
+
this.save();
|
|
143
|
+
else
|
|
144
|
+
this.checkpoint.dispose();
|
|
145
|
+
hub.unregister(this.liveKey);
|
|
146
|
+
this.liveKey = null;
|
|
147
|
+
}
|
|
148
|
+
// ---- web bridge (see src/web/hub.ts) ---------------------------------
|
|
149
|
+
liveCwd() {
|
|
150
|
+
return this.liveDir;
|
|
151
|
+
}
|
|
152
|
+
liveConfigure(selection) {
|
|
153
|
+
if (this.busy || this.pendingApproval) {
|
|
154
|
+
return { ok: false, error: "pause the current turn before changing models" };
|
|
155
|
+
}
|
|
156
|
+
this.modelProvider = selection.provider;
|
|
157
|
+
this.model = { ...selection.model };
|
|
158
|
+
this.intensity = selection.intensity;
|
|
159
|
+
Object.assign(this.app.state, {
|
|
160
|
+
selectedProvider: this.modelProvider, selectedModelName: this.model.name,
|
|
161
|
+
selectedModelUrl: this.model.url, selectedModelApiKey: this.model.api_key,
|
|
162
|
+
selectedModelStreaming: this.model.supports_streaming, selectedIntensity: this.intensity,
|
|
163
|
+
});
|
|
164
|
+
const header = this.messages.find((m) => m.role === "system");
|
|
165
|
+
if (header?.role === "system")
|
|
166
|
+
header.content = `Model: ${this.modelProvider}/${this.model.name} | Intensity: ${this.intensity}`;
|
|
167
|
+
this.save();
|
|
168
|
+
this.app.render();
|
|
169
|
+
this.publishLive();
|
|
170
|
+
return { ok: true };
|
|
171
|
+
}
|
|
172
|
+
liveMeta() {
|
|
173
|
+
return {
|
|
174
|
+
sessionId: this.sessionId,
|
|
175
|
+
title: this.liveTitle(),
|
|
176
|
+
provider: this.modelProvider,
|
|
177
|
+
model: this.model.name,
|
|
178
|
+
intensity: this.intensity,
|
|
179
|
+
approvalMode: this.approvalMode,
|
|
180
|
+
busy: this.busy,
|
|
181
|
+
paused: this.paused,
|
|
182
|
+
queued: this.queued,
|
|
183
|
+
compressing: this.compressing,
|
|
184
|
+
saveError: this.saveError,
|
|
185
|
+
tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
|
|
186
|
+
approval: this.pendingApproval
|
|
187
|
+
? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.liveDir)
|
|
188
|
+
: null,
|
|
189
|
+
reviewing: this.reviewing,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
liveSnapshot(key) {
|
|
193
|
+
const messages = [];
|
|
194
|
+
for (const message of this.messages) {
|
|
195
|
+
if (message.role === "system")
|
|
196
|
+
continue;
|
|
197
|
+
if (message.role === "summary") {
|
|
198
|
+
messages.push({ role: "summary", content: message.content, count: message.count });
|
|
199
|
+
}
|
|
200
|
+
else if (message.role === "tool") {
|
|
201
|
+
messages.push({ role: "tool", name: message.name, arguments: message.arguments, result: message.result });
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
messages.push({ role: message.role, content: message.content });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
...this.liveMeta(),
|
|
209
|
+
key,
|
|
210
|
+
cwd: this.liveDir,
|
|
211
|
+
messages,
|
|
212
|
+
streamingContent: this.streamingContent,
|
|
213
|
+
context: this.context,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
livePause() {
|
|
217
|
+
if (this.paused || (!this.busy && !this.queued))
|
|
218
|
+
return;
|
|
219
|
+
this.paused = true;
|
|
220
|
+
this.turnToken++;
|
|
221
|
+
// A queued decision would otherwise keep the agent awaiting forever.
|
|
222
|
+
if (this.pendingApproval)
|
|
223
|
+
this.settleApproval(false);
|
|
224
|
+
this.busy = false;
|
|
225
|
+
this.app.state.isThinking = false;
|
|
226
|
+
this.retainStreamingContent();
|
|
227
|
+
this.save();
|
|
228
|
+
this.app.render();
|
|
229
|
+
this.publishLive();
|
|
230
|
+
}
|
|
231
|
+
liveResume() {
|
|
232
|
+
if (this.paused)
|
|
233
|
+
this.togglePause();
|
|
234
|
+
}
|
|
235
|
+
/** Answer the pending call from the web UI. */
|
|
236
|
+
liveApprove(approved) {
|
|
237
|
+
if (!this.pendingApproval)
|
|
238
|
+
return false;
|
|
239
|
+
this.settleApproval(approved);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
liveSetApprovalMode(mode) {
|
|
243
|
+
if (this.approvalMode === mode)
|
|
244
|
+
return;
|
|
245
|
+
this.approvalMode = mode;
|
|
246
|
+
if (this.pendingApproval)
|
|
247
|
+
this.settleApproval(null);
|
|
248
|
+
this.save();
|
|
249
|
+
this.app.render();
|
|
250
|
+
this.publishLive();
|
|
251
|
+
}
|
|
252
|
+
liveSend(text) {
|
|
253
|
+
if (!text.trim())
|
|
254
|
+
return;
|
|
255
|
+
this.send(text);
|
|
256
|
+
this.publishLive();
|
|
257
|
+
}
|
|
258
|
+
liveTitle() {
|
|
259
|
+
for (const message of this.messages) {
|
|
260
|
+
if (message.role === "user") {
|
|
261
|
+
const title = message.content.replace(/\s+/g, " ").trim();
|
|
262
|
+
return title.length > 80 ? title.slice(0, 80) : title;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
publishLive() {
|
|
268
|
+
hub.notify(this.liveKey);
|
|
269
|
+
}
|
|
270
|
+
approvalToggleY(height = this.app.height) {
|
|
271
|
+
// Short terminals need the space above the input for the approval card.
|
|
272
|
+
return this.pendingApproval && height < 13 ? 1 : height - 5;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The input row is persistent chrome: it is drawn with absolute positioning
|
|
276
|
+
* and must survive any terminal-side drift (ConPTY reflow, a stray scroll).
|
|
277
|
+
* Repainting it every frame makes the input area self-healing — the log can
|
|
278
|
+
* never end up "covering" it.
|
|
279
|
+
*/
|
|
280
|
+
alwaysRepaintRows() {
|
|
281
|
+
const h = this.app.height;
|
|
282
|
+
const rows = [];
|
|
283
|
+
for (let y = h - 5; y <= h - 1; y++)
|
|
284
|
+
if (y >= 0)
|
|
285
|
+
rows.push(y);
|
|
286
|
+
if (this.pendingApproval && h < 13)
|
|
287
|
+
rows.push(1);
|
|
288
|
+
return rows;
|
|
289
|
+
}
|
|
82
290
|
showsCursor() {
|
|
83
291
|
// The chat input is always focused; a cursor is drawn when it holds text.
|
|
84
292
|
return this.inputValue.length > 0;
|
|
85
293
|
}
|
|
86
294
|
onPaste(text) {
|
|
295
|
+
if (!text)
|
|
296
|
+
return;
|
|
297
|
+
// A pasted tab/ESC would otherwise sit in the input box and be re-emitted
|
|
298
|
+
// by the renderer on every frame, corrupting the rows around it.
|
|
299
|
+
text = sanitizeText(text, { singleLine: true });
|
|
87
300
|
if (!text)
|
|
88
301
|
return;
|
|
89
302
|
if (this.showInfo)
|
|
@@ -94,6 +307,19 @@ export class ChatScreen extends Screen {
|
|
|
94
307
|
this.app.render();
|
|
95
308
|
}
|
|
96
309
|
onKey(key) {
|
|
310
|
+
if (key.ctrl && key.name === "a") {
|
|
311
|
+
this.liveSetApprovalMode(this.approvalMode === "auto" ? "request" : "auto");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// A pending approval is modal: it owns the keyboard until answered.
|
|
315
|
+
if (this.pendingApproval) {
|
|
316
|
+
const name = key.name.toLowerCase();
|
|
317
|
+
if (name === "y" || name === "return")
|
|
318
|
+
this.settleApproval(true);
|
|
319
|
+
else if (name === "n" || name === "escape")
|
|
320
|
+
this.settleApproval(false);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
97
323
|
if (this.showInfo) {
|
|
98
324
|
this.showInfo = false;
|
|
99
325
|
this.app.render();
|
|
@@ -184,11 +410,29 @@ export class ChatScreen extends Screen {
|
|
|
184
410
|
return;
|
|
185
411
|
}
|
|
186
412
|
if (key.ctrl && key.name === "l") {
|
|
413
|
+
// Clearing starts a fresh conversation; do not overwrite the old history.
|
|
414
|
+
this.save();
|
|
415
|
+
if (this.saveError) {
|
|
416
|
+
this.app.render();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
this.turnToken++;
|
|
420
|
+
this.busy = false;
|
|
421
|
+
this.paused = false;
|
|
422
|
+
this.queued = null;
|
|
423
|
+
this.compressing = false;
|
|
424
|
+
this.app.state.isThinking = false;
|
|
425
|
+
this.lastPromptTokens = 0;
|
|
426
|
+
this.sessionId = null;
|
|
427
|
+
this.context = null;
|
|
428
|
+
this.expandedContext = false;
|
|
187
429
|
const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
|
|
188
430
|
this.messages = [{ role: "system", content: sysText }];
|
|
431
|
+
this.expandedTools.clear();
|
|
189
432
|
this.streamingContent = null;
|
|
190
433
|
this.scrollTop = 0;
|
|
191
434
|
this.followBottom = true;
|
|
435
|
+
this.publishLive();
|
|
192
436
|
this.app.render();
|
|
193
437
|
return;
|
|
194
438
|
}
|
|
@@ -208,6 +452,23 @@ export class ChatScreen extends Screen {
|
|
|
208
452
|
if (ev.type === "click") {
|
|
209
453
|
if (ev.button === 2)
|
|
210
454
|
return; // right click: no-op in chat
|
|
455
|
+
if (inRect(ev.x, ev.y, approvalToggle(this.app.width, this.approvalToggleY(), this.approvalMode))) {
|
|
456
|
+
this.liveSetApprovalMode(this.approvalMode === "auto" ? "request" : "auto");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
// The approval card floats over the log: test its buttons first.
|
|
460
|
+
const buttons = this.approvalButtons;
|
|
461
|
+
if (this.pendingApproval && buttons) {
|
|
462
|
+
const hit = (r) => ev.y === r.y && ev.x >= r.x && ev.x < r.x + r.w;
|
|
463
|
+
if (hit(buttons.approve)) {
|
|
464
|
+
this.settleApproval(true);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (hit(buttons.deny)) {
|
|
468
|
+
this.settleApproval(false);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
211
472
|
// header row: right corner toggles token stats, the rest opens model config
|
|
212
473
|
if (ev.y === 0) {
|
|
213
474
|
if (ev.x >= this.app.width - 3) {
|
|
@@ -235,6 +496,19 @@ export class ChatScreen extends Screen {
|
|
|
235
496
|
this.app.render();
|
|
236
497
|
return;
|
|
237
498
|
}
|
|
499
|
+
// A click inside the conversation log toggles a tool block open/closed.
|
|
500
|
+
if (this.contextRows.has(ev.y)) {
|
|
501
|
+
this.expandedContext = !this.expandedContext;
|
|
502
|
+
this.followBottom = false;
|
|
503
|
+
this.scrollTop = Math.min(this.scrollTop, Math.min(...this.contextLines));
|
|
504
|
+
this.app.render();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const tool = this.toolRows.get(ev.y);
|
|
508
|
+
if (tool !== undefined) {
|
|
509
|
+
this.toggleTool(tool);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
238
512
|
return;
|
|
239
513
|
}
|
|
240
514
|
if (ev.type === "wheel") {
|
|
@@ -276,10 +550,12 @@ export class ChatScreen extends Screen {
|
|
|
276
550
|
this.turnToken++;
|
|
277
551
|
this.busy = false;
|
|
278
552
|
this.app.state.isThinking = false;
|
|
553
|
+
this.retainStreamingContent();
|
|
279
554
|
this.save();
|
|
280
555
|
}
|
|
281
556
|
this.app.render();
|
|
282
557
|
this.maybeFlushQueue();
|
|
558
|
+
this.publishLive();
|
|
283
559
|
}
|
|
284
560
|
maybeFlushQueue() {
|
|
285
561
|
if (this.queued) {
|
|
@@ -292,9 +568,12 @@ export class ChatScreen extends Screen {
|
|
|
292
568
|
if (!trimmed)
|
|
293
569
|
return;
|
|
294
570
|
this.messages.push({ role: "user", content: trimmed });
|
|
571
|
+
// Create the history entry before requesting a model or executing tools.
|
|
572
|
+
this.save();
|
|
295
573
|
this.inputValue = "";
|
|
296
574
|
this.cursor = 0;
|
|
297
575
|
this.followBottom = true;
|
|
576
|
+
this.publishLive();
|
|
298
577
|
if (this.busy && !this.paused) {
|
|
299
578
|
this.queued = trimmed;
|
|
300
579
|
this.app.render();
|
|
@@ -303,29 +582,77 @@ export class ChatScreen extends Screen {
|
|
|
303
582
|
this.startTurn();
|
|
304
583
|
}
|
|
305
584
|
startTurn() {
|
|
585
|
+
const turnMessages = this.messages.slice();
|
|
586
|
+
const approvalContext = captureApprovalContext(turnMessages);
|
|
306
587
|
this.busy = true;
|
|
307
588
|
this.paused = false;
|
|
308
589
|
this.app.state.isThinking = true;
|
|
309
590
|
const token = ++this.turnToken;
|
|
310
|
-
const apiMessages = this.buildApiMessages();
|
|
311
591
|
this.streamingContent = "";
|
|
312
592
|
this.app.render();
|
|
313
|
-
|
|
593
|
+
this.publishLive();
|
|
594
|
+
void (async () => {
|
|
595
|
+
// Shrink the history before building the request when it has outgrown the
|
|
596
|
+
// configured budget.
|
|
597
|
+
await this.autoCompress(token);
|
|
598
|
+
if (token !== this.turnToken)
|
|
599
|
+
return;
|
|
600
|
+
const apiMessages = this.buildApiMessages(turnMessages);
|
|
601
|
+
await this.agentLoop(apiMessages, token, approvalContext);
|
|
602
|
+
})().catch((err) => {
|
|
603
|
+
if (token === this.turnToken)
|
|
604
|
+
this.failTurn(err);
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Ends a turn that failed before or around the agent loop.
|
|
609
|
+
*
|
|
610
|
+
* The turn body is a floating promise, so anything thrown outside the loop's
|
|
611
|
+
* own try/catch would otherwise surface as an unhandled rejection, which Node
|
|
612
|
+
* turns into a fatal error. Ending the turn here keeps the app alive and the
|
|
613
|
+
* failure visible in the transcript.
|
|
614
|
+
*/
|
|
615
|
+
failTurn(err) {
|
|
616
|
+
this.retainStreamingContent();
|
|
617
|
+
this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
|
|
618
|
+
this.streamingContent = null;
|
|
619
|
+
try {
|
|
620
|
+
this.onTurnFinished();
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
// If even the cleanup fails, force the idle state so the UI stays usable.
|
|
624
|
+
this.busy = false;
|
|
625
|
+
this.paused = false;
|
|
626
|
+
this.app.state.isThinking = false;
|
|
627
|
+
try {
|
|
628
|
+
this.app.render();
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// The app-level render guard reports this one.
|
|
632
|
+
}
|
|
633
|
+
}
|
|
314
634
|
}
|
|
315
|
-
buildApiMessages() {
|
|
635
|
+
buildApiMessages(messages = this.messages) {
|
|
316
636
|
const api = [{ role: "system", content: loadPrompt() }];
|
|
317
|
-
for (const m of this.
|
|
637
|
+
for (const m of modelHistory(messages, this.context)) {
|
|
318
638
|
if (m.role === "user" || m.role === "assistant") {
|
|
319
639
|
api.push({ role: m.role, content: m.content });
|
|
320
640
|
}
|
|
641
|
+
else if (m.role === "summary") {
|
|
642
|
+
// A compressed summary is fed back to the model as its own prior output.
|
|
643
|
+
api.push({ role: "assistant", content: m.content });
|
|
644
|
+
}
|
|
321
645
|
}
|
|
322
646
|
return api;
|
|
323
647
|
}
|
|
324
|
-
async agentLoop(apiMessages, token) {
|
|
648
|
+
async agentLoop(apiMessages, token, approvalContext) {
|
|
325
649
|
try {
|
|
326
650
|
while (true) {
|
|
327
651
|
if (token !== this.turnToken)
|
|
328
652
|
return;
|
|
653
|
+
// Keep the payload inside budget during a long single turn: once the
|
|
654
|
+
// context is too large, drop the body of older tool results.
|
|
655
|
+
apiMessages = this.trimApiMessages(apiMessages);
|
|
329
656
|
const client = new LLMClient(this.model, this.modelProvider, this.intensity);
|
|
330
657
|
let content = "";
|
|
331
658
|
let hasContent = false;
|
|
@@ -338,22 +665,23 @@ export class ChatScreen extends Screen {
|
|
|
338
665
|
content += chunk;
|
|
339
666
|
hasContent = true;
|
|
340
667
|
this.streamingContent = content;
|
|
668
|
+
this.checkpoint.schedule();
|
|
341
669
|
this.app.render();
|
|
670
|
+
this.publishLive();
|
|
671
|
+
}
|
|
672
|
+
if (token !== this.turnToken) {
|
|
673
|
+
// Pause already retained the partial reply. A stale stream must not
|
|
674
|
+
// overwrite the buffer belonging to a newer turn.
|
|
675
|
+
return;
|
|
342
676
|
}
|
|
343
677
|
if (client.usage) {
|
|
344
678
|
this.tokens.prompt += client.usage.prompt;
|
|
345
679
|
this.tokens.completion += client.usage.completion;
|
|
680
|
+
this.lastPromptTokens = client.usage.prompt;
|
|
346
681
|
}
|
|
347
682
|
else {
|
|
348
683
|
this.tokens.completion += Math.max(1, Math.ceil(content.length / 4));
|
|
349
684
|
}
|
|
350
|
-
if (token !== this.turnToken) {
|
|
351
|
-
if (content)
|
|
352
|
-
this.messages.push({ role: "assistant", content });
|
|
353
|
-
this.streamingContent = null;
|
|
354
|
-
this.app.render();
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
685
|
if (aborted || this.paused) {
|
|
358
686
|
if (content)
|
|
359
687
|
this.messages.push({ role: "assistant", content });
|
|
@@ -372,6 +700,7 @@ export class ChatScreen extends Screen {
|
|
|
372
700
|
if (hasContent)
|
|
373
701
|
this.messages.push({ role: "assistant", content });
|
|
374
702
|
this.streamingContent = null;
|
|
703
|
+
this.save();
|
|
375
704
|
apiMessages.push({
|
|
376
705
|
role: "assistant",
|
|
377
706
|
content,
|
|
@@ -387,19 +716,54 @@ export class ChatScreen extends Screen {
|
|
|
387
716
|
const args = parseArgs(tc.arguments);
|
|
388
717
|
const toolMsg = { role: "tool", name: tc.name, arguments: args, result: "" };
|
|
389
718
|
this.messages.push(toolMsg);
|
|
719
|
+
this.save();
|
|
720
|
+
this.publishLive();
|
|
390
721
|
this.app.render();
|
|
391
|
-
|
|
722
|
+
// Risky calls stop here until a human (or the reviewer agent) clears
|
|
723
|
+
// them. The loop awaits a promise that the UI resolves.
|
|
724
|
+
const decision = await this.requestApproval(tc.name, args, {
|
|
725
|
+
...approvalContext,
|
|
726
|
+
context: captureApprovalContext(apiMessages).context,
|
|
727
|
+
});
|
|
392
728
|
if (token !== this.turnToken || this.paused)
|
|
393
729
|
return;
|
|
730
|
+
if (decision.source !== "prompt" && decision.source !== "input") {
|
|
731
|
+
this.approvals.set(toolMsg, decision);
|
|
732
|
+
}
|
|
733
|
+
if (!decision.approved) {
|
|
734
|
+
// A malformed request was never shown to anyone, so there is no
|
|
735
|
+
// verdict to respect and the right move is a corrected retry.
|
|
736
|
+
toolMsg.result =
|
|
737
|
+
decision.source === "input"
|
|
738
|
+
? `[Not executed: ${decision.reason}]`
|
|
739
|
+
: `[Not executed: approval denied (${decision.source}): ${decision.reason}] ` +
|
|
740
|
+
describeApprovalIntent(approvalContext) +
|
|
741
|
+
"Do not retry this call unchanged; ask the user how to proceed.";
|
|
742
|
+
apiMessages.push({ role: "tool", tool_call_id: tc.id, content: toolMsg.result });
|
|
743
|
+
this.save();
|
|
744
|
+
this.publishLive();
|
|
745
|
+
this.app.render();
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
const result = await executeTool(tc.name, args, this.liveDir);
|
|
749
|
+
// A clear starts a different transcript while an old tool may finish.
|
|
750
|
+
if (!this.messages.includes(toolMsg))
|
|
751
|
+
return;
|
|
394
752
|
toolMsg.result = result;
|
|
753
|
+
this.save();
|
|
754
|
+
if (token !== this.turnToken || this.paused)
|
|
755
|
+
return;
|
|
395
756
|
apiMessages.push({ role: "tool", tool_call_id: tc.id, content: result });
|
|
757
|
+
this.publishLive();
|
|
396
758
|
this.app.render();
|
|
397
759
|
}
|
|
398
760
|
}
|
|
399
761
|
this.onTurnFinished();
|
|
400
762
|
}
|
|
401
763
|
catch (e) {
|
|
402
|
-
|
|
764
|
+
if (token !== this.turnToken)
|
|
765
|
+
return;
|
|
766
|
+
const err = describeError(e);
|
|
403
767
|
this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
|
|
404
768
|
this.streamingContent = null;
|
|
405
769
|
this.onTurnFinished();
|
|
@@ -412,52 +776,371 @@ export class ChatScreen extends Screen {
|
|
|
412
776
|
this.save();
|
|
413
777
|
this.app.render();
|
|
414
778
|
this.maybeFlushQueue();
|
|
779
|
+
this.publishLive();
|
|
780
|
+
if (!this.busy && !this.liveKey)
|
|
781
|
+
this.checkpoint.dispose();
|
|
782
|
+
// After the queue flush: a queued prompt that took over immediately is not
|
|
783
|
+
// the end of the user's work, and ringing then would be a false alarm.
|
|
784
|
+
// Hosts are stubbed in tests, hence the optional call.
|
|
785
|
+
if (!this.busy)
|
|
786
|
+
this.app.bell?.("turn");
|
|
415
787
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
788
|
+
// ---- approval gate ---------------------------------------------------
|
|
789
|
+
/** Classify a tool call and, when it is risky, run it past the gate. */
|
|
790
|
+
async requestApproval(tool, args, context = captureApprovalContext(this.messages)) {
|
|
791
|
+
const risk = assessToolCall(tool, args, this.liveDir);
|
|
792
|
+
if (!risk)
|
|
793
|
+
return { approved: true, source: "prompt", reason: "routine call" };
|
|
794
|
+
const gate = new ApprovalGate({
|
|
795
|
+
askHuman: () => this.askHuman(tool, args, risk),
|
|
796
|
+
reviewer: () => this.reviewerModel(),
|
|
797
|
+
autoApprovalEnabled: () => this.approvalMode === "auto",
|
|
798
|
+
extremeAutoApprovalEnabled: () => loadConfig().auto_approve_extreme_risk === true,
|
|
799
|
+
// Reviews happen before any card is shown, so without this the app looks
|
|
800
|
+
// frozen while the independent assessments run.
|
|
801
|
+
onReview: (info) => {
|
|
802
|
+
this.reviewing = info ? {
|
|
803
|
+
total: info.total, round: info.round ?? 1, rounds: info.rounds ?? 1,
|
|
804
|
+
request: liveApprovalDetails(tool, args, risk, this.liveDir),
|
|
805
|
+
} : null;
|
|
806
|
+
this.publishLive();
|
|
807
|
+
if (!info) {
|
|
808
|
+
this.app.showToast("");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
// Delete-class calls are reviewed from scratch three times; name the round
|
|
812
|
+
// so a long wait does not look like a hang.
|
|
813
|
+
const rounds = info.rounds ?? 1;
|
|
814
|
+
const which = rounds > 1 ? `round ${info.round ?? 1} of ${rounds}, ` : "";
|
|
815
|
+
this.app.showToast(`independent review running (${which}${info.total} reviewer${info.total > 1 ? "s" : ""})...`);
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
return gate.decide({ tool, args, cwd: this.liveDir, ...context, risk });
|
|
819
|
+
}
|
|
820
|
+
/** Park the turn until the user answers; the header turns orange meanwhile. */
|
|
821
|
+
askHuman(tool, args, risk) {
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
this.pendingApproval = { tool, args, risk, resolve };
|
|
824
|
+
this.app.state.approval = { tool, level: risk.level };
|
|
825
|
+
this.app.bell?.("approval");
|
|
826
|
+
this.app.render();
|
|
827
|
+
this.publishLive();
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
/** Answer the pending request (keyboard, mouse, or the web UI). */
|
|
831
|
+
settleApproval(approved) {
|
|
832
|
+
const pending = this.pendingApproval;
|
|
833
|
+
if (!pending)
|
|
419
834
|
return;
|
|
835
|
+
this.pendingApproval = null;
|
|
836
|
+
this.app.state.approval = null;
|
|
837
|
+
this.approvalButtons = null;
|
|
838
|
+
pending.resolve(approved);
|
|
839
|
+
this.app.render();
|
|
840
|
+
this.publishLive();
|
|
841
|
+
}
|
|
842
|
+
/** Reviewer model: `approval_model` when set, otherwise the chat model. */
|
|
843
|
+
reviewerModel() {
|
|
844
|
+
const name = String(loadConfig().approval_model ?? "").trim();
|
|
845
|
+
const found = name ? findModel(name) : null;
|
|
846
|
+
if (found)
|
|
847
|
+
return { model: found.model, provider: found.provider, intensity: this.intensity };
|
|
848
|
+
return { model: this.model, provider: this.modelProvider, intensity: this.intensity };
|
|
849
|
+
}
|
|
850
|
+
/** Orange card listing the pending call and its buttons. */
|
|
851
|
+
drawApprovalCard(buf, logTop, inputY) {
|
|
852
|
+
const pending = this.pendingApproval;
|
|
853
|
+
if (!pending)
|
|
854
|
+
return;
|
|
855
|
+
const boxW = Math.max(24, Math.min(buf.width - 2, 78));
|
|
856
|
+
// A ten-row terminal has no room for the risk line as well, and losing the
|
|
857
|
+
// buttons under the input would leave the call impossible to answer, so the
|
|
858
|
+
// card drops the secondary line instead of overflowing.
|
|
859
|
+
const compact = inputY - logTop + 1 < 6;
|
|
860
|
+
const boxH = compact ? 5 : 6;
|
|
861
|
+
const boxX = 1;
|
|
862
|
+
const boxY = Math.max(logTop, inputY - boxH - 1);
|
|
863
|
+
buf.fillRect(boxX, boxY, boxW, boxH, { bg: C.orangeBg });
|
|
864
|
+
buf.drawBox(boxX, boxY, boxW, boxH, { fg: C.orange });
|
|
865
|
+
// Same treatment as the header: the two read as a single alert.
|
|
866
|
+
buf.fillRect(boxX + 1, boxY, boxW - 2, 1, { bg: C.orange });
|
|
867
|
+
buf.writeText(boxX + 2, boxY, "Request Approval", { fg: C.black, bg: C.orange, bold: true });
|
|
868
|
+
const tag = pending.risk.level === "extreme" ? "EXTREME RISK" : "HIGH RISK";
|
|
869
|
+
const tagX = boxX + boxW - 2 - tag.length;
|
|
870
|
+
if (tagX > boxX + 22) {
|
|
871
|
+
buf.writeText(tagX, boxY, tag, { fg: C.black, bg: C.orange, bold: true });
|
|
872
|
+
}
|
|
873
|
+
const summary = sanitizeText(pending.risk.summary, { singleLine: true });
|
|
874
|
+
buf.writeText(boxX + 2, boxY + 1, ellipsize(summary, boxW - 4), {
|
|
875
|
+
fg: C.assistant,
|
|
876
|
+
bg: C.orangeBg,
|
|
877
|
+
});
|
|
878
|
+
// Two different claims, kept apart on purpose: "Why" is what the agent says
|
|
879
|
+
// the call is for, "Flag" is what the risk scan actually detected.
|
|
880
|
+
const whyLabel = "Why: ";
|
|
881
|
+
const why = sanitizeText(statedReason(pending.args) ?? "the agent gave no reason", {
|
|
882
|
+
singleLine: true,
|
|
883
|
+
});
|
|
884
|
+
buf.writeText(boxX + 2, boxY + 2, whyLabel, { fg: C.orange, bg: C.orangeBg, bold: true });
|
|
885
|
+
buf.writeText(boxX + 2 + whyLabel.length, boxY + 2, ellipsize(why, Math.max(0, boxW - 4 - whyLabel.length)), { fg: C.assistant, bg: C.orangeBg });
|
|
886
|
+
if (!compact) {
|
|
887
|
+
const flagLabel = "Flag: ";
|
|
888
|
+
const flag = sanitizeText(pending.risk.reasons[0] ?? "", { singleLine: true });
|
|
889
|
+
buf.writeText(boxX + 2, boxY + 3, flagLabel, { fg: C.dim, bg: C.orangeBg, bold: true });
|
|
890
|
+
buf.writeText(boxX + 2 + flagLabel.length, boxY + 3, ellipsize(flag, Math.max(0, boxW - 4 - flagLabel.length)), { fg: C.dim, bg: C.orangeBg });
|
|
891
|
+
}
|
|
892
|
+
const approve = "[ Y ] Approve";
|
|
893
|
+
const deny = "[ N ] Deny";
|
|
894
|
+
const by = boxY + boxH - 2;
|
|
895
|
+
const denyX = boxX + 2 + approve.length + 3;
|
|
896
|
+
buf.writeText(boxX + 2, by, approve, { fg: C.black, bg: C.orange, bold: true });
|
|
897
|
+
buf.writeText(denyX, by, deny, { fg: C.orange, bg: C.orangeBg, bold: true });
|
|
898
|
+
this.approvalButtons = {
|
|
899
|
+
approve: { x: boxX + 2, y: by, w: approve.length },
|
|
900
|
+
deny: { x: denyX, y: by, w: deny.length },
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
/** Expand/collapse the tool block belonging to message `index`. */
|
|
904
|
+
toggleTool(index) {
|
|
905
|
+
if (this.expandedTools.has(index))
|
|
906
|
+
this.expandedTools.delete(index);
|
|
907
|
+
else
|
|
908
|
+
this.expandedTools.add(index);
|
|
909
|
+
this.app.render();
|
|
910
|
+
}
|
|
911
|
+
/** Compress at 80% of the current model's context capacity. */
|
|
912
|
+
compressThreshold() {
|
|
913
|
+
return contextCompressionThreshold(this.modelProvider, this.model);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Summarise only the model's history. Original messages stay in the transcript
|
|
917
|
+
* and on disk; the UI folds the summarised prefix until the reader expands it.
|
|
918
|
+
*/
|
|
919
|
+
async autoCompress(token) {
|
|
920
|
+
const threshold = this.compressThreshold();
|
|
921
|
+
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
|
922
|
+
return;
|
|
923
|
+
const rest = this.messages.filter((m) => m.role !== "system");
|
|
924
|
+
const items = modelHistory(this.messages, this.context).map((m) => ({
|
|
925
|
+
role: m.role,
|
|
926
|
+
content: m.role === "tool" ? m.result : m.content ?? "",
|
|
927
|
+
name: m.role === "tool" ? m.name : undefined,
|
|
928
|
+
}));
|
|
929
|
+
const plan = planCompaction(items, COMPACT_KEEP);
|
|
930
|
+
if (!plan)
|
|
931
|
+
return;
|
|
932
|
+
const end = rest.length - plan.tail.length;
|
|
933
|
+
if (end <= (this.context?.end ?? 0))
|
|
934
|
+
return;
|
|
935
|
+
const transcript = transcriptFor(plan.head, COMPACT_MAX_CHARS_PER_MESSAGE);
|
|
936
|
+
this.compressing = true;
|
|
937
|
+
this.publishLive();
|
|
938
|
+
this.app.render();
|
|
939
|
+
const snapshotLen = this.messages.length;
|
|
940
|
+
let summary = "";
|
|
420
941
|
try {
|
|
421
|
-
|
|
942
|
+
summary = await this.summarizeTranscript(transcript);
|
|
422
943
|
}
|
|
423
944
|
catch {
|
|
424
|
-
|
|
945
|
+
summary = "";
|
|
946
|
+
}
|
|
947
|
+
this.compressing = false;
|
|
948
|
+
this.publishLive();
|
|
949
|
+
if (token !== this.turnToken)
|
|
950
|
+
return;
|
|
951
|
+
// Keep the summary boundary tied to this snapshot if a user queued another
|
|
952
|
+
// message while summarising; retry against the new snapshot next turn.
|
|
953
|
+
if (this.messages.length !== snapshotLen) {
|
|
954
|
+
this.app.render();
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const content = summary
|
|
958
|
+
? `[Context summary]\n${summary}`
|
|
959
|
+
: "Earlier messages are preserved in history, but could not be summarized for this request.";
|
|
960
|
+
this.context = { end, summary: content };
|
|
961
|
+
this.expandedContext = false;
|
|
962
|
+
// The context is small again; its real size is re-learned from the next reply.
|
|
963
|
+
this.lastPromptTokens = 0;
|
|
964
|
+
this.save();
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Summarise a transcript produced by `transcriptFor`. Split out so tests can
|
|
968
|
+
* stub it without touching the network.
|
|
969
|
+
*/
|
|
970
|
+
async summarizeTranscript(transcript) {
|
|
971
|
+
const client = new LLMClient(this.model, this.modelProvider, this.intensity);
|
|
972
|
+
const text = await client.summarize(transcript);
|
|
973
|
+
if (client.usage) {
|
|
974
|
+
this.tokens.prompt += client.usage.prompt;
|
|
975
|
+
this.tokens.completion += client.usage.completion;
|
|
976
|
+
}
|
|
977
|
+
return text;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Keep the outbound payload within budget during a long single turn: once the
|
|
981
|
+
* context is over the threshold, blank out all but the most recent tool
|
|
982
|
+
* results. Their full text stays in the visible log, which is collapsible.
|
|
983
|
+
*/
|
|
984
|
+
trimApiMessages(apiMessages) {
|
|
985
|
+
const threshold = this.compressThreshold();
|
|
986
|
+
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
|
987
|
+
return apiMessages;
|
|
988
|
+
const toolIdx = [];
|
|
989
|
+
for (let i = 0; i < apiMessages.length; i++) {
|
|
990
|
+
if (apiMessages[i].role === "tool")
|
|
991
|
+
toolIdx.push(i);
|
|
992
|
+
}
|
|
993
|
+
const dropCount = toolIdx.length - COMPACT_KEEP;
|
|
994
|
+
if (dropCount <= 0)
|
|
995
|
+
return apiMessages;
|
|
996
|
+
const copy = apiMessages.slice();
|
|
997
|
+
for (let k = 0; k < dropCount; k++) {
|
|
998
|
+
copy[toolIdx[k]] = { ...copy[toolIdx[k]], content: "[tool output omitted to save context]" };
|
|
999
|
+
}
|
|
1000
|
+
return copy;
|
|
1001
|
+
}
|
|
1002
|
+
retainStreamingContent() {
|
|
1003
|
+
if (this.streamingContent) {
|
|
1004
|
+
this.messages.push({ role: "assistant", content: this.streamingContent });
|
|
1005
|
+
}
|
|
1006
|
+
this.streamingContent = null;
|
|
1007
|
+
}
|
|
1008
|
+
save() {
|
|
1009
|
+
this.checkpoint.flush();
|
|
1010
|
+
}
|
|
1011
|
+
writeSession() {
|
|
1012
|
+
const chatMessages = [];
|
|
1013
|
+
for (const m of this.messages) {
|
|
1014
|
+
if (m.role === "user" || m.role === "assistant" || m.role === "tool") {
|
|
1015
|
+
chatMessages.push(m);
|
|
1016
|
+
}
|
|
1017
|
+
else if (m.role === "summary") {
|
|
1018
|
+
// Persist a compressed summary as a plain assistant message.
|
|
1019
|
+
chatMessages.push({ role: "assistant", content: m.content });
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// Snapshot the partial reply without adding it to the in-memory transcript:
|
|
1023
|
+
// the final reply will replace this snapshot, rather than duplicate it.
|
|
1024
|
+
if (this.streamingContent) {
|
|
1025
|
+
chatMessages.push({ role: "assistant", content: this.streamingContent });
|
|
1026
|
+
}
|
|
1027
|
+
if (chatMessages.length === 0)
|
|
1028
|
+
return true;
|
|
1029
|
+
try {
|
|
1030
|
+
this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode);
|
|
1031
|
+
this.saveError = null;
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
catch (err) {
|
|
1035
|
+
const detail = describeError(err);
|
|
1036
|
+
if (this.saveError === null)
|
|
1037
|
+
writeCrashLog("save session", err);
|
|
1038
|
+
this.saveError = detail;
|
|
1039
|
+
return false;
|
|
425
1040
|
}
|
|
426
1041
|
}
|
|
427
1042
|
buildLines(width) {
|
|
428
1043
|
const lines = [];
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
1044
|
+
// Parallel map from rendered line -> owning tool message index (-1 = none),
|
|
1045
|
+
// used to turn a click on any line of a tool block into an expand/collapse.
|
|
1046
|
+
const toolLineIndex = [];
|
|
1047
|
+
const add = (line, tool = -1) => {
|
|
1048
|
+
lines.push(line);
|
|
1049
|
+
toolLineIndex.push(tool);
|
|
433
1050
|
};
|
|
434
|
-
|
|
1051
|
+
const pushText = (text, style, tool = -1) => {
|
|
1052
|
+
for (const ln of wrapText(text, width))
|
|
1053
|
+
add({ spans: [{ text: ln, style }] }, tool);
|
|
1054
|
+
};
|
|
1055
|
+
const pushMarkdown = (text, style) => {
|
|
1056
|
+
for (const ln of renderMarkdownText(text, width, style))
|
|
1057
|
+
add(ln);
|
|
1058
|
+
};
|
|
1059
|
+
const context = normalizeContext(this.context, this.messages.filter((m) => m.role !== "system").length);
|
|
1060
|
+
this.contextLines.clear();
|
|
1061
|
+
let recordIndex = 0;
|
|
1062
|
+
for (let mi = 0; mi < this.messages.length; mi++) {
|
|
1063
|
+
const m = this.messages[mi];
|
|
1064
|
+
let archived = false;
|
|
1065
|
+
if (m.role !== "system") {
|
|
1066
|
+
if (recordIndex === 0 && context) {
|
|
1067
|
+
this.contextLines.add(lines.length);
|
|
1068
|
+
add({ spans: [{ text: `${this.expandedContext ? "▾" : "▸"} Earlier conversation (${context.end} messages) — click to ${this.expandedContext ? "collapse" : "expand"}`, style: { fg: C.amber, bold: true } }] });
|
|
1069
|
+
}
|
|
1070
|
+
archived = context !== null && recordIndex < context.end;
|
|
1071
|
+
recordIndex++;
|
|
1072
|
+
if (archived && !this.expandedContext)
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
435
1075
|
if (m.role === "system") {
|
|
436
|
-
pushText(m.content, { fg: C.dim });
|
|
1076
|
+
pushText(sanitizeText(m.content), { fg: C.dim });
|
|
437
1077
|
}
|
|
438
1078
|
else if (m.role === "user") {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
1079
|
+
add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
|
|
1080
|
+
pushMarkdown(sanitizeText(m.content), { fg: C.user });
|
|
1081
|
+
add({ spans: [] });
|
|
442
1082
|
}
|
|
443
1083
|
else if (m.role === "assistant") {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
1084
|
+
add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
|
|
1085
|
+
pushMarkdown(sanitizeText(m.content), { fg: C.assistant });
|
|
1086
|
+
add({ spans: [] });
|
|
1087
|
+
}
|
|
1088
|
+
else if (m.role === "summary") {
|
|
1089
|
+
add({
|
|
1090
|
+
spans: [
|
|
1091
|
+
{
|
|
1092
|
+
text: `✂ Context compressed (${m.count} earlier messages)`,
|
|
1093
|
+
style: { fg: C.amber, bold: true },
|
|
1094
|
+
},
|
|
1095
|
+
],
|
|
1096
|
+
});
|
|
1097
|
+
pushText(sanitizeText(m.content), { fg: C.dim });
|
|
1098
|
+
add({ spans: [] });
|
|
447
1099
|
}
|
|
448
1100
|
else if (m.role === "tool") {
|
|
449
|
-
|
|
450
|
-
|
|
1101
|
+
// Header + the command/path always show; the (often huge) output body is
|
|
1102
|
+
// collapsed by default and toggled by clicking the block.
|
|
1103
|
+
add({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] }, mi);
|
|
1104
|
+
const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? "", { singleLine: true });
|
|
451
1105
|
if (argText)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
1106
|
+
add({ spans: [{ text: " " + argText, style: { fg: C.dim } }] }, mi);
|
|
1107
|
+
const decision = this.approvals.get(m);
|
|
1108
|
+
if (decision) {
|
|
1109
|
+
const verdict = decision.approved ? "approved" : "denied";
|
|
1110
|
+
add({
|
|
1111
|
+
spans: [
|
|
1112
|
+
{
|
|
1113
|
+
text: ` ${decision.approved ? "✓" : "✗"} ${verdict} by ${decision.source} — ${ellipsize(sanitizeText(decision.reason, { singleLine: true }), Math.max(12, width - 12))}`,
|
|
1114
|
+
style: { fg: decision.approved ? C.green : C.orange },
|
|
1115
|
+
},
|
|
1116
|
+
],
|
|
1117
|
+
}, mi);
|
|
1118
|
+
}
|
|
1119
|
+
// Command output routinely carries ANSI colours and tabs; clean it once
|
|
1120
|
+
// so the collapsed line count and the expanded body agree.
|
|
1121
|
+
const body = sanitizeText(m.result || "");
|
|
1122
|
+
if (this.expandedTools.has(mi)) {
|
|
1123
|
+
pushText(body || "(no output)", { fg: C.dim }, mi);
|
|
1124
|
+
add({ spans: [{ text: " ▾ click to collapse", style: { fg: C.toolHeader } }] }, mi);
|
|
1125
|
+
}
|
|
1126
|
+
else {
|
|
1127
|
+
const n = body ? body.split("\n").length : 0;
|
|
1128
|
+
const summary = n > 0 ? `${n} line${n === 1 ? "" : "s"} hidden` : "no output";
|
|
1129
|
+
add({ spans: [{ text: ` ▸ ${summary} — click to expand`, style: { fg: C.dim } }] }, mi);
|
|
1130
|
+
}
|
|
1131
|
+
add({ spans: [] });
|
|
1132
|
+
}
|
|
1133
|
+
if (context && archived && recordIndex === context.end) {
|
|
1134
|
+
this.contextLines.add(lines.length);
|
|
1135
|
+
add({ spans: [{ text: "▾ Collapse earlier conversation", style: { fg: C.amber } }] });
|
|
1136
|
+
add({ spans: [] });
|
|
455
1137
|
}
|
|
456
1138
|
}
|
|
457
1139
|
if (this.streamingContent !== null) {
|
|
458
|
-
|
|
459
|
-
|
|
1140
|
+
add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
|
|
1141
|
+
pushMarkdown(sanitizeText(this.streamingContent), { fg: C.assistant });
|
|
460
1142
|
}
|
|
1143
|
+
this.toolLineIndex = toolLineIndex;
|
|
461
1144
|
return lines;
|
|
462
1145
|
}
|
|
463
1146
|
render(buf) {
|
|
@@ -466,7 +1149,15 @@ export class ChatScreen extends Screen {
|
|
|
466
1149
|
const h = buf.height;
|
|
467
1150
|
let status = "";
|
|
468
1151
|
let statusStyle = { fg: C.dim };
|
|
469
|
-
if (this.
|
|
1152
|
+
if (this.saveError) {
|
|
1153
|
+
status = `History save failed: ${sanitizeText(this.saveError, { singleLine: true })}`;
|
|
1154
|
+
statusStyle = { fg: C.orange, bold: true };
|
|
1155
|
+
}
|
|
1156
|
+
else if (this.compressing) {
|
|
1157
|
+
status = "Compressing context…";
|
|
1158
|
+
statusStyle = { fg: C.amber, bold: true };
|
|
1159
|
+
}
|
|
1160
|
+
else if (this.queued) {
|
|
470
1161
|
status = "Will send when paused or idle...";
|
|
471
1162
|
statusStyle = { fg: C.amber };
|
|
472
1163
|
}
|
|
@@ -478,7 +1169,7 @@ export class ChatScreen extends Screen {
|
|
|
478
1169
|
status = "Streaming... press ESC ESC to pause";
|
|
479
1170
|
statusStyle = { fg: C.dim };
|
|
480
1171
|
}
|
|
481
|
-
if (status)
|
|
1172
|
+
if (status && this.approvalToggleY(h) !== 1)
|
|
482
1173
|
buf.centerText(1, status.slice(0, w), statusStyle);
|
|
483
1174
|
const inputY = h - 4;
|
|
484
1175
|
const logTop = 2;
|
|
@@ -500,16 +1191,30 @@ export class ChatScreen extends Screen {
|
|
|
500
1191
|
lines.length > viewH
|
|
501
1192
|
? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
|
|
502
1193
|
: null;
|
|
1194
|
+
// Remember which tool block (if any) each visible row belongs to, so a
|
|
1195
|
+
// click can expand/collapse it (see the onMouse handler).
|
|
1196
|
+
this.toolRows.clear();
|
|
1197
|
+
this.contextRows.clear();
|
|
503
1198
|
for (let y = logTop; y <= logBottom; y++) {
|
|
504
1199
|
const li = this.scrollTop + (y - logTop);
|
|
505
1200
|
if (li >= lines.length)
|
|
506
1201
|
break;
|
|
1202
|
+
const tool = this.toolLineIndex[li];
|
|
1203
|
+
if (tool !== undefined && tool >= 0)
|
|
1204
|
+
this.toolRows.set(y, tool);
|
|
1205
|
+
if (this.contextLines.has(li))
|
|
1206
|
+
this.contextRows.add(y);
|
|
507
1207
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
508
1208
|
}
|
|
509
1209
|
// A scrollbar on the right edge of the log, shown only when the
|
|
510
1210
|
// conversation overflows the visible area.
|
|
511
1211
|
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
1212
|
+
if (this.pendingApproval)
|
|
1213
|
+
this.drawApprovalCard(buf, logTop, inputY);
|
|
1214
|
+
else
|
|
1215
|
+
this.approvalButtons = null;
|
|
512
1216
|
const inW = Math.max(12, w - 2);
|
|
1217
|
+
drawApprovalToggle(buf, this.approvalToggleY(h), this.approvalMode);
|
|
513
1218
|
drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
|
|
514
1219
|
active: true,
|
|
515
1220
|
dimmed: this.busy,
|
|
@@ -517,7 +1222,7 @@ export class ChatScreen extends Screen {
|
|
|
517
1222
|
placeholder: "Type a message...",
|
|
518
1223
|
});
|
|
519
1224
|
const hintY = h - 1;
|
|
520
|
-
const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear · drag to copy";
|
|
1225
|
+
const hints = "Enter send · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
|
|
521
1226
|
const hintW = displayWidth(hints);
|
|
522
1227
|
const hintX = Math.max(0, w - 1 - hintW);
|
|
523
1228
|
const pathAvail = Math.max(0, hintX - 2);
|