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