@xingjisoft/neutrino 0.1.6 → 0.2.0

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,13 +1,16 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { findModel, contextCompressionThreshold, loadConfig, normalizeApprovalMode, loadPrompt, saveSession, } from "../config.js";
3
+ import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadPrompt, saveSession, } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
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
- import { executeTool, getToolDefinitions } from "../llm/tools.js";
10
+ import { formatDelay } from "../llm/retry.js";
11
+ import { advancePlan, buildPlannerInput, lastRequestText, parsePlan, planCardRows, planMessage, planRowsWidth, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
12
+ import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
13
+ import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
11
14
  import { assessToolCall } from "../llm/risk.js";
12
15
  import { ApprovalGate, captureApprovalContext, describeApprovalIntent, statedReason } from "../llm/approval.js";
13
16
  import { COMPACT_KEEP, COMPACT_MAX_CHARS_PER_MESSAGE, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
@@ -15,11 +18,15 @@ import { hub, liveApprovalDetails } from "../web/hub.js";
15
18
  import { SessionCheckpoint } from "../persistence.js";
16
19
  import { writeCrashLog } from "../terminal/restore.js";
17
20
  const TOOLS = getToolDefinitions();
21
+ /** A sub-agent gets the same tools minus delegation: one level, never a tree. */
22
+ const SUB_AGENT_TOOLS = getSubAgentToolDefinitions();
18
23
  /**
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.
24
+ * A finished turn folds its *work* away — the prompt body, the thinking and the
25
+ * tool calls — and never its final reply, which always stays on screen. Doing
26
+ * that is only worth a click when the work is substantial: four or more blocks,
27
+ * or roughly six wrapped lines of text. Short exchanges stay readable without a
28
+ * click. `web/turns.js` applies the same rule in the browser, so both UIs fold
29
+ * exactly the same turns.
23
30
  */
24
31
  export const TURN_FOLD_MIN_BLOCKS = 4;
25
32
  export const TURN_FOLD_MIN_CHARS = 600;
@@ -34,6 +41,32 @@ function parseArgs(s) {
34
41
  return {};
35
42
  }
36
43
  }
44
+ /**
45
+ * How each kind of info-card row is drawn.
46
+ *
47
+ * The three step states are deliberately distinct: the step being worked on is a
48
+ * lit bar, steps still to come are normal text, and finished steps recede into
49
+ * the dark so the eye lands on what is happening now.
50
+ */
51
+ function planRowStyle(kind) {
52
+ switch (kind) {
53
+ case "header":
54
+ return { fg: C.accent, bg: C.black, bold: true };
55
+ case "step-active":
56
+ return { fg: C.accent, bg: C.highlightBg, bold: true };
57
+ case "step-done":
58
+ return { fg: C.dim, bg: C.black };
59
+ case "step-pending":
60
+ return { fg: C.assistant, bg: C.black };
61
+ case "step-failed":
62
+ return { fg: C.red, bg: C.black, bold: true };
63
+ case "connector":
64
+ case "more":
65
+ return { fg: C.dim, bg: C.black };
66
+ default:
67
+ return { fg: C.assistant, bg: C.black };
68
+ }
69
+ }
37
70
  export class ChatScreen extends Screen {
38
71
  initialMessage;
39
72
  loadedMessages = null;
@@ -45,15 +78,25 @@ export class ChatScreen extends Screen {
45
78
  loadedTokens = null;
46
79
  loadedContext = null;
47
80
  loadedApprovalMode = null;
81
+ loadedPlan = null;
48
82
  approvalMode = "request";
49
83
  modelProvider = "DeepSeek";
50
84
  model = { name: "deepseek-chat", url: "https://api.deepseek.com/v1", api_key: "", supports_streaming: true };
51
85
  intensity = "High";
52
86
  tokens = { prompt: 0, completion: 0 };
53
87
  showInfo = false;
88
+ // ---- plan-first turns ----
89
+ /** The plan of the turn being worked on, or the last one. */
90
+ plan = null;
91
+ /** True while the planner agent is writing the steps. */
92
+ planning = false;
93
+ /** What the sub-agent is doing, or null when none is running. */
94
+ subagent = null;
54
95
  messages = [];
55
96
  streamingContent = null;
56
97
  saveError = null;
98
+ /** Why a busy turn is silent: a transient network failure being retried. */
99
+ retryNotice = null;
57
100
  checkpoint = new SessionCheckpoint(() => {
58
101
  const saved = this.writeSession();
59
102
  this.publishLive();
@@ -144,6 +187,7 @@ export class ChatScreen extends Screen {
144
187
  // Everything already on disk was answered, so history opens compact.
145
188
  for (const start of this.userMessageIndexes())
146
189
  this.finishedTurns.add(start);
190
+ this.plan = resumePlan(this.loadedPlan);
147
191
  this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
148
192
  this.liveDir = this.loadedCwd ?? process.cwd();
149
193
  this.checkpoint.register();
@@ -207,6 +251,7 @@ export class ChatScreen extends Screen {
207
251
  compressing: this.compressing,
208
252
  saveError: this.saveError,
209
253
  tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
254
+ subagent: this.subagent,
210
255
  approval: this.pendingApproval
211
256
  ? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.liveDir)
212
257
  : null,
@@ -235,6 +280,7 @@ export class ChatScreen extends Screen {
235
280
  messages,
236
281
  streamingContent: this.streamingContent,
237
282
  context: this.context,
283
+ plan: this.plan,
238
284
  };
239
285
  }
240
286
  livePause() {
@@ -248,6 +294,7 @@ export class ChatScreen extends Screen {
248
294
  this.busy = false;
249
295
  this.app.state.isThinking = false;
250
296
  this.retainStreamingContent();
297
+ this.haltPlan();
251
298
  this.save();
252
299
  this.app.render();
253
300
  this.publishLive();
@@ -344,6 +391,12 @@ export class ChatScreen extends Screen {
344
391
  this.settleApproval(false);
345
392
  return;
346
393
  }
394
+ // The card is the plan: Ctrl+P shows it even when the pointer is elsewhere.
395
+ if (key.ctrl && key.name === "p") {
396
+ this.showInfo = !this.showInfo;
397
+ this.app.render();
398
+ return;
399
+ }
347
400
  if (this.showInfo) {
348
401
  this.showInfo = false;
349
402
  this.app.render();
@@ -450,6 +503,9 @@ export class ChatScreen extends Screen {
450
503
  this.sessionId = null;
451
504
  this.context = null;
452
505
  this.expandedContext = false;
506
+ this.plan = null;
507
+ this.planning = false;
508
+ this.subagent = null;
453
509
  const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
454
510
  this.messages = [{ role: "system", content: sysText }];
455
511
  this.expandedTools.clear();
@@ -583,6 +639,8 @@ export class ChatScreen extends Screen {
583
639
  this.busy = false;
584
640
  this.app.state.isThinking = false;
585
641
  this.retainStreamingContent();
642
+ // The loop that ran the plan is gone with the paused turn.
643
+ this.haltPlan();
586
644
  this.save();
587
645
  }
588
646
  this.app.render();
@@ -595,6 +653,139 @@ export class ChatScreen extends Screen {
595
653
  this.startTurn();
596
654
  }
597
655
  }
656
+ // ---- plan-first turns -------------------------------------------------
657
+ /**
658
+ * Run the planner agent over the current request.
659
+ *
660
+ * Returns null rather than throwing in every case where there is no usable
661
+ * plan — planning switched off, a one-step request, a provider that fails, an
662
+ * answer that is not a plan. The turn then runs exactly as it did before
663
+ * plan-first existed; a plan is an improvement, never a new way to fail.
664
+ */
665
+ async makePlan(token) {
666
+ if (!planFirstEnabled())
667
+ return null;
668
+ const request = lastRequestText(this.messages);
669
+ if (!request.trim())
670
+ return null;
671
+ this.planning = true;
672
+ this.app.render();
673
+ this.publishLive();
674
+ try {
675
+ const client = new LLMClient(this.model, this.modelProvider, this.intensity);
676
+ const answer = await client.complete(loadPlannerPrompt(), buildPlannerInput({ request, cwd: this.liveDir, transcript: planTranscript(this.messages) }), {
677
+ onRetry: (notice) => {
678
+ this.retryNotice =
679
+ `Planning — network hiccup, retrying in ${formatDelay(notice.delayMs)} ` +
680
+ `(attempt ${notice.attempt}/${notice.attempts})`;
681
+ this.app.render();
682
+ },
683
+ cancelled: () => token !== this.turnToken || this.paused,
684
+ });
685
+ if (client.usage) {
686
+ this.tokens.prompt += client.usage.prompt;
687
+ this.tokens.completion += client.usage.completion;
688
+ this.lastPromptTokens = client.usage.prompt;
689
+ }
690
+ if (token !== this.turnToken)
691
+ return null;
692
+ return parsePlan(answer);
693
+ }
694
+ catch {
695
+ return null;
696
+ }
697
+ finally {
698
+ this.planning = false;
699
+ this.retryNotice = null;
700
+ if (token === this.turnToken) {
701
+ this.app.render();
702
+ this.publishLive();
703
+ }
704
+ }
705
+ }
706
+ /** Move the plan to one step and show it: the card follows every transition. */
707
+ setStep(index, status) {
708
+ if (!advancePlan(this.plan, index, status))
709
+ return;
710
+ this.save();
711
+ this.publishLive();
712
+ this.app.render();
713
+ }
714
+ /** A turn that can no longer continue must not leave a step looking alive. */
715
+ haltPlan() {
716
+ stopPlan(this.plan);
717
+ }
718
+ /**
719
+ * One user turn.
720
+ *
721
+ * A planned turn is answered in two passes: the planner writes the steps, then
722
+ * the worker executes them one at a time, top to bottom, each with the whole
723
+ * plan in its system prompt and the instruction to do only its own step. That
724
+ * keeps the work of a long request explicit — the card shows exactly which
725
+ * step is running and which are done — and keeps every step verifiable on its
726
+ * own. Without a plan the turn runs in one pass, as before.
727
+ */
728
+ async runTurn(token, turnMessages, approvalContext) {
729
+ // Shrink the history before building the request when it has outgrown the
730
+ // configured budget.
731
+ await this.autoCompress(token);
732
+ if (token !== this.turnToken)
733
+ return;
734
+ const plan = await this.makePlan(token);
735
+ if (token !== this.turnToken)
736
+ return;
737
+ if (plan) {
738
+ this.plan = plan;
739
+ // The plan is an ordinary assistant message: it is saved, folded, replayed
740
+ // and sent back to the model exactly like any other answer, so later turns
741
+ // (and the user) still know what was agreed.
742
+ this.messages.push({ role: "assistant", content: planMessage(plan) });
743
+ // Opening the card is the point of planning: the steps were asked for up
744
+ // front, so they are shown up front. A click or Esc closes it again.
745
+ this.showInfo = true;
746
+ this.save();
747
+ this.publishLive();
748
+ this.app.render();
749
+ const template = loadStepPrompt();
750
+ for (let i = 0; i < plan.steps.length; i++) {
751
+ if (token !== this.turnToken)
752
+ return;
753
+ this.setStep(i, "active");
754
+ const directive = renderStepDirective(template, plan, i);
755
+ const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), directive), token, approvalContext);
756
+ if (token !== this.turnToken)
757
+ return;
758
+ this.setStep(i, finished ? "done" : "failed");
759
+ // A step that failed stops the plan: the remaining steps assumed this one
760
+ // succeeded, and the user has to see the failure before anything runs on
761
+ // top of it.
762
+ if (!finished)
763
+ break;
764
+ }
765
+ }
766
+ else {
767
+ await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages)), token, approvalContext);
768
+ if (token !== this.turnToken)
769
+ return;
770
+ }
771
+ this.onTurnFinished();
772
+ }
773
+ /**
774
+ * The transcript this turn may see: the messages it started with plus what it
775
+ * has added since — the plan message, replies, tool calls and results.
776
+ *
777
+ * A user message that arrives while the turn runs is queued for the *next*
778
+ * turn, so it is deliberately not part of this one: the model and the approval
779
+ * reviewers must always judge the same request.
780
+ */
781
+ turnView(snapshot) {
782
+ const view = snapshot.slice();
783
+ for (let i = snapshot.length; i < this.messages.length; i++) {
784
+ if (this.messages[i].role !== "user")
785
+ view.push(this.messages[i]);
786
+ }
787
+ return view;
788
+ }
598
789
  send(text) {
599
790
  const trimmed = text.trim();
600
791
  if (!trimmed)
@@ -631,15 +822,7 @@ export class ChatScreen extends Screen {
631
822
  this.streamingContent = "";
632
823
  this.app.render();
633
824
  this.publishLive();
634
- void (async () => {
635
- // Shrink the history before building the request when it has outgrown the
636
- // configured budget.
637
- await this.autoCompress(token);
638
- if (token !== this.turnToken)
639
- return;
640
- const apiMessages = this.buildApiMessages(turnMessages);
641
- await this.agentLoop(apiMessages, token, approvalContext);
642
- })().catch((err) => {
825
+ void this.runTurn(token, turnMessages, approvalContext).catch((err) => {
643
826
  if (token === this.turnToken)
644
827
  this.failTurn(err);
645
828
  });
@@ -656,6 +839,10 @@ export class ChatScreen extends Screen {
656
839
  this.retainStreamingContent();
657
840
  this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
658
841
  this.streamingContent = null;
842
+ // The turn died outside the loop, so the step it was on never reported back.
843
+ const active = this.plan?.steps.findIndex((step) => step.status === "active") ?? -1;
844
+ if (active >= 0)
845
+ advancePlan(this.plan, active, "failed");
659
846
  try {
660
847
  this.onTurnFinished();
661
848
  }
@@ -672,8 +859,11 @@ export class ChatScreen extends Screen {
672
859
  }
673
860
  }
674
861
  }
675
- buildApiMessages(messages = this.messages) {
676
- const api = [{ role: "system", content: loadPrompt() }];
862
+ buildApiMessages(messages = this.messages, directive = "") {
863
+ // A step directive extends the system prompt rather than adding a user
864
+ // message: the plan is an instruction, not something the user just said.
865
+ const system = directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt();
866
+ const api = [{ role: "system", content: system }];
677
867
  for (const m of modelHistory(messages, this.context)) {
678
868
  if (m.role === "user" || m.role === "assistant") {
679
869
  api.push({ role: m.role, content: m.content });
@@ -685,11 +875,19 @@ export class ChatScreen extends Screen {
685
875
  }
686
876
  return api;
687
877
  }
878
+ /**
879
+ * One agent conversation: request, tool calls, request again, until the model
880
+ * answers without asking for a tool.
881
+ *
882
+ * Returns true when the model finished on its own and false when the turn was
883
+ * cut short (paused, superseded, or failed). The caller owns the turn's end,
884
+ * because a planned turn runs this once per step.
885
+ */
688
886
  async agentLoop(apiMessages, token, approvalContext) {
689
887
  try {
690
888
  while (true) {
691
889
  if (token !== this.turnToken)
692
- return;
890
+ return false;
693
891
  // Keep the payload inside budget during a long single turn: once the
694
892
  // context is too large, drop the body of older tool results.
695
893
  apiMessages = this.trimApiMessages(apiMessages);
@@ -697,7 +895,19 @@ export class ChatScreen extends Screen {
697
895
  let content = "";
698
896
  let hasContent = false;
699
897
  let aborted = false;
700
- for await (const chunk of client.streamTurn(apiMessages, TOOLS)) {
898
+ for await (const chunk of client.streamTurn(apiMessages, TOOLS, {
899
+ // A retried request can go quiet for up to 160s, so the status line
900
+ // says why instead of looking hung. The moment the user pauses or a
901
+ // newer turn takes over, the backoff is dropped instead of waited out.
902
+ onRetry: (notice) => {
903
+ this.retryNotice =
904
+ `Network hiccup — retrying in ${formatDelay(notice.delayMs)} ` +
905
+ `(attempt ${notice.attempt}/${notice.attempts}): ${notice.reason}`;
906
+ this.app.render();
907
+ },
908
+ cancelled: () => token !== this.turnToken || this.paused,
909
+ })) {
910
+ this.retryNotice = null;
701
911
  if (token !== this.turnToken || this.paused) {
702
912
  aborted = true;
703
913
  break;
@@ -709,10 +919,11 @@ export class ChatScreen extends Screen {
709
919
  this.app.render();
710
920
  this.publishLive();
711
921
  }
922
+ this.retryNotice = null;
712
923
  if (token !== this.turnToken) {
713
924
  // Pause already retained the partial reply. A stale stream must not
714
925
  // overwrite the buffer belonging to a newer turn.
715
- return;
926
+ return false;
716
927
  }
717
928
  if (client.usage) {
718
929
  this.tokens.prompt += client.usage.prompt;
@@ -727,8 +938,7 @@ export class ChatScreen extends Screen {
727
938
  this.messages.push({ role: "assistant", content });
728
939
  this.streamingContent = null;
729
940
  this.app.render();
730
- this.onTurnFinished(false);
731
- return;
941
+ return false;
732
942
  }
733
943
  const toolCalls = client.toolCalls;
734
944
  if (toolCalls.length === 0) {
@@ -752,7 +962,7 @@ export class ChatScreen extends Screen {
752
962
  });
753
963
  for (const tc of toolCalls) {
754
964
  if (token !== this.turnToken || this.paused)
755
- return;
965
+ return false;
756
966
  const args = parseArgs(tc.arguments);
757
967
  const toolMsg = { role: "tool", name: tc.name, arguments: args, result: "" };
758
968
  this.messages.push(toolMsg);
@@ -766,7 +976,7 @@ export class ChatScreen extends Screen {
766
976
  context: captureApprovalContext(apiMessages).context,
767
977
  });
768
978
  if (token !== this.turnToken || this.paused)
769
- return;
979
+ return false;
770
980
  if (decision.source !== "prompt" && decision.source !== "input") {
771
981
  this.approvals.set(toolMsg, decision);
772
982
  }
@@ -785,28 +995,28 @@ export class ChatScreen extends Screen {
785
995
  this.app.render();
786
996
  continue;
787
997
  }
788
- const result = await executeTool(tc.name, args, this.liveDir);
998
+ const result = await this.runTool(tc.name, args, toolMsg, token, apiMessages, approvalContext);
789
999
  // A clear starts a different transcript while an old tool may finish.
790
1000
  if (!this.messages.includes(toolMsg))
791
- return;
1001
+ return false;
792
1002
  toolMsg.result = result;
793
1003
  this.save();
794
1004
  if (token !== this.turnToken || this.paused)
795
- return;
1005
+ return false;
796
1006
  apiMessages.push({ role: "tool", tool_call_id: tc.id, content: result });
797
1007
  this.publishLive();
798
1008
  this.app.render();
799
1009
  }
800
1010
  }
801
- this.onTurnFinished();
1011
+ return true;
802
1012
  }
803
1013
  catch (e) {
804
1014
  if (token !== this.turnToken)
805
- return;
1015
+ return false;
806
1016
  const err = describeError(e);
807
1017
  this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
808
1018
  this.streamingContent = null;
809
- this.onTurnFinished();
1019
+ return false;
810
1020
  }
811
1021
  }
812
1022
  /**
@@ -869,6 +1079,10 @@ export class ChatScreen extends Screen {
869
1079
  return new Promise((resolve) => {
870
1080
  this.pendingApproval = { tool, args, risk, resolve };
871
1081
  this.app.state.approval = { tool, level: risk.level };
1082
+ // The card floats over the same corner the approval card needs on a short
1083
+ // terminal, and a decision the user cannot see is not a decision. The plan
1084
+ // stays open one click away.
1085
+ this.showInfo = false;
872
1086
  this.app.bell?.("approval");
873
1087
  this.app.render();
874
1088
  this.publishLive();
@@ -947,6 +1161,69 @@ export class ChatScreen extends Screen {
947
1161
  deny: { x: denyX, y: by, w: deny.length },
948
1162
  };
949
1163
  }
1164
+ // ---- sub-agents ------------------------------------------------------
1165
+ /**
1166
+ * Run one approved tool call. `delegate` is handled here rather than inside
1167
+ * tools.ts: a sub-agent has to share this turn's model, approval gate, working
1168
+ * directory and abort signal, and only the session knows about those.
1169
+ */
1170
+ async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
1171
+ if (name !== DELEGATE_TOOL)
1172
+ return executeTool(name, args, this.liveDir);
1173
+ return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
1174
+ }
1175
+ /**
1176
+ * Run a sub-agent for one `delegate` call and return its report.
1177
+ *
1178
+ * The child gets its own conversation, the parent's tools (minus delegation)
1179
+ * and the parent's approval gate; what it does in between is deliberately not
1180
+ * part of this transcript. While it runs, its current phase is shown in the
1181
+ * status line and in the tool block, so a quiet log still has an explanation.
1182
+ */
1183
+ async delegate(args, toolMsg, token, apiMessages, approvalContext) {
1184
+ const task = String(args.task ?? "").trim();
1185
+ if (!task)
1186
+ return "[Error: delegate needs a task: what should the sub-agent do?]";
1187
+ const context = typeof args.context === "string" ? args.context.trim() : "";
1188
+ const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
1189
+ const run = await runSubAgentTask({
1190
+ createClient: () => new LLMClient(this.model, this.modelProvider, this.intensity),
1191
+ system: loadSubAgentPrompt(),
1192
+ task: buildSubAgentTask({ task, context, cwd: this.liveDir }),
1193
+ tools: SUB_AGENT_TOOLS,
1194
+ approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
1195
+ execute: (name, callArgs) => name === DELEGATE_TOOL
1196
+ ? Promise.resolve("[Error: sub-agents cannot delegate further]")
1197
+ : executeTool(name, callArgs, this.liveDir),
1198
+ onProgress: (label) => {
1199
+ this.subagent = label;
1200
+ // The tool block is what a reader of the transcript sees while the child
1201
+ // works; the status line says the same thing at the top of the screen.
1202
+ toolMsg.result = `… ${label}`;
1203
+ this.app.render();
1204
+ this.publishLive();
1205
+ },
1206
+ onUsage: (usage) => {
1207
+ this.tokens.prompt += usage.prompt;
1208
+ this.tokens.completion += usage.completion;
1209
+ },
1210
+ onRetry: (notice) => {
1211
+ this.retryNotice =
1212
+ `Sub-agent — network hiccup, retrying in ${formatDelay(notice.delayMs)} ` +
1213
+ `(attempt ${notice.attempt}/${notice.attempts})`;
1214
+ this.app.render();
1215
+ },
1216
+ cancelled: () => token !== this.turnToken || this.paused,
1217
+ });
1218
+ // The final report replaces the live marker, and the status line goes back
1219
+ // to the parent's own state.
1220
+ this.subagent = null;
1221
+ this.retryNotice = null;
1222
+ toolMsg.result = "";
1223
+ this.app.render();
1224
+ this.publishLive();
1225
+ return formatSubAgentResult(run);
1226
+ }
950
1227
  // ---- turn folding ----------------------------------------------------
951
1228
  /** First message index of every turn: a turn starts at a user message and
952
1229
  * ends where the next one begins. */
@@ -966,15 +1243,21 @@ export class ChatScreen extends Screen {
966
1243
  return this.messages.length;
967
1244
  }
968
1245
  /**
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`).
1246
+ * A finished turn is only worth folding when the work it hides is substantial.
1247
+ * Whether it is folded right now is a separate question (`expandedTurns`), and
1248
+ * its reply is never part of what it hides.
971
1249
  */
972
1250
  turnHides(start, end) {
973
1251
  if (!this.finishedTurns.has(start))
974
1252
  return false;
1253
+ const answer = this.turnAnswer(start, end);
975
1254
  let blocks = 0;
976
1255
  let chars = 0;
977
1256
  for (let i = start; i < end; i++) {
1257
+ // The reply stays on screen, so it is neither hidden content nor a reason
1258
+ // to fold: a long answer alone must never collapse its own turn.
1259
+ if (i === answer)
1260
+ continue;
978
1261
  const m = this.messages[i];
979
1262
  if (m.role === "user" || m.role === "assistant") {
980
1263
  blocks++;
@@ -983,6 +1266,42 @@ export class ChatScreen extends Screen {
983
1266
  }
984
1267
  return blocks >= TURN_FOLD_MIN_BLOCKS || chars >= TURN_FOLD_MIN_CHARS;
985
1268
  }
1269
+ /**
1270
+ * The index of the turn's reply: its last assistant message. A fold hides the
1271
+ * work that led to the answer, so this message is always rendered.
1272
+ */
1273
+ turnAnswer(start, end) {
1274
+ for (let i = end - 1; i > start; i--) {
1275
+ if (this.messages[i].role === "assistant")
1276
+ return i;
1277
+ }
1278
+ return -1;
1279
+ }
1280
+ /** For every message, the reply of the turn it belongs to (-1 = none). */
1281
+ turnAnswers() {
1282
+ const answers = new Array(this.messages.length).fill(-1);
1283
+ let start = -1;
1284
+ let answer = -1;
1285
+ const close = (end) => {
1286
+ if (start < 0 || answer < 0)
1287
+ return;
1288
+ for (let i = start; i < end; i++)
1289
+ answers[i] = answer;
1290
+ };
1291
+ for (let i = 0; i < this.messages.length; i++) {
1292
+ const role = this.messages[i].role;
1293
+ if (role === "user") {
1294
+ close(i);
1295
+ start = i;
1296
+ answer = -1;
1297
+ }
1298
+ else if (role === "assistant") {
1299
+ answer = i;
1300
+ }
1301
+ }
1302
+ close(this.messages.length);
1303
+ return answers;
1304
+ }
986
1305
  /** For every message, the first index of the turn folding it (-1 = none). */
987
1306
  turnMap() {
988
1307
  const map = new Array(this.messages.length).fill(-1);
@@ -996,12 +1315,11 @@ export class ChatScreen extends Screen {
996
1315
  return map;
997
1316
  }
998
1317
  /**
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.
1318
+ * The first line of the first message in `role`. Only one line: the header is
1319
+ * a signpost, and the full text is one click away.
1001
1320
  */
1002
- turnPreview(start, end, role, last = false) {
1003
- const slice = this.messages.slice(start, end);
1004
- for (const m of last ? slice.reverse() : slice) {
1321
+ turnPreview(start, end, role) {
1322
+ for (const m of this.messages.slice(start, end)) {
1005
1323
  if (m.role !== role)
1006
1324
  continue;
1007
1325
  const line = sanitizeText(m.content)
@@ -1031,16 +1349,11 @@ export class ChatScreen extends Screen {
1031
1349
  }
1032
1350
  const you = `You: ${this.turnPreview(start, end, "user") || "(no text)"}`;
1033
1351
  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);
1352
+ // The prompt and the reply sit either side of the work this fold hides, so
1353
+ // the control line goes between them and the answer stays on screen below.
1354
+ const hidden = this.turnAnswer(start, end) < 0 ? count : count - 1;
1355
+ const meta = `${hidden} of ${count} message${count === 1 ? "" : "s"} folded`;
1356
+ add({ spans: [{ text: ellipsize(` ${meta} — click to expand`, width), style: { fg: C.dim } }] }, -1, start);
1044
1357
  }
1045
1358
  /** Fold or unfold one turn. */
1046
1359
  toggleTurn(start) {
@@ -1091,7 +1404,7 @@ export class ChatScreen extends Screen {
1091
1404
  const snapshotLen = this.messages.length;
1092
1405
  let summary = "";
1093
1406
  try {
1094
- summary = await this.summarizeTranscript(transcript);
1407
+ summary = await this.summarizeTranscript(transcript, () => token !== this.turnToken);
1095
1408
  }
1096
1409
  catch {
1097
1410
  summary = "";
@@ -1119,9 +1432,11 @@ export class ChatScreen extends Screen {
1119
1432
  * Summarise a transcript produced by `transcriptFor`. Split out so tests can
1120
1433
  * stub it without touching the network.
1121
1434
  */
1122
- async summarizeTranscript(transcript) {
1435
+ async summarizeTranscript(transcript, cancelled) {
1123
1436
  const client = new LLMClient(this.model, this.modelProvider, this.intensity);
1124
- const text = await client.summarize(transcript);
1437
+ // Compression runs before the turn proper: a pause or a newer message must
1438
+ // be able to abandon it instead of waiting out the retry backoff.
1439
+ const text = await client.summarize(transcript, { cancelled });
1125
1440
  if (client.usage) {
1126
1441
  this.tokens.prompt += client.usage.prompt;
1127
1442
  this.tokens.completion += client.usage.completion;
@@ -1179,7 +1494,7 @@ export class ChatScreen extends Screen {
1179
1494
  if (chatMessages.length === 0)
1180
1495
  return true;
1181
1496
  try {
1182
- this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode);
1497
+ this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode, this.plan);
1183
1498
  this.saveError = null;
1184
1499
  return true;
1185
1500
  }
@@ -1215,6 +1530,8 @@ export class ChatScreen extends Screen {
1215
1530
  const context = normalizeContext(this.context, this.messages.filter((m) => m.role !== "system").length);
1216
1531
  // Which turn owns each message: [] -> first index of that turn, -1 = none.
1217
1532
  const turns = this.turnMap();
1533
+ // Per message, the reply of its turn: a folded turn always renders that one.
1534
+ const answers = this.turnAnswers();
1218
1535
  this.contextLines.clear();
1219
1536
  let recordIndex = 0;
1220
1537
  for (let mi = 0; mi < this.messages.length; mi++) {
@@ -1229,18 +1546,21 @@ export class ChatScreen extends Screen {
1229
1546
  archived = context !== null && recordIndex < context.end;
1230
1547
  recordIndex++;
1231
1548
  }
1232
- // A foldable turn always shows its control line first; its messages then
1233
- // render normally when the reader has opened it.
1549
+ // A foldable turn always shows its control lines first; its work then
1550
+ // renders when the reader has opened it, and its reply always does.
1234
1551
  if (m.role !== "system" && turn === mi && !(archived && !this.expandedContext)) {
1235
1552
  this.pushTurnHeader(mi, this.turnEnd(mi), width, add);
1236
1553
  }
1554
+ const folded = turn >= 0 && !this.expandedTurns.has(turn);
1555
+ const reply = folded && answers[mi] === mi;
1237
1556
  if (m.role === "system") {
1238
1557
  pushText(sanitizeText(m.content), { fg: C.dim });
1239
1558
  }
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.
1559
+ else if ((archived && !this.expandedContext) || (folded && !reply)) {
1560
+ // Hidden: by the collapsed "Earlier conversation" fold, or by the turn's
1561
+ // own fold, which hides the prompt body and the work but never the
1562
+ // reply. Either way the message stays in the transcript, on disk and in
1563
+ // the request; the header above says how to get it back on screen.
1244
1564
  }
1245
1565
  else if (m.role === "user") {
1246
1566
  add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
@@ -1268,7 +1588,9 @@ export class ChatScreen extends Screen {
1268
1588
  // Header + the command/path always show; the (often huge) output body is
1269
1589
  // collapsed by default and toggled by clicking the block.
1270
1590
  add({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] }, mi);
1271
- const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? "", { singleLine: true });
1591
+ // The line under the tool name is the call's own material: a command, a
1592
+ // path, or — for `delegate` — the task handed to the sub-agent.
1593
+ const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? m.arguments.task ?? "", { singleLine: true });
1272
1594
  if (argText)
1273
1595
  add({ spans: [{ text: " " + argText, style: { fg: C.dim } }] }, mi);
1274
1596
  const decision = this.approvals.get(m);
@@ -1325,6 +1647,10 @@ export class ChatScreen extends Screen {
1325
1647
  status = "Compressing context…";
1326
1648
  statusStyle = { fg: C.amber, bold: true };
1327
1649
  }
1650
+ else if (this.planning) {
1651
+ status = "Planning the steps…";
1652
+ statusStyle = { fg: C.accent, bold: true };
1653
+ }
1328
1654
  else if (this.queued) {
1329
1655
  status = "Will send when paused or idle...";
1330
1656
  statusStyle = { fg: C.amber };
@@ -1333,6 +1659,14 @@ export class ChatScreen extends Screen {
1333
1659
  status = "PAUSED — press ESC ESC to resume";
1334
1660
  statusStyle = { fg: C.amber, bold: true };
1335
1661
  }
1662
+ else if (this.retryNotice) {
1663
+ status = this.retryNotice;
1664
+ statusStyle = { fg: C.amber, bold: true };
1665
+ }
1666
+ else if (this.subagent) {
1667
+ status = `Sub-agent — ${this.subagent}`;
1668
+ statusStyle = { fg: C.green, bold: true };
1669
+ }
1336
1670
  else if (this.busy) {
1337
1671
  status = "Streaming... press Ctrl+C or ESC ESC to pause";
1338
1672
  statusStyle = { fg: C.dim };
@@ -1394,7 +1728,7 @@ export class ChatScreen extends Screen {
1394
1728
  placeholder: "Type a message...",
1395
1729
  });
1396
1730
  const hintY = h - 1;
1397
- const hints = "Enter send · Ctrl+C pause · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
1731
+ const hints = "Enter send · Ctrl+P plan · Ctrl+C pause · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
1398
1732
  const hintW = displayWidth(hints);
1399
1733
  const hintX = Math.max(0, w - 1 - hintW);
1400
1734
  const pathAvail = Math.max(0, hintX - 2);
@@ -1404,30 +1738,57 @@ export class ChatScreen extends Screen {
1404
1738
  }
1405
1739
  renderInfoOverlay(buf) {
1406
1740
  const w = buf.width;
1407
- buf.set(w - 2, 0, "ⓘ", { fg: C.accent, bg: C.highlightBg, bold: true });
1741
+ const h = buf.height;
1742
+ // A plan in flight tints the corner marker, so the card stays findable once
1743
+ // the user has closed it.
1744
+ const running = this.plan?.steps.some((step) => step.status === "active") ?? false;
1745
+ buf.set(w - 2, 0, "ⓘ", running
1746
+ ? { fg: C.black, bg: C.accent, bold: true }
1747
+ : { fg: C.accent, bg: C.highlightBg, bold: true });
1408
1748
  if (!this.showInfo)
1409
1749
  return;
1410
- const total = this.tokens.prompt + this.tokens.completion;
1411
- const lines = [
1412
- "Tokens",
1413
- `Prompt: ${this.tokens.prompt}`,
1414
- `Completion: ${this.tokens.completion}`,
1415
- `Total: ${total}`,
1416
- ];
1417
- let contentW = 8;
1418
- for (const l of lines)
1419
- contentW = Math.max(contentW, displayWidth(l));
1420
- let boxW = contentW + 4;
1421
- if (boxW > w - 2)
1422
- boxW = Math.max(6, w - 2);
1423
- const boxX = w - 1 - boxW;
1750
+ // The card floats in the top-right corner of the log. It is never wider than
1751
+ // the conversation and never taller than the space above the input box, so a
1752
+ // long step title or a short terminal cannot push it over the composer.
1753
+ const contentW = Math.max(18, Math.min(56, w - 6));
1754
+ const maxRows = Math.max(1, h - 8);
1755
+ const rows = this.infoCardRows(contentW, maxRows);
1756
+ const boxW = Math.min(Math.max(6, w - 2), Math.max(14, planRowsWidth(rows) + 4));
1757
+ const boxH = Math.min(rows.length + 2, Math.max(3, h - 6));
1758
+ const boxX = Math.max(0, w - 1 - boxW);
1424
1759
  const boxY = 1;
1425
- const boxH = lines.length + 2;
1426
1760
  buf.fillRect(boxX, boxY, boxW, boxH, { bg: C.black });
1427
1761
  buf.drawBox(boxX, boxY, boxW, boxH, { fg: C.accent });
1428
- for (let i = 0; i < lines.length; i++) {
1429
- const style = i === 0 ? { fg: C.accent, bg: C.black, bold: true } : { fg: C.assistant, bg: C.black };
1430
- buf.writeText(boxX + 2, boxY + 1 + i, lines[i].slice(0, boxW - 4), style);
1762
+ for (let i = 0; i < rows.length; i++) {
1763
+ const y = boxY + 1 + i;
1764
+ if (y >= boxY + boxH - 1)
1765
+ break;
1766
+ if (rows[i].kind === "step-active") {
1767
+ // The running step is a full-width bar rather than coloured text: it is
1768
+ // the one row the reader should find without reading the list.
1769
+ buf.fillRect(boxX + 1, y, boxW - 2, 1, { bg: C.highlightBg });
1770
+ }
1771
+ buf.writeText(boxX + 2, y, ellipsize(rows[i].text, Math.max(1, boxW - 4)), planRowStyle(rows[i].kind));
1431
1772
  }
1432
1773
  }
1774
+ /**
1775
+ * What the card shows: the plan as a vertical timeline, then the token counts.
1776
+ * Without a plan it is exactly the token card it has always been. One row is
1777
+ * kept for the counts, so a long plan cannot push them out of the card.
1778
+ */
1779
+ infoCardRows(width, maxRows) {
1780
+ const plan = this.plan;
1781
+ if (!plan) {
1782
+ const total = this.tokens.prompt + this.tokens.completion;
1783
+ return [
1784
+ { kind: "header", text: "Tokens" },
1785
+ { kind: "line", text: `Prompt: ${this.tokens.prompt}` },
1786
+ { kind: "line", text: `Completion: ${this.tokens.completion}` },
1787
+ { kind: "line", text: `Total: ${total}` },
1788
+ ];
1789
+ }
1790
+ const rows = planCardRows(plan, { width, maxRows: Math.max(1, maxRows - 1) });
1791
+ rows.push({ kind: "line", text: `Tokens ${this.tokens.prompt} → ${this.tokens.completion}` });
1792
+ return rows;
1793
+ }
1433
1794
  }