@xingjisoft/neutrino 0.1.7 → 0.2.1

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, loadModels, 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, needsPlan, 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,6 +18,8 @@ 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
24
  * A finished turn folds its *work* away — the prompt body, the thinking and the
20
25
  * tool calls — and never its final reply, which always stays on screen. Doing
@@ -36,6 +41,32 @@ function parseArgs(s) {
36
41
  return {};
37
42
  }
38
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
+ }
39
70
  export class ChatScreen extends Screen {
40
71
  initialMessage;
41
72
  loadedMessages = null;
@@ -47,15 +78,25 @@ export class ChatScreen extends Screen {
47
78
  loadedTokens = null;
48
79
  loadedContext = null;
49
80
  loadedApprovalMode = null;
81
+ loadedPlan = null;
50
82
  approvalMode = "request";
51
83
  modelProvider = "DeepSeek";
52
84
  model = { name: "deepseek-chat", url: "https://api.deepseek.com/v1", api_key: "", supports_streaming: true };
53
85
  intensity = "High";
54
86
  tokens = { prompt: 0, completion: 0 };
55
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;
56
95
  messages = [];
57
96
  streamingContent = null;
58
97
  saveError = null;
98
+ /** Why a busy turn is silent: a transient network failure being retried. */
99
+ retryNotice = null;
59
100
  checkpoint = new SessionCheckpoint(() => {
60
101
  const saved = this.writeSession();
61
102
  this.publishLive();
@@ -132,6 +173,13 @@ export class ChatScreen extends Screen {
132
173
  api_key: st.selectedModelApiKey,
133
174
  supports_streaming: st.selectedModelStreaming,
134
175
  };
176
+ // The selected-state snapshot has no room for provider options; re-read the
177
+ // thinking-mode flag from model.json so a hand-configured endpoint keeps it.
178
+ const configured = loadModels().providers
179
+ .find((provider) => provider.name === this.modelProvider)?.models
180
+ .find((model) => model.name === this.model.name);
181
+ if (configured && typeof configured.reasoning === "boolean")
182
+ this.model.reasoning = configured.reasoning;
135
183
  this.intensity = this.loadedIntensity ?? st.selectedIntensity;
136
184
  if (this.loadedTokens) {
137
185
  this.tokens = { prompt: this.loadedTokens.prompt, completion: this.loadedTokens.completion };
@@ -146,6 +194,7 @@ export class ChatScreen extends Screen {
146
194
  // Everything already on disk was answered, so history opens compact.
147
195
  for (const start of this.userMessageIndexes())
148
196
  this.finishedTurns.add(start);
197
+ this.plan = resumePlan(this.loadedPlan);
149
198
  this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
150
199
  this.liveDir = this.loadedCwd ?? process.cwd();
151
200
  this.checkpoint.register();
@@ -209,6 +258,7 @@ export class ChatScreen extends Screen {
209
258
  compressing: this.compressing,
210
259
  saveError: this.saveError,
211
260
  tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
261
+ subagent: this.subagent,
212
262
  approval: this.pendingApproval
213
263
  ? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.liveDir)
214
264
  : null,
@@ -237,6 +287,7 @@ export class ChatScreen extends Screen {
237
287
  messages,
238
288
  streamingContent: this.streamingContent,
239
289
  context: this.context,
290
+ plan: this.plan,
240
291
  };
241
292
  }
242
293
  livePause() {
@@ -250,6 +301,7 @@ export class ChatScreen extends Screen {
250
301
  this.busy = false;
251
302
  this.app.state.isThinking = false;
252
303
  this.retainStreamingContent();
304
+ this.haltPlan();
253
305
  this.save();
254
306
  this.app.render();
255
307
  this.publishLive();
@@ -346,6 +398,12 @@ export class ChatScreen extends Screen {
346
398
  this.settleApproval(false);
347
399
  return;
348
400
  }
401
+ // The card is the plan: Ctrl+P shows it even when the pointer is elsewhere.
402
+ if (key.ctrl && key.name === "p") {
403
+ this.showInfo = !this.showInfo;
404
+ this.app.render();
405
+ return;
406
+ }
349
407
  if (this.showInfo) {
350
408
  this.showInfo = false;
351
409
  this.app.render();
@@ -452,6 +510,9 @@ export class ChatScreen extends Screen {
452
510
  this.sessionId = null;
453
511
  this.context = null;
454
512
  this.expandedContext = false;
513
+ this.plan = null;
514
+ this.planning = false;
515
+ this.subagent = null;
455
516
  const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
456
517
  this.messages = [{ role: "system", content: sysText }];
457
518
  this.expandedTools.clear();
@@ -585,6 +646,8 @@ export class ChatScreen extends Screen {
585
646
  this.busy = false;
586
647
  this.app.state.isThinking = false;
587
648
  this.retainStreamingContent();
649
+ // The loop that ran the plan is gone with the paused turn.
650
+ this.haltPlan();
588
651
  this.save();
589
652
  }
590
653
  this.app.render();
@@ -597,6 +660,144 @@ export class ChatScreen extends Screen {
597
660
  this.startTurn();
598
661
  }
599
662
  }
663
+ // ---- plan-first turns -------------------------------------------------
664
+ /**
665
+ * Run the planner agent over the current request.
666
+ *
667
+ * Returns null rather than throwing in every case where there is no usable
668
+ * plan — planning switched off, a one-step request, a provider that fails, an
669
+ * answer that is not a plan. The turn then runs exactly as it did before
670
+ * plan-first existed; a plan is an improvement, never a new way to fail.
671
+ */
672
+ async makePlan(token) {
673
+ if (!planFirstEnabled())
674
+ return null;
675
+ const request = lastRequestText(this.messages);
676
+ if (!request.trim())
677
+ return null;
678
+ // A request that is one action is not planned: the planner round-trip would
679
+ // cost more than the steps are worth, and the worker states its steps in its
680
+ // own answer. The card then shows the token counts, not a one-line plan.
681
+ if (!needsPlan(request))
682
+ return null;
683
+ this.planning = true;
684
+ this.app.render();
685
+ this.publishLive();
686
+ try {
687
+ const client = new LLMClient(this.model, this.modelProvider, this.intensity);
688
+ const answer = await client.complete(loadPlannerPrompt(), buildPlannerInput({ request, cwd: this.liveDir, transcript: planTranscript(this.messages) }), {
689
+ onRetry: (notice) => {
690
+ this.retryNotice =
691
+ `Planning — network hiccup, retrying in ${formatDelay(notice.delayMs)} ` +
692
+ `(attempt ${notice.attempt}/${notice.attempts})`;
693
+ this.app.render();
694
+ },
695
+ cancelled: () => token !== this.turnToken || this.paused,
696
+ });
697
+ if (client.usage) {
698
+ this.tokens.prompt += client.usage.prompt;
699
+ this.tokens.completion += client.usage.completion;
700
+ this.lastPromptTokens = client.usage.prompt;
701
+ }
702
+ if (token !== this.turnToken)
703
+ return null;
704
+ return parsePlan(answer);
705
+ }
706
+ catch {
707
+ return null;
708
+ }
709
+ finally {
710
+ this.planning = false;
711
+ this.retryNotice = null;
712
+ if (token === this.turnToken) {
713
+ this.app.render();
714
+ this.publishLive();
715
+ }
716
+ }
717
+ }
718
+ /** Move the plan to one step and show it: the card follows every transition. */
719
+ setStep(index, status) {
720
+ if (!advancePlan(this.plan, index, status))
721
+ return;
722
+ this.save();
723
+ this.publishLive();
724
+ this.app.render();
725
+ }
726
+ /** A turn that can no longer continue must not leave a step looking alive. */
727
+ haltPlan() {
728
+ stopPlan(this.plan);
729
+ }
730
+ /**
731
+ * One user turn.
732
+ *
733
+ * A planned turn is answered in two passes: the planner writes the steps, then
734
+ * the worker executes them one at a time, top to bottom, each with the whole
735
+ * plan in its system prompt and the instruction to do only its own step. That
736
+ * keeps the work of a long request explicit — the card shows exactly which
737
+ * step is running and which are done — and keeps every step verifiable on its
738
+ * own. Without a plan the turn runs in one pass, as before.
739
+ */
740
+ async runTurn(token, turnMessages, approvalContext) {
741
+ // Shrink the history before building the request when it has outgrown the
742
+ // configured budget.
743
+ await this.autoCompress(token);
744
+ if (token !== this.turnToken)
745
+ return;
746
+ const plan = await this.makePlan(token);
747
+ if (token !== this.turnToken)
748
+ return;
749
+ if (plan) {
750
+ this.plan = plan;
751
+ // The plan is an ordinary assistant message: it is saved, folded, replayed
752
+ // and sent back to the model exactly like any other answer, so later turns
753
+ // (and the user) still know what was agreed.
754
+ this.messages.push({ role: "assistant", content: planMessage(plan) });
755
+ // Opening the card is the point of planning: the steps were asked for up
756
+ // front, so they are shown up front. A click or Esc closes it again.
757
+ this.showInfo = true;
758
+ this.save();
759
+ this.publishLive();
760
+ this.app.render();
761
+ const template = loadStepPrompt();
762
+ for (let i = 0; i < plan.steps.length; i++) {
763
+ if (token !== this.turnToken)
764
+ return;
765
+ this.setStep(i, "active");
766
+ const directive = renderStepDirective(template, plan, i);
767
+ const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), directive), token, approvalContext);
768
+ if (token !== this.turnToken)
769
+ return;
770
+ this.setStep(i, finished ? "done" : "failed");
771
+ // A step that failed stops the plan: the remaining steps assumed this one
772
+ // succeeded, and the user has to see the failure before anything runs on
773
+ // top of it.
774
+ if (!finished)
775
+ break;
776
+ }
777
+ }
778
+ else {
779
+ await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages)), token, approvalContext);
780
+ if (token !== this.turnToken)
781
+ return;
782
+ }
783
+ this.onTurnFinished();
784
+ }
785
+ /**
786
+ * The transcript this turn may see: the messages it started with plus what it
787
+ * has added since — the plan message, replies, tool calls and results.
788
+ *
789
+ * A user message that arrives while the turn runs is queued for the *next*
790
+ * turn, so it is deliberately not part of this one: the model and the approval
791
+ * reviewers must always judge the same request.
792
+ */
793
+ turnView(snapshot) {
794
+ const view = snapshot.slice();
795
+ for (let i = snapshot.length; i < this.messages.length; i++) {
796
+ if (this.messages[i].role !== "user")
797
+ view.push(this.messages[i]);
798
+ }
799
+ return view;
800
+ }
600
801
  send(text) {
601
802
  const trimmed = text.trim();
602
803
  if (!trimmed)
@@ -633,15 +834,7 @@ export class ChatScreen extends Screen {
633
834
  this.streamingContent = "";
634
835
  this.app.render();
635
836
  this.publishLive();
636
- void (async () => {
637
- // Shrink the history before building the request when it has outgrown the
638
- // configured budget.
639
- await this.autoCompress(token);
640
- if (token !== this.turnToken)
641
- return;
642
- const apiMessages = this.buildApiMessages(turnMessages);
643
- await this.agentLoop(apiMessages, token, approvalContext);
644
- })().catch((err) => {
837
+ void this.runTurn(token, turnMessages, approvalContext).catch((err) => {
645
838
  if (token === this.turnToken)
646
839
  this.failTurn(err);
647
840
  });
@@ -658,6 +851,10 @@ export class ChatScreen extends Screen {
658
851
  this.retainStreamingContent();
659
852
  this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
660
853
  this.streamingContent = null;
854
+ // The turn died outside the loop, so the step it was on never reported back.
855
+ const active = this.plan?.steps.findIndex((step) => step.status === "active") ?? -1;
856
+ if (active >= 0)
857
+ advancePlan(this.plan, active, "failed");
661
858
  try {
662
859
  this.onTurnFinished();
663
860
  }
@@ -674,8 +871,11 @@ export class ChatScreen extends Screen {
674
871
  }
675
872
  }
676
873
  }
677
- buildApiMessages(messages = this.messages) {
678
- const api = [{ role: "system", content: loadPrompt() }];
874
+ buildApiMessages(messages = this.messages, directive = "") {
875
+ // A step directive extends the system prompt rather than adding a user
876
+ // message: the plan is an instruction, not something the user just said.
877
+ const system = directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt();
878
+ const api = [{ role: "system", content: system }];
679
879
  for (const m of modelHistory(messages, this.context)) {
680
880
  if (m.role === "user" || m.role === "assistant") {
681
881
  api.push({ role: m.role, content: m.content });
@@ -687,11 +887,19 @@ export class ChatScreen extends Screen {
687
887
  }
688
888
  return api;
689
889
  }
890
+ /**
891
+ * One agent conversation: request, tool calls, request again, until the model
892
+ * answers without asking for a tool.
893
+ *
894
+ * Returns true when the model finished on its own and false when the turn was
895
+ * cut short (paused, superseded, or failed). The caller owns the turn's end,
896
+ * because a planned turn runs this once per step.
897
+ */
690
898
  async agentLoop(apiMessages, token, approvalContext) {
691
899
  try {
692
900
  while (true) {
693
901
  if (token !== this.turnToken)
694
- return;
902
+ return false;
695
903
  // Keep the payload inside budget during a long single turn: once the
696
904
  // context is too large, drop the body of older tool results.
697
905
  apiMessages = this.trimApiMessages(apiMessages);
@@ -699,7 +907,19 @@ export class ChatScreen extends Screen {
699
907
  let content = "";
700
908
  let hasContent = false;
701
909
  let aborted = false;
702
- for await (const chunk of client.streamTurn(apiMessages, TOOLS)) {
910
+ for await (const chunk of client.streamTurn(apiMessages, TOOLS, {
911
+ // A retried request can go quiet for up to 160s, so the status line
912
+ // says why instead of looking hung. The moment the user pauses or a
913
+ // newer turn takes over, the backoff is dropped instead of waited out.
914
+ onRetry: (notice) => {
915
+ this.retryNotice =
916
+ `Network hiccup — retrying in ${formatDelay(notice.delayMs)} ` +
917
+ `(attempt ${notice.attempt}/${notice.attempts}): ${notice.reason}`;
918
+ this.app.render();
919
+ },
920
+ cancelled: () => token !== this.turnToken || this.paused,
921
+ })) {
922
+ this.retryNotice = null;
703
923
  if (token !== this.turnToken || this.paused) {
704
924
  aborted = true;
705
925
  break;
@@ -711,10 +931,11 @@ export class ChatScreen extends Screen {
711
931
  this.app.render();
712
932
  this.publishLive();
713
933
  }
934
+ this.retryNotice = null;
714
935
  if (token !== this.turnToken) {
715
936
  // Pause already retained the partial reply. A stale stream must not
716
937
  // overwrite the buffer belonging to a newer turn.
717
- return;
938
+ return false;
718
939
  }
719
940
  if (client.usage) {
720
941
  this.tokens.prompt += client.usage.prompt;
@@ -729,8 +950,7 @@ export class ChatScreen extends Screen {
729
950
  this.messages.push({ role: "assistant", content });
730
951
  this.streamingContent = null;
731
952
  this.app.render();
732
- this.onTurnFinished(false);
733
- return;
953
+ return false;
734
954
  }
735
955
  const toolCalls = client.toolCalls;
736
956
  if (toolCalls.length === 0) {
@@ -746,6 +966,9 @@ export class ChatScreen extends Screen {
746
966
  apiMessages.push({
747
967
  role: "assistant",
748
968
  content,
969
+ // DeepSeek's thinking mode needs the reasoning behind a tool call
970
+ // returned with it; without it the next request is a 400.
971
+ reasoning_content: client.reasoningContent ?? "",
749
972
  tool_calls: toolCalls.map((tc) => ({
750
973
  id: tc.id,
751
974
  type: "function",
@@ -754,7 +977,7 @@ export class ChatScreen extends Screen {
754
977
  });
755
978
  for (const tc of toolCalls) {
756
979
  if (token !== this.turnToken || this.paused)
757
- return;
980
+ return false;
758
981
  const args = parseArgs(tc.arguments);
759
982
  const toolMsg = { role: "tool", name: tc.name, arguments: args, result: "" };
760
983
  this.messages.push(toolMsg);
@@ -768,7 +991,7 @@ export class ChatScreen extends Screen {
768
991
  context: captureApprovalContext(apiMessages).context,
769
992
  });
770
993
  if (token !== this.turnToken || this.paused)
771
- return;
994
+ return false;
772
995
  if (decision.source !== "prompt" && decision.source !== "input") {
773
996
  this.approvals.set(toolMsg, decision);
774
997
  }
@@ -787,28 +1010,28 @@ export class ChatScreen extends Screen {
787
1010
  this.app.render();
788
1011
  continue;
789
1012
  }
790
- const result = await executeTool(tc.name, args, this.liveDir);
1013
+ const result = await this.runTool(tc.name, args, toolMsg, token, apiMessages, approvalContext);
791
1014
  // A clear starts a different transcript while an old tool may finish.
792
1015
  if (!this.messages.includes(toolMsg))
793
- return;
1016
+ return false;
794
1017
  toolMsg.result = result;
795
1018
  this.save();
796
1019
  if (token !== this.turnToken || this.paused)
797
- return;
1020
+ return false;
798
1021
  apiMessages.push({ role: "tool", tool_call_id: tc.id, content: result });
799
1022
  this.publishLive();
800
1023
  this.app.render();
801
1024
  }
802
1025
  }
803
- this.onTurnFinished();
1026
+ return true;
804
1027
  }
805
1028
  catch (e) {
806
1029
  if (token !== this.turnToken)
807
- return;
1030
+ return false;
808
1031
  const err = describeError(e);
809
1032
  this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
810
1033
  this.streamingContent = null;
811
- this.onTurnFinished();
1034
+ return false;
812
1035
  }
813
1036
  }
814
1037
  /**
@@ -871,6 +1094,10 @@ export class ChatScreen extends Screen {
871
1094
  return new Promise((resolve) => {
872
1095
  this.pendingApproval = { tool, args, risk, resolve };
873
1096
  this.app.state.approval = { tool, level: risk.level };
1097
+ // The card floats over the same corner the approval card needs on a short
1098
+ // terminal, and a decision the user cannot see is not a decision. The plan
1099
+ // stays open one click away.
1100
+ this.showInfo = false;
874
1101
  this.app.bell?.("approval");
875
1102
  this.app.render();
876
1103
  this.publishLive();
@@ -949,6 +1176,69 @@ export class ChatScreen extends Screen {
949
1176
  deny: { x: denyX, y: by, w: deny.length },
950
1177
  };
951
1178
  }
1179
+ // ---- sub-agents ------------------------------------------------------
1180
+ /**
1181
+ * Run one approved tool call. `delegate` is handled here rather than inside
1182
+ * tools.ts: a sub-agent has to share this turn's model, approval gate, working
1183
+ * directory and abort signal, and only the session knows about those.
1184
+ */
1185
+ async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
1186
+ if (name !== DELEGATE_TOOL)
1187
+ return executeTool(name, args, this.liveDir);
1188
+ return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
1189
+ }
1190
+ /**
1191
+ * Run a sub-agent for one `delegate` call and return its report.
1192
+ *
1193
+ * The child gets its own conversation, the parent's tools (minus delegation)
1194
+ * and the parent's approval gate; what it does in between is deliberately not
1195
+ * part of this transcript. While it runs, its current phase is shown in the
1196
+ * status line and in the tool block, so a quiet log still has an explanation.
1197
+ */
1198
+ async delegate(args, toolMsg, token, apiMessages, approvalContext) {
1199
+ const task = String(args.task ?? "").trim();
1200
+ if (!task)
1201
+ return "[Error: delegate needs a task: what should the sub-agent do?]";
1202
+ const context = typeof args.context === "string" ? args.context.trim() : "";
1203
+ const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
1204
+ const run = await runSubAgentTask({
1205
+ createClient: () => new LLMClient(this.model, this.modelProvider, this.intensity),
1206
+ system: loadSubAgentPrompt(),
1207
+ task: buildSubAgentTask({ task, context, cwd: this.liveDir }),
1208
+ tools: SUB_AGENT_TOOLS,
1209
+ approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
1210
+ execute: (name, callArgs) => name === DELEGATE_TOOL
1211
+ ? Promise.resolve("[Error: sub-agents cannot delegate further]")
1212
+ : executeTool(name, callArgs, this.liveDir),
1213
+ onProgress: (label) => {
1214
+ this.subagent = label;
1215
+ // The tool block is what a reader of the transcript sees while the child
1216
+ // works; the status line says the same thing at the top of the screen.
1217
+ toolMsg.result = `… ${label}`;
1218
+ this.app.render();
1219
+ this.publishLive();
1220
+ },
1221
+ onUsage: (usage) => {
1222
+ this.tokens.prompt += usage.prompt;
1223
+ this.tokens.completion += usage.completion;
1224
+ },
1225
+ onRetry: (notice) => {
1226
+ this.retryNotice =
1227
+ `Sub-agent — network hiccup, retrying in ${formatDelay(notice.delayMs)} ` +
1228
+ `(attempt ${notice.attempt}/${notice.attempts})`;
1229
+ this.app.render();
1230
+ },
1231
+ cancelled: () => token !== this.turnToken || this.paused,
1232
+ });
1233
+ // The final report replaces the live marker, and the status line goes back
1234
+ // to the parent's own state.
1235
+ this.subagent = null;
1236
+ this.retryNotice = null;
1237
+ toolMsg.result = "";
1238
+ this.app.render();
1239
+ this.publishLive();
1240
+ return formatSubAgentResult(run);
1241
+ }
952
1242
  // ---- turn folding ----------------------------------------------------
953
1243
  /** First message index of every turn: a turn starts at a user message and
954
1244
  * ends where the next one begins. */
@@ -1129,7 +1419,7 @@ export class ChatScreen extends Screen {
1129
1419
  const snapshotLen = this.messages.length;
1130
1420
  let summary = "";
1131
1421
  try {
1132
- summary = await this.summarizeTranscript(transcript);
1422
+ summary = await this.summarizeTranscript(transcript, () => token !== this.turnToken);
1133
1423
  }
1134
1424
  catch {
1135
1425
  summary = "";
@@ -1157,9 +1447,11 @@ export class ChatScreen extends Screen {
1157
1447
  * Summarise a transcript produced by `transcriptFor`. Split out so tests can
1158
1448
  * stub it without touching the network.
1159
1449
  */
1160
- async summarizeTranscript(transcript) {
1450
+ async summarizeTranscript(transcript, cancelled) {
1161
1451
  const client = new LLMClient(this.model, this.modelProvider, this.intensity);
1162
- const text = await client.summarize(transcript);
1452
+ // Compression runs before the turn proper: a pause or a newer message must
1453
+ // be able to abandon it instead of waiting out the retry backoff.
1454
+ const text = await client.summarize(transcript, { cancelled });
1163
1455
  if (client.usage) {
1164
1456
  this.tokens.prompt += client.usage.prompt;
1165
1457
  this.tokens.completion += client.usage.completion;
@@ -1217,7 +1509,7 @@ export class ChatScreen extends Screen {
1217
1509
  if (chatMessages.length === 0)
1218
1510
  return true;
1219
1511
  try {
1220
- this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode);
1512
+ this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode, this.plan);
1221
1513
  this.saveError = null;
1222
1514
  return true;
1223
1515
  }
@@ -1311,7 +1603,9 @@ export class ChatScreen extends Screen {
1311
1603
  // Header + the command/path always show; the (often huge) output body is
1312
1604
  // collapsed by default and toggled by clicking the block.
1313
1605
  add({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] }, mi);
1314
- const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? "", { singleLine: true });
1606
+ // The line under the tool name is the call's own material: a command, a
1607
+ // path, or — for `delegate` — the task handed to the sub-agent.
1608
+ const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? m.arguments.task ?? "", { singleLine: true });
1315
1609
  if (argText)
1316
1610
  add({ spans: [{ text: " " + argText, style: { fg: C.dim } }] }, mi);
1317
1611
  const decision = this.approvals.get(m);
@@ -1368,6 +1662,10 @@ export class ChatScreen extends Screen {
1368
1662
  status = "Compressing context…";
1369
1663
  statusStyle = { fg: C.amber, bold: true };
1370
1664
  }
1665
+ else if (this.planning) {
1666
+ status = "Planning the steps…";
1667
+ statusStyle = { fg: C.accent, bold: true };
1668
+ }
1371
1669
  else if (this.queued) {
1372
1670
  status = "Will send when paused or idle...";
1373
1671
  statusStyle = { fg: C.amber };
@@ -1376,6 +1674,14 @@ export class ChatScreen extends Screen {
1376
1674
  status = "PAUSED — press ESC ESC to resume";
1377
1675
  statusStyle = { fg: C.amber, bold: true };
1378
1676
  }
1677
+ else if (this.retryNotice) {
1678
+ status = this.retryNotice;
1679
+ statusStyle = { fg: C.amber, bold: true };
1680
+ }
1681
+ else if (this.subagent) {
1682
+ status = `Sub-agent — ${this.subagent}`;
1683
+ statusStyle = { fg: C.green, bold: true };
1684
+ }
1379
1685
  else if (this.busy) {
1380
1686
  status = "Streaming... press Ctrl+C or ESC ESC to pause";
1381
1687
  statusStyle = { fg: C.dim };
@@ -1437,7 +1743,7 @@ export class ChatScreen extends Screen {
1437
1743
  placeholder: "Type a message...",
1438
1744
  });
1439
1745
  const hintY = h - 1;
1440
- const hints = "Enter send · Ctrl+C pause · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
1746
+ 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";
1441
1747
  const hintW = displayWidth(hints);
1442
1748
  const hintX = Math.max(0, w - 1 - hintW);
1443
1749
  const pathAvail = Math.max(0, hintX - 2);
@@ -1447,30 +1753,57 @@ export class ChatScreen extends Screen {
1447
1753
  }
1448
1754
  renderInfoOverlay(buf) {
1449
1755
  const w = buf.width;
1450
- buf.set(w - 2, 0, "ⓘ", { fg: C.accent, bg: C.highlightBg, bold: true });
1756
+ const h = buf.height;
1757
+ // A plan in flight tints the corner marker, so the card stays findable once
1758
+ // the user has closed it.
1759
+ const running = this.plan?.steps.some((step) => step.status === "active") ?? false;
1760
+ buf.set(w - 2, 0, "ⓘ", running
1761
+ ? { fg: C.black, bg: C.accent, bold: true }
1762
+ : { fg: C.accent, bg: C.highlightBg, bold: true });
1451
1763
  if (!this.showInfo)
1452
1764
  return;
1453
- const total = this.tokens.prompt + this.tokens.completion;
1454
- const lines = [
1455
- "Tokens",
1456
- `Prompt: ${this.tokens.prompt}`,
1457
- `Completion: ${this.tokens.completion}`,
1458
- `Total: ${total}`,
1459
- ];
1460
- let contentW = 8;
1461
- for (const l of lines)
1462
- contentW = Math.max(contentW, displayWidth(l));
1463
- let boxW = contentW + 4;
1464
- if (boxW > w - 2)
1465
- boxW = Math.max(6, w - 2);
1466
- const boxX = w - 1 - boxW;
1765
+ // The card floats in the top-right corner of the log. It is never wider than
1766
+ // the conversation and never taller than the space above the input box, so a
1767
+ // long step title or a short terminal cannot push it over the composer.
1768
+ const contentW = Math.max(18, Math.min(56, w - 6));
1769
+ const maxRows = Math.max(1, h - 8);
1770
+ const rows = this.infoCardRows(contentW, maxRows);
1771
+ const boxW = Math.min(Math.max(6, w - 2), Math.max(14, planRowsWidth(rows) + 4));
1772
+ const boxH = Math.min(rows.length + 2, Math.max(3, h - 6));
1773
+ const boxX = Math.max(0, w - 1 - boxW);
1467
1774
  const boxY = 1;
1468
- const boxH = lines.length + 2;
1469
1775
  buf.fillRect(boxX, boxY, boxW, boxH, { bg: C.black });
1470
1776
  buf.drawBox(boxX, boxY, boxW, boxH, { fg: C.accent });
1471
- for (let i = 0; i < lines.length; i++) {
1472
- const style = i === 0 ? { fg: C.accent, bg: C.black, bold: true } : { fg: C.assistant, bg: C.black };
1473
- buf.writeText(boxX + 2, boxY + 1 + i, lines[i].slice(0, boxW - 4), style);
1777
+ for (let i = 0; i < rows.length; i++) {
1778
+ const y = boxY + 1 + i;
1779
+ if (y >= boxY + boxH - 1)
1780
+ break;
1781
+ if (rows[i].kind === "step-active") {
1782
+ // The running step is a full-width bar rather than coloured text: it is
1783
+ // the one row the reader should find without reading the list.
1784
+ buf.fillRect(boxX + 1, y, boxW - 2, 1, { bg: C.highlightBg });
1785
+ }
1786
+ buf.writeText(boxX + 2, y, ellipsize(rows[i].text, Math.max(1, boxW - 4)), planRowStyle(rows[i].kind));
1474
1787
  }
1475
1788
  }
1789
+ /**
1790
+ * What the card shows: the plan as a vertical timeline, then the token counts.
1791
+ * Without a plan it is exactly the token card it has always been. One row is
1792
+ * kept for the counts, so a long plan cannot push them out of the card.
1793
+ */
1794
+ infoCardRows(width, maxRows) {
1795
+ const plan = this.plan;
1796
+ if (!plan) {
1797
+ const total = this.tokens.prompt + this.tokens.completion;
1798
+ return [
1799
+ { kind: "header", text: "Tokens" },
1800
+ { kind: "line", text: `Prompt: ${this.tokens.prompt}` },
1801
+ { kind: "line", text: `Completion: ${this.tokens.completion}` },
1802
+ { kind: "line", text: `Total: ${total}` },
1803
+ ];
1804
+ }
1805
+ const rows = planCardRows(plan, { width, maxRows: Math.max(1, maxRows - 1) });
1806
+ rows.push({ kind: "line", text: `Tokens ${this.tokens.prompt} → ${this.tokens.completion}` });
1807
+ return rows;
1808
+ }
1476
1809
  }