@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.
- package/README.md +136 -28
- package/assets/planner.txt +35 -0
- package/assets/prompt.txt +56 -21
- package/assets/step.txt +14 -0
- package/assets/subagent.txt +20 -0
- package/dist/app.js +44 -2
- package/dist/bin.js +38 -13
- package/dist/config.js +45 -1
- package/dist/header.js +5 -0
- package/dist/llm/client.js +64 -140
- package/dist/llm/plan.js +403 -0
- package/dist/llm/retry.js +209 -0
- package/dist/llm/subagent.js +134 -0
- package/dist/llm/tools.js +43 -1
- package/dist/screens/chat.js +369 -51
- package/dist/screens/settings.js +30 -1
- package/dist/state.js +1 -0
- package/dist/web/session.js +211 -22
- package/package.json +2 -2
- package/web/README.md +46 -10
- package/web/app.js +52 -16
- package/web/index.html +55 -17
- package/web/style.css +2070 -1093
package/dist/screens/chat.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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 <
|
|
1472
|
-
const
|
|
1473
|
-
|
|
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
|
}
|