@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.
- package/README.md +151 -25
- package/assets/planner.txt +35 -0
- package/assets/prompt.txt +57 -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 +58 -1
- package/dist/header.js +5 -0
- package/dist/llm/client.js +101 -142
- package/dist/llm/plan.js +452 -0
- package/dist/llm/retry.js +209 -0
- package/dist/llm/subagent.js +137 -0
- package/dist/llm/tools.js +43 -1
- package/dist/screens/chat.js +384 -51
- package/dist/screens/settings.js +30 -1
- package/dist/state.js +1 -0
- package/dist/web/session.js +235 -24
- 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, 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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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 <
|
|
1472
|
-
const
|
|
1473
|
-
|
|
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
|
}
|