@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.
@@ -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 { COMPACT_KEEP, COMPACT_MAX_CHARS_PER_MESSAGE, planCompaction, transcriptFor, } from "../compact.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";
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
- buildApiMessages() {
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.messages) {
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
- 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
+ });
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
- /** Auto-compress threshold in (estimated) prompt tokens; 0 means "off". */
911
+ /** Compress at 80% of the current model's context capacity. */
482
912
  compressThreshold() {
483
- const raw = loadConfig().context_compress_threshold;
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
- * Once the context has grown past the configured threshold, replace the older
489
- * part of the conversation with a model-written summary. Runs at the start of
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 = rest.map((m) => ({
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
- // A new message (queued by the user while we were summarising) would be
523
- // dropped by the swap below, so bail out and retry on the next turn.
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 compressed]\n${summary}`
531
- : `[Context compressed]\n(${count} earlier messages were omitted to free up context.)`;
532
- const summaryMsg = { role: "summary", content, count };
533
- const kept = plan.tail.map((i) => i.ref);
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, process.cwd(), this.sessionId ?? undefined, this.tokens);
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
- // ignore save errors
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.compressing) {
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);