lsd-pi 1.3.7 → 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.
Files changed (92) hide show
  1. package/README.md +82 -0
  2. package/dist/resources/extensions/mcp-client/index.js +230 -54
  3. package/dist/resources/extensions/mcp-client/mcp-manager-component.js +220 -0
  4. package/dist/resources/extensions/slash-commands/plan.js +72 -18
  5. package/dist/resources/extensions/subagent/agents.js +7 -0
  6. package/dist/resources/extensions/subagent/index.js +25 -8
  7. package/dist/resources/extensions/subagent/model-resolution.js +1 -0
  8. package/dist/resources/extensions/usage/index.js +34 -2
  9. package/dist/resources/extensions/voice/index.js +1 -0
  10. package/dist/resources/extensions/voice/push-to-talk.js +2 -0
  11. package/package.json +1 -1
  12. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts +2 -0
  13. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts.map +1 -0
  14. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js +72 -0
  15. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js.map +1 -0
  16. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +4 -0
  17. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  18. package/packages/pi-coding-agent/dist/core/agent-session.js +29 -2
  19. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  20. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  21. package/packages/pi-coding-agent/dist/core/extensions/runner.js +1 -0
  22. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  23. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  24. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/tool-priority.js +1 -1
  27. package/packages/pi-coding-agent/dist/core/tool-priority.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  29. package/packages/pi-coding-agent/dist/main.js +1 -0
  30. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  31. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +104 -2
  32. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +39 -2
  34. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  35. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +135 -18
  36. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  37. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +2 -0
  38. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  39. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -1
  40. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  41. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +21 -2
  42. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -1
  43. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +147 -9
  44. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
  45. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +51 -13
  46. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +112 -18
  49. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +1 -0
  52. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +4 -0
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
  57. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +34 -4
  59. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  60. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  61. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +3 -0
  62. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  63. package/packages/pi-coding-agent/package.json +1 -1
  64. package/packages/pi-coding-agent/src/core/agent-session.context-usage.test.ts +87 -0
  65. package/packages/pi-coding-agent/src/core/agent-session.ts +40 -2
  66. package/packages/pi-coding-agent/src/core/extensions/runner.ts +1 -0
  67. package/packages/pi-coding-agent/src/core/extensions/types.ts +3 -0
  68. package/packages/pi-coding-agent/src/core/tool-priority.ts +1 -1
  69. package/packages/pi-coding-agent/src/main.ts +1 -0
  70. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +129 -2
  71. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +158 -18
  72. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +18 -1
  73. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +164 -10
  74. package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +60 -13
  75. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +123 -20
  76. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +1 -0
  77. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  78. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +34 -4
  79. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +4 -0
  80. package/pkg/package.json +1 -1
  81. package/src/resources/extensions/mcp-client/index.ts +259 -58
  82. package/src/resources/extensions/mcp-client/mcp-manager-component.ts +256 -0
  83. package/src/resources/extensions/mcp-client/tests/mcp-manager-component.test.ts +141 -0
  84. package/src/resources/extensions/mcp-client/tests/server-name-spaces.test.ts +32 -0
  85. package/src/resources/extensions/slash-commands/plan.ts +76 -19
  86. package/src/resources/extensions/subagent/agents.ts +9 -0
  87. package/src/resources/extensions/subagent/index.ts +30 -8
  88. package/src/resources/extensions/subagent/model-resolution.ts +1 -0
  89. package/src/resources/extensions/usage/index.ts +40 -2
  90. package/src/resources/extensions/voice/index.ts +1 -0
  91. package/src/resources/extensions/voice/push-to-talk.ts +3 -0
  92. package/src/resources/extensions/voice/tests/push-to-talk.test.ts +6 -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"), false);
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
- * Component that renders a complete assistant message
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 in order
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 : `${responseMarker}${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
- // But only if there are no tool calls (tool execution components will show the error)
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
- // Timestamp display removed
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
  }
@@ -134,6 +134,7 @@ export class ToolExecutionComponent extends Container {
134
134
  // When true, this component intentionally renders no lines
135
135
  private hideComponent = false;
136
136
  private manuallyHidden = false;
137
+ private indented = false;
137
138
  private startTime = Date.now();
138
139
 
139
140
  // Tool status spinner state
@@ -457,6 +458,11 @@ export class ToolExecutionComponent extends Container {
457
458
  this.updateDisplay();
458
459
  }
459
460
 
461
+ setIndented(indented: boolean): void {
462
+ this.indented = indented;
463
+ this.updateDisplay();
464
+ }
465
+
460
466
  isHidden(): boolean {
461
467
  return this.hideComponent;
462
468
  }
@@ -502,7 +508,18 @@ export class ToolExecutionComponent extends Container {
502
508
  if (this.hideComponent) {
503
509
  return [];
504
510
  }
505
- return [...super.render(width), ""];
511
+ const lines = super.render(width);
512
+ if (this.indented) {
513
+ const gutter = theme.fg("dim", "│");
514
+ return lines.map((line, i) => {
515
+ if (i === lines.length - 1 && line === "") {
516
+ // Trailing empty spacer line → gutter only
517
+ return gutter;
518
+ }
519
+ return gutter + " " + theme.fg("dim", line);
520
+ });
521
+ }
522
+ return [...lines, ""];
506
523
  }
507
524
 
508
525
  private updateDisplay(): void {
@@ -1,4 +1,5 @@
1
- import { Container, Text } from "@gsd/pi-tui";
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
- // Mixed-groupable tools can share a summary line regardless of order
65
- if (MIXED_GROUPABLE_TOOLS.has(toolName) && this.tools.every((t) => MIXED_GROUPABLE_TOOLS.has(t.name))) {
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
- // Otherwise only same-tool grouping
69
- return this.tools.every((tool) => tool.name === toolName);
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) {
@@ -114,7 +268,7 @@ export class ToolSummaryLine extends Container {
114
268
  .join(" · ");
115
269
  const elapsed = (totalElapsed / 1000).toFixed(1);
116
270
  const indicator = theme.fg("success", "●");
117
- const details = theme.fg("muted", `${groupedTools} · ${elapsed}s`);
271
+ const details = theme.fg("text", groupedTools) + theme.fg("muted", ` · ${elapsed}s`);
118
272
  this.contentText.setText(`${indicator} ${details}`);
119
273
  }
120
274
  }