agent-sh 0.4.0 → 0.6.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 +37 -115
- package/dist/agent/agent-loop.d.ts +86 -0
- package/dist/agent/agent-loop.js +704 -0
- package/dist/agent/conversation-state.d.ts +27 -0
- package/dist/agent/conversation-state.js +59 -0
- package/dist/agent/index.d.ts +11 -0
- package/dist/agent/index.js +9 -0
- package/dist/agent/skills.d.ts +25 -0
- package/dist/agent/skills.js +186 -0
- package/dist/agent/subagent.d.ts +37 -0
- package/dist/agent/subagent.js +119 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +103 -0
- package/dist/agent/tool-registry.d.ts +15 -0
- package/dist/agent/tool-registry.js +30 -0
- package/dist/agent/tools/bash.d.ts +7 -0
- package/dist/agent/tools/bash.js +71 -0
- package/dist/agent/tools/display.d.ts +13 -0
- package/dist/agent/tools/display.js +70 -0
- package/dist/agent/tools/edit-file.d.ts +2 -0
- package/dist/agent/tools/edit-file.js +148 -0
- package/dist/agent/tools/glob.d.ts +2 -0
- package/dist/agent/tools/glob.js +87 -0
- package/dist/agent/tools/grep.d.ts +2 -0
- package/dist/agent/tools/grep.js +168 -0
- package/dist/agent/tools/list-skills.d.ts +2 -0
- package/dist/agent/tools/list-skills.js +28 -0
- package/dist/agent/tools/ls.d.ts +2 -0
- package/dist/agent/tools/ls.js +72 -0
- package/dist/agent/tools/read-file.d.ts +10 -0
- package/dist/agent/tools/read-file.js +101 -0
- package/dist/agent/tools/user-shell.d.ts +13 -0
- package/dist/agent/tools/user-shell.js +84 -0
- package/dist/agent/tools/write-file.d.ts +2 -0
- package/dist/agent/tools/write-file.js +82 -0
- package/dist/agent/types.d.ts +78 -0
- package/dist/agent/types.js +1 -0
- package/dist/core.d.ts +22 -14
- package/dist/core.js +256 -36
- package/dist/event-bus.d.ts +98 -17
- package/dist/event-bus.js +10 -1
- package/dist/extension-loader.d.ts +1 -1
- package/dist/extension-loader.js +10 -1
- package/dist/extensions/command-suggest.d.ts +10 -0
- package/dist/extensions/command-suggest.js +41 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +161 -64
- package/dist/extensions/tui-renderer.js +426 -126
- package/dist/index.js +110 -129
- package/dist/input-handler.js +78 -9
- package/dist/output-parser.d.ts +7 -0
- package/dist/output-parser.js +27 -0
- package/dist/settings.d.ts +53 -2
- package/dist/settings.js +46 -3
- package/dist/shell.js +35 -28
- package/dist/types.d.ts +33 -6
- package/dist/utils/box-frame.d.ts +3 -1
- package/dist/utils/box-frame.js +12 -5
- package/dist/utils/diff.js +10 -0
- package/dist/utils/llm-client.d.ts +45 -0
- package/dist/utils/llm-client.js +60 -0
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +25 -3
- package/dist/utils/stream-transform.js +20 -47
- package/dist/utils/tool-display.d.ts +4 -0
- package/dist/utils/tool-display.js +35 -8
- package/examples/extensions/claude-code-bridge/README.md +35 -0
- package/examples/extensions/claude-code-bridge/index.ts +194 -0
- package/examples/extensions/claude-code-bridge/package.json +11 -0
- package/examples/extensions/openrouter.ts +87 -0
- package/examples/extensions/pi-bridge/README.md +35 -0
- package/examples/extensions/pi-bridge/index.ts +263 -0
- package/examples/extensions/pi-bridge/package.json +13 -0
- package/examples/extensions/secret-guard.ts +100 -0
- package/examples/extensions/subagents.ts +87 -0
- package/package.json +3 -5
- package/dist/acp-client.d.ts +0 -105
- package/dist/acp-client.js +0 -684
- package/dist/extensions/shell-exec.d.ts +0 -24
- package/dist/extensions/shell-exec.js +0 -188
- package/dist/mcp-server.d.ts +0 -13
- package/dist/mcp-server.js +0 -234
- package/examples/pi-agent-sh.ts +0 -166
|
@@ -14,7 +14,7 @@ import { highlight } from "cli-highlight";
|
|
|
14
14
|
import { MarkdownRenderer, wrapLine } from "../utils/markdown.js";
|
|
15
15
|
import { createFencedBlockTransform } from "../utils/stream-transform.js";
|
|
16
16
|
import { palette as p } from "../utils/palette.js";
|
|
17
|
-
import { renderToolCall, createSpinner, renderSpinnerLine, } from "../utils/tool-display.js";
|
|
17
|
+
import { renderToolCall, createSpinner, renderSpinnerLine, formatElapsed, } from "../utils/tool-display.js";
|
|
18
18
|
import { renderDiff } from "../utils/diff-renderer.js";
|
|
19
19
|
import { renderBoxFrame } from "../utils/box-frame.js";
|
|
20
20
|
import { getSettings } from "../settings.js";
|
|
@@ -42,17 +42,25 @@ function createRenderState() {
|
|
|
42
42
|
return {
|
|
43
43
|
renderer: null,
|
|
44
44
|
hadToolCalls: false,
|
|
45
|
+
lastContentKind: null,
|
|
45
46
|
spinner: null,
|
|
46
47
|
spinnerLabel: "",
|
|
47
48
|
spinnerOpts: {},
|
|
48
49
|
spinnerInterval: null,
|
|
49
50
|
spinnerStartTime: 0,
|
|
50
|
-
lastCommand: "",
|
|
51
51
|
toolLineOpen: false,
|
|
52
52
|
currentToolKind: undefined,
|
|
53
|
+
toolStartTime: 0,
|
|
54
|
+
toolExitCode: null,
|
|
53
55
|
commandOutputBuffer: "",
|
|
54
56
|
commandOutputLineCount: 0,
|
|
55
57
|
commandOutputOverflow: 0,
|
|
58
|
+
commandOverflowLines: [],
|
|
59
|
+
toolGroupKind: undefined,
|
|
60
|
+
toolGroupCount: 0,
|
|
61
|
+
toolGroupAllOk: true,
|
|
62
|
+
toolGroupRendered: 0,
|
|
63
|
+
toolGroupSummaries: [],
|
|
56
64
|
isThinking: false,
|
|
57
65
|
showThinkingText: false,
|
|
58
66
|
thinkingPending: false,
|
|
@@ -60,9 +68,12 @@ function createRenderState() {
|
|
|
60
68
|
};
|
|
61
69
|
}
|
|
62
70
|
export default function activate(ctx) {
|
|
63
|
-
const { bus,
|
|
71
|
+
const { bus, llmClient, define } = ctx;
|
|
64
72
|
const writer = new StdoutWriter();
|
|
65
73
|
const s = createRenderState();
|
|
74
|
+
// Track backend/model info for display on response border
|
|
75
|
+
let backendInfo = null;
|
|
76
|
+
bus.on("agent:info", (info) => { backendInfo = info; });
|
|
66
77
|
// ── Register fenced block transform (code blocks → ContentBlock) ──
|
|
67
78
|
// Nobody is special — tui-renderer uses the same primitive as any extension.
|
|
68
79
|
const fencedTransform = createFencedBlockTransform(bus, {
|
|
@@ -75,7 +86,7 @@ export default function activate(ctx) {
|
|
|
75
86
|
// ── Event subscriptions ─────────────────────────────────────
|
|
76
87
|
bus.on("agent:query", (e) => {
|
|
77
88
|
s.spinnerStartTime = 0;
|
|
78
|
-
showUserQuery(e.query
|
|
89
|
+
showUserQuery(e.query);
|
|
79
90
|
startAgentResponse();
|
|
80
91
|
startThinkingSpinner();
|
|
81
92
|
});
|
|
@@ -104,70 +115,152 @@ export default function activate(ctx) {
|
|
|
104
115
|
}
|
|
105
116
|
});
|
|
106
117
|
bus.on("agent:response-chunk", (e) => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
block.text += "\n";
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
for (const block of blocks) {
|
|
118
|
-
switch (block.type) {
|
|
119
|
-
case "text":
|
|
120
|
-
if (block.text)
|
|
121
|
-
writeAgentText(block.text);
|
|
122
|
-
break;
|
|
123
|
-
case "code-block":
|
|
124
|
-
writeCodeBlock(block.language, block.code);
|
|
125
|
-
break;
|
|
126
|
-
case "image":
|
|
127
|
-
writeInlineImage(block.data);
|
|
128
|
-
break;
|
|
129
|
-
case "raw":
|
|
130
|
-
flushForRaw();
|
|
131
|
-
writer.write(block.escape);
|
|
132
|
-
break;
|
|
133
|
-
}
|
|
118
|
+
const { blocks } = e;
|
|
119
|
+
// Inject spacing: append \n to text blocks that precede non-text blocks
|
|
120
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
121
|
+
const block = blocks[i];
|
|
122
|
+
const next = blocks[i + 1];
|
|
123
|
+
if (block.type === "text" && next && next.type !== "text") {
|
|
124
|
+
block.text += "\n";
|
|
134
125
|
}
|
|
135
126
|
}
|
|
136
|
-
|
|
137
|
-
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
switch (block.type) {
|
|
129
|
+
case "text":
|
|
130
|
+
if (block.text)
|
|
131
|
+
writeAgentText(block.text);
|
|
132
|
+
break;
|
|
133
|
+
case "code-block":
|
|
134
|
+
writeCodeBlock(block.language, block.code);
|
|
135
|
+
break;
|
|
136
|
+
case "image":
|
|
137
|
+
writeInlineImage(block.data);
|
|
138
|
+
break;
|
|
139
|
+
case "raw":
|
|
140
|
+
flushForRaw();
|
|
141
|
+
writer.write(block.escape);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
138
144
|
}
|
|
139
145
|
});
|
|
146
|
+
// Track token usage for display
|
|
147
|
+
let pendingUsage = null;
|
|
148
|
+
bus.on("agent:usage", (e) => { pendingUsage = e; });
|
|
140
149
|
bus.on("agent:response-done", () => {
|
|
141
150
|
s.isThinking = false;
|
|
151
|
+
if (pendingUsage && s.renderer) {
|
|
152
|
+
const { prompt_tokens, completion_tokens } = pendingUsage;
|
|
153
|
+
const maxTokens = backendInfo?.contextWindow ?? 128_000;
|
|
154
|
+
// prompt_tokens of the latest call = current context usage
|
|
155
|
+
// (it includes the full conversation history)
|
|
156
|
+
const ctxK = (prompt_tokens / 1000).toFixed(1);
|
|
157
|
+
const maxK = (maxTokens / 1000).toFixed(0);
|
|
158
|
+
const pct = Math.min(100, (prompt_tokens / maxTokens) * 100).toFixed(0);
|
|
159
|
+
s.renderer.writeLine("");
|
|
160
|
+
s.renderer.writeLine(`${p.dim}⬆ ${prompt_tokens} ⬇ ${completion_tokens} ctx: ${ctxK}k/${maxK}k (${pct}%)${p.reset}`);
|
|
161
|
+
drain();
|
|
162
|
+
pendingUsage = null;
|
|
163
|
+
}
|
|
142
164
|
endAgentResponse();
|
|
143
165
|
});
|
|
144
|
-
|
|
145
|
-
|
|
166
|
+
// ── Tool batch grouping ──────────────────────────────────────────
|
|
167
|
+
const GROUPABLE_KINDS = new Set(["read", "search"]);
|
|
168
|
+
const GROUP_MAX_VISIBLE = 5;
|
|
169
|
+
const KIND_ICONS = { read: "◆", search: "⌕" };
|
|
170
|
+
// Batch groups: kind → { total, rendered, headerShown }
|
|
171
|
+
let batchGroups = new Map();
|
|
172
|
+
bus.on("agent:tool-batch", (e) => {
|
|
173
|
+
fencedTransform.flush();
|
|
174
|
+
finalizeToolGroup();
|
|
175
|
+
batchGroups = new Map();
|
|
176
|
+
for (const group of e.groups) {
|
|
177
|
+
batchGroups.set(group.kind, {
|
|
178
|
+
total: group.tools.length,
|
|
179
|
+
rendered: 0,
|
|
180
|
+
headerShown: false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
146
183
|
});
|
|
147
184
|
bus.on("agent:tool-started", (e) => {
|
|
148
185
|
fencedTransform.flush();
|
|
149
186
|
stopCurrentSpinner();
|
|
150
187
|
s.currentToolKind = e.kind;
|
|
188
|
+
s.toolStartTime = Date.now();
|
|
151
189
|
if (e.title === "user_shell") {
|
|
190
|
+
finalizeToolGroup();
|
|
152
191
|
closeToolLine();
|
|
153
192
|
if (!s.renderer)
|
|
154
193
|
startAgentResponse();
|
|
194
|
+
contentGap("tool");
|
|
155
195
|
s.renderer.flush();
|
|
156
196
|
const cmd = e.rawInput?.command || "";
|
|
157
197
|
s.renderer.writeLine(`${p.dim}▶ user_shell: ${cmd}${p.reset}`);
|
|
158
198
|
drain();
|
|
159
199
|
s.hadToolCalls = true;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const kind = e.kind ?? "execute";
|
|
203
|
+
const group = batchGroups.get(kind);
|
|
204
|
+
const isGrouped = group && group.total > 1 && GROUPABLE_KINDS.has(kind);
|
|
205
|
+
if (isGrouped) {
|
|
206
|
+
// Render group header on first tool of this kind in the batch
|
|
207
|
+
if (!group.headerShown) {
|
|
208
|
+
finalizeToolGroup();
|
|
209
|
+
closeToolLine();
|
|
210
|
+
if (!s.renderer)
|
|
211
|
+
startAgentResponse();
|
|
212
|
+
showCollapsedThinking();
|
|
213
|
+
contentGap("tool");
|
|
214
|
+
s.renderer.flush();
|
|
215
|
+
drain();
|
|
216
|
+
const icon = KIND_ICONS[kind] ?? "▶";
|
|
217
|
+
s.renderer.writeLine(`${p.warning}${icon}${p.reset} ${kind}`);
|
|
218
|
+
drain();
|
|
219
|
+
group.headerShown = true;
|
|
220
|
+
s.toolGroupKind = kind;
|
|
221
|
+
s.toolGroupCount = 0;
|
|
222
|
+
s.toolGroupRendered = 0;
|
|
223
|
+
s.toolGroupAllOk = true;
|
|
224
|
+
s.toolGroupSummaries = [];
|
|
225
|
+
}
|
|
226
|
+
s.toolGroupCount++;
|
|
227
|
+
if (s.toolGroupRendered < GROUP_MAX_VISIBLE) {
|
|
228
|
+
showToolCall(e.title, "", {
|
|
229
|
+
...e,
|
|
230
|
+
batchIndex: e.batchIndex,
|
|
231
|
+
batchTotal: e.batchTotal,
|
|
232
|
+
groupContinuation: true,
|
|
233
|
+
});
|
|
234
|
+
s.toolGroupRendered++;
|
|
235
|
+
}
|
|
160
236
|
}
|
|
161
237
|
else {
|
|
162
|
-
|
|
238
|
+
// Standalone tool — single in its batch kind, or not groupable
|
|
239
|
+
finalizeToolGroup();
|
|
240
|
+
showToolCall(e.title, "", {
|
|
241
|
+
...e,
|
|
242
|
+
batchIndex: e.batchIndex,
|
|
243
|
+
batchTotal: e.batchTotal,
|
|
244
|
+
});
|
|
163
245
|
}
|
|
164
|
-
s.lastCommand = "";
|
|
165
246
|
});
|
|
166
247
|
bus.on("agent:tool-completed", (e) => {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
248
|
+
s.toolExitCode = e.exitCode;
|
|
249
|
+
if (e.exitCode !== 0)
|
|
250
|
+
s.toolGroupAllOk = false;
|
|
251
|
+
if (s.toolGroupKind) {
|
|
252
|
+
// Grouped tool — track success/failure and summaries, show aggregate on ⎿ line.
|
|
253
|
+
// Don't restart spinner between grouped tools — it's already running from group start.
|
|
254
|
+
if (e.resultDisplay?.summary)
|
|
255
|
+
s.toolGroupSummaries.push(e.resultDisplay.summary);
|
|
256
|
+
s.currentToolKind = undefined;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
showToolComplete(e.exitCode, e.resultDisplay);
|
|
260
|
+
s.currentToolKind = undefined;
|
|
261
|
+
s.spinnerStartTime = 0;
|
|
262
|
+
startThinkingSpinner();
|
|
263
|
+
}
|
|
171
264
|
});
|
|
172
265
|
bus.on("agent:tool-output-chunk", (e) => writeCommandOutput(e.chunk));
|
|
173
266
|
bus.on("agent:tool-output", () => flushCommandOutput());
|
|
@@ -182,7 +275,16 @@ export default function activate(ctx) {
|
|
|
182
275
|
stopCurrentSpinner();
|
|
183
276
|
endAgentResponse();
|
|
184
277
|
});
|
|
185
|
-
bus.on("agent:error", (e) =>
|
|
278
|
+
bus.on("agent:error", (e) => {
|
|
279
|
+
stopCurrentSpinner();
|
|
280
|
+
showCollapsedThinking();
|
|
281
|
+
if (!s.renderer)
|
|
282
|
+
startAgentResponse();
|
|
283
|
+
contentGap("info");
|
|
284
|
+
s.renderer.writeLine(`${p.error}Error: ${e.message}${p.reset}`);
|
|
285
|
+
s.renderer.writeLine("");
|
|
286
|
+
drain();
|
|
287
|
+
});
|
|
186
288
|
bus.on("permission:request", (e) => {
|
|
187
289
|
stopCurrentSpinner();
|
|
188
290
|
flushCommandOutput();
|
|
@@ -191,11 +293,12 @@ export default function activate(ctx) {
|
|
|
191
293
|
drain();
|
|
192
294
|
}
|
|
193
295
|
if (e.kind === "file-write" && e.metadata?.diff) {
|
|
296
|
+
showCollapsedThinking();
|
|
194
297
|
showFileDiff(e.title, e.metadata.diff);
|
|
195
298
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
299
|
+
// Don't endAgentResponse() here — permission requests that aren't
|
|
300
|
+
// file-write diffs are handled inline (auto-approved or by extensions).
|
|
301
|
+
// Closing the response prematurely causes double separator borders.
|
|
199
302
|
});
|
|
200
303
|
bus.on("input:keypress", (e) => {
|
|
201
304
|
if (e.key === "\x0f")
|
|
@@ -203,44 +306,74 @@ export default function activate(ctx) {
|
|
|
203
306
|
if (e.key === "\x14")
|
|
204
307
|
toggleThinkingDisplay(); // Ctrl+T
|
|
205
308
|
});
|
|
206
|
-
bus.on("ui:info", (e) =>
|
|
309
|
+
bus.on("ui:info", (e) => {
|
|
310
|
+
stopCurrentSpinner();
|
|
311
|
+
showInfo(e.message);
|
|
312
|
+
// Restart spinner if agent is still processing
|
|
313
|
+
if (s.renderer)
|
|
314
|
+
startThinkingSpinner();
|
|
315
|
+
});
|
|
207
316
|
bus.on("ui:error", (e) => showError(e.message));
|
|
317
|
+
bus.on("ui:suggestion", (e) => {
|
|
318
|
+
writer.write(`${p.dim}💡 ${e.text}${p.reset}\n`);
|
|
319
|
+
});
|
|
208
320
|
// ── Rendering functions ─────────────────────────────────────
|
|
209
321
|
function drain() {
|
|
210
322
|
if (!s.renderer)
|
|
211
323
|
return;
|
|
212
324
|
for (const line of s.renderer.drainLines()) {
|
|
213
325
|
writer.write(line + "\n");
|
|
326
|
+
// Track whether we just emitted a blank line (for contentGap dedup).
|
|
327
|
+
// Lines from the renderer are indented (" "), so a blank line is " " or empty.
|
|
328
|
+
lastEmittedLineBlank = line.trimEnd() === "" || line.trimEnd().replace(/\x1b\[[^m]*m/g, "").trim() === "";
|
|
214
329
|
}
|
|
215
330
|
}
|
|
216
331
|
function startAgentResponse() {
|
|
217
332
|
s.renderer = new MarkdownRenderer(writer.columns);
|
|
218
333
|
s.hadToolCalls = false;
|
|
334
|
+
// Preserve lastContentKind across responses so text→tool gaps work
|
|
219
335
|
s.renderer.printTopBorder();
|
|
220
336
|
drain();
|
|
221
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Insert an empty line when transitioning between different content kinds
|
|
340
|
+
* (e.g., text → tool, tool → text, diff → tool) for visual breathing room.
|
|
341
|
+
* Avoids double-blanks by checking if the last emitted line was already empty.
|
|
342
|
+
*/
|
|
343
|
+
let lastEmittedLineBlank = false;
|
|
344
|
+
function contentGap(kind) {
|
|
345
|
+
if (s.lastContentKind && s.lastContentKind !== kind) {
|
|
346
|
+
if (s.renderer) {
|
|
347
|
+
s.renderer.flush();
|
|
348
|
+
drain();
|
|
349
|
+
}
|
|
350
|
+
writer.write("\n");
|
|
351
|
+
}
|
|
352
|
+
s.lastContentKind = kind;
|
|
353
|
+
}
|
|
222
354
|
function showCollapsedThinking() {
|
|
223
355
|
if (s.thinkingPending && !s.showThinkingText) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
s.renderer.writeLine(`${p.muted}… thinking${p.reset}`);
|
|
356
|
+
// Just clear the pending flag — the spinner already indicates thinking.
|
|
357
|
+
// No need for a separate "… thinking" label that clutters the output.
|
|
227
358
|
s.thinkingPending = false;
|
|
228
359
|
}
|
|
229
360
|
}
|
|
230
361
|
function endAgentResponse() {
|
|
362
|
+
finalizeToolGroup();
|
|
231
363
|
closeToolLine();
|
|
232
364
|
stopCurrentSpinner();
|
|
233
365
|
if (s.renderer) {
|
|
234
366
|
s.renderer.flush();
|
|
235
367
|
s.renderer.printBottomBorder();
|
|
236
368
|
drain();
|
|
369
|
+
writer.write("\n");
|
|
237
370
|
s.renderer = null;
|
|
238
371
|
}
|
|
239
372
|
}
|
|
240
|
-
function showUserQuery(query
|
|
241
|
-
const boxW =
|
|
373
|
+
function showUserQuery(query) {
|
|
374
|
+
const boxW = writer.columns;
|
|
242
375
|
const contentW = boxW - 4;
|
|
243
|
-
|
|
376
|
+
let lines = [];
|
|
244
377
|
for (const raw of query.split("\n")) {
|
|
245
378
|
if (raw.length <= contentW) {
|
|
246
379
|
lines.push(`${p.accent}${raw}${p.reset}`);
|
|
@@ -258,17 +391,37 @@ export default function activate(ctx) {
|
|
|
258
391
|
lines.push(`${p.accent}${remaining}${p.reset}`);
|
|
259
392
|
}
|
|
260
393
|
}
|
|
394
|
+
// Truncate very long queries to keep the response visible
|
|
395
|
+
const MAX_QUERY_LINES = 20;
|
|
396
|
+
if (lines.length > MAX_QUERY_LINES) {
|
|
397
|
+
const overflow = lines.length - MAX_QUERY_LINES;
|
|
398
|
+
lines = [
|
|
399
|
+
...lines.slice(0, MAX_QUERY_LINES),
|
|
400
|
+
`${p.dim}… ${overflow} more lines${p.reset}`,
|
|
401
|
+
];
|
|
402
|
+
}
|
|
261
403
|
// Mode-specific border color and title
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
404
|
+
const borderColor = p.accent;
|
|
405
|
+
const title = `${p.accent}${p.bold}❯${p.reset}`;
|
|
406
|
+
// Backend/model label on the right (backend/model, highlighted)
|
|
407
|
+
const model = backendInfo?.model ?? llmClient?.model;
|
|
408
|
+
const backend = backendInfo?.name;
|
|
409
|
+
let modelLabel;
|
|
410
|
+
if (backend && model) {
|
|
411
|
+
modelLabel = `${p.dim}${backend}/${p.reset}${p.bold}${model}${p.reset}`;
|
|
412
|
+
}
|
|
413
|
+
else if (model) {
|
|
414
|
+
modelLabel = `${p.bold}${model}${p.reset}`;
|
|
415
|
+
}
|
|
416
|
+
else if (backend) {
|
|
417
|
+
modelLabel = `${p.bold}${backend}${p.reset}`;
|
|
418
|
+
}
|
|
267
419
|
const framed = renderBoxFrame(lines, {
|
|
268
420
|
width: boxW,
|
|
269
421
|
style: "rounded",
|
|
270
422
|
borderColor,
|
|
271
423
|
title,
|
|
424
|
+
titleRight: modelLabel,
|
|
272
425
|
});
|
|
273
426
|
writer.write("\n");
|
|
274
427
|
for (const line of framed) {
|
|
@@ -276,8 +429,8 @@ export default function activate(ctx) {
|
|
|
276
429
|
}
|
|
277
430
|
}
|
|
278
431
|
function writeAgentText(text) {
|
|
432
|
+
finalizeToolGroup();
|
|
279
433
|
closeToolLine();
|
|
280
|
-
const needsGap = s.hadToolCalls;
|
|
281
434
|
s.hadToolCalls = false;
|
|
282
435
|
if (s.isThinking) {
|
|
283
436
|
s.isThinking = false;
|
|
@@ -292,28 +445,24 @@ export default function activate(ctx) {
|
|
|
292
445
|
stopCurrentSpinner();
|
|
293
446
|
if (!s.renderer)
|
|
294
447
|
startAgentResponse();
|
|
295
|
-
|
|
296
|
-
writer.write("\n");
|
|
448
|
+
contentGap("text");
|
|
297
449
|
s.renderer.push(text);
|
|
298
450
|
drain();
|
|
299
451
|
}
|
|
300
452
|
define("render:code-block", (language, code, width) => {
|
|
301
453
|
flushForRaw();
|
|
454
|
+
contentGap("code");
|
|
302
455
|
if (language) {
|
|
303
456
|
s.renderer.writeLine(`${p.dim}${language}${p.reset}`);
|
|
304
457
|
}
|
|
305
458
|
let highlighted;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
459
|
+
try {
|
|
460
|
+
highlighted = language
|
|
461
|
+
? highlight(code, { language })
|
|
462
|
+
: highlight(code); // auto-detect
|
|
309
463
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
highlighted = highlight(code, { language });
|
|
313
|
-
}
|
|
314
|
-
catch {
|
|
315
|
-
highlighted = `${p.success}${code}${p.reset}`;
|
|
316
|
-
}
|
|
464
|
+
catch {
|
|
465
|
+
highlighted = code;
|
|
317
466
|
}
|
|
318
467
|
const contentWidth = Math.min(90, width - 2);
|
|
319
468
|
for (const line of highlighted.split("\n")) {
|
|
@@ -346,41 +495,168 @@ export default function activate(ctx) {
|
|
|
346
495
|
function writeInlineImage(data) {
|
|
347
496
|
ctx.call("render:image", data);
|
|
348
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Default renderer for tool result bodies. Extensions can advise this handler
|
|
500
|
+
* to override rendering for specific body kinds or add new ones:
|
|
501
|
+
*
|
|
502
|
+
* ctx.advise("render:result-body", (next, body, width) => {
|
|
503
|
+
* if (body.kind === "diff") return myCustomDiffRenderer(body, width);
|
|
504
|
+
* return next(body, width);
|
|
505
|
+
* });
|
|
506
|
+
*/
|
|
507
|
+
define("render:result-body", (body, width) => {
|
|
508
|
+
if (body.kind === "diff") {
|
|
509
|
+
return renderDiffBody(body.diff, body.filePath, width);
|
|
510
|
+
}
|
|
511
|
+
if (body.kind === "lines") {
|
|
512
|
+
return renderLinesBody(body.lines, width, body.maxLines);
|
|
513
|
+
}
|
|
514
|
+
return [];
|
|
515
|
+
});
|
|
516
|
+
/** Render a diff as framed box lines (pure — no TUI state side effects). */
|
|
517
|
+
function renderDiffBody(diff, filePath, width) {
|
|
518
|
+
if (diff.isIdentical)
|
|
519
|
+
return [];
|
|
520
|
+
const boxW = Math.min(120, width);
|
|
521
|
+
const contentW = boxW - 4;
|
|
522
|
+
const diffLines = renderDiff(diff, {
|
|
523
|
+
width: contentW,
|
|
524
|
+
filePath,
|
|
525
|
+
maxLines: getSettings().diffMaxLines,
|
|
526
|
+
trueColor: true,
|
|
527
|
+
});
|
|
528
|
+
const lastLine = diffLines[diffLines.length - 1] ?? "";
|
|
529
|
+
const isTruncated = lastLine.includes("… ");
|
|
530
|
+
if (isTruncated) {
|
|
531
|
+
s.lastTruncatedDiff = { filePath, diff, expanded: false };
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
s.lastTruncatedDiff = null;
|
|
535
|
+
}
|
|
536
|
+
const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
|
|
537
|
+
const footer = isTruncated
|
|
538
|
+
? [` ${p.dim}ctrl+o to expand${p.reset}`]
|
|
539
|
+
: undefined;
|
|
540
|
+
return renderBoxFrame(body, {
|
|
541
|
+
width: boxW,
|
|
542
|
+
style: "rounded",
|
|
543
|
+
borderColor: p.dim,
|
|
544
|
+
title: diffTitle(filePath, diff),
|
|
545
|
+
footer,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
/** Render output lines with truncation. */
|
|
549
|
+
function renderLinesBody(lines, width, maxLines) {
|
|
550
|
+
const max = maxLines ?? 10;
|
|
551
|
+
const shown = lines.slice(0, max);
|
|
552
|
+
const contentW = Math.max(1, width - 6);
|
|
553
|
+
const output = [];
|
|
554
|
+
for (const line of shown) {
|
|
555
|
+
const text = line.length > contentW ? line.slice(0, contentW - 1) + "…" : line;
|
|
556
|
+
output.push(` ${p.dim} ${text}${p.reset}`);
|
|
557
|
+
}
|
|
558
|
+
if (lines.length > max) {
|
|
559
|
+
output.push(` ${p.dim} … ${lines.length - max} more lines${p.reset}`);
|
|
560
|
+
}
|
|
561
|
+
return output;
|
|
562
|
+
}
|
|
563
|
+
/** Extract a detail string from tool args for group continuation display. */
|
|
564
|
+
function extractDetail(extra) {
|
|
565
|
+
if (extra.locations && extra.locations.length > 0) {
|
|
566
|
+
const loc = extra.locations[0];
|
|
567
|
+
const cwd = process.cwd();
|
|
568
|
+
const home = process.env.HOME;
|
|
569
|
+
let fp = loc.path;
|
|
570
|
+
if (fp.startsWith(cwd + "/"))
|
|
571
|
+
fp = fp.slice(cwd.length + 1);
|
|
572
|
+
else if (home && fp.startsWith(home + "/"))
|
|
573
|
+
fp = "~/" + fp.slice(home.length + 1);
|
|
574
|
+
return loc.line ? `${fp}:${loc.line}` : fp;
|
|
575
|
+
}
|
|
576
|
+
const raw = extra.rawInput;
|
|
577
|
+
if (!raw)
|
|
578
|
+
return "";
|
|
579
|
+
if (typeof raw.command === "string")
|
|
580
|
+
return `$ ${raw.command}`;
|
|
581
|
+
if (typeof raw.pattern === "string")
|
|
582
|
+
return raw.pattern;
|
|
583
|
+
if (typeof raw.path === "string") {
|
|
584
|
+
const cwd = process.cwd();
|
|
585
|
+
const home = process.env.HOME;
|
|
586
|
+
let fp = raw.path;
|
|
587
|
+
if (fp.startsWith(cwd + "/"))
|
|
588
|
+
fp = fp.slice(cwd.length + 1);
|
|
589
|
+
else if (home && fp.startsWith(home + "/"))
|
|
590
|
+
fp = "~/" + fp.slice(home.length + 1);
|
|
591
|
+
return fp;
|
|
592
|
+
}
|
|
593
|
+
if (typeof raw.query === "string")
|
|
594
|
+
return `"${raw.query}"`;
|
|
595
|
+
return "";
|
|
596
|
+
}
|
|
349
597
|
function showToolCall(title, command, extra) {
|
|
350
598
|
closeToolLine();
|
|
351
599
|
stopCurrentSpinner();
|
|
352
600
|
if (!s.renderer)
|
|
353
601
|
startAgentResponse();
|
|
354
602
|
showCollapsedThinking();
|
|
603
|
+
// No gap between grouped tools — they're visually connected
|
|
604
|
+
if (!extra?.groupContinuation)
|
|
605
|
+
contentGap("tool");
|
|
355
606
|
s.renderer.flush();
|
|
356
607
|
drain();
|
|
357
608
|
const lines = renderToolCall({
|
|
358
609
|
title,
|
|
359
610
|
command: command || undefined,
|
|
360
611
|
kind: extra?.kind,
|
|
612
|
+
icon: extra?.icon,
|
|
361
613
|
locations: extra?.locations,
|
|
362
614
|
rawInput: extra?.rawInput,
|
|
615
|
+
displayDetail: extra?.displayDetail,
|
|
363
616
|
}, writer.columns);
|
|
617
|
+
if (extra?.groupContinuation && lines.length > 0) {
|
|
618
|
+
// Swap the colored kind icon for a muted tree connector,
|
|
619
|
+
// and strip the tool name prefix — show detail only.
|
|
620
|
+
const detail = extra.displayDetail || extractDetail(extra);
|
|
621
|
+
const maxW = Math.max(1, writer.columns - 6);
|
|
622
|
+
const text = detail.length > maxW ? detail.slice(0, maxW - 1) + "…" : detail;
|
|
623
|
+
lines[0] = detail
|
|
624
|
+
? `${p.muted}├${p.reset} ${p.dim}${text}${p.reset}`
|
|
625
|
+
: lines[0].replace(/^\x1b\[[^m]*m.\x1b\[0m/, `${p.muted}├${p.reset}`);
|
|
626
|
+
}
|
|
627
|
+
const batchPrefix = "";
|
|
364
628
|
for (let i = 0; i < lines.length - 1; i++) {
|
|
365
629
|
s.renderer.writeLine(lines[i]);
|
|
366
630
|
}
|
|
367
631
|
drain();
|
|
368
632
|
if (lines.length > 0) {
|
|
369
|
-
|
|
370
|
-
|
|
633
|
+
if (extra?.groupContinuation) {
|
|
634
|
+
// Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
|
|
635
|
+
s.renderer.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
636
|
+
drain();
|
|
637
|
+
s.toolLineOpen = false;
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
writer.write(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
641
|
+
s.toolLineOpen = true;
|
|
642
|
+
}
|
|
371
643
|
}
|
|
372
644
|
s.hadToolCalls = true;
|
|
373
645
|
s.commandOutputLineCount = 0;
|
|
374
646
|
s.commandOutputOverflow = 0;
|
|
375
647
|
}
|
|
376
|
-
function showToolComplete(exitCode) {
|
|
648
|
+
function showToolComplete(exitCode, resultDisplay) {
|
|
377
649
|
if (!s.renderer)
|
|
378
650
|
return;
|
|
651
|
+
stopCurrentSpinner();
|
|
652
|
+
const elapsed = s.toolStartTime ? formatElapsed(Date.now() - s.toolStartTime) : "";
|
|
653
|
+
const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
|
|
654
|
+
const summary = resultDisplay?.summary ? ` ${p.dim}${resultDisplay.summary}${p.reset}` : "";
|
|
379
655
|
const mark = exitCode === null
|
|
380
656
|
? `${p.muted}(timed out)${p.reset}`
|
|
381
657
|
: exitCode === 0
|
|
382
|
-
? `${p.success}✓${p.reset}`
|
|
383
|
-
: `${p.error}✗ exit ${exitCode}${p.reset}`;
|
|
658
|
+
? `${p.success}✓${p.reset}${summary}${timer}`
|
|
659
|
+
: `${p.error}✗ exit ${exitCode}${p.reset}${summary}${timer}`;
|
|
384
660
|
if (s.toolLineOpen && s.commandOutputLineCount === 0) {
|
|
385
661
|
writer.write(` ${mark}\n`);
|
|
386
662
|
s.toolLineOpen = false;
|
|
@@ -391,10 +667,25 @@ export default function activate(ctx) {
|
|
|
391
667
|
s.renderer.writeLine(` ${mark}`);
|
|
392
668
|
drain();
|
|
393
669
|
}
|
|
670
|
+
// Render structured body if present
|
|
671
|
+
if (resultDisplay?.body) {
|
|
672
|
+
renderResultBody(resultDisplay.body);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function renderResultBody(body) {
|
|
676
|
+
if (!s.renderer)
|
|
677
|
+
return;
|
|
678
|
+
const lines = ctx.call("render:result-body", body, writer.columns) ?? [];
|
|
679
|
+
for (const line of lines) {
|
|
680
|
+
s.renderer.writeLine(line);
|
|
681
|
+
}
|
|
682
|
+
if (lines.length > 0)
|
|
683
|
+
drain();
|
|
394
684
|
}
|
|
685
|
+
// Thinking is always assumed available — the TUI renders thinking
|
|
686
|
+
// tokens whenever they arrive, regardless of backend.
|
|
395
687
|
function hasThinkingMode() {
|
|
396
|
-
|
|
397
|
-
return !mode || mode.id !== "off";
|
|
688
|
+
return true;
|
|
398
689
|
}
|
|
399
690
|
function startThinkingSpinner() {
|
|
400
691
|
if (!s.spinnerStartTime)
|
|
@@ -430,6 +721,40 @@ export default function activate(ctx) {
|
|
|
430
721
|
s.toolLineOpen = false;
|
|
431
722
|
}
|
|
432
723
|
}
|
|
724
|
+
/** Finalize a group of collapsed tool calls, rendering the summary. */
|
|
725
|
+
function finalizeToolGroup() {
|
|
726
|
+
if (s.toolGroupCount <= 1) {
|
|
727
|
+
// 0–1 tools: standalone, nothing to finalize
|
|
728
|
+
s.toolGroupKind = undefined;
|
|
729
|
+
s.toolGroupCount = 0;
|
|
730
|
+
s.toolGroupRendered = 0;
|
|
731
|
+
s.toolGroupSummaries = [];
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
closeToolLine();
|
|
735
|
+
if (!s.renderer)
|
|
736
|
+
startAgentResponse();
|
|
737
|
+
const mark = s.toolGroupAllOk
|
|
738
|
+
? `${p.success}✓${p.reset}`
|
|
739
|
+
: `${p.error}✗${p.reset}`;
|
|
740
|
+
const summary = s.toolGroupSummaries.length > 0
|
|
741
|
+
? ` ${p.dim}${s.toolGroupSummaries.join(", ")}${p.reset}`
|
|
742
|
+
: "";
|
|
743
|
+
const collapsed = s.toolGroupCount - s.toolGroupRendered;
|
|
744
|
+
if (collapsed > 0) {
|
|
745
|
+
s.renderer.writeLine(` ${p.muted}└${p.reset} ${p.dim}+${collapsed} more${p.reset} ${mark}${summary}`);
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
// All items visible — close the tree with └ mark + summary
|
|
749
|
+
s.renderer.writeLine(` ${p.muted}└${p.reset} ${mark}${summary}`);
|
|
750
|
+
}
|
|
751
|
+
drain();
|
|
752
|
+
s.toolGroupKind = undefined;
|
|
753
|
+
s.toolGroupCount = 0;
|
|
754
|
+
s.toolGroupAllOk = true;
|
|
755
|
+
s.toolGroupRendered = 0;
|
|
756
|
+
s.toolGroupSummaries = [];
|
|
757
|
+
}
|
|
433
758
|
function writeCommandOutput(chunk) {
|
|
434
759
|
if (!s.renderer)
|
|
435
760
|
return;
|
|
@@ -447,10 +772,13 @@ export default function activate(ctx) {
|
|
|
447
772
|
}
|
|
448
773
|
else {
|
|
449
774
|
s.commandOutputOverflow++;
|
|
775
|
+
s.commandOverflowLines.push(line);
|
|
450
776
|
}
|
|
451
777
|
}
|
|
452
778
|
drain();
|
|
453
779
|
}
|
|
780
|
+
/** Max overflow lines to show when a command fails. */
|
|
781
|
+
const FAIL_OVERFLOW_MAX = 20;
|
|
454
782
|
function flushCommandOutput() {
|
|
455
783
|
if (!s.renderer)
|
|
456
784
|
return;
|
|
@@ -464,13 +792,28 @@ export default function activate(ctx) {
|
|
|
464
792
|
}
|
|
465
793
|
else {
|
|
466
794
|
s.commandOutputOverflow++;
|
|
795
|
+
s.commandOverflowLines.push(s.commandOutputBuffer);
|
|
467
796
|
}
|
|
468
797
|
s.commandOutputBuffer = "";
|
|
469
798
|
}
|
|
470
|
-
|
|
799
|
+
// On failure, show the tail of the overflow so the user can see the error
|
|
800
|
+
const failed = s.toolExitCode !== null && s.toolExitCode !== 0;
|
|
801
|
+
if (failed && s.commandOverflowLines.length > 0) {
|
|
802
|
+
const tail = s.commandOverflowLines.slice(-FAIL_OVERFLOW_MAX);
|
|
803
|
+
const skipped = s.commandOverflowLines.length - tail.length;
|
|
804
|
+
if (skipped > 0) {
|
|
805
|
+
s.renderer.writeLine(`${p.dim} … ${skipped} lines hidden${p.reset}`);
|
|
806
|
+
}
|
|
807
|
+
for (const line of tail) {
|
|
808
|
+
s.renderer.writeLine(`${p.dim} ${line}${p.reset}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else if (s.commandOutputOverflow > 0 && maxLines > 0) {
|
|
471
812
|
s.renderer.writeLine(`${p.dim} … ${s.commandOutputOverflow} more lines${p.reset}`);
|
|
472
813
|
}
|
|
473
814
|
s.commandOutputOverflow = 0;
|
|
815
|
+
s.commandOverflowLines = [];
|
|
816
|
+
s.toolExitCode = null;
|
|
474
817
|
drain();
|
|
475
818
|
}
|
|
476
819
|
function diffTitle(filePath, diff) {
|
|
@@ -482,37 +825,11 @@ export default function activate(ctx) {
|
|
|
482
825
|
function showFileDiff(filePath, diff) {
|
|
483
826
|
if (diff.isIdentical)
|
|
484
827
|
return;
|
|
485
|
-
|
|
486
|
-
const
|
|
487
|
-
const diffLines = renderDiff(diff, {
|
|
488
|
-
width: contentW,
|
|
489
|
-
filePath,
|
|
490
|
-
maxLines: getSettings().diffMaxLines,
|
|
491
|
-
trueColor: true,
|
|
492
|
-
mode: "unified",
|
|
493
|
-
});
|
|
494
|
-
const lastLine = diffLines[diffLines.length - 1] ?? "";
|
|
495
|
-
const isTruncated = lastLine.includes("… ");
|
|
496
|
-
if (isTruncated) {
|
|
497
|
-
s.lastTruncatedDiff = { filePath, diff, expanded: false };
|
|
498
|
-
}
|
|
499
|
-
else {
|
|
500
|
-
s.lastTruncatedDiff = null;
|
|
501
|
-
}
|
|
502
|
-
const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
|
|
503
|
-
const footer = isTruncated
|
|
504
|
-
? [` ${p.dim}ctrl+o to expand${p.reset}`]
|
|
505
|
-
: undefined;
|
|
506
|
-
const framed = renderBoxFrame(body, {
|
|
507
|
-
width: boxW,
|
|
508
|
-
style: "rounded",
|
|
509
|
-
borderColor: p.dim,
|
|
510
|
-
title: diffTitle(filePath, diff),
|
|
511
|
-
footer,
|
|
512
|
-
});
|
|
828
|
+
contentGap("diff");
|
|
829
|
+
const lines = ctx.call("render:result-body", { kind: "diff", diff, filePath }, writer.columns) ?? [];
|
|
513
830
|
if (!s.renderer)
|
|
514
831
|
startAgentResponse();
|
|
515
|
-
for (const line of
|
|
832
|
+
for (const line of lines) {
|
|
516
833
|
s.renderer.writeLine(line);
|
|
517
834
|
}
|
|
518
835
|
drain();
|
|
@@ -551,26 +868,9 @@ export default function activate(ctx) {
|
|
|
551
868
|
}
|
|
552
869
|
}
|
|
553
870
|
function showFileDiffCached(entry) {
|
|
554
|
-
const {
|
|
555
|
-
const boxW = Math.min(84, writer.columns);
|
|
556
|
-
const contentW = boxW - 4;
|
|
557
|
-
const diffLines = renderDiff(diff, {
|
|
558
|
-
width: contentW,
|
|
559
|
-
filePath,
|
|
560
|
-
maxLines: getSettings().diffMaxLines,
|
|
561
|
-
trueColor: true,
|
|
562
|
-
mode: "unified",
|
|
563
|
-
});
|
|
564
|
-
const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
|
|
565
|
-
const framed = renderBoxFrame(body, {
|
|
566
|
-
width: boxW,
|
|
567
|
-
style: "rounded",
|
|
568
|
-
borderColor: p.dim,
|
|
569
|
-
title: diffTitle(filePath, diff),
|
|
570
|
-
footer: [` ${p.dim}ctrl+o to expand${p.reset}`],
|
|
571
|
-
});
|
|
871
|
+
const lines = ctx.call("render:result-body", { kind: "diff", diff: entry.diff, filePath: entry.filePath }, writer.columns) ?? [];
|
|
572
872
|
writer.write("\n");
|
|
573
|
-
for (const line of
|
|
873
|
+
for (const line of lines) {
|
|
574
874
|
writer.write(line + "\n");
|
|
575
875
|
}
|
|
576
876
|
}
|