@xingjisoft/neutrino 0.1.4 → 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 -163
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +85 -3
- 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 +533 -45
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +131 -14
- package/dist/screens/start.js +50 -16
- package/dist/state.js +1 -0
- package/dist/terminal/input.js +14 -3
- package/dist/terminal/renderer.js +31 -6
- package/dist/terminal/restore.js +147 -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 +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,14 +1,19 @@
|
|
|
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();
|
|
13
18
|
function parseArgs(s) {
|
|
14
19
|
if (!s || !s.trim())
|
|
@@ -24,11 +29,15 @@ function parseArgs(s) {
|
|
|
24
29
|
export class ChatScreen extends Screen {
|
|
25
30
|
initialMessage;
|
|
26
31
|
loadedMessages = null;
|
|
32
|
+
loadedCwd = null;
|
|
27
33
|
sessionId = null;
|
|
28
34
|
loadedProvider = null;
|
|
29
35
|
loadedModel = null;
|
|
30
36
|
loadedIntensity = null;
|
|
31
37
|
loadedTokens = null;
|
|
38
|
+
loadedContext = null;
|
|
39
|
+
loadedApprovalMode = null;
|
|
40
|
+
approvalMode = "request";
|
|
32
41
|
modelProvider = "DeepSeek";
|
|
33
42
|
model = { name: "deepseek-chat", url: "https://api.deepseek.com/v1", api_key: "", supports_streaming: true };
|
|
34
43
|
intensity = "High";
|
|
@@ -36,6 +45,12 @@ export class ChatScreen extends Screen {
|
|
|
36
45
|
showInfo = false;
|
|
37
46
|
messages = [];
|
|
38
47
|
streamingContent = null;
|
|
48
|
+
saveError = null;
|
|
49
|
+
checkpoint = new SessionCheckpoint(() => {
|
|
50
|
+
const saved = this.writeSession();
|
|
51
|
+
this.publishLive();
|
|
52
|
+
return saved;
|
|
53
|
+
});
|
|
39
54
|
busy = false;
|
|
40
55
|
paused = false;
|
|
41
56
|
queued = null;
|
|
@@ -52,6 +67,10 @@ export class ChatScreen extends Screen {
|
|
|
52
67
|
* the current context has grown. Reset once the history is compressed. */
|
|
53
68
|
lastPromptTokens = 0;
|
|
54
69
|
compressing = false;
|
|
70
|
+
context = null;
|
|
71
|
+
expandedContext = false;
|
|
72
|
+
contextLines = new Set();
|
|
73
|
+
contextRows = new Set();
|
|
55
74
|
// ---- collapsible tool output ----
|
|
56
75
|
/** Message indices whose tool output is expanded (everything starts
|
|
57
76
|
* collapsed). */
|
|
@@ -61,6 +80,18 @@ export class ChatScreen extends Screen {
|
|
|
61
80
|
toolLineIndex = [];
|
|
62
81
|
/** Terminal row -> tool message index, rebuilt each render for hit-testing. */
|
|
63
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;
|
|
64
95
|
constructor(initialMessage = "") {
|
|
65
96
|
super();
|
|
66
97
|
this.initialMessage = initialMessage;
|
|
@@ -69,6 +100,7 @@ export class ChatScreen extends Screen {
|
|
|
69
100
|
if (this.initialized)
|
|
70
101
|
return;
|
|
71
102
|
this.initialized = true;
|
|
103
|
+
this.approvalMode = normalizeApprovalMode(this.loadedApprovalMode ?? loadConfig().approval_mode);
|
|
72
104
|
const st = this.app.state;
|
|
73
105
|
this.modelProvider = this.loadedProvider ?? st.selectedProvider;
|
|
74
106
|
this.model = {
|
|
@@ -88,6 +120,11 @@ export class ChatScreen extends Screen {
|
|
|
88
120
|
for (const m of this.loadedMessages ?? []) {
|
|
89
121
|
this.messages.push(m);
|
|
90
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();
|
|
91
128
|
if (this.initialMessage) {
|
|
92
129
|
this.send(this.initialMessage);
|
|
93
130
|
}
|
|
@@ -95,6 +132,145 @@ export class ChatScreen extends Screen {
|
|
|
95
132
|
this.app.render();
|
|
96
133
|
}
|
|
97
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
|
+
}
|
|
98
274
|
/**
|
|
99
275
|
* The input row is persistent chrome: it is drawn with absolute positioning
|
|
100
276
|
* and must survive any terminal-side drift (ConPTY reflow, a stray scroll).
|
|
@@ -107,6 +283,8 @@ export class ChatScreen extends Screen {
|
|
|
107
283
|
for (let y = h - 5; y <= h - 1; y++)
|
|
108
284
|
if (y >= 0)
|
|
109
285
|
rows.push(y);
|
|
286
|
+
if (this.pendingApproval && h < 13)
|
|
287
|
+
rows.push(1);
|
|
110
288
|
return rows;
|
|
111
289
|
}
|
|
112
290
|
showsCursor() {
|
|
@@ -129,6 +307,19 @@ export class ChatScreen extends Screen {
|
|
|
129
307
|
this.app.render();
|
|
130
308
|
}
|
|
131
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
|
+
}
|
|
132
323
|
if (this.showInfo) {
|
|
133
324
|
this.showInfo = false;
|
|
134
325
|
this.app.render();
|
|
@@ -219,12 +410,29 @@ export class ChatScreen extends Screen {
|
|
|
219
410
|
return;
|
|
220
411
|
}
|
|
221
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;
|
|
222
429
|
const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
|
|
223
430
|
this.messages = [{ role: "system", content: sysText }];
|
|
224
431
|
this.expandedTools.clear();
|
|
225
432
|
this.streamingContent = null;
|
|
226
433
|
this.scrollTop = 0;
|
|
227
434
|
this.followBottom = true;
|
|
435
|
+
this.publishLive();
|
|
228
436
|
this.app.render();
|
|
229
437
|
return;
|
|
230
438
|
}
|
|
@@ -244,6 +452,23 @@ export class ChatScreen extends Screen {
|
|
|
244
452
|
if (ev.type === "click") {
|
|
245
453
|
if (ev.button === 2)
|
|
246
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
|
+
}
|
|
247
472
|
// header row: right corner toggles token stats, the rest opens model config
|
|
248
473
|
if (ev.y === 0) {
|
|
249
474
|
if (ev.x >= this.app.width - 3) {
|
|
@@ -272,6 +497,13 @@ export class ChatScreen extends Screen {
|
|
|
272
497
|
return;
|
|
273
498
|
}
|
|
274
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
|
+
}
|
|
275
507
|
const tool = this.toolRows.get(ev.y);
|
|
276
508
|
if (tool !== undefined) {
|
|
277
509
|
this.toggleTool(tool);
|
|
@@ -318,10 +550,12 @@ export class ChatScreen extends Screen {
|
|
|
318
550
|
this.turnToken++;
|
|
319
551
|
this.busy = false;
|
|
320
552
|
this.app.state.isThinking = false;
|
|
553
|
+
this.retainStreamingContent();
|
|
321
554
|
this.save();
|
|
322
555
|
}
|
|
323
556
|
this.app.render();
|
|
324
557
|
this.maybeFlushQueue();
|
|
558
|
+
this.publishLive();
|
|
325
559
|
}
|
|
326
560
|
maybeFlushQueue() {
|
|
327
561
|
if (this.queued) {
|
|
@@ -334,9 +568,12 @@ export class ChatScreen extends Screen {
|
|
|
334
568
|
if (!trimmed)
|
|
335
569
|
return;
|
|
336
570
|
this.messages.push({ role: "user", content: trimmed });
|
|
571
|
+
// Create the history entry before requesting a model or executing tools.
|
|
572
|
+
this.save();
|
|
337
573
|
this.inputValue = "";
|
|
338
574
|
this.cursor = 0;
|
|
339
575
|
this.followBottom = true;
|
|
576
|
+
this.publishLive();
|
|
340
577
|
if (this.busy && !this.paused) {
|
|
341
578
|
this.queued = trimmed;
|
|
342
579
|
this.app.render();
|
|
@@ -345,25 +582,59 @@ export class ChatScreen extends Screen {
|
|
|
345
582
|
this.startTurn();
|
|
346
583
|
}
|
|
347
584
|
startTurn() {
|
|
585
|
+
const turnMessages = this.messages.slice();
|
|
586
|
+
const approvalContext = captureApprovalContext(turnMessages);
|
|
348
587
|
this.busy = true;
|
|
349
588
|
this.paused = false;
|
|
350
589
|
this.app.state.isThinking = true;
|
|
351
590
|
const token = ++this.turnToken;
|
|
352
591
|
this.streamingContent = "";
|
|
353
592
|
this.app.render();
|
|
593
|
+
this.publishLive();
|
|
354
594
|
void (async () => {
|
|
355
595
|
// Shrink the history before building the request when it has outgrown the
|
|
356
596
|
// configured budget.
|
|
357
597
|
await this.autoCompress(token);
|
|
358
598
|
if (token !== this.turnToken)
|
|
359
599
|
return;
|
|
360
|
-
const apiMessages = this.buildApiMessages();
|
|
361
|
-
await this.agentLoop(apiMessages, token);
|
|
362
|
-
})()
|
|
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
|
+
});
|
|
363
606
|
}
|
|
364
|
-
|
|
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
|
+
}
|
|
634
|
+
}
|
|
635
|
+
buildApiMessages(messages = this.messages) {
|
|
365
636
|
const api = [{ role: "system", content: loadPrompt() }];
|
|
366
|
-
for (const m of this.
|
|
637
|
+
for (const m of modelHistory(messages, this.context)) {
|
|
367
638
|
if (m.role === "user" || m.role === "assistant") {
|
|
368
639
|
api.push({ role: m.role, content: m.content });
|
|
369
640
|
}
|
|
@@ -374,7 +645,7 @@ export class ChatScreen extends Screen {
|
|
|
374
645
|
}
|
|
375
646
|
return api;
|
|
376
647
|
}
|
|
377
|
-
async agentLoop(apiMessages, token) {
|
|
648
|
+
async agentLoop(apiMessages, token, approvalContext) {
|
|
378
649
|
try {
|
|
379
650
|
while (true) {
|
|
380
651
|
if (token !== this.turnToken)
|
|
@@ -394,7 +665,14 @@ export class ChatScreen extends Screen {
|
|
|
394
665
|
content += chunk;
|
|
395
666
|
hasContent = true;
|
|
396
667
|
this.streamingContent = content;
|
|
668
|
+
this.checkpoint.schedule();
|
|
397
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;
|
|
398
676
|
}
|
|
399
677
|
if (client.usage) {
|
|
400
678
|
this.tokens.prompt += client.usage.prompt;
|
|
@@ -404,13 +682,6 @@ export class ChatScreen extends Screen {
|
|
|
404
682
|
else {
|
|
405
683
|
this.tokens.completion += Math.max(1, Math.ceil(content.length / 4));
|
|
406
684
|
}
|
|
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
685
|
if (aborted || this.paused) {
|
|
415
686
|
if (content)
|
|
416
687
|
this.messages.push({ role: "assistant", content });
|
|
@@ -429,6 +700,7 @@ export class ChatScreen extends Screen {
|
|
|
429
700
|
if (hasContent)
|
|
430
701
|
this.messages.push({ role: "assistant", content });
|
|
431
702
|
this.streamingContent = null;
|
|
703
|
+
this.save();
|
|
432
704
|
apiMessages.push({
|
|
433
705
|
role: "assistant",
|
|
434
706
|
content,
|
|
@@ -444,18 +716,53 @@ export class ChatScreen extends Screen {
|
|
|
444
716
|
const args = parseArgs(tc.arguments);
|
|
445
717
|
const toolMsg = { role: "tool", name: tc.name, arguments: args, result: "" };
|
|
446
718
|
this.messages.push(toolMsg);
|
|
719
|
+
this.save();
|
|
720
|
+
this.publishLive();
|
|
447
721
|
this.app.render();
|
|
448
|
-
|
|
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
|
+
});
|
|
449
728
|
if (token !== this.turnToken || this.paused)
|
|
450
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;
|
|
451
752
|
toolMsg.result = result;
|
|
753
|
+
this.save();
|
|
754
|
+
if (token !== this.turnToken || this.paused)
|
|
755
|
+
return;
|
|
452
756
|
apiMessages.push({ role: "tool", tool_call_id: tc.id, content: result });
|
|
757
|
+
this.publishLive();
|
|
453
758
|
this.app.render();
|
|
454
759
|
}
|
|
455
760
|
}
|
|
456
761
|
this.onTurnFinished();
|
|
457
762
|
}
|
|
458
763
|
catch (e) {
|
|
764
|
+
if (token !== this.turnToken)
|
|
765
|
+
return;
|
|
459
766
|
const err = describeError(e);
|
|
460
767
|
this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
|
|
461
768
|
this.streamingContent = null;
|
|
@@ -469,6 +776,129 @@ export class ChatScreen extends Screen {
|
|
|
469
776
|
this.save();
|
|
470
777
|
this.app.render();
|
|
471
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");
|
|
787
|
+
}
|
|
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)
|
|
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
|
+
};
|
|
472
902
|
}
|
|
473
903
|
/** Expand/collapse the tool block belonging to message `index`. */
|
|
474
904
|
toggleTool(index) {
|
|
@@ -478,35 +908,33 @@ export class ChatScreen extends Screen {
|
|
|
478
908
|
this.expandedTools.add(index);
|
|
479
909
|
this.app.render();
|
|
480
910
|
}
|
|
481
|
-
/**
|
|
911
|
+
/** Compress at 80% of the current model's context capacity. */
|
|
482
912
|
compressThreshold() {
|
|
483
|
-
|
|
484
|
-
const n = typeof raw === "number" ? raw : Number(raw);
|
|
485
|
-
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
913
|
+
return contextCompressionThreshold(this.modelProvider, this.model);
|
|
486
914
|
}
|
|
487
915
|
/**
|
|
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.
|
|
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.
|
|
492
918
|
*/
|
|
493
919
|
async autoCompress(token) {
|
|
494
920
|
const threshold = this.compressThreshold();
|
|
495
921
|
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
|
496
922
|
return;
|
|
497
|
-
const system = this.messages.find((m) => m.role === "system");
|
|
498
923
|
const rest = this.messages.filter((m) => m.role !== "system");
|
|
499
|
-
const items =
|
|
924
|
+
const items = modelHistory(this.messages, this.context).map((m) => ({
|
|
500
925
|
role: m.role,
|
|
501
926
|
content: m.role === "tool" ? m.result : m.content ?? "",
|
|
502
927
|
name: m.role === "tool" ? m.name : undefined,
|
|
503
|
-
ref: m,
|
|
504
928
|
}));
|
|
505
929
|
const plan = planCompaction(items, COMPACT_KEEP);
|
|
506
930
|
if (!plan)
|
|
507
931
|
return;
|
|
932
|
+
const end = rest.length - plan.tail.length;
|
|
933
|
+
if (end <= (this.context?.end ?? 0))
|
|
934
|
+
return;
|
|
508
935
|
const transcript = transcriptFor(plan.head, COMPACT_MAX_CHARS_PER_MESSAGE);
|
|
509
936
|
this.compressing = true;
|
|
937
|
+
this.publishLive();
|
|
510
938
|
this.app.render();
|
|
511
939
|
const snapshotLen = this.messages.length;
|
|
512
940
|
let summary = "";
|
|
@@ -517,23 +945,20 @@ export class ChatScreen extends Screen {
|
|
|
517
945
|
summary = "";
|
|
518
946
|
}
|
|
519
947
|
this.compressing = false;
|
|
948
|
+
this.publishLive();
|
|
520
949
|
if (token !== this.turnToken)
|
|
521
950
|
return;
|
|
522
|
-
//
|
|
523
|
-
//
|
|
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.
|
|
524
953
|
if (this.messages.length !== snapshotLen) {
|
|
525
954
|
this.app.render();
|
|
526
955
|
return;
|
|
527
956
|
}
|
|
528
|
-
const count = plan.head.length;
|
|
529
957
|
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();
|
|
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;
|
|
537
962
|
// The context is small again; its real size is re-learned from the next reply.
|
|
538
963
|
this.lastPromptTokens = 0;
|
|
539
964
|
this.save();
|
|
@@ -574,7 +999,16 @@ export class ChatScreen extends Screen {
|
|
|
574
999
|
}
|
|
575
1000
|
return copy;
|
|
576
1001
|
}
|
|
1002
|
+
retainStreamingContent() {
|
|
1003
|
+
if (this.streamingContent) {
|
|
1004
|
+
this.messages.push({ role: "assistant", content: this.streamingContent });
|
|
1005
|
+
}
|
|
1006
|
+
this.streamingContent = null;
|
|
1007
|
+
}
|
|
577
1008
|
save() {
|
|
1009
|
+
this.checkpoint.flush();
|
|
1010
|
+
}
|
|
1011
|
+
writeSession() {
|
|
578
1012
|
const chatMessages = [];
|
|
579
1013
|
for (const m of this.messages) {
|
|
580
1014
|
if (m.role === "user" || m.role === "assistant" || m.role === "tool") {
|
|
@@ -585,13 +1019,24 @@ export class ChatScreen extends Screen {
|
|
|
585
1019
|
chatMessages.push({ role: "assistant", content: m.content });
|
|
586
1020
|
}
|
|
587
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
|
+
}
|
|
588
1027
|
if (chatMessages.length === 0)
|
|
589
|
-
return;
|
|
1028
|
+
return true;
|
|
590
1029
|
try {
|
|
591
|
-
this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity,
|
|
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;
|
|
592
1033
|
}
|
|
593
|
-
catch {
|
|
594
|
-
|
|
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;
|
|
595
1040
|
}
|
|
596
1041
|
}
|
|
597
1042
|
buildLines(width) {
|
|
@@ -611,8 +1056,22 @@ export class ChatScreen extends Screen {
|
|
|
611
1056
|
for (const ln of renderMarkdownText(text, width, style))
|
|
612
1057
|
add(ln);
|
|
613
1058
|
};
|
|
1059
|
+
const context = normalizeContext(this.context, this.messages.filter((m) => m.role !== "system").length);
|
|
1060
|
+
this.contextLines.clear();
|
|
1061
|
+
let recordIndex = 0;
|
|
614
1062
|
for (let mi = 0; mi < this.messages.length; mi++) {
|
|
615
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
|
+
}
|
|
616
1075
|
if (m.role === "system") {
|
|
617
1076
|
pushText(sanitizeText(m.content), { fg: C.dim });
|
|
618
1077
|
}
|
|
@@ -645,6 +1104,18 @@ export class ChatScreen extends Screen {
|
|
|
645
1104
|
const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? "", { singleLine: true });
|
|
646
1105
|
if (argText)
|
|
647
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
|
+
}
|
|
648
1119
|
// Command output routinely carries ANSI colours and tabs; clean it once
|
|
649
1120
|
// so the collapsed line count and the expanded body agree.
|
|
650
1121
|
const body = sanitizeText(m.result || "");
|
|
@@ -659,6 +1130,11 @@ export class ChatScreen extends Screen {
|
|
|
659
1130
|
}
|
|
660
1131
|
add({ spans: [] });
|
|
661
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: [] });
|
|
1137
|
+
}
|
|
662
1138
|
}
|
|
663
1139
|
if (this.streamingContent !== null) {
|
|
664
1140
|
add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
|
|
@@ -673,7 +1149,11 @@ export class ChatScreen extends Screen {
|
|
|
673
1149
|
const h = buf.height;
|
|
674
1150
|
let status = "";
|
|
675
1151
|
let statusStyle = { fg: C.dim };
|
|
676
|
-
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) {
|
|
677
1157
|
status = "Compressing context…";
|
|
678
1158
|
statusStyle = { fg: C.amber, bold: true };
|
|
679
1159
|
}
|
|
@@ -689,7 +1169,7 @@ export class ChatScreen extends Screen {
|
|
|
689
1169
|
status = "Streaming... press ESC ESC to pause";
|
|
690
1170
|
statusStyle = { fg: C.dim };
|
|
691
1171
|
}
|
|
692
|
-
if (status)
|
|
1172
|
+
if (status && this.approvalToggleY(h) !== 1)
|
|
693
1173
|
buf.centerText(1, status.slice(0, w), statusStyle);
|
|
694
1174
|
const inputY = h - 4;
|
|
695
1175
|
const logTop = 2;
|
|
@@ -714,6 +1194,7 @@ export class ChatScreen extends Screen {
|
|
|
714
1194
|
// Remember which tool block (if any) each visible row belongs to, so a
|
|
715
1195
|
// click can expand/collapse it (see the onMouse handler).
|
|
716
1196
|
this.toolRows.clear();
|
|
1197
|
+
this.contextRows.clear();
|
|
717
1198
|
for (let y = logTop; y <= logBottom; y++) {
|
|
718
1199
|
const li = this.scrollTop + (y - logTop);
|
|
719
1200
|
if (li >= lines.length)
|
|
@@ -721,12 +1202,19 @@ export class ChatScreen extends Screen {
|
|
|
721
1202
|
const tool = this.toolLineIndex[li];
|
|
722
1203
|
if (tool !== undefined && tool >= 0)
|
|
723
1204
|
this.toolRows.set(y, tool);
|
|
1205
|
+
if (this.contextLines.has(li))
|
|
1206
|
+
this.contextRows.add(y);
|
|
724
1207
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
725
1208
|
}
|
|
726
1209
|
// A scrollbar on the right edge of the log, shown only when the
|
|
727
1210
|
// conversation overflows the visible area.
|
|
728
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;
|
|
729
1216
|
const inW = Math.max(12, w - 2);
|
|
1217
|
+
drawApprovalToggle(buf, this.approvalToggleY(h), this.approvalMode);
|
|
730
1218
|
drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
|
|
731
1219
|
active: true,
|
|
732
1220
|
dimmed: this.busy,
|
|
@@ -734,7 +1222,7 @@ export class ChatScreen extends Screen {
|
|
|
734
1222
|
placeholder: "Type a message...",
|
|
735
1223
|
});
|
|
736
1224
|
const hintY = h - 1;
|
|
737
|
-
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";
|
|
738
1226
|
const hintW = displayWidth(hints);
|
|
739
1227
|
const hintX = Math.max(0, w - 1 - hintW);
|
|
740
1228
|
const pathAvail = Math.max(0, hintX - 2);
|