agent-sh 0.15.0 → 0.15.2
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/agent/agent-loop.js +11 -8
- package/dist/agent/events.d.ts +4 -0
- package/docs/README.md +14 -0
- package/docs/agent.md +398 -0
- package/docs/architecture.md +196 -0
- package/docs/context-management.md +200 -0
- package/docs/extensions.md +951 -0
- package/docs/library.md +84 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/tui-composition.md +294 -0
- package/docs/usage.md +306 -0
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +2 -2
- package/examples/extensions/ashi/README.md +2 -2
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
- package/examples/extensions/ashi/package.json +5 -3
- package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
- package/examples/extensions/ashi/src/cli.ts +9 -8
- package/examples/extensions/ashi/src/dialogs.ts +16 -1
- package/examples/extensions/ashi/src/events.ts +1 -0
- package/examples/extensions/ashi/src/frontend.ts +26 -6
- package/examples/extensions/ashi/src/renderer.ts +24 -4
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
- package/examples/extensions/ashi/src/ui.ts +11 -0
- package/examples/extensions/ashi-ink/package.json +2 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/package.json +3 -1
- package/src/agent/agent-loop.ts +1566 -0
- package/src/agent/entry-format.ts +19 -0
- package/src/agent/events.ts +153 -0
- package/src/agent/extensions/rolling-history/constants.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +202 -0
- package/src/agent/extensions/rolling-history/recall.ts +131 -0
- package/src/agent/extensions/rolling-history/strategy.ts +404 -0
- package/src/agent/host-types.ts +192 -0
- package/src/agent/index.ts +591 -0
- package/src/agent/live-view.ts +279 -0
- package/src/agent/llm-client.ts +111 -0
- package/src/agent/llm-facade.ts +43 -0
- package/src/agent/normalize-args.ts +61 -0
- package/src/agent/nuclear-form.ts +382 -0
- package/src/agent/providers/deepseek.ts +39 -0
- package/src/agent/providers/ollama.ts +92 -0
- package/src/agent/providers/openai-compatible.ts +36 -0
- package/src/agent/providers/openai.ts +52 -0
- package/src/agent/providers/opencode.ts +142 -0
- package/src/agent/providers/openrouter.ts +105 -0
- package/src/agent/providers/zai-coding-plan.ts +33 -0
- package/src/agent/session-store.ts +336 -0
- package/src/agent/skills.ts +228 -0
- package/src/agent/store.ts +310 -0
- package/src/agent/subagent.ts +305 -0
- package/src/agent/system-prompt.ts +151 -0
- package/src/agent/token-budget.ts +12 -0
- package/src/agent/tool-protocol.ts +722 -0
- package/src/agent/tool-registry.ts +66 -0
- package/src/agent/tools/bash.ts +95 -0
- package/src/agent/tools/edit-file.ts +154 -0
- package/src/agent/tools/expand-home.ts +7 -0
- package/src/agent/tools/glob.ts +108 -0
- package/src/agent/tools/grep.ts +228 -0
- package/src/agent/tools/list-skills.ts +37 -0
- package/src/agent/tools/ls.ts +81 -0
- package/src/agent/tools/pwsh.ts +140 -0
- package/src/agent/tools/read-file.ts +164 -0
- package/src/agent/tools/write-file.ts +72 -0
- package/src/agent/types.ts +149 -0
- package/src/cli/args.ts +91 -0
- package/src/cli/auth/cli.ts +244 -0
- package/src/cli/auth/discover.ts +52 -0
- package/src/cli/auth/keys.ts +143 -0
- package/src/cli/index.ts +295 -0
- package/src/cli/init.ts +74 -0
- package/src/cli/install.ts +439 -0
- package/src/cli/shell-env.ts +68 -0
- package/src/cli/subcommands.ts +24 -0
- package/src/core/event-bus.ts +252 -0
- package/src/core/extension-loader.ts +347 -0
- package/src/core/index.ts +152 -0
- package/src/core/settings.ts +398 -0
- package/src/core/types.ts +61 -0
- package/src/extensions/file-autocomplete.ts +71 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/slash-commands/events.ts +14 -0
- package/src/extensions/slash-commands/index.ts +269 -0
- package/src/shell/events.ts +73 -0
- package/src/shell/host-types.ts +150 -0
- package/src/shell/index.ts +159 -0
- package/src/shell/input-handler.ts +505 -0
- package/src/shell/output-parser.ts +156 -0
- package/src/shell/shell-context.ts +193 -0
- package/src/shell/shell.ts +414 -0
- package/src/shell/strategies/bash.ts +83 -0
- package/src/shell/strategies/fish.ts +77 -0
- package/src/shell/strategies/index.ts +24 -0
- package/src/shell/strategies/types.ts +64 -0
- package/src/shell/strategies/zsh.ts +92 -0
- package/src/shell/terminal.ts +124 -0
- package/src/shell/tui-input-view.ts +222 -0
- package/src/shell/tui-renderer.ts +1126 -0
- package/src/utils/ansi.ts +140 -0
- package/src/utils/box-frame.ts +138 -0
- package/src/utils/compositor.ts +157 -0
- package/src/utils/diff-renderer.ts +829 -0
- package/src/utils/diff.ts +244 -0
- package/src/utils/executor.ts +305 -0
- package/src/utils/file-watcher.ts +110 -0
- package/src/utils/floating-panel.ts +1160 -0
- package/src/utils/handler-registry.ts +110 -0
- package/src/utils/line-editor.ts +636 -0
- package/src/utils/markdown.ts +437 -0
- package/src/utils/message-utils.ts +113 -0
- package/src/utils/package-version.ts +12 -0
- package/src/utils/palette.ts +64 -0
- package/src/utils/ref-counter.ts +9 -0
- package/src/utils/ripgrep-path.ts +17 -0
- package/src/utils/shell-output-spill.ts +76 -0
- package/src/utils/stream-transform.ts +292 -0
- package/src/utils/terminal-buffer.ts +213 -0
- package/src/utils/tool-display.ts +315 -0
- package/src/utils/tool-interactive.ts +71 -0
- package/src/utils/tty.ts +14 -0
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI renderer extension.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to EventBus events and renders agent output to the terminal:
|
|
5
|
+
* bordered markdown responses, spinner, tool call display, streaming
|
|
6
|
+
* command output, error/info messages.
|
|
7
|
+
*
|
|
8
|
+
* Without this extension loaded, agent-sh runs headlessly — PTY
|
|
9
|
+
* passthrough, agent queries, tool execution all function; output is
|
|
10
|
+
* silently dropped. Alternative renderers (web UI, logging, minimal)
|
|
11
|
+
* can subscribe to the same events.
|
|
12
|
+
*/
|
|
13
|
+
import { highlight } from "cli-highlight";
|
|
14
|
+
import { MarkdownRenderer, wrapLine, MAX_CONTENT_WIDTH } from "../utils/markdown.js";
|
|
15
|
+
import { DEFAULT_CONTEXT_WINDOW } from "../agent/token-budget.js";
|
|
16
|
+
import { createFencedBlockTransform, type FencedBlockTransformHandle } from "../utils/stream-transform.js";
|
|
17
|
+
import { palette as p } from "../utils/palette.js";
|
|
18
|
+
import {
|
|
19
|
+
renderToolCall,
|
|
20
|
+
createSpinner,
|
|
21
|
+
formatElapsed,
|
|
22
|
+
SPINNER_FRAMES,
|
|
23
|
+
type SpinnerState,
|
|
24
|
+
type SpinnerOpts,
|
|
25
|
+
} from "../utils/tool-display.js";
|
|
26
|
+
import { renderDiff } from "../utils/diff-renderer.js";
|
|
27
|
+
import { renderBoxFrame } from "../utils/box-frame.js";
|
|
28
|
+
import type { DiffResult } from "../utils/diff.js";
|
|
29
|
+
import { getSettings } from "../core/settings.js";
|
|
30
|
+
import type { ExtensionContext } from "./host-types.js";
|
|
31
|
+
import type { ToolResultDisplay, ToolResultBody } from "../agent/types.js";
|
|
32
|
+
import type { RenderSurface } from "../utils/compositor.js";
|
|
33
|
+
|
|
34
|
+
/** Encode a PNG buffer as a terminal inline image escape sequence. */
|
|
35
|
+
function encodeImageForTerminal(data: Buffer): string | null {
|
|
36
|
+
const b64 = data.toString("base64");
|
|
37
|
+
if (process.env.TERM_PROGRAM === "iTerm.app" || process.env.TERM_PROGRAM === "WezTerm") {
|
|
38
|
+
return `\x1b]1337;File=inline=1;size=${data.length};preserveAspectRatio=1:${b64}\x07`;
|
|
39
|
+
}
|
|
40
|
+
if (process.env.KITTY_WINDOW_ID || process.env.TERM_PROGRAM === "ghostty") {
|
|
41
|
+
const chunks: string[] = [];
|
|
42
|
+
for (let i = 0; i < b64.length; i += 4096) {
|
|
43
|
+
const chunk = b64.slice(i, i + 4096);
|
|
44
|
+
const isLast = i + 4096 >= b64.length;
|
|
45
|
+
chunks.push(i === 0
|
|
46
|
+
? `\x1b_Gf=100,t=d,a=T,m=${isLast ? 0 : 1};${chunk}\x1b\\`
|
|
47
|
+
: `\x1b_Gm=${isLast ? 0 : 1};${chunk}\x1b\\`);
|
|
48
|
+
}
|
|
49
|
+
return chunks.join("");
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Render state ─────────────────────────────────────────────────
|
|
55
|
+
// All mutable TUI state in one place for clarity and future
|
|
56
|
+
// migration to a frame-based rendering model.
|
|
57
|
+
|
|
58
|
+
interface RenderState {
|
|
59
|
+
// ── Response rendering ──
|
|
60
|
+
renderer: MarkdownRenderer | null;
|
|
61
|
+
hadToolCalls: boolean;
|
|
62
|
+
/** Tracks the last content kind rendered for gap injection. */
|
|
63
|
+
lastContentKind: "text" | "tool" | "diff" | "code" | "info" | null;
|
|
64
|
+
|
|
65
|
+
// ── Spinner ──
|
|
66
|
+
spinner: SpinnerState | null;
|
|
67
|
+
spinnerLabel: string;
|
|
68
|
+
spinnerOpts: SpinnerOpts;
|
|
69
|
+
spinnerInterval: ReturnType<typeof setInterval> | null;
|
|
70
|
+
spinnerStartTime: number;
|
|
71
|
+
|
|
72
|
+
// ── Tool output ──
|
|
73
|
+
openTool: { callId: string; title: string; kind?: string; displayDetail?: string } | null;
|
|
74
|
+
/** Tools whose start line was closed before their complete fired.
|
|
75
|
+
* Their ✓ renders as a labeled ⎿ line instead of an orphan.
|
|
76
|
+
* `orphaned` = the group was finalized before they returned, so the
|
|
77
|
+
* ⎿ renders under a re-emitted "(cont.)" header to avoid looking
|
|
78
|
+
* like a child of whatever tool rendered in between. */
|
|
79
|
+
pendingToolCompletes: Map<string, { title: string; kind?: string; displayDetail?: string; orphaned?: boolean }>;
|
|
80
|
+
currentToolKind: string | undefined;
|
|
81
|
+
toolStartTime: number;
|
|
82
|
+
toolExitCode: number | null;
|
|
83
|
+
commandOutputBuffer: string;
|
|
84
|
+
commandOutputLineCount: number;
|
|
85
|
+
commandOutputOverflow: number;
|
|
86
|
+
commandOverflowLines: string[];
|
|
87
|
+
|
|
88
|
+
/** Consecutive orphans of the same kind share one "(cont.)" header;
|
|
89
|
+
* cleared when any non-orphan render happens. */
|
|
90
|
+
orphanContHeaderKind: string | undefined;
|
|
91
|
+
|
|
92
|
+
// ── Tool grouping (collapse sequential same-type read-only tools) ──
|
|
93
|
+
toolGroupKind: string | undefined;
|
|
94
|
+
toolGroupCount: number;
|
|
95
|
+
/** Completes-seen count — skip aggregate if finalize fires at 0. */
|
|
96
|
+
toolGroupCompletedCount: number;
|
|
97
|
+
toolGroupAllOk: boolean;
|
|
98
|
+
/** Number of tools rendered individually in current group. */
|
|
99
|
+
toolGroupRendered: number;
|
|
100
|
+
/** Accumulated result summaries from grouped tools. */
|
|
101
|
+
toolGroupSummaries: string[];
|
|
102
|
+
|
|
103
|
+
// ── Thinking ──
|
|
104
|
+
isThinking: boolean;
|
|
105
|
+
showThinkingText: boolean;
|
|
106
|
+
thinkingPending: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createRenderState(): RenderState {
|
|
110
|
+
return {
|
|
111
|
+
renderer: null,
|
|
112
|
+
hadToolCalls: false,
|
|
113
|
+
lastContentKind: null,
|
|
114
|
+
spinner: null,
|
|
115
|
+
spinnerLabel: "",
|
|
116
|
+
spinnerOpts: {},
|
|
117
|
+
spinnerInterval: null,
|
|
118
|
+
spinnerStartTime: 0,
|
|
119
|
+
openTool: null,
|
|
120
|
+
pendingToolCompletes: new Map(),
|
|
121
|
+
orphanContHeaderKind: undefined,
|
|
122
|
+
currentToolKind: undefined,
|
|
123
|
+
toolStartTime: 0,
|
|
124
|
+
toolExitCode: null,
|
|
125
|
+
commandOutputBuffer: "",
|
|
126
|
+
commandOutputLineCount: 0,
|
|
127
|
+
commandOutputOverflow: 0,
|
|
128
|
+
commandOverflowLines: [],
|
|
129
|
+
toolGroupKind: undefined,
|
|
130
|
+
toolGroupCount: 0,
|
|
131
|
+
toolGroupCompletedCount: 0,
|
|
132
|
+
toolGroupAllOk: true,
|
|
133
|
+
toolGroupRendered: 0,
|
|
134
|
+
toolGroupSummaries: [],
|
|
135
|
+
isThinking: false,
|
|
136
|
+
showThinkingText: false,
|
|
137
|
+
thinkingPending: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
142
|
+
const { bus, define } = ctx;
|
|
143
|
+
const compositor = ctx.shell!.compositor;
|
|
144
|
+
const s = createRenderState();
|
|
145
|
+
|
|
146
|
+
/** Track the shell's cwd so path shortening is relative to where the user actually is. */
|
|
147
|
+
let shellCwd = process.cwd();
|
|
148
|
+
bus.on("shell:cwd-change", (e) => { shellCwd = e.cwd; });
|
|
149
|
+
|
|
150
|
+
/** Shorthand — get the current agent surface. */
|
|
151
|
+
function out(): RenderSurface { return compositor.surface("agent"); }
|
|
152
|
+
|
|
153
|
+
/** Capped width for borders, tool lines, and content — keeps everything aligned.
|
|
154
|
+
* MarkdownRenderer.writeLine prepends a 2-char indent (" ") to every line,
|
|
155
|
+
* so available width for actual content is columns - 2. Subtract an additional
|
|
156
|
+
* 1 to prevent terminal auto-wrap when a line lands exactly at the right edge. */
|
|
157
|
+
function cappedW(): number { return Math.min(MAX_CONTENT_WIDTH + 2, out().columns) - 2 - 1; }
|
|
158
|
+
|
|
159
|
+
// Gate: other extensions (e.g. overlay) can advise this to suppress
|
|
160
|
+
// TUI rendering of agent output while they own the display.
|
|
161
|
+
define("tui:should-render-agent", (): boolean => true);
|
|
162
|
+
function shouldRender(): boolean { return ctx.call("tui:should-render-agent"); }
|
|
163
|
+
|
|
164
|
+
// ── Advisable rendering handlers ───────────────────────────────
|
|
165
|
+
// Extensions advise these to customize how the TUI renders content.
|
|
166
|
+
// Each handler receives data and returns rendered strings.
|
|
167
|
+
|
|
168
|
+
define("tui:response-border", (position: "top" | "bottom", width: number): string | null => {
|
|
169
|
+
return `${p.dim}${p.accent}${"─".repeat(width)}${p.reset}`;
|
|
170
|
+
});
|
|
171
|
+
define("tui:response-start", (): void => {});
|
|
172
|
+
define("tui:response-end", (_hadToolCalls: boolean): void => {});
|
|
173
|
+
|
|
174
|
+
define("tui:render-info", (message: string): string =>
|
|
175
|
+
`${p.muted}${message}${p.reset}`);
|
|
176
|
+
|
|
177
|
+
define("tui:render-error", (message: string): string =>
|
|
178
|
+
`${p.error}Error: ${message}${p.reset}`);
|
|
179
|
+
|
|
180
|
+
define("tui:render-usage", (promptTokens: number, completionTokens: number, maxTokens: number): string => {
|
|
181
|
+
const ctxK = (promptTokens / 1000).toFixed(1);
|
|
182
|
+
const maxK = (maxTokens / 1000).toFixed(0);
|
|
183
|
+
const pct = Math.min(100, (promptTokens / maxTokens) * 100).toFixed(0);
|
|
184
|
+
return `${p.dim}⬆ ${promptTokens} ⬇ ${completionTokens} ctx: ${ctxK}k/${maxK}k (${pct}%)${p.reset}`;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
define("tui:render-content-gap", (fromKind: string, toKind: string): string | null =>
|
|
188
|
+
fromKind !== toKind ? "\n" : null);
|
|
189
|
+
|
|
190
|
+
define("tui:render-tool-complete", (exitCode: number | null, elapsed: string, summary: string | undefined): string => {
|
|
191
|
+
const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
|
|
192
|
+
const summaryStr = summary ? ` ${p.dim}${summary}${p.reset}` : "";
|
|
193
|
+
if (exitCode === null) return `${p.muted}(timed out)${p.reset}`;
|
|
194
|
+
if (exitCode === 0) return `${p.success}✓${p.reset}${summaryStr}${timer}`;
|
|
195
|
+
return `${p.error}✗ exit ${exitCode}${p.reset}${summaryStr}${timer}`;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
define("tui:render-tool-group-summary", (count: number, rendered: number, allOk: boolean, summaries: string[]): string => {
|
|
199
|
+
const mark = allOk ? `${p.success}✓${p.reset}` : `${p.error}✗${p.reset}`;
|
|
200
|
+
const summaryStr = summaries.length > 0 ? ` ${p.dim}${summaries.join(", ")}${p.reset}` : "";
|
|
201
|
+
const collapsed = count - rendered;
|
|
202
|
+
if (collapsed > 0) {
|
|
203
|
+
return ` ${p.muted}└${p.reset} ${p.dim}+${collapsed} more${p.reset} ${mark}${summaryStr}`;
|
|
204
|
+
}
|
|
205
|
+
return ` ${p.muted}└${p.reset} ${mark}${summaryStr}`;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
define("tui:render-command-output", (line: string, _kind: string | undefined): string =>
|
|
209
|
+
`${p.dim} ${line}${p.reset}`);
|
|
210
|
+
|
|
211
|
+
define("tui:render-spinner", (label: string, frame: string, elapsed: string, hint: string | undefined): string => {
|
|
212
|
+
const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
|
|
213
|
+
const hintStr = hint ? ` ${p.dim}${hint}${p.reset}` : "";
|
|
214
|
+
return `${p.accent}${frame} ${label}...${p.reset}${timer}${hintStr}`;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
define("tui:render-user-query", (query: string, width: number, modelLabel: string | undefined): string[] => {
|
|
218
|
+
const contentW = width - 4;
|
|
219
|
+
let lines: string[] = [];
|
|
220
|
+
for (const raw of query.split("\n")) {
|
|
221
|
+
for (const wrapped of wrapLine(`${p.accent}${raw}${p.reset}`, contentW)) {
|
|
222
|
+
lines.push(wrapped);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const MAX_QUERY_LINES = 20;
|
|
226
|
+
if (lines.length > MAX_QUERY_LINES) {
|
|
227
|
+
const overflow = lines.length - MAX_QUERY_LINES;
|
|
228
|
+
lines = [...lines.slice(0, MAX_QUERY_LINES), `${p.dim}… ${overflow} more lines${p.reset}`];
|
|
229
|
+
}
|
|
230
|
+
return renderBoxFrame(lines, {
|
|
231
|
+
width,
|
|
232
|
+
style: "rounded",
|
|
233
|
+
borderColor: p.accent,
|
|
234
|
+
title: `${p.accent}${p.bold}❯${p.reset}`,
|
|
235
|
+
titleRight: modelLabel,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
let backendInfo: { name: string; model?: string; provider?: string; contextWindow?: number } | null = null;
|
|
240
|
+
bus.on("agent:info", (info) => { backendInfo = info; });
|
|
241
|
+
|
|
242
|
+
// ── Register fenced block transform (code blocks → ContentBlock) ──
|
|
243
|
+
// Nobody is special — tui-renderer uses the same primitive as any extension.
|
|
244
|
+
const fencedTransform = createFencedBlockTransform(bus, {
|
|
245
|
+
open: /^```(\w*)\s*$/,
|
|
246
|
+
close: /^```\s*$/,
|
|
247
|
+
transform(match, content) {
|
|
248
|
+
return { type: "code-block", language: match[1] || "", code: content };
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── Event subscriptions ─────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
bus.on("agent:query", (e) => {
|
|
255
|
+
if (!shouldRender()) return;
|
|
256
|
+
s.spinnerStartTime = 0;
|
|
257
|
+
showUserQuery(e.query);
|
|
258
|
+
startAgentResponse();
|
|
259
|
+
startThinkingSpinner();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
bus.on("agent:thinking-chunk", (e) => {
|
|
263
|
+
if (!shouldRender()) return;
|
|
264
|
+
s.thinkingPending = true;
|
|
265
|
+
if (!s.isThinking) {
|
|
266
|
+
s.isThinking = true;
|
|
267
|
+
if (!s.showThinkingText) startThinkingSpinner();
|
|
268
|
+
}
|
|
269
|
+
if (s.showThinkingText) {
|
|
270
|
+
stopCurrentSpinner();
|
|
271
|
+
if (!s.renderer) startAgentResponse();
|
|
272
|
+
if (e.text) {
|
|
273
|
+
s.thinkingPending = false;
|
|
274
|
+
// Wrap each sub-line so dim survives \n boundaries in the renderer.
|
|
275
|
+
const wrapped = `${p.dim}${e.text.replace(/\n/g, `${p.reset}\n${p.dim}`)}${p.reset}`;
|
|
276
|
+
s.renderer!.push(wrapped);
|
|
277
|
+
drain();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
bus.on("agent:response-chunk", (e) => {
|
|
283
|
+
if (!shouldRender()) return;
|
|
284
|
+
const { blocks } = e;
|
|
285
|
+
// Inject spacing: append \n to text blocks that precede non-text blocks
|
|
286
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
287
|
+
const block = blocks[i]!;
|
|
288
|
+
const next = blocks[i + 1];
|
|
289
|
+
if (block.type === "text" && next && next.type !== "text") {
|
|
290
|
+
block.text += "\n";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const block of blocks) {
|
|
294
|
+
switch (block.type) {
|
|
295
|
+
case "text":
|
|
296
|
+
if (block.text) writeAgentText(block.text);
|
|
297
|
+
break;
|
|
298
|
+
case "code-block":
|
|
299
|
+
writeCodeBlock(block.language, block.code);
|
|
300
|
+
break;
|
|
301
|
+
case "image":
|
|
302
|
+
writeInlineImage(block.data);
|
|
303
|
+
break;
|
|
304
|
+
case "raw":
|
|
305
|
+
flushForRaw();
|
|
306
|
+
out().write(block.escape);
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
let pendingUsage: { prompt_tokens: number; completion_tokens: number } | null = null;
|
|
312
|
+
bus.on("agent:usage", (e) => { pendingUsage = e; });
|
|
313
|
+
|
|
314
|
+
bus.on("agent:response-done", () => {
|
|
315
|
+
if (!shouldRender()) return;
|
|
316
|
+
s.isThinking = false;
|
|
317
|
+
if (pendingUsage && s.renderer) {
|
|
318
|
+
// Flush any buffered partial line first — otherwise responses that
|
|
319
|
+
// don't end with a newline emit the usage line before their final text.
|
|
320
|
+
s.renderer.flush();
|
|
321
|
+
const { prompt_tokens, completion_tokens } = pendingUsage;
|
|
322
|
+
const maxTokens = backendInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
323
|
+
s.renderer.writeLine("");
|
|
324
|
+
s.renderer.writeLine(ctx.call("tui:render-usage", prompt_tokens, completion_tokens, maxTokens));
|
|
325
|
+
drain();
|
|
326
|
+
pendingUsage = null;
|
|
327
|
+
}
|
|
328
|
+
endAgentResponse();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── Tool batch grouping ──────────────────────────────────────────
|
|
332
|
+
const GROUPABLE_KINDS = new Set(["read", "search"]);
|
|
333
|
+
const GROUP_MAX_VISIBLE = 5;
|
|
334
|
+
const KIND_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
|
|
335
|
+
|
|
336
|
+
// Batch groups: kind → { total, rendered, headerShown }
|
|
337
|
+
let batchGroups = new Map<string, { total: number; rendered: number; headerShown: boolean }>();
|
|
338
|
+
|
|
339
|
+
bus.on("agent:tool-batch", (e) => {
|
|
340
|
+
if (!shouldRender()) return;
|
|
341
|
+
fencedTransform.flush();
|
|
342
|
+
finalizeToolGroup();
|
|
343
|
+
s.orphanContHeaderKind = undefined;
|
|
344
|
+
batchGroups = new Map();
|
|
345
|
+
for (const group of e.groups) {
|
|
346
|
+
batchGroups.set(group.kind, {
|
|
347
|
+
total: group.tools.length,
|
|
348
|
+
rendered: 0,
|
|
349
|
+
headerShown: false,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
bus.on("agent:tool-started", (e) => {
|
|
355
|
+
if (!shouldRender()) return;
|
|
356
|
+
fencedTransform.flush();
|
|
357
|
+
stopCurrentSpinner();
|
|
358
|
+
s.currentToolKind = e.kind;
|
|
359
|
+
s.toolStartTime = Date.now();
|
|
360
|
+
s.orphanContHeaderKind = undefined;
|
|
361
|
+
|
|
362
|
+
if (e.title === "user_shell") {
|
|
363
|
+
finalizeToolGroup();
|
|
364
|
+
closeToolLine();
|
|
365
|
+
if (!s.renderer) startAgentResponse();
|
|
366
|
+
contentGap("tool");
|
|
367
|
+
s.renderer!.flush();
|
|
368
|
+
const cmd = (e.rawInput as any)?.command || "";
|
|
369
|
+
s.renderer!.writeLine(`${p.dim}▶ user_shell: ${cmd}${p.reset}`);
|
|
370
|
+
drain();
|
|
371
|
+
s.hadToolCalls = true;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const kind = e.kind ?? "execute";
|
|
376
|
+
const group = batchGroups.get(kind);
|
|
377
|
+
const isGrouped = group && group.total > 1 && GROUPABLE_KINDS.has(kind);
|
|
378
|
+
|
|
379
|
+
if (isGrouped) {
|
|
380
|
+
// Render group header on first tool of this kind in the batch
|
|
381
|
+
if (!group.headerShown) {
|
|
382
|
+
finalizeToolGroup();
|
|
383
|
+
closeToolLine();
|
|
384
|
+
if (!s.renderer) startAgentResponse();
|
|
385
|
+
showCollapsedThinking();
|
|
386
|
+
contentGap("tool");
|
|
387
|
+
s.renderer!.flush();
|
|
388
|
+
drain();
|
|
389
|
+
|
|
390
|
+
const icon = KIND_ICONS[kind] ?? "▶";
|
|
391
|
+
s.renderer!.writeLine(`${p.warning}${icon}${p.reset} ${kind}`);
|
|
392
|
+
drain();
|
|
393
|
+
|
|
394
|
+
group.headerShown = true;
|
|
395
|
+
s.toolGroupKind = kind;
|
|
396
|
+
s.toolGroupCount = 0;
|
|
397
|
+
s.toolGroupCompletedCount = 0;
|
|
398
|
+
s.toolGroupRendered = 0;
|
|
399
|
+
s.toolGroupAllOk = true;
|
|
400
|
+
s.toolGroupSummaries = [];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
s.toolGroupCount++;
|
|
404
|
+
|
|
405
|
+
if (s.toolGroupRendered < GROUP_MAX_VISIBLE) {
|
|
406
|
+
showToolCall(e.title, "", { ...e, groupContinuation: true });
|
|
407
|
+
s.toolGroupRendered++;
|
|
408
|
+
}
|
|
409
|
+
if (e.toolCallId) {
|
|
410
|
+
s.pendingToolCompletes.set(e.toolCallId, {
|
|
411
|
+
title: e.title,
|
|
412
|
+
kind,
|
|
413
|
+
displayDetail: e.displayDetail ?? extractDetail(e),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
// Standalone tool — single in its batch kind, or not groupable
|
|
418
|
+
finalizeToolGroup();
|
|
419
|
+
showToolCall(e.title, "", { ...e });
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
bus.on("agent:tool-completed", (e) => {
|
|
424
|
+
if (!shouldRender()) return;
|
|
425
|
+
s.toolExitCode = e.exitCode;
|
|
426
|
+
if (e.exitCode !== 0) s.toolGroupAllOk = false;
|
|
427
|
+
|
|
428
|
+
const resultDisplay = e.resultDisplay;
|
|
429
|
+
|
|
430
|
+
if (s.toolGroupKind) {
|
|
431
|
+
// Grouped tool — track success/failure and summaries, show aggregate on ⎿ line.
|
|
432
|
+
// Don't restart spinner between grouped tools — it's already running from group start.
|
|
433
|
+
if (resultDisplay?.summary) s.toolGroupSummaries.push(resultDisplay.summary);
|
|
434
|
+
if (e.toolCallId) s.pendingToolCompletes.delete(e.toolCallId);
|
|
435
|
+
s.toolGroupCompletedCount++;
|
|
436
|
+
s.currentToolKind = undefined;
|
|
437
|
+
// Finalize as soon as all members return so aggregate lands right
|
|
438
|
+
// after its children, not below out-of-band renders from the next tool.
|
|
439
|
+
const batchGroup = batchGroups.get(s.toolGroupKind);
|
|
440
|
+
if (batchGroup && s.toolGroupCompletedCount >= batchGroup.total) {
|
|
441
|
+
finalizeToolGroup();
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
// Tools that lost the inline slot render as a labeled ⎿. Orphans
|
|
445
|
+
// (group finalized before they returned) reroute via showOrphanedComplete.
|
|
446
|
+
const pending = e.toolCallId ? s.pendingToolCompletes.get(e.toolCallId) : undefined;
|
|
447
|
+
if (pending) s.pendingToolCompletes.delete(e.toolCallId!);
|
|
448
|
+
if (pending?.orphaned) {
|
|
449
|
+
showOrphanedComplete(e.exitCode, resultDisplay, pending.title, pending.kind, pending.displayDetail);
|
|
450
|
+
} else {
|
|
451
|
+
showToolComplete(e.exitCode, resultDisplay, pending?.displayDetail ?? pending?.title);
|
|
452
|
+
}
|
|
453
|
+
s.currentToolKind = undefined;
|
|
454
|
+
s.spinnerStartTime = 0;
|
|
455
|
+
startThinkingSpinner();
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
bus.on("agent:tool-output-chunk", (e) => { if (shouldRender()) writeCommandOutput(e.chunk); });
|
|
459
|
+
bus.on("agent:tool-output", () => { if (shouldRender()) flushCommandOutput(); });
|
|
460
|
+
|
|
461
|
+
bus.on("agent:cancelled", () => {
|
|
462
|
+
if (!shouldRender()) return;
|
|
463
|
+
s.isThinking = false;
|
|
464
|
+
stopCurrentSpinner();
|
|
465
|
+
showInfo("(cancelled)");
|
|
466
|
+
endAgentResponse();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
bus.on("agent:processing-done", () => {
|
|
470
|
+
if (!shouldRender()) return;
|
|
471
|
+
s.isThinking = false;
|
|
472
|
+
stopCurrentSpinner();
|
|
473
|
+
endAgentResponse();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
bus.on("agent:error", (e) => {
|
|
477
|
+
if (!shouldRender()) return;
|
|
478
|
+
stopCurrentSpinner();
|
|
479
|
+
showCollapsedThinking();
|
|
480
|
+
if (!s.renderer) startAgentResponse();
|
|
481
|
+
contentGap("info");
|
|
482
|
+
s.renderer!.writeLine(`${p.error}Error: ${e.message}${p.reset}`);
|
|
483
|
+
s.renderer!.writeLine("");
|
|
484
|
+
drain();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
bus.on("input:keypress", (e) => {
|
|
488
|
+
if (e.key === "\x14") toggleThinkingDisplay(); // Ctrl+T
|
|
489
|
+
});
|
|
490
|
+
// Interactive tool UI — stop spinner while tool has control
|
|
491
|
+
bus.on("tool:interactive-start", () => { stopCurrentSpinner(); });
|
|
492
|
+
|
|
493
|
+
bus.on("ui:info", (e) => {
|
|
494
|
+
stopCurrentSpinner();
|
|
495
|
+
showInfo(e.message);
|
|
496
|
+
bus.emit("input:redraw", {});
|
|
497
|
+
if (s.renderer) startThinkingSpinner();
|
|
498
|
+
});
|
|
499
|
+
bus.on("ui:error", (e) => {
|
|
500
|
+
showError(e.message);
|
|
501
|
+
bus.emit("input:redraw", {});
|
|
502
|
+
});
|
|
503
|
+
bus.on("ui:suggestion", (e) => {
|
|
504
|
+
compositor.surface("status").writeLine(`${p.dim}💡 ${e.text}${p.reset}`);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// ── Rendering functions ─────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
function drain(): void {
|
|
510
|
+
if (!s.renderer) return;
|
|
511
|
+
for (const line of s.renderer.drainLines()) {
|
|
512
|
+
out().write(line + "\n");
|
|
513
|
+
// Track whether we just emitted a blank line (for contentGap dedup).
|
|
514
|
+
// Lines from the renderer are indented (" "), so a blank line is " " or empty.
|
|
515
|
+
lastEmittedLineBlank = line.trimEnd() === "" || line.trimEnd().replace(/\x1b\[[^m]*m/g, "").trim() === "";
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function startAgentResponse(): void {
|
|
520
|
+
s.renderer = new MarkdownRenderer(cappedW());
|
|
521
|
+
s.hadToolCalls = false;
|
|
522
|
+
const border: string | null = ctx.call("tui:response-border", "top", cappedW());
|
|
523
|
+
if (border) s.renderer.writeLine(border);
|
|
524
|
+
drain();
|
|
525
|
+
ctx.call("tui:response-start");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Insert an empty line when transitioning between different content kinds
|
|
530
|
+
* (e.g., text → tool, tool → text, diff → tool) for visual breathing room.
|
|
531
|
+
* Avoids double-blanks by checking if the last emitted line was already empty.
|
|
532
|
+
*/
|
|
533
|
+
let lastEmittedLineBlank = false;
|
|
534
|
+
|
|
535
|
+
function contentGap(kind: "text" | "tool" | "diff" | "code" | "info"): void {
|
|
536
|
+
if (s.lastContentKind) {
|
|
537
|
+
const gap: string | null = ctx.call("tui:render-content-gap", s.lastContentKind, kind);
|
|
538
|
+
if (gap) {
|
|
539
|
+
if (s.renderer) { s.renderer.flush(); drain(); }
|
|
540
|
+
out().write(gap);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
s.lastContentKind = kind;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function showCollapsedThinking(): void {
|
|
547
|
+
if (s.thinkingPending && !s.showThinkingText) {
|
|
548
|
+
// Just clear the pending flag — the spinner already indicates thinking.
|
|
549
|
+
// No need for a separate "… thinking" label that clutters the output.
|
|
550
|
+
s.thinkingPending = false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function endAgentResponse(): void {
|
|
555
|
+
finalizeToolGroup();
|
|
556
|
+
closeToolLine();
|
|
557
|
+
stopCurrentSpinner();
|
|
558
|
+
if (s.renderer) {
|
|
559
|
+
ctx.call("tui:response-end", s.hadToolCalls);
|
|
560
|
+
s.renderer.flush();
|
|
561
|
+
const border: string | null = ctx.call("tui:response-border", "bottom", cappedW());
|
|
562
|
+
if (border) s.renderer.writeLine(border);
|
|
563
|
+
drain();
|
|
564
|
+
out().write("\n");
|
|
565
|
+
s.renderer = null;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function showUserQuery(query: string): void {
|
|
570
|
+
const model = backendInfo?.model;
|
|
571
|
+
const backend = backendInfo?.name;
|
|
572
|
+
let modelLabel: string | undefined;
|
|
573
|
+
if (backend && model) {
|
|
574
|
+
modelLabel = `${p.dim}${backend}/${p.reset}${p.bold}${model}${p.reset}`;
|
|
575
|
+
} else if (model) {
|
|
576
|
+
modelLabel = `${p.bold}${model}${p.reset}`;
|
|
577
|
+
} else if (backend) {
|
|
578
|
+
modelLabel = `${p.bold}${backend}${p.reset}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const querySurface = compositor.surface("query");
|
|
582
|
+
const framed: string[] = ctx.call("tui:render-user-query", query, querySurface.columns, modelLabel);
|
|
583
|
+
if (framed.length > 0) {
|
|
584
|
+
querySurface.write("\n");
|
|
585
|
+
for (const line of framed) {
|
|
586
|
+
querySurface.writeLine(line);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function writeAgentText(text: string): void {
|
|
592
|
+
finalizeToolGroup();
|
|
593
|
+
closeToolLine();
|
|
594
|
+
s.hadToolCalls = false;
|
|
595
|
+
if (s.isThinking) {
|
|
596
|
+
s.isThinking = false;
|
|
597
|
+
if (s.showThinkingText && s.renderer) {
|
|
598
|
+
s.renderer.flush();
|
|
599
|
+
const w = Math.min(80, out().columns);
|
|
600
|
+
s.renderer.writeLine(`${p.dim}${"─".repeat(w)}${p.reset}`);
|
|
601
|
+
drain();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
showCollapsedThinking();
|
|
605
|
+
stopCurrentSpinner();
|
|
606
|
+
if (!s.renderer) startAgentResponse();
|
|
607
|
+
contentGap("text");
|
|
608
|
+
s.renderer!.push(text);
|
|
609
|
+
drain();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
define("render:code-block", (language: string, code: string, width: number) => {
|
|
613
|
+
flushForRaw();
|
|
614
|
+
contentGap("code");
|
|
615
|
+
if (language) {
|
|
616
|
+
s.renderer!.writeLine(`${p.dim}${language}${p.reset}`);
|
|
617
|
+
}
|
|
618
|
+
let highlighted: string;
|
|
619
|
+
try {
|
|
620
|
+
// highlight.js warns to console.error for unsupported languages (elisp, org, etc).
|
|
621
|
+
// Suppress so it doesn't leak into the terminal.
|
|
622
|
+
const origError = console.error;
|
|
623
|
+
console.error = (...args: unknown[]) => {
|
|
624
|
+
const msg = args.join(" ");
|
|
625
|
+
if (msg.includes("Could not find the language")) return;
|
|
626
|
+
origError.apply(console, args);
|
|
627
|
+
};
|
|
628
|
+
try {
|
|
629
|
+
highlighted = language
|
|
630
|
+
? highlight(code, { language })
|
|
631
|
+
: highlight(code); // auto-detect
|
|
632
|
+
} finally {
|
|
633
|
+
console.error = origError;
|
|
634
|
+
}
|
|
635
|
+
} catch {
|
|
636
|
+
highlighted = code;
|
|
637
|
+
}
|
|
638
|
+
const contentWidth = Math.min(90, width - 2);
|
|
639
|
+
for (const line of highlighted.split("\n")) {
|
|
640
|
+
const indented = ` ${line}`;
|
|
641
|
+
const wrapped = wrapLine(indented, contentWidth);
|
|
642
|
+
for (const wl of wrapped) {
|
|
643
|
+
s.renderer!.writeLine(wl);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
drain();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
function writeCodeBlock(language: string, code: string): void {
|
|
650
|
+
ctx.call("render:code-block", language, code, cappedW());
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function flushForRaw(): void {
|
|
654
|
+
closeToolLine();
|
|
655
|
+
stopCurrentSpinner();
|
|
656
|
+
if (!s.renderer) startAgentResponse();
|
|
657
|
+
s.renderer!.flush();
|
|
658
|
+
drain();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
define("render:image", (data: Buffer) => {
|
|
662
|
+
flushForRaw();
|
|
663
|
+
const escape = encodeImageForTerminal(data);
|
|
664
|
+
if (escape) {
|
|
665
|
+
out().write(" " + escape + "\n");
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
function writeInlineImage(data: Buffer): void {
|
|
670
|
+
ctx.call("render:image", data);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Default renderer for tool result bodies. Extensions can advise this handler
|
|
675
|
+
* to override rendering for specific body kinds or add new ones:
|
|
676
|
+
*
|
|
677
|
+
* ctx.advise("render:result-body", (next, body, width) => {
|
|
678
|
+
* if (body.kind === "diff") return myCustomDiffRenderer(body, width);
|
|
679
|
+
* return next(body, width);
|
|
680
|
+
* });
|
|
681
|
+
*/
|
|
682
|
+
define("render:result-body", (body: ToolResultBody, width: number): string[] => {
|
|
683
|
+
if (body.kind === "diff") {
|
|
684
|
+
return renderDiffBody(body.diff as DiffResult, body.filePath, width);
|
|
685
|
+
}
|
|
686
|
+
if (body.kind === "lines") {
|
|
687
|
+
return renderLinesBody(body.lines, width, body.maxLines);
|
|
688
|
+
}
|
|
689
|
+
return [];
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Default renderer for standalone diffs (e.g. permission prompts).
|
|
694
|
+
* Extensions can advise this to customize diff rendering:
|
|
695
|
+
*
|
|
696
|
+
* ctx.advise("tui:render-diff", (next, filePath, diff, width) => {
|
|
697
|
+
* return myCustomDiffBox(filePath, diff, width);
|
|
698
|
+
* });
|
|
699
|
+
*/
|
|
700
|
+
define("tui:render-diff", (filePath: string, diff: DiffResult, width: number): string[] => {
|
|
701
|
+
return renderDiffBody(diff, filePath, width);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
/** Display a diff inline above the agent response (e.g. for pre-execute
|
|
705
|
+
* preview from a gating advisor). Formats via `tui:render-diff` so
|
|
706
|
+
* advisors can theme it. */
|
|
707
|
+
define("tui:show-diff", (filePath: string, diff: DiffResult): void => {
|
|
708
|
+
if (!shouldRender()) return;
|
|
709
|
+
stopCurrentSpinner();
|
|
710
|
+
showCollapsedThinking();
|
|
711
|
+
const lines = ctx.call("tui:render-diff", filePath, diff, cappedW()) as string[];
|
|
712
|
+
if (lines.length === 0) return;
|
|
713
|
+
if (!s.renderer) startAgentResponse();
|
|
714
|
+
contentGap("diff");
|
|
715
|
+
for (const line of lines) s.renderer!.writeLine(line);
|
|
716
|
+
drain();
|
|
717
|
+
s.lastContentKind = "tool";
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
/** Render a diff as framed box lines (pure — no TUI state side effects). */
|
|
721
|
+
function renderDiffBody(diff: DiffResult, filePath: string, width: number): string[] {
|
|
722
|
+
if (diff.isIdentical) return [];
|
|
723
|
+
const boxW = Math.min(120, width - 2);
|
|
724
|
+
const contentW = boxW - 4;
|
|
725
|
+
|
|
726
|
+
const maxLines = diff.isNewFile
|
|
727
|
+
? getSettings().newFilePreviewLines
|
|
728
|
+
: getSettings().diffMaxLines;
|
|
729
|
+
const diffLines = renderDiff(diff, {
|
|
730
|
+
width: contentW,
|
|
731
|
+
filePath,
|
|
732
|
+
maxLines,
|
|
733
|
+
trueColor: true,
|
|
734
|
+
});
|
|
735
|
+
const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
|
|
736
|
+
|
|
737
|
+
return renderBoxFrame(body, {
|
|
738
|
+
width: boxW,
|
|
739
|
+
style: "rounded",
|
|
740
|
+
borderColor: p.dim,
|
|
741
|
+
title: diffTitle(filePath, diff),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Render output lines with truncation. */
|
|
746
|
+
function renderLinesBody(lines: string[], width: number, maxLines?: number): string[] {
|
|
747
|
+
const max = maxLines ?? 10;
|
|
748
|
+
const shown = lines.slice(0, max);
|
|
749
|
+
const contentW = Math.max(1, width - 6);
|
|
750
|
+
const output: string[] = [];
|
|
751
|
+
for (const line of shown) {
|
|
752
|
+
const text = line.length > contentW ? line.slice(0, contentW - 1) + "…" : line;
|
|
753
|
+
output.push(` ${p.dim} ${text}${p.reset}`);
|
|
754
|
+
}
|
|
755
|
+
if (lines.length > max) {
|
|
756
|
+
output.push(` ${p.dim} … ${lines.length - max} more lines${p.reset}`);
|
|
757
|
+
}
|
|
758
|
+
return output;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Extract a detail string from tool args for group continuation display. */
|
|
762
|
+
function extractDetail(extra: { rawInput?: unknown; locations?: { path: string; line?: number | null }[] }): string {
|
|
763
|
+
if (extra.locations && extra.locations.length > 0) {
|
|
764
|
+
const loc = extra.locations[0]!;
|
|
765
|
+
const home = process.env.HOME;
|
|
766
|
+
let fp = loc.path;
|
|
767
|
+
if (fp.startsWith(shellCwd + "/")) fp = fp.slice(shellCwd.length + 1);
|
|
768
|
+
else if (home && fp.startsWith(home + "/")) fp = "~/" + fp.slice(home.length + 1);
|
|
769
|
+
return loc.line ? `${fp}:${loc.line}` : fp;
|
|
770
|
+
}
|
|
771
|
+
const raw = extra.rawInput as Record<string, unknown> | undefined;
|
|
772
|
+
if (!raw) return "";
|
|
773
|
+
if (typeof raw.command === "string") return `$ ${raw.command}`;
|
|
774
|
+
if (typeof raw.source === "string") {
|
|
775
|
+
const firstLine = raw.source.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
776
|
+
return firstLine.length > 80 ? firstLine.slice(0, 77) + "…" : firstLine;
|
|
777
|
+
}
|
|
778
|
+
if (typeof raw.pattern === "string") return raw.pattern;
|
|
779
|
+
if (typeof raw.path === "string") {
|
|
780
|
+
const home = process.env.HOME;
|
|
781
|
+
let fp = raw.path as string;
|
|
782
|
+
if (fp.startsWith(shellCwd + "/")) fp = fp.slice(shellCwd.length + 1);
|
|
783
|
+
else if (home && fp.startsWith(home + "/")) fp = "~/" + fp.slice(home.length + 1);
|
|
784
|
+
return fp;
|
|
785
|
+
}
|
|
786
|
+
if (typeof raw.query === "string") return `"${raw.query}"`;
|
|
787
|
+
return "";
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function showToolCall(
|
|
791
|
+
title: string,
|
|
792
|
+
command?: string,
|
|
793
|
+
extra?: {
|
|
794
|
+
toolCallId?: string;
|
|
795
|
+
kind?: string;
|
|
796
|
+
icon?: string;
|
|
797
|
+
locations?: { path: string; line?: number | null }[];
|
|
798
|
+
rawInput?: unknown;
|
|
799
|
+
displayDetail?: string;
|
|
800
|
+
batchIndex?: number;
|
|
801
|
+
batchTotal?: number;
|
|
802
|
+
groupContinuation?: boolean;
|
|
803
|
+
},
|
|
804
|
+
): void {
|
|
805
|
+
closeToolLine();
|
|
806
|
+
stopCurrentSpinner();
|
|
807
|
+
if (!s.renderer) startAgentResponse();
|
|
808
|
+
showCollapsedThinking();
|
|
809
|
+
// No gap between grouped tools — they're visually connected
|
|
810
|
+
if (!extra?.groupContinuation) contentGap("tool");
|
|
811
|
+
s.renderer!.flush();
|
|
812
|
+
drain();
|
|
813
|
+
const lines = renderToolCall({
|
|
814
|
+
title,
|
|
815
|
+
command: command || undefined,
|
|
816
|
+
kind: extra?.kind,
|
|
817
|
+
icon: extra?.icon,
|
|
818
|
+
locations: extra?.locations,
|
|
819
|
+
rawInput: extra?.rawInput,
|
|
820
|
+
displayDetail: extra?.displayDetail,
|
|
821
|
+
}, cappedW(), shellCwd);
|
|
822
|
+
|
|
823
|
+
if (extra?.groupContinuation && lines.length > 0) {
|
|
824
|
+
// Swap the colored kind icon for a muted tree connector,
|
|
825
|
+
// and strip the tool name prefix — show detail only.
|
|
826
|
+
const detail = extra.displayDetail || extractDetail(extra);
|
|
827
|
+
const maxW = Math.max(1, cappedW() - 6);
|
|
828
|
+
const text = detail.length > maxW ? detail.slice(0, maxW - 1) + "…" : detail;
|
|
829
|
+
lines[0] = detail
|
|
830
|
+
? `${p.muted}├${p.reset} ${p.dim}${text}${p.reset}`
|
|
831
|
+
: lines[0]!.replace(/^\x1b\[[^m]*m.\x1b\[0m/, `${p.muted}├${p.reset}`);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const batchPrefix = "";
|
|
835
|
+
|
|
836
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
837
|
+
s.renderer!.writeLine(lines[i]!);
|
|
838
|
+
}
|
|
839
|
+
drain();
|
|
840
|
+
if (lines.length > 0) {
|
|
841
|
+
if (extra?.groupContinuation) {
|
|
842
|
+
// Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
|
|
843
|
+
s.renderer!.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
844
|
+
drain();
|
|
845
|
+
} else {
|
|
846
|
+
out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
847
|
+
if (extra?.toolCallId) {
|
|
848
|
+
s.openTool = {
|
|
849
|
+
callId: extra.toolCallId,
|
|
850
|
+
title,
|
|
851
|
+
kind: extra.kind,
|
|
852
|
+
displayDetail: extra.displayDetail ?? extractDetail(extra),
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
s.hadToolCalls = true;
|
|
858
|
+
s.commandOutputLineCount = 0;
|
|
859
|
+
s.commandOutputOverflow = 0;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function showToolComplete(
|
|
863
|
+
exitCode: number | null,
|
|
864
|
+
resultDisplay?: ToolResultDisplay,
|
|
865
|
+
labelTitle?: string,
|
|
866
|
+
): void {
|
|
867
|
+
if (!s.renderer) return;
|
|
868
|
+
stopCurrentSpinner();
|
|
869
|
+
const elapsed = s.toolStartTime ? formatElapsed(Date.now() - s.toolStartTime) : "";
|
|
870
|
+
const mark: string = ctx.call("tui:render-tool-complete", exitCode, elapsed, resultDisplay?.summary);
|
|
871
|
+
|
|
872
|
+
if (!labelTitle && s.openTool && s.commandOutputLineCount === 0) {
|
|
873
|
+
out().write(` ${mark}\n`);
|
|
874
|
+
s.openTool = null;
|
|
875
|
+
} else {
|
|
876
|
+
closeToolLine();
|
|
877
|
+
flushCommandOutput();
|
|
878
|
+
s.renderer.writeLine(labelTitle
|
|
879
|
+
? ` ${p.muted}⎿${p.reset} ${p.dim}${labelTitle}${p.reset} ${mark}`
|
|
880
|
+
: ` ${mark}`);
|
|
881
|
+
drain();
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (resultDisplay?.body) renderResultBody(resultDisplay.body);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Late completion from a finalized group — re-emit the kind header
|
|
888
|
+
* in muted "(cont.)" form so the ⎿ has a legitimate parent, then
|
|
889
|
+
* render the completion as a normal labeled ⎿. Subsequent orphans
|
|
890
|
+
* of the same kind reuse the existing (cont.) header. */
|
|
891
|
+
function showOrphanedComplete(
|
|
892
|
+
exitCode: number | null,
|
|
893
|
+
resultDisplay: ToolResultDisplay | undefined,
|
|
894
|
+
title: string,
|
|
895
|
+
kind: string | undefined,
|
|
896
|
+
displayDetail: string | undefined,
|
|
897
|
+
): void {
|
|
898
|
+
if (s.orphanContHeaderKind !== kind) {
|
|
899
|
+
stopCurrentSpinner();
|
|
900
|
+
closeToolLine();
|
|
901
|
+
flushCommandOutput();
|
|
902
|
+
if (!s.renderer) startAgentResponse();
|
|
903
|
+
showCollapsedThinking();
|
|
904
|
+
const icon = (kind && KIND_ICONS[kind]) ?? "▶";
|
|
905
|
+
const label = kind ?? "tool";
|
|
906
|
+
s.renderer!.writeLine(`${p.muted}${icon} ${label} (cont.)${p.reset}`);
|
|
907
|
+
drain();
|
|
908
|
+
s.orphanContHeaderKind = kind;
|
|
909
|
+
}
|
|
910
|
+
showToolComplete(exitCode, resultDisplay, displayDetail || title);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function renderResultBody(body: ToolResultBody): void {
|
|
914
|
+
if (!s.renderer) return;
|
|
915
|
+
const lines: string[] = ctx.call("render:result-body", body, cappedW()) ?? [];
|
|
916
|
+
for (const line of lines) {
|
|
917
|
+
s.renderer!.writeLine(line);
|
|
918
|
+
}
|
|
919
|
+
if (lines.length > 0) drain();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Thinking is always assumed available — the TUI renders thinking
|
|
923
|
+
// tokens whenever they arrive, regardless of backend.
|
|
924
|
+
function hasThinkingMode(): boolean {
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function startThinkingSpinner(): void {
|
|
929
|
+
if (!s.spinnerStartTime) s.spinnerStartTime = Date.now();
|
|
930
|
+
stopCurrentSpinner();
|
|
931
|
+
const thinking = hasThinkingMode();
|
|
932
|
+
s.spinnerLabel = thinking ? "Thinking" : "Working";
|
|
933
|
+
s.spinnerOpts = { startTime: s.spinnerStartTime };
|
|
934
|
+
s.spinner = createSpinner({ startTime: s.spinnerStartTime });
|
|
935
|
+
s.spinnerInterval = setInterval(() => {
|
|
936
|
+
if (s.spinner) {
|
|
937
|
+
const frame = SPINNER_FRAMES[s.spinner.frame % SPINNER_FRAMES.length]!;
|
|
938
|
+
s.spinner.frame++;
|
|
939
|
+
const elapsed = formatElapsed(Date.now() - s.spinner.startTime);
|
|
940
|
+
const line: string = ctx.call("tui:render-spinner", s.spinnerLabel, frame, elapsed, s.spinnerOpts.hint);
|
|
941
|
+
out().write(`\r ${line}\x1b[K`);
|
|
942
|
+
}
|
|
943
|
+
}, 80);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function stopCurrentSpinner(): void {
|
|
947
|
+
if (s.spinnerInterval) {
|
|
948
|
+
clearInterval(s.spinnerInterval);
|
|
949
|
+
s.spinnerInterval = null;
|
|
950
|
+
}
|
|
951
|
+
if (s.spinner) {
|
|
952
|
+
out().write("\r\x1b[2K");
|
|
953
|
+
s.spinner = null;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function closeToolLine(): void {
|
|
958
|
+
if (s.openTool) {
|
|
959
|
+
out().write("\n");
|
|
960
|
+
// Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
|
|
961
|
+
s.pendingToolCompletes.set(s.openTool.callId, {
|
|
962
|
+
title: s.openTool.title,
|
|
963
|
+
kind: s.openTool.kind,
|
|
964
|
+
displayDetail: s.openTool.displayDetail,
|
|
965
|
+
});
|
|
966
|
+
s.openTool = null;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Render the group aggregate ⎿ line, or skip if no members have
|
|
971
|
+
* completed yet (late completes will render individually as ⎿ labeled). */
|
|
972
|
+
function finalizeToolGroup(): void {
|
|
973
|
+
// Late completes from this group have lost their inline slot; mark
|
|
974
|
+
// them so showOrphanedComplete re-emits a (cont.) header for their ⎿.
|
|
975
|
+
if (s.toolGroupKind) {
|
|
976
|
+
for (const pending of s.pendingToolCompletes.values()) {
|
|
977
|
+
if (pending.kind === s.toolGroupKind) pending.orphaned = true;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
|
|
981
|
+
if (s.toolGroupCount <= 1 || skipAggregate) {
|
|
982
|
+
s.toolGroupKind = undefined;
|
|
983
|
+
s.toolGroupCount = 0;
|
|
984
|
+
s.toolGroupCompletedCount = 0;
|
|
985
|
+
s.toolGroupRendered = 0;
|
|
986
|
+
s.toolGroupAllOk = true;
|
|
987
|
+
s.toolGroupSummaries = [];
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
stopCurrentSpinner();
|
|
991
|
+
closeToolLine();
|
|
992
|
+
if (!s.renderer) startAgentResponse();
|
|
993
|
+
const groupLine: string = ctx.call(
|
|
994
|
+
"tui:render-tool-group-summary",
|
|
995
|
+
s.toolGroupCount, s.toolGroupRendered, s.toolGroupAllOk, s.toolGroupSummaries,
|
|
996
|
+
);
|
|
997
|
+
s.renderer!.writeLine(groupLine);
|
|
998
|
+
drain();
|
|
999
|
+
s.toolGroupKind = undefined;
|
|
1000
|
+
s.toolGroupCount = 0;
|
|
1001
|
+
s.toolGroupCompletedCount = 0;
|
|
1002
|
+
s.toolGroupAllOk = true;
|
|
1003
|
+
s.toolGroupRendered = 0;
|
|
1004
|
+
s.toolGroupSummaries = [];
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function renderCommandLine(line: string): string {
|
|
1008
|
+
return ctx.call("tui:render-command-output", line, s.currentToolKind) as string;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function writeCommandOutput(chunk: string): void {
|
|
1012
|
+
if (!s.renderer) return;
|
|
1013
|
+
closeToolLine();
|
|
1014
|
+
const maxLines = s.currentToolKind === "read"
|
|
1015
|
+
? getSettings().readOutputMaxLines
|
|
1016
|
+
: getSettings().maxCommandOutputLines;
|
|
1017
|
+
s.commandOutputBuffer += chunk;
|
|
1018
|
+
const lines = s.commandOutputBuffer.split("\n");
|
|
1019
|
+
s.commandOutputBuffer = lines.pop()!;
|
|
1020
|
+
for (const line of lines) {
|
|
1021
|
+
if (s.commandOutputLineCount < maxLines) {
|
|
1022
|
+
s.renderer.writeLine(renderCommandLine(line));
|
|
1023
|
+
s.commandOutputLineCount++;
|
|
1024
|
+
} else {
|
|
1025
|
+
s.commandOutputOverflow++;
|
|
1026
|
+
s.commandOverflowLines.push(line);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
drain();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** Max overflow lines to show when a command fails. */
|
|
1033
|
+
const FAIL_OVERFLOW_MAX = 20;
|
|
1034
|
+
|
|
1035
|
+
function flushCommandOutput(): void {
|
|
1036
|
+
if (!s.renderer) return;
|
|
1037
|
+
const maxLines = s.currentToolKind === "read"
|
|
1038
|
+
? getSettings().readOutputMaxLines
|
|
1039
|
+
: getSettings().maxCommandOutputLines;
|
|
1040
|
+
if (s.commandOutputBuffer) {
|
|
1041
|
+
if (s.commandOutputLineCount < maxLines) {
|
|
1042
|
+
s.renderer.writeLine(renderCommandLine(s.commandOutputBuffer));
|
|
1043
|
+
s.commandOutputLineCount++;
|
|
1044
|
+
} else {
|
|
1045
|
+
s.commandOutputOverflow++;
|
|
1046
|
+
s.commandOverflowLines.push(s.commandOutputBuffer);
|
|
1047
|
+
}
|
|
1048
|
+
s.commandOutputBuffer = "";
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// On failure, show the tail of the overflow so the user can see the error
|
|
1052
|
+
const failed = s.toolExitCode !== null && s.toolExitCode !== 0;
|
|
1053
|
+
if (failed && s.commandOverflowLines.length > 0) {
|
|
1054
|
+
const tail = s.commandOverflowLines.slice(-FAIL_OVERFLOW_MAX);
|
|
1055
|
+
const skipped = s.commandOverflowLines.length - tail.length;
|
|
1056
|
+
if (skipped > 0) {
|
|
1057
|
+
s.renderer.writeLine(renderCommandLine(`… ${skipped} lines hidden`));
|
|
1058
|
+
}
|
|
1059
|
+
for (const line of tail) {
|
|
1060
|
+
s.renderer.writeLine(renderCommandLine(line));
|
|
1061
|
+
}
|
|
1062
|
+
} else if (s.commandOutputOverflow > 0 && maxLines > 0) {
|
|
1063
|
+
// Show last line of output so the user sees the tail (often the most useful part)
|
|
1064
|
+
const tail = s.commandOverflowLines[s.commandOverflowLines.length - 1];
|
|
1065
|
+
const hidden = tail ? s.commandOutputOverflow - 1 : s.commandOutputOverflow;
|
|
1066
|
+
if (hidden > 0) {
|
|
1067
|
+
s.renderer.writeLine(renderCommandLine(`… ${hidden} more lines`));
|
|
1068
|
+
}
|
|
1069
|
+
if (tail) {
|
|
1070
|
+
s.renderer.writeLine(renderCommandLine(tail));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
s.commandOutputOverflow = 0;
|
|
1075
|
+
s.commandOverflowLines = [];
|
|
1076
|
+
s.toolExitCode = null;
|
|
1077
|
+
drain();
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function diffTitle(filePath: string, diff: DiffResult): string {
|
|
1081
|
+
const stats = diff.isNewFile
|
|
1082
|
+
? `${p.success}+${diff.added}${p.reset}`
|
|
1083
|
+
: `${p.success}+${diff.added}${p.reset} ${p.error}-${diff.removed}${p.reset}`;
|
|
1084
|
+
return `${p.dim}${filePath}${p.reset} ${stats}`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function toggleThinkingDisplay(): void {
|
|
1088
|
+
s.showThinkingText = !s.showThinkingText;
|
|
1089
|
+
|
|
1090
|
+
if (s.spinner) {
|
|
1091
|
+
stopCurrentSpinner();
|
|
1092
|
+
if (s.showThinkingText) {
|
|
1093
|
+
if (!s.renderer) startAgentResponse();
|
|
1094
|
+
} else {
|
|
1095
|
+
startThinkingSpinner();
|
|
1096
|
+
}
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (!s.isThinking) return;
|
|
1101
|
+
|
|
1102
|
+
if (s.showThinkingText) {
|
|
1103
|
+
stopCurrentSpinner();
|
|
1104
|
+
if (!s.renderer) startAgentResponse();
|
|
1105
|
+
s.renderer!.writeLine(`${p.dim}Thinking (ctrl+t to collapse)${p.reset}`);
|
|
1106
|
+
drain();
|
|
1107
|
+
} else {
|
|
1108
|
+
if (s.renderer) {
|
|
1109
|
+
s.renderer.flush();
|
|
1110
|
+
const w = Math.min(80, out().columns);
|
|
1111
|
+
s.renderer.writeLine(`${p.dim}${"─".repeat(w)}${p.reset}`);
|
|
1112
|
+
drain();
|
|
1113
|
+
}
|
|
1114
|
+
startThinkingSpinner();
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function showError(message: string): void {
|
|
1119
|
+
const s = compositor.surface("status");
|
|
1120
|
+
s.write("\n" + ctx.call("tui:render-error", message) + "\n");
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function showInfo(message: string): void {
|
|
1124
|
+
compositor.surface("status").writeLine(ctx.call("tui:render-info", message));
|
|
1125
|
+
}
|
|
1126
|
+
}
|