@xingjisoft/neutrino 0.1.3 → 0.1.5

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