lsd-pi 1.3.9 → 1.3.10
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/dist/resources/extensions/mcp-client/index.js +191 -83
- package/dist/resources/extensions/mcp-client/mcp-manager-component.js +220 -0
- package/dist/resources/extensions/slash-commands/plan.js +67 -13
- package/dist/resources/extensions/subagent/agents.js +7 -0
- package/dist/resources/extensions/subagent/index.js +25 -8
- package/dist/resources/extensions/subagent/model-resolution.js +1 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +104 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +39 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +135 -18
- package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +21 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +146 -8
- package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +51 -13
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +75 -4
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +4 -0
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +31 -2
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +129 -2
- package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +158 -18
- package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +163 -9
- package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +60 -13
- package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +86 -5
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +31 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/mcp-client/index.ts +212 -90
- package/src/resources/extensions/mcp-client/mcp-manager-component.ts +256 -0
- package/src/resources/extensions/mcp-client/tests/mcp-manager-component.test.ts +141 -0
- package/src/resources/extensions/mcp-client/tests/server-name-spaces.test.ts +18 -2
- package/src/resources/extensions/slash-commands/plan.ts +70 -13
- package/src/resources/extensions/subagent/agents.ts +9 -0
- package/src/resources/extensions/subagent/index.ts +30 -8
- package/src/resources/extensions/subagent/model-resolution.ts +1 -0
|
@@ -2,7 +2,7 @@ import { describe, it } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import stripAnsi from "strip-ansi";
|
|
4
4
|
|
|
5
|
-
import { ToolSummaryLine } from "../tool-summary-line.js";
|
|
5
|
+
import { ToolSummaryLine, extractToolLabel } from "../tool-summary-line.js";
|
|
6
6
|
import { initTheme } from "../../theme/theme.js";
|
|
7
7
|
|
|
8
8
|
initTheme("dark");
|
|
@@ -17,7 +17,8 @@ describe("ToolSummaryLine", () => {
|
|
|
17
17
|
assert.match(rendered, /^ ● /);
|
|
18
18
|
assert.ok(rendered.includes("reading 2 files · 0.8s"));
|
|
19
19
|
assert.equal(summary.canGroupWith("read"), true);
|
|
20
|
-
assert.equal(summary.canGroupWith("find"),
|
|
20
|
+
assert.equal(summary.canGroupWith("find"), true);
|
|
21
|
+
assert.equal(summary.canGroupWith("bash"), false);
|
|
21
22
|
assert.equal(rendered.includes("collapsed tools"), false);
|
|
22
23
|
assert.equal(rendered.includes("⎯"), false);
|
|
23
24
|
});
|
|
@@ -38,4 +39,130 @@ describe("ToolSummaryLine", () => {
|
|
|
38
39
|
summary.setHidden(true);
|
|
39
40
|
assert.deepEqual(summary.render(80), []);
|
|
40
41
|
});
|
|
42
|
+
|
|
43
|
+
it("renders spinner and label line when pending tools exist", () => {
|
|
44
|
+
const summary = new ToolSummaryLine();
|
|
45
|
+
summary.addPendingTool("t1", "read", { path: "src/foo/bar.ts" });
|
|
46
|
+
|
|
47
|
+
const rendered = stripAnsi(summary.render(160).join("\n"));
|
|
48
|
+
assert.ok(!rendered.startsWith(" ●"));
|
|
49
|
+
assert.ok(rendered.includes(" └ bar.ts"));
|
|
50
|
+
assert.ok(rendered.includes("bar.ts"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps last tool label after pending tool completes", () => {
|
|
54
|
+
const summary = new ToolSummaryLine();
|
|
55
|
+
summary.addPendingTool("t1", "read", { path: "file.ts" });
|
|
56
|
+
assert.equal(summary.hasPendingTools(), true);
|
|
57
|
+
|
|
58
|
+
summary.removePendingTool("t1");
|
|
59
|
+
summary.addTool("read", 500);
|
|
60
|
+
assert.equal(summary.hasPendingTools(), false);
|
|
61
|
+
|
|
62
|
+
const rendered = stripAnsi(summary.render(160).join("\n"));
|
|
63
|
+
assert.ok(rendered.includes("●"));
|
|
64
|
+
assert.ok(rendered.includes("0.5s"));
|
|
65
|
+
assert.ok(rendered.includes("└ file.ts"));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("aggregates completed and pending tools in summary text", () => {
|
|
69
|
+
const summary = new ToolSummaryLine();
|
|
70
|
+
summary.addTool("read", 300);
|
|
71
|
+
summary.addPendingTool("t1", "grep", { pattern: "TODO" });
|
|
72
|
+
|
|
73
|
+
const rendered = stripAnsi(summary.render(160).join("\n"));
|
|
74
|
+
assert.ok(rendered.includes("1 file"));
|
|
75
|
+
assert.ok(rendered.includes("1 pattern"));
|
|
76
|
+
assert.ok(rendered.includes("…"));
|
|
77
|
+
assert.ok(rendered.includes("TODO"));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("shows expand hint when set and tools are pending", () => {
|
|
81
|
+
const summary = new ToolSummaryLine();
|
|
82
|
+
summary.setExpandHint("(ctrl+o to expand)");
|
|
83
|
+
summary.addPendingTool("t1", "read", { path: "test.ts" });
|
|
84
|
+
|
|
85
|
+
const rendered = stripAnsi(summary.render(160).join("\n"));
|
|
86
|
+
assert.ok(rendered.includes("ctrl+o to expand"));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not show expand hint when no pending tools", () => {
|
|
90
|
+
const summary = new ToolSummaryLine();
|
|
91
|
+
summary.setExpandHint("(ctrl+o to expand)");
|
|
92
|
+
summary.addTool("read", 300);
|
|
93
|
+
|
|
94
|
+
const rendered = stripAnsi(summary.render(160).join("\n"));
|
|
95
|
+
assert.ok(!rendered.includes("ctrl+o to expand"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("updates label when pending tool args change", () => {
|
|
99
|
+
const summary = new ToolSummaryLine();
|
|
100
|
+
summary.addPendingTool("t1", "read", { path: "old.ts" });
|
|
101
|
+
|
|
102
|
+
let rendered = stripAnsi(summary.render(160).join("\n"));
|
|
103
|
+
assert.ok(rendered.includes("old.ts"));
|
|
104
|
+
|
|
105
|
+
summary.updatePendingToolArgs("t1", { path: "new.ts" });
|
|
106
|
+
rendered = stripAnsi(summary.render(160).join("\n"));
|
|
107
|
+
assert.ok(rendered.includes("new.ts"));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("clears pending spinner without removing last label", () => {
|
|
111
|
+
const summary = new ToolSummaryLine();
|
|
112
|
+
summary.addPendingTool("t1", "read", { path: "file.ts" });
|
|
113
|
+
|
|
114
|
+
summary.clearPendingTools();
|
|
115
|
+
|
|
116
|
+
const rendered = stripAnsi(summary.render(160).join("\n"));
|
|
117
|
+
assert.ok(!rendered.includes("◯"));
|
|
118
|
+
assert.ok(!rendered.includes("◔"));
|
|
119
|
+
assert.ok(!rendered.includes("◑"));
|
|
120
|
+
assert.ok(!rendered.includes("◕"));
|
|
121
|
+
assert.ok(!rendered.includes("● Listing"));
|
|
122
|
+
assert.ok(!rendered.includes("…"));
|
|
123
|
+
assert.ok(rendered === "" || rendered.includes("file.ts"));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("canGroupWith considers pending tools", () => {
|
|
127
|
+
const summary = new ToolSummaryLine();
|
|
128
|
+
summary.addPendingTool("t1", "read", { path: "a.ts" });
|
|
129
|
+
|
|
130
|
+
assert.equal(summary.canGroupWith("read"), true);
|
|
131
|
+
assert.equal(summary.canGroupWith("grep"), true);
|
|
132
|
+
assert.equal(summary.canGroupWith("bash"), false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("extractToolLabel", () => {
|
|
137
|
+
it("extracts basename for read tool", () => {
|
|
138
|
+
assert.equal(extractToolLabel("read", { path: "src/foo/bar.ts" }), "bar.ts");
|
|
139
|
+
assert.equal(extractToolLabel("read", { file_path: "/abs/path/file.json" }), "file.json");
|
|
140
|
+
assert.equal(extractToolLabel("read", {}), "read");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("extracts pattern for grep tool", () => {
|
|
144
|
+
assert.equal(extractToolLabel("grep", { pattern: "TODO" }), "TODO");
|
|
145
|
+
assert.equal(extractToolLabel("grep", {}), "grep");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("extracts pattern for find tool", () => {
|
|
149
|
+
assert.equal(extractToolLabel("find", { pattern: "*.ts" }), "*.ts");
|
|
150
|
+
assert.equal(extractToolLabel("find", {}), "find");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("extracts path for ls tool", () => {
|
|
154
|
+
assert.equal(extractToolLabel("ls", { path: "src/components" }), "components");
|
|
155
|
+
assert.equal(extractToolLabel("ls", {}), ".");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("extracts symbol or file for lsp tool", () => {
|
|
159
|
+
assert.equal(extractToolLabel("lsp", { symbol: "MyClass" }), "MyClass");
|
|
160
|
+
assert.equal(extractToolLabel("lsp", { file: "src/index.ts" }), "index.ts");
|
|
161
|
+
assert.equal(extractToolLabel("lsp", { symbol: "foo", file: "bar.ts" }), "foo");
|
|
162
|
+
assert.equal(extractToolLabel("lsp", {}), "lsp");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns tool name for unknown tools", () => {
|
|
166
|
+
assert.equal(extractToolLabel("custom_tool", { whatever: "value" }), "custom_tool");
|
|
167
|
+
});
|
|
41
168
|
});
|
|
@@ -1,10 +1,60 @@
|
|
|
1
1
|
import type { AssistantMessage } from "@gsd/pi-ai";
|
|
2
|
+
import type { Component } from "@gsd/pi-tui";
|
|
2
3
|
import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@gsd/pi-tui";
|
|
3
4
|
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
|
4
5
|
import { formatTimestamp, type TimestampFormat } from "./timestamp.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
+
* Create the response marker prefixed to the first visible text block.
|
|
9
|
+
* Lazy to avoid calling theme.fg() at module load time (fails in tests).
|
|
10
|
+
*/
|
|
11
|
+
function getResponseMarker(): string {
|
|
12
|
+
return `${theme.fg("accent", "●")} `;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a Markdown component for an assistant text block.
|
|
17
|
+
* @param text - Text content (should be trimmed by caller)
|
|
18
|
+
* @param withMarker - Whether to prefix with the response marker
|
|
19
|
+
* @param markdownTheme - Markdown theme
|
|
20
|
+
*/
|
|
21
|
+
export function createTextMarkdown(
|
|
22
|
+
text: string,
|
|
23
|
+
withMarker: boolean,
|
|
24
|
+
markdownTheme: MarkdownTheme,
|
|
25
|
+
): Markdown {
|
|
26
|
+
const withMarker_ = withMarker ? `${getResponseMarker()}${text}` : text;
|
|
27
|
+
return new Markdown(withMarker_, 1, 0, markdownTheme);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a Markdown component for a thinking block.
|
|
32
|
+
*/
|
|
33
|
+
export function createThinkingMarkdown(
|
|
34
|
+
thinking: string,
|
|
35
|
+
markdownTheme: MarkdownTheme,
|
|
36
|
+
): Markdown {
|
|
37
|
+
return new Markdown(thinking.trim(), 1, 0, markdownTheme, {
|
|
38
|
+
color: (text: string) => theme.fg("thinkingText", text),
|
|
39
|
+
italic: true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create an error/abort Text component.
|
|
45
|
+
*/
|
|
46
|
+
export function createErrorText(message: string): Text {
|
|
47
|
+
return new Text(theme.fg("error", message), 1, 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Component that renders a complete assistant message.
|
|
52
|
+
*
|
|
53
|
+
* Supports two rendering modes:
|
|
54
|
+
* 1. Legacy: `updateContent(message)` renders all text/thinking into a contentContainer.
|
|
55
|
+
* Tool rows are expected to be added as siblings in the parent container.
|
|
56
|
+
* 2. Interleaved: `updateContentOrdered(message, toolComponents)` renders text/thinking
|
|
57
|
+
* AND tool components in content order. Tool components become children of this container.
|
|
8
58
|
*/
|
|
9
59
|
export class AssistantMessageComponent extends Container {
|
|
10
60
|
private contentContainer: Container;
|
|
@@ -52,6 +102,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
52
102
|
this.thinkingLevel = level;
|
|
53
103
|
}
|
|
54
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Legacy rendering: renders text/thinking blocks into contentContainer.
|
|
107
|
+
* Stops rendering at the first tool-type block (toolCall/serverToolUse).
|
|
108
|
+
* Post-tool text blocks are handled by the chat-controller to preserve
|
|
109
|
+
* content ordering relative to tool rows.
|
|
110
|
+
*/
|
|
55
111
|
updateContent(message: AssistantMessage): void {
|
|
56
112
|
this.lastMessage = message;
|
|
57
113
|
|
|
@@ -68,33 +124,34 @@ export class AssistantMessageComponent extends Container {
|
|
|
68
124
|
this.contentContainer.addChild(new Spacer(1));
|
|
69
125
|
}
|
|
70
126
|
|
|
71
|
-
// Render content
|
|
127
|
+
// Render content blocks up to (but not including) the first tool block.
|
|
128
|
+
// Text blocks after tools are rendered by the chat-controller as separate
|
|
129
|
+
// components to maintain correct visual ordering with tool rows.
|
|
72
130
|
let markerAdded = false;
|
|
73
|
-
const responseMarker = `${theme.fg("accent", "●")} `;
|
|
74
131
|
for (let i = 0; i < message.content.length; i++) {
|
|
75
132
|
const content = message.content[i];
|
|
133
|
+
|
|
134
|
+
// Stop at the first tool-type block — post-tool content is handled externally
|
|
135
|
+
if (content.type === "toolCall" || content.type === "serverToolUse") {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
76
139
|
if (content.type === "text" && content.text.trim()) {
|
|
77
|
-
// Assistant text messages with no background - trim the text
|
|
78
|
-
// Set paddingY=0 to avoid extra spacing before tool executions
|
|
79
140
|
const text = content.text.trim();
|
|
80
|
-
const withMarker = markerAdded ? text : `${
|
|
141
|
+
const withMarker = markerAdded ? text : `${getResponseMarker()}${text}`;
|
|
81
142
|
this.contentContainer.addChild(new Markdown(withMarker, 1, 0, this.markdownTheme));
|
|
82
143
|
markerAdded = true;
|
|
83
144
|
} else if (content.type === "thinking" && content.thinking.trim()) {
|
|
84
145
|
if (this.hideThinkingBlock) {
|
|
85
|
-
// Hide thinking content entirely when hide-thinking is enabled.
|
|
86
146
|
continue;
|
|
87
147
|
}
|
|
88
148
|
|
|
89
|
-
// Add spacing only when another visible assistant content block follows.
|
|
90
|
-
// This avoids a superfluous blank line before separately-rendered tool execution blocks.
|
|
91
149
|
const hasVisibleContentAfter = message.content.slice(i + 1).some((c) => {
|
|
92
150
|
if (c.type === "text") return Boolean(c.text.trim());
|
|
93
151
|
if (c.type === "thinking") return !this.hideThinkingBlock && Boolean(c.thinking.trim());
|
|
94
152
|
return false;
|
|
95
153
|
});
|
|
96
154
|
|
|
97
|
-
// Thinking traces in thinkingText color, italic
|
|
98
155
|
this.contentContainer.addChild(
|
|
99
156
|
new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, {
|
|
100
157
|
color: (text: string) => theme.fg("thinkingText", text),
|
|
@@ -108,8 +165,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
108
165
|
}
|
|
109
166
|
|
|
110
167
|
// Check if aborted - show after partial content
|
|
111
|
-
|
|
112
|
-
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
|
|
168
|
+
const hasToolCalls = message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse");
|
|
113
169
|
if (!hasToolCalls) {
|
|
114
170
|
if (message.stopReason === "aborted") {
|
|
115
171
|
const abortMessage =
|
|
@@ -128,12 +184,96 @@ export class AssistantMessageComponent extends Container {
|
|
|
128
184
|
this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
|
|
129
185
|
}
|
|
130
186
|
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Interleaved rendering: renders text/thinking AND tool components in content order.
|
|
191
|
+
* Tool components become children of this container, preserving visual ordering.
|
|
192
|
+
*
|
|
193
|
+
* @param message - The assistant message
|
|
194
|
+
* @param toolComponents - Map of content block ID → pre-created Component (ToolExecutionComponent etc.)
|
|
195
|
+
* @returns Map of content block ID → the tool Component that was placed (for pending tool tracking)
|
|
196
|
+
*/
|
|
197
|
+
updateContentOrdered(
|
|
198
|
+
message: AssistantMessage,
|
|
199
|
+
toolComponents?: Map<string, Component>,
|
|
200
|
+
): Map<string, Component> {
|
|
201
|
+
this.lastMessage = message;
|
|
202
|
+
|
|
203
|
+
// Clear contentContainer so we can re-render all blocks in order
|
|
204
|
+
this.contentContainer.clear();
|
|
205
|
+
|
|
206
|
+
const placedTools = new Map<string, Component>();
|
|
207
|
+
|
|
208
|
+
// Check if there's any visible content at all
|
|
209
|
+
const hasVisibleContent = message.content.some((c) => {
|
|
210
|
+
if (c.type === "text") return Boolean(c.text.trim());
|
|
211
|
+
if (c.type === "thinking") return !this.hideThinkingBlock && Boolean(c.thinking.trim());
|
|
212
|
+
return false;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (hasVisibleContent) {
|
|
216
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Render all content blocks in order
|
|
220
|
+
let markerAdded = false;
|
|
221
|
+
for (let i = 0; i < message.content.length; i++) {
|
|
222
|
+
const block = message.content[i];
|
|
223
|
+
|
|
224
|
+
if (block.type === "text" && block.text.trim()) {
|
|
225
|
+
const text = block.text.trim();
|
|
226
|
+
const withMarker = markerAdded ? text : `${getResponseMarker()}${text}`;
|
|
227
|
+
this.contentContainer.addChild(new Markdown(withMarker, 1, 0, this.markdownTheme));
|
|
228
|
+
markerAdded = true;
|
|
229
|
+
} else if (block.type === "thinking" && block.thinking.trim()) {
|
|
230
|
+
if (this.hideThinkingBlock) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Add spacing only when another visible assistant content block follows.
|
|
235
|
+
const hasVisibleContentAfter = message.content.slice(i + 1).some((c) => {
|
|
236
|
+
if (c.type === "text") return Boolean(c.text.trim());
|
|
237
|
+
if (c.type === "thinking") return !this.hideThinkingBlock && Boolean(c.thinking.trim());
|
|
238
|
+
return false;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
this.contentContainer.addChild(
|
|
242
|
+
new Markdown(block.thinking.trim(), 1, 0, this.markdownTheme, {
|
|
243
|
+
color: (text: string) => theme.fg("thinkingText", text),
|
|
244
|
+
italic: true,
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
if (hasVisibleContentAfter) {
|
|
248
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
249
|
+
}
|
|
250
|
+
} else if ((block.type === "toolCall" || block.type === "serverToolUse") && toolComponents?.has(block.id)) {
|
|
251
|
+
// Place the pre-created tool component in content order
|
|
252
|
+
const toolComponent = toolComponents.get(block.id)!;
|
|
253
|
+
this.contentContainer.addChild(toolComponent);
|
|
254
|
+
placedTools.set(block.id, toolComponent);
|
|
255
|
+
}
|
|
256
|
+
// webSearchResult blocks don't produce their own component;
|
|
257
|
+
// they update the matching serverToolUse component via updateResult()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Handle abort/error after content (only if no tool calls)
|
|
261
|
+
const hasToolCalls = message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse");
|
|
262
|
+
if (!hasToolCalls) {
|
|
263
|
+
if (message.stopReason === "aborted") {
|
|
264
|
+
const abortMessage =
|
|
265
|
+
message.errorMessage && message.errorMessage !== "Request was aborted"
|
|
266
|
+
? message.errorMessage
|
|
267
|
+
: "Operation aborted";
|
|
268
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
269
|
+
this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
|
|
270
|
+
} else if (message.stopReason === "error") {
|
|
271
|
+
const errorMsg = message.errorMessage || "Unknown error";
|
|
272
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
273
|
+
this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
131
276
|
|
|
132
|
-
|
|
133
|
-
// Show timestamp when the message is complete (has a stop reason)
|
|
134
|
-
// if (message.stopReason && message.timestamp) {
|
|
135
|
-
// const timeStr = formatTimestamp(message.timestamp, this.timestampFormat);
|
|
136
|
-
// this.contentContainer.addChild(new Text(theme.fg("dim", timeStr), 1, 0));
|
|
137
|
-
// }
|
|
277
|
+
return placedTools;
|
|
138
278
|
}
|
|
139
279
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { Container, Text, type TUI } from "@gsd/pi-tui";
|
|
2
3
|
|
|
3
4
|
import { theme } from "../theme/theme.js";
|
|
4
5
|
|
|
@@ -7,6 +8,11 @@ interface CollapsedTool {
|
|
|
7
8
|
elapsed: number;
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
interface PendingTool {
|
|
12
|
+
name: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
// Tools that can be mixed together in one summary line
|
|
11
17
|
const MIXED_GROUPABLE_TOOLS = new Set([
|
|
12
18
|
"read", "find", "ls", "grep", "lsp",
|
|
@@ -37,6 +43,9 @@ const TOOL_SUMMARY_DESCRIPTORS: Record<string, SummaryDescriptor> = {
|
|
|
37
43
|
google_search: { action: "searching web for", singular: "query", plural: "queries" },
|
|
38
44
|
};
|
|
39
45
|
|
|
46
|
+
const SPINNER_FRAMES = ["◯", "◔", "◑", "◕", "●"];
|
|
47
|
+
const SPINNER_INTERVAL_MS = 150;
|
|
48
|
+
|
|
40
49
|
function formatCount(count: number, singular: string, plural: string): string {
|
|
41
50
|
return `${count} ${count === 1 ? singular : plural}`;
|
|
42
51
|
}
|
|
@@ -54,25 +63,146 @@ function summarizeToolGroup(name: string, count: number): string {
|
|
|
54
63
|
return `${descriptor.action} ${formatCount(count, descriptor.singular, descriptor.plural)}`;
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
function capitalize(s: string): string {
|
|
67
|
+
if (!s) return s;
|
|
68
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function extractToolLabel(toolName: string, args: Record<string, unknown>): string {
|
|
72
|
+
switch (toolName) {
|
|
73
|
+
case "read": {
|
|
74
|
+
const path = (args.path ?? args.file_path) as string | undefined;
|
|
75
|
+
return path ? basename(path) : toolName;
|
|
76
|
+
}
|
|
77
|
+
case "grep":
|
|
78
|
+
case "find": {
|
|
79
|
+
const pattern = args.pattern as string | undefined;
|
|
80
|
+
return pattern ?? toolName;
|
|
81
|
+
}
|
|
82
|
+
case "ls": {
|
|
83
|
+
const path = args.path as string | undefined;
|
|
84
|
+
return path ? basename(path) || path : ".";
|
|
85
|
+
}
|
|
86
|
+
case "lsp": {
|
|
87
|
+
const symbol = args.symbol as string | undefined;
|
|
88
|
+
const file = args.file as string | undefined;
|
|
89
|
+
if (symbol) return symbol;
|
|
90
|
+
if (file) return basename(file);
|
|
91
|
+
return toolName;
|
|
92
|
+
}
|
|
93
|
+
default:
|
|
94
|
+
return toolName;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
57
98
|
export class ToolSummaryLine extends Container {
|
|
58
99
|
private tools: CollapsedTool[] = [];
|
|
100
|
+
private pendingTools: Map<string, PendingTool> = new Map();
|
|
59
101
|
private hidden = false;
|
|
60
102
|
private contentText: Text;
|
|
103
|
+
private labelText: Text;
|
|
104
|
+
private expandHint = "";
|
|
105
|
+
private ui?: TUI;
|
|
106
|
+
private spinnerTimer: NodeJS.Timeout | null = null;
|
|
107
|
+
private spinnerFrame = 0;
|
|
108
|
+
private lastToolLabel = "";
|
|
61
109
|
|
|
62
110
|
canGroupWith(toolName: string): boolean {
|
|
63
|
-
if (this.tools.length === 0) return true;
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
if (this.tools.length === 0 && this.pendingTools.size === 0) return true;
|
|
112
|
+
const allCompletedMixed = this.tools.every((t) => MIXED_GROUPABLE_TOOLS.has(t.name));
|
|
113
|
+
const allPendingMixed = [...this.pendingTools.values()].every((t) => MIXED_GROUPABLE_TOOLS.has(t.name));
|
|
114
|
+
if (MIXED_GROUPABLE_TOOLS.has(toolName) && allCompletedMixed && allPendingMixed) {
|
|
66
115
|
return true;
|
|
67
116
|
}
|
|
68
|
-
|
|
69
|
-
|
|
117
|
+
return this.tools.every((tool) => tool.name === toolName)
|
|
118
|
+
&& [...this.pendingTools.values()].every((tool) => tool.name === toolName);
|
|
70
119
|
}
|
|
71
120
|
|
|
72
|
-
constructor() {
|
|
121
|
+
constructor(ui?: TUI) {
|
|
73
122
|
super();
|
|
123
|
+
this.ui = ui;
|
|
74
124
|
this.contentText = new Text("", 1, 0);
|
|
125
|
+
this.labelText = new Text("", 1, 0);
|
|
75
126
|
this.addChild(this.contentText);
|
|
127
|
+
this.addChild(this.labelText);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
setUI(ui: TUI): void {
|
|
131
|
+
this.ui = ui;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setExpandHint(hint: string): void {
|
|
135
|
+
this.expandHint = hint;
|
|
136
|
+
this.updateDisplay();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
addPendingTool(toolCallId: string, name: string, args: Record<string, unknown>): void {
|
|
140
|
+
const label = extractToolLabel(name, args);
|
|
141
|
+
this.pendingTools.set(toolCallId, { name, label });
|
|
142
|
+
this.lastToolLabel = label;
|
|
143
|
+
this.startSpinner();
|
|
144
|
+
this.updateDisplay();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
removePendingTool(toolCallId: string): void {
|
|
148
|
+
const removed = this.pendingTools.get(toolCallId);
|
|
149
|
+
this.pendingTools.delete(toolCallId);
|
|
150
|
+
if (removed) {
|
|
151
|
+
this.lastToolLabel = removed.label;
|
|
152
|
+
}
|
|
153
|
+
if (this.pendingTools.size === 0) {
|
|
154
|
+
this.stopSpinner();
|
|
155
|
+
} else {
|
|
156
|
+
const lastPending = [...this.pendingTools.values()].at(-1);
|
|
157
|
+
if (lastPending) this.lastToolLabel = lastPending.label;
|
|
158
|
+
}
|
|
159
|
+
this.updateDisplay();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
hasPendingTools(): boolean {
|
|
163
|
+
return this.pendingTools.size > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
hasPendingTool(toolCallId: string): boolean {
|
|
167
|
+
return this.pendingTools.has(toolCallId);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
clearPendingTools(): void {
|
|
171
|
+
const lastPending = [...this.pendingTools.values()].at(-1);
|
|
172
|
+
if (lastPending) {
|
|
173
|
+
this.lastToolLabel = lastPending.label;
|
|
174
|
+
}
|
|
175
|
+
this.pendingTools.clear();
|
|
176
|
+
this.stopSpinner();
|
|
177
|
+
this.updateDisplay();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
updatePendingToolArgs(toolCallId: string, args: Record<string, unknown>): void {
|
|
181
|
+
const pending = this.pendingTools.get(toolCallId);
|
|
182
|
+
if (!pending) return;
|
|
183
|
+
pending.label = extractToolLabel(pending.name, args);
|
|
184
|
+
this.lastToolLabel = pending.label;
|
|
185
|
+
this.updateDisplay();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private startSpinner(): void {
|
|
189
|
+
if (this.spinnerTimer) return;
|
|
190
|
+
this.spinnerTimer = setInterval(() => {
|
|
191
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
192
|
+
this.updateDisplay();
|
|
193
|
+
this.ui?.requestRender();
|
|
194
|
+
}, SPINNER_INTERVAL_MS);
|
|
195
|
+
this.spinnerTimer.unref?.();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private stopSpinner(): void {
|
|
199
|
+
if (!this.spinnerTimer) return;
|
|
200
|
+
clearInterval(this.spinnerTimer);
|
|
201
|
+
this.spinnerTimer = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
dispose(): void {
|
|
205
|
+
this.stopSpinner();
|
|
76
206
|
}
|
|
77
207
|
|
|
78
208
|
addTool(name: string, elapsed: number): void {
|
|
@@ -90,18 +220,42 @@ export class ToolSummaryLine extends Container {
|
|
|
90
220
|
}
|
|
91
221
|
|
|
92
222
|
override render(width: number): string[] {
|
|
93
|
-
if (this.hidden || this.tools.length === 0) {
|
|
223
|
+
if (this.hidden || (this.tools.length === 0 && this.pendingTools.size === 0)) {
|
|
94
224
|
return [];
|
|
95
225
|
}
|
|
96
226
|
return super.render(width);
|
|
97
227
|
}
|
|
98
228
|
|
|
99
229
|
private updateDisplay(): void {
|
|
100
|
-
if (this.tools.length === 0) {
|
|
230
|
+
if (this.tools.length === 0 && this.pendingTools.size === 0) {
|
|
101
231
|
this.contentText.setText("");
|
|
232
|
+
this.labelText.setText("");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (this.pendingTools.size > 0) {
|
|
237
|
+
const counts = new Map<string, number>();
|
|
238
|
+
for (const tool of this.tools) {
|
|
239
|
+
counts.set(tool.name, (counts.get(tool.name) ?? 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
for (const tool of this.pendingTools.values()) {
|
|
242
|
+
counts.set(tool.name, (counts.get(tool.name) ?? 0) + 1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const groupedTools = [...counts.entries()]
|
|
246
|
+
.map(([name, count]) => summarizeToolGroup(name, count))
|
|
247
|
+
.map((value, index) => index === 0 ? capitalize(value) : value)
|
|
248
|
+
.join(", ");
|
|
249
|
+
const spinner = theme.fg("accent", SPINNER_FRAMES[this.spinnerFrame]);
|
|
250
|
+
const hint = this.expandHint ? theme.fg("muted", ` ${this.expandHint}`) : "";
|
|
251
|
+
this.contentText.setText(`${spinner} ${theme.fg("text", groupedTools)}${theme.fg("muted", "…")}${hint}`);
|
|
252
|
+
|
|
253
|
+
const lastPending = [...this.pendingTools.values()].at(-1);
|
|
254
|
+
this.labelText.setText(lastPending ? theme.fg("muted", ` └ ${lastPending.label}`) : "");
|
|
102
255
|
return;
|
|
103
256
|
}
|
|
104
257
|
|
|
258
|
+
this.labelText.setText(this.lastToolLabel ? theme.fg("muted", ` └ ${this.lastToolLabel}`) : "");
|
|
105
259
|
const counts = new Map<string, number>();
|
|
106
260
|
let totalElapsed = 0;
|
|
107
261
|
for (const tool of this.tools) {
|