@xingjisoft/neutrino 0.1.4 → 0.1.6

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