@xingjisoft/neutrino 0.1.4 → 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 +413 -163
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +442 -30
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +19 -0
- package/dist/config.js +121 -28
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +14 -0
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +709 -49
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +131 -14
- package/dist/screens/start.js +50 -16
- package/dist/selection.js +60 -0
- package/dist/state.js +1 -0
- package/dist/terminal/input.js +34 -5
- package/dist/terminal/renderer.js +58 -12
- package/dist/terminal/restore.js +148 -0
- package/dist/ui.js +16 -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 +210 -0
- package/web/app.js +1100 -0
- package/web/index.html +90 -0
- package/web/settings.js +193 -0
- package/web/style.css +1088 -0
- package/web/turns.js +52 -0
package/dist/screens/chat.js
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { loadConfig, 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
8
|
import { sanitizeText } from "../sanitize.js";
|
|
9
9
|
import { LLMClient, describeError } from "../llm/client.js";
|
|
10
10
|
import { executeTool, getToolDefinitions } from "../llm/tools.js";
|
|
11
|
-
import {
|
|
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";
|
|
12
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;
|
|
13
26
|
function parseArgs(s) {
|
|
14
27
|
if (!s || !s.trim())
|
|
15
28
|
return {};
|
|
@@ -24,11 +37,15 @@ function parseArgs(s) {
|
|
|
24
37
|
export class ChatScreen extends Screen {
|
|
25
38
|
initialMessage;
|
|
26
39
|
loadedMessages = null;
|
|
40
|
+
loadedCwd = null;
|
|
27
41
|
sessionId = null;
|
|
28
42
|
loadedProvider = null;
|
|
29
43
|
loadedModel = null;
|
|
30
44
|
loadedIntensity = null;
|
|
31
45
|
loadedTokens = null;
|
|
46
|
+
loadedContext = null;
|
|
47
|
+
loadedApprovalMode = null;
|
|
48
|
+
approvalMode = "request";
|
|
32
49
|
modelProvider = "DeepSeek";
|
|
33
50
|
model = { name: "deepseek-chat", url: "https://api.deepseek.com/v1", api_key: "", supports_streaming: true };
|
|
34
51
|
intensity = "High";
|
|
@@ -36,6 +53,12 @@ export class ChatScreen extends Screen {
|
|
|
36
53
|
showInfo = false;
|
|
37
54
|
messages = [];
|
|
38
55
|
streamingContent = null;
|
|
56
|
+
saveError = null;
|
|
57
|
+
checkpoint = new SessionCheckpoint(() => {
|
|
58
|
+
const saved = this.writeSession();
|
|
59
|
+
this.publishLive();
|
|
60
|
+
return saved;
|
|
61
|
+
});
|
|
39
62
|
busy = false;
|
|
40
63
|
paused = false;
|
|
41
64
|
queued = null;
|
|
@@ -52,6 +75,10 @@ export class ChatScreen extends Screen {
|
|
|
52
75
|
* the current context has grown. Reset once the history is compressed. */
|
|
53
76
|
lastPromptTokens = 0;
|
|
54
77
|
compressing = false;
|
|
78
|
+
context = null;
|
|
79
|
+
expandedContext = false;
|
|
80
|
+
contextLines = new Set();
|
|
81
|
+
contextRows = new Set();
|
|
55
82
|
// ---- collapsible tool output ----
|
|
56
83
|
/** Message indices whose tool output is expanded (everything starts
|
|
57
84
|
* collapsed). */
|
|
@@ -61,6 +88,31 @@ export class ChatScreen extends Screen {
|
|
|
61
88
|
toolLineIndex = [];
|
|
62
89
|
/** Terminal row -> tool message index, rebuilt each render for hit-testing. */
|
|
63
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();
|
|
104
|
+
// ---- web bridge ----
|
|
105
|
+
liveKey = null;
|
|
106
|
+
liveDir = process.cwd();
|
|
107
|
+
// ---- approval gate ---------------------------------------------------
|
|
108
|
+
/** Non-null while a tool call is waiting for a human decision. */
|
|
109
|
+
pendingApproval = null;
|
|
110
|
+
reviewing = null;
|
|
111
|
+
/** Decisions taken by the gate, keyed by the tool message (survives
|
|
112
|
+
* context compression, which renumbers the array). */
|
|
113
|
+
approvals = new Map();
|
|
114
|
+
/** Hit boxes of the approval card, rebuilt on every render. */
|
|
115
|
+
approvalButtons = null;
|
|
64
116
|
constructor(initialMessage = "") {
|
|
65
117
|
super();
|
|
66
118
|
this.initialMessage = initialMessage;
|
|
@@ -69,6 +121,7 @@ export class ChatScreen extends Screen {
|
|
|
69
121
|
if (this.initialized)
|
|
70
122
|
return;
|
|
71
123
|
this.initialized = true;
|
|
124
|
+
this.approvalMode = normalizeApprovalMode(this.loadedApprovalMode ?? loadConfig().approval_mode);
|
|
72
125
|
const st = this.app.state;
|
|
73
126
|
this.modelProvider = this.loadedProvider ?? st.selectedProvider;
|
|
74
127
|
this.model = {
|
|
@@ -88,6 +141,14 @@ export class ChatScreen extends Screen {
|
|
|
88
141
|
for (const m of this.loadedMessages ?? []) {
|
|
89
142
|
this.messages.push(m);
|
|
90
143
|
}
|
|
144
|
+
// Everything already on disk was answered, so history opens compact.
|
|
145
|
+
for (const start of this.userMessageIndexes())
|
|
146
|
+
this.finishedTurns.add(start);
|
|
147
|
+
this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
|
|
148
|
+
this.liveDir = this.loadedCwd ?? process.cwd();
|
|
149
|
+
this.checkpoint.register();
|
|
150
|
+
this.liveKey = hub.register(this);
|
|
151
|
+
this.publishLive();
|
|
91
152
|
if (this.initialMessage) {
|
|
92
153
|
this.send(this.initialMessage);
|
|
93
154
|
}
|
|
@@ -95,6 +156,145 @@ export class ChatScreen extends Screen {
|
|
|
95
156
|
this.app.render();
|
|
96
157
|
}
|
|
97
158
|
}
|
|
159
|
+
onDeactivate() {
|
|
160
|
+
// Never leave the agent awaiting a decision nobody can give.
|
|
161
|
+
if (this.pendingApproval)
|
|
162
|
+
this.settleApproval(false);
|
|
163
|
+
// A turn can continue after returning to the menu. Keep its exit checkpoint
|
|
164
|
+
// registered until it finishes, including while a tool is still running.
|
|
165
|
+
if (this.busy)
|
|
166
|
+
this.save();
|
|
167
|
+
else
|
|
168
|
+
this.checkpoint.dispose();
|
|
169
|
+
hub.unregister(this.liveKey);
|
|
170
|
+
this.liveKey = null;
|
|
171
|
+
}
|
|
172
|
+
// ---- web bridge (see src/web/hub.ts) ---------------------------------
|
|
173
|
+
liveCwd() {
|
|
174
|
+
return this.liveDir;
|
|
175
|
+
}
|
|
176
|
+
liveConfigure(selection) {
|
|
177
|
+
if (this.busy || this.pendingApproval) {
|
|
178
|
+
return { ok: false, error: "pause the current turn before changing models" };
|
|
179
|
+
}
|
|
180
|
+
this.modelProvider = selection.provider;
|
|
181
|
+
this.model = { ...selection.model };
|
|
182
|
+
this.intensity = selection.intensity;
|
|
183
|
+
Object.assign(this.app.state, {
|
|
184
|
+
selectedProvider: this.modelProvider, selectedModelName: this.model.name,
|
|
185
|
+
selectedModelUrl: this.model.url, selectedModelApiKey: this.model.api_key,
|
|
186
|
+
selectedModelStreaming: this.model.supports_streaming, selectedIntensity: this.intensity,
|
|
187
|
+
});
|
|
188
|
+
const header = this.messages.find((m) => m.role === "system");
|
|
189
|
+
if (header?.role === "system")
|
|
190
|
+
header.content = `Model: ${this.modelProvider}/${this.model.name} | Intensity: ${this.intensity}`;
|
|
191
|
+
this.save();
|
|
192
|
+
this.app.render();
|
|
193
|
+
this.publishLive();
|
|
194
|
+
return { ok: true };
|
|
195
|
+
}
|
|
196
|
+
liveMeta() {
|
|
197
|
+
return {
|
|
198
|
+
sessionId: this.sessionId,
|
|
199
|
+
title: this.liveTitle(),
|
|
200
|
+
provider: this.modelProvider,
|
|
201
|
+
model: this.model.name,
|
|
202
|
+
intensity: this.intensity,
|
|
203
|
+
approvalMode: this.approvalMode,
|
|
204
|
+
busy: this.busy,
|
|
205
|
+
paused: this.paused,
|
|
206
|
+
queued: this.queued,
|
|
207
|
+
compressing: this.compressing,
|
|
208
|
+
saveError: this.saveError,
|
|
209
|
+
tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
|
|
210
|
+
approval: this.pendingApproval
|
|
211
|
+
? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.liveDir)
|
|
212
|
+
: null,
|
|
213
|
+
reviewing: this.reviewing,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
liveSnapshot(key) {
|
|
217
|
+
const messages = [];
|
|
218
|
+
for (const message of this.messages) {
|
|
219
|
+
if (message.role === "system")
|
|
220
|
+
continue;
|
|
221
|
+
if (message.role === "summary") {
|
|
222
|
+
messages.push({ role: "summary", content: message.content, count: message.count });
|
|
223
|
+
}
|
|
224
|
+
else if (message.role === "tool") {
|
|
225
|
+
messages.push({ role: "tool", name: message.name, arguments: message.arguments, result: message.result });
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
messages.push({ role: message.role, content: message.content });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
...this.liveMeta(),
|
|
233
|
+
key,
|
|
234
|
+
cwd: this.liveDir,
|
|
235
|
+
messages,
|
|
236
|
+
streamingContent: this.streamingContent,
|
|
237
|
+
context: this.context,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
livePause() {
|
|
241
|
+
if (this.paused || (!this.busy && !this.queued))
|
|
242
|
+
return;
|
|
243
|
+
this.paused = true;
|
|
244
|
+
this.turnToken++;
|
|
245
|
+
// A queued decision would otherwise keep the agent awaiting forever.
|
|
246
|
+
if (this.pendingApproval)
|
|
247
|
+
this.settleApproval(false);
|
|
248
|
+
this.busy = false;
|
|
249
|
+
this.app.state.isThinking = false;
|
|
250
|
+
this.retainStreamingContent();
|
|
251
|
+
this.save();
|
|
252
|
+
this.app.render();
|
|
253
|
+
this.publishLive();
|
|
254
|
+
}
|
|
255
|
+
liveResume() {
|
|
256
|
+
if (this.paused)
|
|
257
|
+
this.togglePause();
|
|
258
|
+
}
|
|
259
|
+
/** Answer the pending call from the web UI. */
|
|
260
|
+
liveApprove(approved) {
|
|
261
|
+
if (!this.pendingApproval)
|
|
262
|
+
return false;
|
|
263
|
+
this.settleApproval(approved);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
liveSetApprovalMode(mode) {
|
|
267
|
+
if (this.approvalMode === mode)
|
|
268
|
+
return;
|
|
269
|
+
this.approvalMode = mode;
|
|
270
|
+
if (this.pendingApproval)
|
|
271
|
+
this.settleApproval(null);
|
|
272
|
+
this.save();
|
|
273
|
+
this.app.render();
|
|
274
|
+
this.publishLive();
|
|
275
|
+
}
|
|
276
|
+
liveSend(text) {
|
|
277
|
+
if (!text.trim())
|
|
278
|
+
return;
|
|
279
|
+
this.send(text);
|
|
280
|
+
this.publishLive();
|
|
281
|
+
}
|
|
282
|
+
liveTitle() {
|
|
283
|
+
for (const message of this.messages) {
|
|
284
|
+
if (message.role === "user") {
|
|
285
|
+
const title = message.content.replace(/\s+/g, " ").trim();
|
|
286
|
+
return title.length > 80 ? title.slice(0, 80) : title;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return "";
|
|
290
|
+
}
|
|
291
|
+
publishLive() {
|
|
292
|
+
hub.notify(this.liveKey);
|
|
293
|
+
}
|
|
294
|
+
approvalToggleY(height = this.app.height) {
|
|
295
|
+
// Short terminals need the space above the input for the approval card.
|
|
296
|
+
return this.pendingApproval && height < 13 ? 1 : height - 5;
|
|
297
|
+
}
|
|
98
298
|
/**
|
|
99
299
|
* The input row is persistent chrome: it is drawn with absolute positioning
|
|
100
300
|
* and must survive any terminal-side drift (ConPTY reflow, a stray scroll).
|
|
@@ -107,6 +307,8 @@ export class ChatScreen extends Screen {
|
|
|
107
307
|
for (let y = h - 5; y <= h - 1; y++)
|
|
108
308
|
if (y >= 0)
|
|
109
309
|
rows.push(y);
|
|
310
|
+
if (this.pendingApproval && h < 13)
|
|
311
|
+
rows.push(1);
|
|
110
312
|
return rows;
|
|
111
313
|
}
|
|
112
314
|
showsCursor() {
|
|
@@ -129,6 +331,19 @@ export class ChatScreen extends Screen {
|
|
|
129
331
|
this.app.render();
|
|
130
332
|
}
|
|
131
333
|
onKey(key) {
|
|
334
|
+
if (key.ctrl && key.name === "a") {
|
|
335
|
+
this.liveSetApprovalMode(this.approvalMode === "auto" ? "request" : "auto");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// A pending approval is modal: it owns the keyboard until answered.
|
|
339
|
+
if (this.pendingApproval) {
|
|
340
|
+
const name = key.name.toLowerCase();
|
|
341
|
+
if (name === "y" || name === "return")
|
|
342
|
+
this.settleApproval(true);
|
|
343
|
+
else if (name === "n" || name === "escape")
|
|
344
|
+
this.settleApproval(false);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
132
347
|
if (this.showInfo) {
|
|
133
348
|
this.showInfo = false;
|
|
134
349
|
this.app.render();
|
|
@@ -219,12 +434,32 @@ export class ChatScreen extends Screen {
|
|
|
219
434
|
return;
|
|
220
435
|
}
|
|
221
436
|
if (key.ctrl && key.name === "l") {
|
|
437
|
+
// Clearing starts a fresh conversation; do not overwrite the old history.
|
|
438
|
+
this.save();
|
|
439
|
+
if (this.saveError) {
|
|
440
|
+
this.app.render();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
this.turnToken++;
|
|
444
|
+
this.busy = false;
|
|
445
|
+
this.paused = false;
|
|
446
|
+
this.queued = null;
|
|
447
|
+
this.compressing = false;
|
|
448
|
+
this.app.state.isThinking = false;
|
|
449
|
+
this.lastPromptTokens = 0;
|
|
450
|
+
this.sessionId = null;
|
|
451
|
+
this.context = null;
|
|
452
|
+
this.expandedContext = false;
|
|
222
453
|
const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
|
|
223
454
|
this.messages = [{ role: "system", content: sysText }];
|
|
224
455
|
this.expandedTools.clear();
|
|
456
|
+
this.finishedTurns.clear();
|
|
457
|
+
this.expandedTurns.clear();
|
|
458
|
+
this.activeTurnStart = null;
|
|
225
459
|
this.streamingContent = null;
|
|
226
460
|
this.scrollTop = 0;
|
|
227
461
|
this.followBottom = true;
|
|
462
|
+
this.publishLive();
|
|
228
463
|
this.app.render();
|
|
229
464
|
return;
|
|
230
465
|
}
|
|
@@ -244,6 +479,23 @@ export class ChatScreen extends Screen {
|
|
|
244
479
|
if (ev.type === "click") {
|
|
245
480
|
if (ev.button === 2)
|
|
246
481
|
return; // right click: no-op in chat
|
|
482
|
+
if (inRect(ev.x, ev.y, approvalToggle(this.app.width, this.approvalToggleY(), this.approvalMode))) {
|
|
483
|
+
this.liveSetApprovalMode(this.approvalMode === "auto" ? "request" : "auto");
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
// The approval card floats over the log: test its buttons first.
|
|
487
|
+
const buttons = this.approvalButtons;
|
|
488
|
+
if (this.pendingApproval && buttons) {
|
|
489
|
+
const hit = (r) => ev.y === r.y && ev.x >= r.x && ev.x < r.x + r.w;
|
|
490
|
+
if (hit(buttons.approve)) {
|
|
491
|
+
this.settleApproval(true);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (hit(buttons.deny)) {
|
|
495
|
+
this.settleApproval(false);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
247
499
|
// header row: right corner toggles token stats, the rest opens model config
|
|
248
500
|
if (ev.y === 0) {
|
|
249
501
|
if (ev.x >= this.app.width - 3) {
|
|
@@ -272,6 +524,18 @@ export class ChatScreen extends Screen {
|
|
|
272
524
|
return;
|
|
273
525
|
}
|
|
274
526
|
// A click inside the conversation log toggles a tool block open/closed.
|
|
527
|
+
if (this.contextRows.has(ev.y)) {
|
|
528
|
+
this.expandedContext = !this.expandedContext;
|
|
529
|
+
this.followBottom = false;
|
|
530
|
+
this.scrollTop = Math.min(this.scrollTop, Math.min(...this.contextLines));
|
|
531
|
+
this.app.render();
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const turn = this.turnRows.get(ev.y);
|
|
535
|
+
if (turn !== undefined) {
|
|
536
|
+
this.toggleTurn(turn);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
275
539
|
const tool = this.toolRows.get(ev.y);
|
|
276
540
|
if (tool !== undefined) {
|
|
277
541
|
this.toggleTool(tool);
|
|
@@ -318,10 +582,12 @@ export class ChatScreen extends Screen {
|
|
|
318
582
|
this.turnToken++;
|
|
319
583
|
this.busy = false;
|
|
320
584
|
this.app.state.isThinking = false;
|
|
585
|
+
this.retainStreamingContent();
|
|
321
586
|
this.save();
|
|
322
587
|
}
|
|
323
588
|
this.app.render();
|
|
324
589
|
this.maybeFlushQueue();
|
|
590
|
+
this.publishLive();
|
|
325
591
|
}
|
|
326
592
|
maybeFlushQueue() {
|
|
327
593
|
if (this.queued) {
|
|
@@ -334,9 +600,12 @@ export class ChatScreen extends Screen {
|
|
|
334
600
|
if (!trimmed)
|
|
335
601
|
return;
|
|
336
602
|
this.messages.push({ role: "user", content: trimmed });
|
|
603
|
+
// Create the history entry before requesting a model or executing tools.
|
|
604
|
+
this.save();
|
|
337
605
|
this.inputValue = "";
|
|
338
606
|
this.cursor = 0;
|
|
339
607
|
this.followBottom = true;
|
|
608
|
+
this.publishLive();
|
|
340
609
|
if (this.busy && !this.paused) {
|
|
341
610
|
this.queued = trimmed;
|
|
342
611
|
this.app.render();
|
|
@@ -345,25 +614,67 @@ export class ChatScreen extends Screen {
|
|
|
345
614
|
this.startTurn();
|
|
346
615
|
}
|
|
347
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;
|
|
625
|
+
const turnMessages = this.messages.slice();
|
|
626
|
+
const approvalContext = captureApprovalContext(turnMessages);
|
|
348
627
|
this.busy = true;
|
|
349
628
|
this.paused = false;
|
|
350
629
|
this.app.state.isThinking = true;
|
|
351
630
|
const token = ++this.turnToken;
|
|
352
631
|
this.streamingContent = "";
|
|
353
632
|
this.app.render();
|
|
633
|
+
this.publishLive();
|
|
354
634
|
void (async () => {
|
|
355
635
|
// Shrink the history before building the request when it has outgrown the
|
|
356
636
|
// configured budget.
|
|
357
637
|
await this.autoCompress(token);
|
|
358
638
|
if (token !== this.turnToken)
|
|
359
639
|
return;
|
|
360
|
-
const apiMessages = this.buildApiMessages();
|
|
361
|
-
await this.agentLoop(apiMessages, token);
|
|
362
|
-
})()
|
|
640
|
+
const apiMessages = this.buildApiMessages(turnMessages);
|
|
641
|
+
await this.agentLoop(apiMessages, token, approvalContext);
|
|
642
|
+
})().catch((err) => {
|
|
643
|
+
if (token === this.turnToken)
|
|
644
|
+
this.failTurn(err);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Ends a turn that failed before or around the agent loop.
|
|
649
|
+
*
|
|
650
|
+
* The turn body is a floating promise, so anything thrown outside the loop's
|
|
651
|
+
* own try/catch would otherwise surface as an unhandled rejection, which Node
|
|
652
|
+
* turns into a fatal error. Ending the turn here keeps the app alive and the
|
|
653
|
+
* failure visible in the transcript.
|
|
654
|
+
*/
|
|
655
|
+
failTurn(err) {
|
|
656
|
+
this.retainStreamingContent();
|
|
657
|
+
this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
|
|
658
|
+
this.streamingContent = null;
|
|
659
|
+
try {
|
|
660
|
+
this.onTurnFinished();
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
// If even the cleanup fails, force the idle state so the UI stays usable.
|
|
664
|
+
this.busy = false;
|
|
665
|
+
this.paused = false;
|
|
666
|
+
this.app.state.isThinking = false;
|
|
667
|
+
try {
|
|
668
|
+
this.app.render();
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
// The app-level render guard reports this one.
|
|
672
|
+
}
|
|
673
|
+
}
|
|
363
674
|
}
|
|
364
|
-
buildApiMessages() {
|
|
675
|
+
buildApiMessages(messages = this.messages) {
|
|
365
676
|
const api = [{ role: "system", content: loadPrompt() }];
|
|
366
|
-
for (const m of this.
|
|
677
|
+
for (const m of modelHistory(messages, this.context)) {
|
|
367
678
|
if (m.role === "user" || m.role === "assistant") {
|
|
368
679
|
api.push({ role: m.role, content: m.content });
|
|
369
680
|
}
|
|
@@ -374,7 +685,7 @@ export class ChatScreen extends Screen {
|
|
|
374
685
|
}
|
|
375
686
|
return api;
|
|
376
687
|
}
|
|
377
|
-
async agentLoop(apiMessages, token) {
|
|
688
|
+
async agentLoop(apiMessages, token, approvalContext) {
|
|
378
689
|
try {
|
|
379
690
|
while (true) {
|
|
380
691
|
if (token !== this.turnToken)
|
|
@@ -394,7 +705,14 @@ export class ChatScreen extends Screen {
|
|
|
394
705
|
content += chunk;
|
|
395
706
|
hasContent = true;
|
|
396
707
|
this.streamingContent = content;
|
|
708
|
+
this.checkpoint.schedule();
|
|
397
709
|
this.app.render();
|
|
710
|
+
this.publishLive();
|
|
711
|
+
}
|
|
712
|
+
if (token !== this.turnToken) {
|
|
713
|
+
// Pause already retained the partial reply. A stale stream must not
|
|
714
|
+
// overwrite the buffer belonging to a newer turn.
|
|
715
|
+
return;
|
|
398
716
|
}
|
|
399
717
|
if (client.usage) {
|
|
400
718
|
this.tokens.prompt += client.usage.prompt;
|
|
@@ -404,19 +722,12 @@ export class ChatScreen extends Screen {
|
|
|
404
722
|
else {
|
|
405
723
|
this.tokens.completion += Math.max(1, Math.ceil(content.length / 4));
|
|
406
724
|
}
|
|
407
|
-
if (token !== this.turnToken) {
|
|
408
|
-
if (content)
|
|
409
|
-
this.messages.push({ role: "assistant", content });
|
|
410
|
-
this.streamingContent = null;
|
|
411
|
-
this.app.render();
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
725
|
if (aborted || this.paused) {
|
|
415
726
|
if (content)
|
|
416
727
|
this.messages.push({ role: "assistant", content });
|
|
417
728
|
this.streamingContent = null;
|
|
418
729
|
this.app.render();
|
|
419
|
-
this.onTurnFinished();
|
|
730
|
+
this.onTurnFinished(false);
|
|
420
731
|
return;
|
|
421
732
|
}
|
|
422
733
|
const toolCalls = client.toolCalls;
|
|
@@ -429,6 +740,7 @@ export class ChatScreen extends Screen {
|
|
|
429
740
|
if (hasContent)
|
|
430
741
|
this.messages.push({ role: "assistant", content });
|
|
431
742
|
this.streamingContent = null;
|
|
743
|
+
this.save();
|
|
432
744
|
apiMessages.push({
|
|
433
745
|
role: "assistant",
|
|
434
746
|
content,
|
|
@@ -444,31 +756,301 @@ export class ChatScreen extends Screen {
|
|
|
444
756
|
const args = parseArgs(tc.arguments);
|
|
445
757
|
const toolMsg = { role: "tool", name: tc.name, arguments: args, result: "" };
|
|
446
758
|
this.messages.push(toolMsg);
|
|
759
|
+
this.save();
|
|
760
|
+
this.publishLive();
|
|
447
761
|
this.app.render();
|
|
448
|
-
|
|
762
|
+
// Risky calls stop here until a human (or the reviewer agent) clears
|
|
763
|
+
// them. The loop awaits a promise that the UI resolves.
|
|
764
|
+
const decision = await this.requestApproval(tc.name, args, {
|
|
765
|
+
...approvalContext,
|
|
766
|
+
context: captureApprovalContext(apiMessages).context,
|
|
767
|
+
});
|
|
449
768
|
if (token !== this.turnToken || this.paused)
|
|
450
769
|
return;
|
|
770
|
+
if (decision.source !== "prompt" && decision.source !== "input") {
|
|
771
|
+
this.approvals.set(toolMsg, decision);
|
|
772
|
+
}
|
|
773
|
+
if (!decision.approved) {
|
|
774
|
+
// A malformed request was never shown to anyone, so there is no
|
|
775
|
+
// verdict to respect and the right move is a corrected retry.
|
|
776
|
+
toolMsg.result =
|
|
777
|
+
decision.source === "input"
|
|
778
|
+
? `[Not executed: ${decision.reason}]`
|
|
779
|
+
: `[Not executed: approval denied (${decision.source}): ${decision.reason}] ` +
|
|
780
|
+
describeApprovalIntent(approvalContext) +
|
|
781
|
+
"Do not retry this call unchanged; ask the user how to proceed.";
|
|
782
|
+
apiMessages.push({ role: "tool", tool_call_id: tc.id, content: toolMsg.result });
|
|
783
|
+
this.save();
|
|
784
|
+
this.publishLive();
|
|
785
|
+
this.app.render();
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const result = await executeTool(tc.name, args, this.liveDir);
|
|
789
|
+
// A clear starts a different transcript while an old tool may finish.
|
|
790
|
+
if (!this.messages.includes(toolMsg))
|
|
791
|
+
return;
|
|
451
792
|
toolMsg.result = result;
|
|
793
|
+
this.save();
|
|
794
|
+
if (token !== this.turnToken || this.paused)
|
|
795
|
+
return;
|
|
452
796
|
apiMessages.push({ role: "tool", tool_call_id: tc.id, content: result });
|
|
797
|
+
this.publishLive();
|
|
453
798
|
this.app.render();
|
|
454
799
|
}
|
|
455
800
|
}
|
|
456
801
|
this.onTurnFinished();
|
|
457
802
|
}
|
|
458
803
|
catch (e) {
|
|
804
|
+
if (token !== this.turnToken)
|
|
805
|
+
return;
|
|
459
806
|
const err = describeError(e);
|
|
460
807
|
this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
|
|
461
808
|
this.streamingContent = null;
|
|
462
809
|
this.onTurnFinished();
|
|
463
810
|
}
|
|
464
811
|
}
|
|
465
|
-
|
|
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;
|
|
466
820
|
this.busy = false;
|
|
467
821
|
this.paused = false;
|
|
468
822
|
this.app.state.isThinking = false;
|
|
469
823
|
this.save();
|
|
470
824
|
this.app.render();
|
|
471
825
|
this.maybeFlushQueue();
|
|
826
|
+
this.publishLive();
|
|
827
|
+
if (!this.busy && !this.liveKey)
|
|
828
|
+
this.checkpoint.dispose();
|
|
829
|
+
// After the queue flush: a queued prompt that took over immediately is not
|
|
830
|
+
// the end of the user's work, and ringing then would be a false alarm.
|
|
831
|
+
// Hosts are stubbed in tests, hence the optional call.
|
|
832
|
+
if (!this.busy)
|
|
833
|
+
this.app.bell?.("turn");
|
|
834
|
+
}
|
|
835
|
+
// ---- approval gate ---------------------------------------------------
|
|
836
|
+
/** Classify a tool call and, when it is risky, run it past the gate. */
|
|
837
|
+
async requestApproval(tool, args, context = captureApprovalContext(this.messages)) {
|
|
838
|
+
const risk = assessToolCall(tool, args, this.liveDir);
|
|
839
|
+
if (!risk)
|
|
840
|
+
return { approved: true, source: "prompt", reason: "routine call" };
|
|
841
|
+
const gate = new ApprovalGate({
|
|
842
|
+
askHuman: () => this.askHuman(tool, args, risk),
|
|
843
|
+
reviewer: () => this.reviewerModel(),
|
|
844
|
+
autoApprovalEnabled: () => this.approvalMode === "auto",
|
|
845
|
+
extremeAutoApprovalEnabled: () => loadConfig().auto_approve_extreme_risk === true,
|
|
846
|
+
// Reviews happen before any card is shown, so without this the app looks
|
|
847
|
+
// frozen while the independent assessments run.
|
|
848
|
+
onReview: (info) => {
|
|
849
|
+
this.reviewing = info ? {
|
|
850
|
+
total: info.total, round: info.round ?? 1, rounds: info.rounds ?? 1,
|
|
851
|
+
request: liveApprovalDetails(tool, args, risk, this.liveDir),
|
|
852
|
+
} : null;
|
|
853
|
+
this.publishLive();
|
|
854
|
+
if (!info) {
|
|
855
|
+
this.app.showToast("");
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
// Delete-class calls are reviewed from scratch three times; name the round
|
|
859
|
+
// so a long wait does not look like a hang.
|
|
860
|
+
const rounds = info.rounds ?? 1;
|
|
861
|
+
const which = rounds > 1 ? `round ${info.round ?? 1} of ${rounds}, ` : "";
|
|
862
|
+
this.app.showToast(`independent review running (${which}${info.total} reviewer${info.total > 1 ? "s" : ""})...`);
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
return gate.decide({ tool, args, cwd: this.liveDir, ...context, risk });
|
|
866
|
+
}
|
|
867
|
+
/** Park the turn until the user answers; the header turns orange meanwhile. */
|
|
868
|
+
askHuman(tool, args, risk) {
|
|
869
|
+
return new Promise((resolve) => {
|
|
870
|
+
this.pendingApproval = { tool, args, risk, resolve };
|
|
871
|
+
this.app.state.approval = { tool, level: risk.level };
|
|
872
|
+
this.app.bell?.("approval");
|
|
873
|
+
this.app.render();
|
|
874
|
+
this.publishLive();
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
/** Answer the pending request (keyboard, mouse, or the web UI). */
|
|
878
|
+
settleApproval(approved) {
|
|
879
|
+
const pending = this.pendingApproval;
|
|
880
|
+
if (!pending)
|
|
881
|
+
return;
|
|
882
|
+
this.pendingApproval = null;
|
|
883
|
+
this.app.state.approval = null;
|
|
884
|
+
this.approvalButtons = null;
|
|
885
|
+
pending.resolve(approved);
|
|
886
|
+
this.app.render();
|
|
887
|
+
this.publishLive();
|
|
888
|
+
}
|
|
889
|
+
/** Reviewer model: `approval_model` when set, otherwise the chat model. */
|
|
890
|
+
reviewerModel() {
|
|
891
|
+
const name = String(loadConfig().approval_model ?? "").trim();
|
|
892
|
+
const found = name ? findModel(name) : null;
|
|
893
|
+
if (found)
|
|
894
|
+
return { model: found.model, provider: found.provider, intensity: this.intensity };
|
|
895
|
+
return { model: this.model, provider: this.modelProvider, intensity: this.intensity };
|
|
896
|
+
}
|
|
897
|
+
/** Orange card listing the pending call and its buttons. */
|
|
898
|
+
drawApprovalCard(buf, logTop, inputY) {
|
|
899
|
+
const pending = this.pendingApproval;
|
|
900
|
+
if (!pending)
|
|
901
|
+
return;
|
|
902
|
+
const boxW = Math.max(24, Math.min(buf.width - 2, 78));
|
|
903
|
+
// A ten-row terminal has no room for the risk line as well, and losing the
|
|
904
|
+
// buttons under the input would leave the call impossible to answer, so the
|
|
905
|
+
// card drops the secondary line instead of overflowing.
|
|
906
|
+
const compact = inputY - logTop + 1 < 6;
|
|
907
|
+
const boxH = compact ? 5 : 6;
|
|
908
|
+
const boxX = 1;
|
|
909
|
+
const boxY = Math.max(logTop, inputY - boxH - 1);
|
|
910
|
+
buf.fillRect(boxX, boxY, boxW, boxH, { bg: C.orangeBg });
|
|
911
|
+
buf.drawBox(boxX, boxY, boxW, boxH, { fg: C.orange });
|
|
912
|
+
// Same treatment as the header: the two read as a single alert.
|
|
913
|
+
buf.fillRect(boxX + 1, boxY, boxW - 2, 1, { bg: C.orange });
|
|
914
|
+
buf.writeText(boxX + 2, boxY, "Request Approval", { fg: C.black, bg: C.orange, bold: true });
|
|
915
|
+
const tag = pending.risk.level === "extreme" ? "EXTREME RISK" : "HIGH RISK";
|
|
916
|
+
const tagX = boxX + boxW - 2 - tag.length;
|
|
917
|
+
if (tagX > boxX + 22) {
|
|
918
|
+
buf.writeText(tagX, boxY, tag, { fg: C.black, bg: C.orange, bold: true });
|
|
919
|
+
}
|
|
920
|
+
const summary = sanitizeText(pending.risk.summary, { singleLine: true });
|
|
921
|
+
buf.writeText(boxX + 2, boxY + 1, ellipsize(summary, boxW - 4), {
|
|
922
|
+
fg: C.assistant,
|
|
923
|
+
bg: C.orangeBg,
|
|
924
|
+
});
|
|
925
|
+
// Two different claims, kept apart on purpose: "Why" is what the agent says
|
|
926
|
+
// the call is for, "Flag" is what the risk scan actually detected.
|
|
927
|
+
const whyLabel = "Why: ";
|
|
928
|
+
const why = sanitizeText(statedReason(pending.args) ?? "the agent gave no reason", {
|
|
929
|
+
singleLine: true,
|
|
930
|
+
});
|
|
931
|
+
buf.writeText(boxX + 2, boxY + 2, whyLabel, { fg: C.orange, bg: C.orangeBg, bold: true });
|
|
932
|
+
buf.writeText(boxX + 2 + whyLabel.length, boxY + 2, ellipsize(why, Math.max(0, boxW - 4 - whyLabel.length)), { fg: C.assistant, bg: C.orangeBg });
|
|
933
|
+
if (!compact) {
|
|
934
|
+
const flagLabel = "Flag: ";
|
|
935
|
+
const flag = sanitizeText(pending.risk.reasons[0] ?? "", { singleLine: true });
|
|
936
|
+
buf.writeText(boxX + 2, boxY + 3, flagLabel, { fg: C.dim, bg: C.orangeBg, bold: true });
|
|
937
|
+
buf.writeText(boxX + 2 + flagLabel.length, boxY + 3, ellipsize(flag, Math.max(0, boxW - 4 - flagLabel.length)), { fg: C.dim, bg: C.orangeBg });
|
|
938
|
+
}
|
|
939
|
+
const approve = "[ Y ] Approve";
|
|
940
|
+
const deny = "[ N ] Deny";
|
|
941
|
+
const by = boxY + boxH - 2;
|
|
942
|
+
const denyX = boxX + 2 + approve.length + 3;
|
|
943
|
+
buf.writeText(boxX + 2, by, approve, { fg: C.black, bg: C.orange, bold: true });
|
|
944
|
+
buf.writeText(denyX, by, deny, { fg: C.orange, bg: C.orangeBg, bold: true });
|
|
945
|
+
this.approvalButtons = {
|
|
946
|
+
approve: { x: boxX + 2, y: by, w: approve.length },
|
|
947
|
+
deny: { x: denyX, y: by, w: deny.length },
|
|
948
|
+
};
|
|
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();
|
|
472
1054
|
}
|
|
473
1055
|
/** Expand/collapse the tool block belonging to message `index`. */
|
|
474
1056
|
toggleTool(index) {
|
|
@@ -478,35 +1060,33 @@ export class ChatScreen extends Screen {
|
|
|
478
1060
|
this.expandedTools.add(index);
|
|
479
1061
|
this.app.render();
|
|
480
1062
|
}
|
|
481
|
-
/**
|
|
1063
|
+
/** Compress at 80% of the current model's context capacity. */
|
|
482
1064
|
compressThreshold() {
|
|
483
|
-
|
|
484
|
-
const n = typeof raw === "number" ? raw : Number(raw);
|
|
485
|
-
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
1065
|
+
return contextCompressionThreshold(this.modelProvider, this.model);
|
|
486
1066
|
}
|
|
487
1067
|
/**
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
* a turn, before the request is built. If summarising fails the old messages
|
|
491
|
-
* are simply dropped, so the context always shrinks.
|
|
1068
|
+
* Summarise only the model's history. Original messages stay in the transcript
|
|
1069
|
+
* and on disk; the UI folds the summarised prefix until the reader expands it.
|
|
492
1070
|
*/
|
|
493
1071
|
async autoCompress(token) {
|
|
494
1072
|
const threshold = this.compressThreshold();
|
|
495
1073
|
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
|
496
1074
|
return;
|
|
497
|
-
const system = this.messages.find((m) => m.role === "system");
|
|
498
1075
|
const rest = this.messages.filter((m) => m.role !== "system");
|
|
499
|
-
const items =
|
|
1076
|
+
const items = modelHistory(this.messages, this.context).map((m) => ({
|
|
500
1077
|
role: m.role,
|
|
501
1078
|
content: m.role === "tool" ? m.result : m.content ?? "",
|
|
502
1079
|
name: m.role === "tool" ? m.name : undefined,
|
|
503
|
-
ref: m,
|
|
504
1080
|
}));
|
|
505
1081
|
const plan = planCompaction(items, COMPACT_KEEP);
|
|
506
1082
|
if (!plan)
|
|
507
1083
|
return;
|
|
1084
|
+
const end = rest.length - plan.tail.length;
|
|
1085
|
+
if (end <= (this.context?.end ?? 0))
|
|
1086
|
+
return;
|
|
508
1087
|
const transcript = transcriptFor(plan.head, COMPACT_MAX_CHARS_PER_MESSAGE);
|
|
509
1088
|
this.compressing = true;
|
|
1089
|
+
this.publishLive();
|
|
510
1090
|
this.app.render();
|
|
511
1091
|
const snapshotLen = this.messages.length;
|
|
512
1092
|
let summary = "";
|
|
@@ -517,23 +1097,20 @@ export class ChatScreen extends Screen {
|
|
|
517
1097
|
summary = "";
|
|
518
1098
|
}
|
|
519
1099
|
this.compressing = false;
|
|
1100
|
+
this.publishLive();
|
|
520
1101
|
if (token !== this.turnToken)
|
|
521
1102
|
return;
|
|
522
|
-
//
|
|
523
|
-
//
|
|
1103
|
+
// Keep the summary boundary tied to this snapshot if a user queued another
|
|
1104
|
+
// message while summarising; retry against the new snapshot next turn.
|
|
524
1105
|
if (this.messages.length !== snapshotLen) {
|
|
525
1106
|
this.app.render();
|
|
526
1107
|
return;
|
|
527
1108
|
}
|
|
528
|
-
const count = plan.head.length;
|
|
529
1109
|
const content = summary
|
|
530
|
-
? `[Context
|
|
531
|
-
:
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
this.messages = system ? [system, summaryMsg, ...kept] : [summaryMsg, ...kept];
|
|
535
|
-
// Message indices shifted, so any expanded tool block no longer lines up.
|
|
536
|
-
this.expandedTools.clear();
|
|
1110
|
+
? `[Context summary]\n${summary}`
|
|
1111
|
+
: "Earlier messages are preserved in history, but could not be summarized for this request.";
|
|
1112
|
+
this.context = { end, summary: content };
|
|
1113
|
+
this.expandedContext = false;
|
|
537
1114
|
// The context is small again; its real size is re-learned from the next reply.
|
|
538
1115
|
this.lastPromptTokens = 0;
|
|
539
1116
|
this.save();
|
|
@@ -574,7 +1151,16 @@ export class ChatScreen extends Screen {
|
|
|
574
1151
|
}
|
|
575
1152
|
return copy;
|
|
576
1153
|
}
|
|
1154
|
+
retainStreamingContent() {
|
|
1155
|
+
if (this.streamingContent) {
|
|
1156
|
+
this.messages.push({ role: "assistant", content: this.streamingContent });
|
|
1157
|
+
}
|
|
1158
|
+
this.streamingContent = null;
|
|
1159
|
+
}
|
|
577
1160
|
save() {
|
|
1161
|
+
this.checkpoint.flush();
|
|
1162
|
+
}
|
|
1163
|
+
writeSession() {
|
|
578
1164
|
const chatMessages = [];
|
|
579
1165
|
for (const m of this.messages) {
|
|
580
1166
|
if (m.role === "user" || m.role === "assistant" || m.role === "tool") {
|
|
@@ -585,13 +1171,24 @@ export class ChatScreen extends Screen {
|
|
|
585
1171
|
chatMessages.push({ role: "assistant", content: m.content });
|
|
586
1172
|
}
|
|
587
1173
|
}
|
|
1174
|
+
// Snapshot the partial reply without adding it to the in-memory transcript:
|
|
1175
|
+
// the final reply will replace this snapshot, rather than duplicate it.
|
|
1176
|
+
if (this.streamingContent) {
|
|
1177
|
+
chatMessages.push({ role: "assistant", content: this.streamingContent });
|
|
1178
|
+
}
|
|
588
1179
|
if (chatMessages.length === 0)
|
|
589
|
-
return;
|
|
1180
|
+
return true;
|
|
590
1181
|
try {
|
|
591
|
-
this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity,
|
|
1182
|
+
this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode);
|
|
1183
|
+
this.saveError = null;
|
|
1184
|
+
return true;
|
|
592
1185
|
}
|
|
593
|
-
catch {
|
|
594
|
-
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
const detail = describeError(err);
|
|
1188
|
+
if (this.saveError === null)
|
|
1189
|
+
writeCrashLog("save session", err);
|
|
1190
|
+
this.saveError = detail;
|
|
1191
|
+
return false;
|
|
595
1192
|
}
|
|
596
1193
|
}
|
|
597
1194
|
buildLines(width) {
|
|
@@ -599,9 +1196,13 @@ export class ChatScreen extends Screen {
|
|
|
599
1196
|
// Parallel map from rendered line -> owning tool message index (-1 = none),
|
|
600
1197
|
// used to turn a click on any line of a tool block into an expand/collapse.
|
|
601
1198
|
const toolLineIndex = [];
|
|
602
|
-
|
|
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) => {
|
|
603
1203
|
lines.push(line);
|
|
604
1204
|
toolLineIndex.push(tool);
|
|
1205
|
+
turnLineIndex.push(turn);
|
|
605
1206
|
};
|
|
606
1207
|
const pushText = (text, style, tool = -1) => {
|
|
607
1208
|
for (const ln of wrapText(text, width))
|
|
@@ -611,11 +1212,36 @@ export class ChatScreen extends Screen {
|
|
|
611
1212
|
for (const ln of renderMarkdownText(text, width, style))
|
|
612
1213
|
add(ln);
|
|
613
1214
|
};
|
|
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();
|
|
1218
|
+
this.contextLines.clear();
|
|
1219
|
+
let recordIndex = 0;
|
|
614
1220
|
for (let mi = 0; mi < this.messages.length; mi++) {
|
|
615
1221
|
const m = this.messages[mi];
|
|
1222
|
+
const turn = m.role === "system" ? -1 : turns[mi];
|
|
1223
|
+
let archived = false;
|
|
1224
|
+
if (m.role !== "system") {
|
|
1225
|
+
if (recordIndex === 0 && context) {
|
|
1226
|
+
this.contextLines.add(lines.length);
|
|
1227
|
+
add({ spans: [{ text: `${this.expandedContext ? "▾" : "▸"} Earlier conversation (${context.end} messages) — click to ${this.expandedContext ? "collapse" : "expand"}`, style: { fg: C.amber, bold: true } }] });
|
|
1228
|
+
}
|
|
1229
|
+
archived = context !== null && recordIndex < context.end;
|
|
1230
|
+
recordIndex++;
|
|
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);
|
|
1236
|
+
}
|
|
616
1237
|
if (m.role === "system") {
|
|
617
1238
|
pushText(sanitizeText(m.content), { fg: C.dim });
|
|
618
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
|
+
}
|
|
619
1245
|
else if (m.role === "user") {
|
|
620
1246
|
add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
|
|
621
1247
|
pushMarkdown(sanitizeText(m.content), { fg: C.user });
|
|
@@ -645,6 +1271,18 @@ export class ChatScreen extends Screen {
|
|
|
645
1271
|
const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? "", { singleLine: true });
|
|
646
1272
|
if (argText)
|
|
647
1273
|
add({ spans: [{ text: " " + argText, style: { fg: C.dim } }] }, mi);
|
|
1274
|
+
const decision = this.approvals.get(m);
|
|
1275
|
+
if (decision) {
|
|
1276
|
+
const verdict = decision.approved ? "approved" : "denied";
|
|
1277
|
+
add({
|
|
1278
|
+
spans: [
|
|
1279
|
+
{
|
|
1280
|
+
text: ` ${decision.approved ? "✓" : "✗"} ${verdict} by ${decision.source} — ${ellipsize(sanitizeText(decision.reason, { singleLine: true }), Math.max(12, width - 12))}`,
|
|
1281
|
+
style: { fg: decision.approved ? C.green : C.orange },
|
|
1282
|
+
},
|
|
1283
|
+
],
|
|
1284
|
+
}, mi);
|
|
1285
|
+
}
|
|
648
1286
|
// Command output routinely carries ANSI colours and tabs; clean it once
|
|
649
1287
|
// so the collapsed line count and the expanded body agree.
|
|
650
1288
|
const body = sanitizeText(m.result || "");
|
|
@@ -659,12 +1297,18 @@ export class ChatScreen extends Screen {
|
|
|
659
1297
|
}
|
|
660
1298
|
add({ spans: [] });
|
|
661
1299
|
}
|
|
1300
|
+
if (context && archived && recordIndex === context.end) {
|
|
1301
|
+
this.contextLines.add(lines.length);
|
|
1302
|
+
add({ spans: [{ text: "▾ Collapse earlier conversation", style: { fg: C.amber } }] });
|
|
1303
|
+
add({ spans: [] });
|
|
1304
|
+
}
|
|
662
1305
|
}
|
|
663
1306
|
if (this.streamingContent !== null) {
|
|
664
1307
|
add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
|
|
665
1308
|
pushMarkdown(sanitizeText(this.streamingContent), { fg: C.assistant });
|
|
666
1309
|
}
|
|
667
1310
|
this.toolLineIndex = toolLineIndex;
|
|
1311
|
+
this.turnLineIndex = turnLineIndex;
|
|
668
1312
|
return lines;
|
|
669
1313
|
}
|
|
670
1314
|
render(buf) {
|
|
@@ -673,7 +1317,11 @@ export class ChatScreen extends Screen {
|
|
|
673
1317
|
const h = buf.height;
|
|
674
1318
|
let status = "";
|
|
675
1319
|
let statusStyle = { fg: C.dim };
|
|
676
|
-
if (this.
|
|
1320
|
+
if (this.saveError) {
|
|
1321
|
+
status = `History save failed: ${sanitizeText(this.saveError, { singleLine: true })}`;
|
|
1322
|
+
statusStyle = { fg: C.orange, bold: true };
|
|
1323
|
+
}
|
|
1324
|
+
else if (this.compressing) {
|
|
677
1325
|
status = "Compressing context…";
|
|
678
1326
|
statusStyle = { fg: C.amber, bold: true };
|
|
679
1327
|
}
|
|
@@ -686,10 +1334,10 @@ export class ChatScreen extends Screen {
|
|
|
686
1334
|
statusStyle = { fg: C.amber, bold: true };
|
|
687
1335
|
}
|
|
688
1336
|
else if (this.busy) {
|
|
689
|
-
status = "Streaming... press ESC ESC to pause";
|
|
1337
|
+
status = "Streaming... press Ctrl+C or ESC ESC to pause";
|
|
690
1338
|
statusStyle = { fg: C.dim };
|
|
691
1339
|
}
|
|
692
|
-
if (status)
|
|
1340
|
+
if (status && this.approvalToggleY(h) !== 1)
|
|
693
1341
|
buf.centerText(1, status.slice(0, w), statusStyle);
|
|
694
1342
|
const inputY = h - 4;
|
|
695
1343
|
const logTop = 2;
|
|
@@ -714,6 +1362,8 @@ export class ChatScreen extends Screen {
|
|
|
714
1362
|
// Remember which tool block (if any) each visible row belongs to, so a
|
|
715
1363
|
// click can expand/collapse it (see the onMouse handler).
|
|
716
1364
|
this.toolRows.clear();
|
|
1365
|
+
this.contextRows.clear();
|
|
1366
|
+
this.turnRows.clear();
|
|
717
1367
|
for (let y = logTop; y <= logBottom; y++) {
|
|
718
1368
|
const li = this.scrollTop + (y - logTop);
|
|
719
1369
|
if (li >= lines.length)
|
|
@@ -721,12 +1371,22 @@ export class ChatScreen extends Screen {
|
|
|
721
1371
|
const tool = this.toolLineIndex[li];
|
|
722
1372
|
if (tool !== undefined && tool >= 0)
|
|
723
1373
|
this.toolRows.set(y, tool);
|
|
1374
|
+
const turn = this.turnLineIndex[li];
|
|
1375
|
+
if (turn !== undefined && turn >= 0)
|
|
1376
|
+
this.turnRows.set(y, turn);
|
|
1377
|
+
if (this.contextLines.has(li))
|
|
1378
|
+
this.contextRows.add(y);
|
|
724
1379
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
725
1380
|
}
|
|
726
1381
|
// A scrollbar on the right edge of the log, shown only when the
|
|
727
1382
|
// conversation overflows the visible area.
|
|
728
1383
|
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
1384
|
+
if (this.pendingApproval)
|
|
1385
|
+
this.drawApprovalCard(buf, logTop, inputY);
|
|
1386
|
+
else
|
|
1387
|
+
this.approvalButtons = null;
|
|
729
1388
|
const inW = Math.max(12, w - 2);
|
|
1389
|
+
drawApprovalToggle(buf, this.approvalToggleY(h), this.approvalMode);
|
|
730
1390
|
drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
|
|
731
1391
|
active: true,
|
|
732
1392
|
dimmed: this.busy,
|
|
@@ -734,7 +1394,7 @@ export class ChatScreen extends Screen {
|
|
|
734
1394
|
placeholder: "Type a message...",
|
|
735
1395
|
});
|
|
736
1396
|
const hintY = h - 1;
|
|
737
|
-
const hints = "Enter send · ESC ESC pause · 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";
|
|
738
1398
|
const hintW = displayWidth(hints);
|
|
739
1399
|
const hintX = Math.max(0, w - 1 - hintW);
|
|
740
1400
|
const pathAvail = Math.max(0, hintX - 2);
|