@xingjisoft/neutrino 0.1.7 → 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,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();
@@ -146,6 +187,7 @@ export class ChatScreen extends Screen {
146
187
  // Everything already on disk was answered, so history opens compact.
147
188
  for (const start of this.userMessageIndexes())
148
189
  this.finishedTurns.add(start);
190
+ this.plan = resumePlan(this.loadedPlan);
149
191
  this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
150
192
  this.liveDir = this.loadedCwd ?? process.cwd();
151
193
  this.checkpoint.register();
@@ -209,6 +251,7 @@ export class ChatScreen extends Screen {
209
251
  compressing: this.compressing,
210
252
  saveError: this.saveError,
211
253
  tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
254
+ subagent: this.subagent,
212
255
  approval: this.pendingApproval
213
256
  ? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.liveDir)
214
257
  : null,
@@ -237,6 +280,7 @@ export class ChatScreen extends Screen {
237
280
  messages,
238
281
  streamingContent: this.streamingContent,
239
282
  context: this.context,
283
+ plan: this.plan,
240
284
  };
241
285
  }
242
286
  livePause() {
@@ -250,6 +294,7 @@ export class ChatScreen extends Screen {
250
294
  this.busy = false;
251
295
  this.app.state.isThinking = false;
252
296
  this.retainStreamingContent();
297
+ this.haltPlan();
253
298
  this.save();
254
299
  this.app.render();
255
300
  this.publishLive();
@@ -346,6 +391,12 @@ export class ChatScreen extends Screen {
346
391
  this.settleApproval(false);
347
392
  return;
348
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
+ }
349
400
  if (this.showInfo) {
350
401
  this.showInfo = false;
351
402
  this.app.render();
@@ -452,6 +503,9 @@ export class ChatScreen extends Screen {
452
503
  this.sessionId = null;
453
504
  this.context = null;
454
505
  this.expandedContext = false;
506
+ this.plan = null;
507
+ this.planning = false;
508
+ this.subagent = null;
455
509
  const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
456
510
  this.messages = [{ role: "system", content: sysText }];
457
511
  this.expandedTools.clear();
@@ -585,6 +639,8 @@ export class ChatScreen extends Screen {
585
639
  this.busy = false;
586
640
  this.app.state.isThinking = false;
587
641
  this.retainStreamingContent();
642
+ // The loop that ran the plan is gone with the paused turn.
643
+ this.haltPlan();
588
644
  this.save();
589
645
  }
590
646
  this.app.render();
@@ -597,6 +653,139 @@ export class ChatScreen extends Screen {
597
653
  this.startTurn();
598
654
  }
599
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
+ }
600
789
  send(text) {
601
790
  const trimmed = text.trim();
602
791
  if (!trimmed)
@@ -633,15 +822,7 @@ export class ChatScreen extends Screen {
633
822
  this.streamingContent = "";
634
823
  this.app.render();
635
824
  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) => {
825
+ void this.runTurn(token, turnMessages, approvalContext).catch((err) => {
645
826
  if (token === this.turnToken)
646
827
  this.failTurn(err);
647
828
  });
@@ -658,6 +839,10 @@ export class ChatScreen extends Screen {
658
839
  this.retainStreamingContent();
659
840
  this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
660
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");
661
846
  try {
662
847
  this.onTurnFinished();
663
848
  }
@@ -674,8 +859,11 @@ export class ChatScreen extends Screen {
674
859
  }
675
860
  }
676
861
  }
677
- buildApiMessages(messages = this.messages) {
678
- 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 }];
679
867
  for (const m of modelHistory(messages, this.context)) {
680
868
  if (m.role === "user" || m.role === "assistant") {
681
869
  api.push({ role: m.role, content: m.content });
@@ -687,11 +875,19 @@ export class ChatScreen extends Screen {
687
875
  }
688
876
  return api;
689
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
+ */
690
886
  async agentLoop(apiMessages, token, approvalContext) {
691
887
  try {
692
888
  while (true) {
693
889
  if (token !== this.turnToken)
694
- return;
890
+ return false;
695
891
  // Keep the payload inside budget during a long single turn: once the
696
892
  // context is too large, drop the body of older tool results.
697
893
  apiMessages = this.trimApiMessages(apiMessages);
@@ -699,7 +895,19 @@ export class ChatScreen extends Screen {
699
895
  let content = "";
700
896
  let hasContent = false;
701
897
  let aborted = false;
702
- 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;
703
911
  if (token !== this.turnToken || this.paused) {
704
912
  aborted = true;
705
913
  break;
@@ -711,10 +919,11 @@ export class ChatScreen extends Screen {
711
919
  this.app.render();
712
920
  this.publishLive();
713
921
  }
922
+ this.retryNotice = null;
714
923
  if (token !== this.turnToken) {
715
924
  // Pause already retained the partial reply. A stale stream must not
716
925
  // overwrite the buffer belonging to a newer turn.
717
- return;
926
+ return false;
718
927
  }
719
928
  if (client.usage) {
720
929
  this.tokens.prompt += client.usage.prompt;
@@ -729,8 +938,7 @@ export class ChatScreen extends Screen {
729
938
  this.messages.push({ role: "assistant", content });
730
939
  this.streamingContent = null;
731
940
  this.app.render();
732
- this.onTurnFinished(false);
733
- return;
941
+ return false;
734
942
  }
735
943
  const toolCalls = client.toolCalls;
736
944
  if (toolCalls.length === 0) {
@@ -754,7 +962,7 @@ export class ChatScreen extends Screen {
754
962
  });
755
963
  for (const tc of toolCalls) {
756
964
  if (token !== this.turnToken || this.paused)
757
- return;
965
+ return false;
758
966
  const args = parseArgs(tc.arguments);
759
967
  const toolMsg = { role: "tool", name: tc.name, arguments: args, result: "" };
760
968
  this.messages.push(toolMsg);
@@ -768,7 +976,7 @@ export class ChatScreen extends Screen {
768
976
  context: captureApprovalContext(apiMessages).context,
769
977
  });
770
978
  if (token !== this.turnToken || this.paused)
771
- return;
979
+ return false;
772
980
  if (decision.source !== "prompt" && decision.source !== "input") {
773
981
  this.approvals.set(toolMsg, decision);
774
982
  }
@@ -787,28 +995,28 @@ export class ChatScreen extends Screen {
787
995
  this.app.render();
788
996
  continue;
789
997
  }
790
- const result = await executeTool(tc.name, args, this.liveDir);
998
+ const result = await this.runTool(tc.name, args, toolMsg, token, apiMessages, approvalContext);
791
999
  // A clear starts a different transcript while an old tool may finish.
792
1000
  if (!this.messages.includes(toolMsg))
793
- return;
1001
+ return false;
794
1002
  toolMsg.result = result;
795
1003
  this.save();
796
1004
  if (token !== this.turnToken || this.paused)
797
- return;
1005
+ return false;
798
1006
  apiMessages.push({ role: "tool", tool_call_id: tc.id, content: result });
799
1007
  this.publishLive();
800
1008
  this.app.render();
801
1009
  }
802
1010
  }
803
- this.onTurnFinished();
1011
+ return true;
804
1012
  }
805
1013
  catch (e) {
806
1014
  if (token !== this.turnToken)
807
- return;
1015
+ return false;
808
1016
  const err = describeError(e);
809
1017
  this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
810
1018
  this.streamingContent = null;
811
- this.onTurnFinished();
1019
+ return false;
812
1020
  }
813
1021
  }
814
1022
  /**
@@ -871,6 +1079,10 @@ export class ChatScreen extends Screen {
871
1079
  return new Promise((resolve) => {
872
1080
  this.pendingApproval = { tool, args, risk, resolve };
873
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;
874
1086
  this.app.bell?.("approval");
875
1087
  this.app.render();
876
1088
  this.publishLive();
@@ -949,6 +1161,69 @@ export class ChatScreen extends Screen {
949
1161
  deny: { x: denyX, y: by, w: deny.length },
950
1162
  };
951
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
+ }
952
1227
  // ---- turn folding ----------------------------------------------------
953
1228
  /** First message index of every turn: a turn starts at a user message and
954
1229
  * ends where the next one begins. */
@@ -1129,7 +1404,7 @@ export class ChatScreen extends Screen {
1129
1404
  const snapshotLen = this.messages.length;
1130
1405
  let summary = "";
1131
1406
  try {
1132
- summary = await this.summarizeTranscript(transcript);
1407
+ summary = await this.summarizeTranscript(transcript, () => token !== this.turnToken);
1133
1408
  }
1134
1409
  catch {
1135
1410
  summary = "";
@@ -1157,9 +1432,11 @@ export class ChatScreen extends Screen {
1157
1432
  * Summarise a transcript produced by `transcriptFor`. Split out so tests can
1158
1433
  * stub it without touching the network.
1159
1434
  */
1160
- async summarizeTranscript(transcript) {
1435
+ async summarizeTranscript(transcript, cancelled) {
1161
1436
  const client = new LLMClient(this.model, this.modelProvider, this.intensity);
1162
- 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 });
1163
1440
  if (client.usage) {
1164
1441
  this.tokens.prompt += client.usage.prompt;
1165
1442
  this.tokens.completion += client.usage.completion;
@@ -1217,7 +1494,7 @@ export class ChatScreen extends Screen {
1217
1494
  if (chatMessages.length === 0)
1218
1495
  return true;
1219
1496
  try {
1220
- 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);
1221
1498
  this.saveError = null;
1222
1499
  return true;
1223
1500
  }
@@ -1311,7 +1588,9 @@ export class ChatScreen extends Screen {
1311
1588
  // Header + the command/path always show; the (often huge) output body is
1312
1589
  // collapsed by default and toggled by clicking the block.
1313
1590
  add({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] }, mi);
1314
- 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 });
1315
1594
  if (argText)
1316
1595
  add({ spans: [{ text: " " + argText, style: { fg: C.dim } }] }, mi);
1317
1596
  const decision = this.approvals.get(m);
@@ -1368,6 +1647,10 @@ export class ChatScreen extends Screen {
1368
1647
  status = "Compressing context…";
1369
1648
  statusStyle = { fg: C.amber, bold: true };
1370
1649
  }
1650
+ else if (this.planning) {
1651
+ status = "Planning the steps…";
1652
+ statusStyle = { fg: C.accent, bold: true };
1653
+ }
1371
1654
  else if (this.queued) {
1372
1655
  status = "Will send when paused or idle...";
1373
1656
  statusStyle = { fg: C.amber };
@@ -1376,6 +1659,14 @@ export class ChatScreen extends Screen {
1376
1659
  status = "PAUSED — press ESC ESC to resume";
1377
1660
  statusStyle = { fg: C.amber, bold: true };
1378
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
+ }
1379
1670
  else if (this.busy) {
1380
1671
  status = "Streaming... press Ctrl+C or ESC ESC to pause";
1381
1672
  statusStyle = { fg: C.dim };
@@ -1437,7 +1728,7 @@ export class ChatScreen extends Screen {
1437
1728
  placeholder: "Type a message...",
1438
1729
  });
1439
1730
  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";
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";
1441
1732
  const hintW = displayWidth(hints);
1442
1733
  const hintX = Math.max(0, w - 1 - hintW);
1443
1734
  const pathAvail = Math.max(0, hintX - 2);
@@ -1447,30 +1738,57 @@ export class ChatScreen extends Screen {
1447
1738
  }
1448
1739
  renderInfoOverlay(buf) {
1449
1740
  const w = buf.width;
1450
- 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 });
1451
1748
  if (!this.showInfo)
1452
1749
  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;
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);
1467
1759
  const boxY = 1;
1468
- const boxH = lines.length + 2;
1469
1760
  buf.fillRect(boxX, boxY, boxW, boxH, { bg: C.black });
1470
1761
  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);
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));
1474
1772
  }
1475
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
+ }
1476
1794
  }