pi-compact-transcript 0.4.0 → 0.5.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 +24 -38
- package/extensions/compact-transcript.ts +361 -685
- package/package.json +1 -1
|
@@ -1,37 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { AssistantMessageComponent,
|
|
3
|
-
import {
|
|
2
|
+
import { AssistantMessageComponent, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
|
|
6
|
-
const LABEL = "Thinking...";
|
|
7
6
|
// Older versions of this extension wrote a footer status under this key; it is
|
|
8
7
|
// kept only to clear that status once per session for users upgrading in place.
|
|
9
8
|
const STATUS_KEY = "compact-transcript";
|
|
10
9
|
const CONFIG_ENTRY_TYPE = "compact-transcript-config";
|
|
11
|
-
|
|
12
|
-
const BUILT_INS = new Set(["bash", "read", "write", "edit", "grep", "find", "ls"]);
|
|
10
|
+
const SUMMARY_ENTRY_TYPE = "compact-transcript-summary";
|
|
13
11
|
|
|
14
12
|
const MIN_PREVIEW_WIDTH = 20;
|
|
15
|
-
const
|
|
16
|
-
const DEBUG_PREVIEW_WIDTH = 140;
|
|
17
|
-
const DEFAULT_PREVIEW_WIDTH = 104;
|
|
13
|
+
const MAX_PREVIEW_WIDTH = 104;
|
|
18
14
|
// Leave room for pi's row gutter/padding so compact lines never wrap.
|
|
19
15
|
const PREVIEW_MARGIN = 6;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// "off" is a legacy alias for "disabled" accepted from commands and persisted config.
|
|
24
|
-
type ModeInput = Mode | "off";
|
|
25
|
-
type ToolKind = "always" | "mutation" | "noise";
|
|
16
|
+
const BLINK_INTERVAL_MS = 400;
|
|
17
|
+
// Status marker is two cells wide ("◆ ").
|
|
18
|
+
const MARKER_WIDTH = 2;
|
|
26
19
|
|
|
27
20
|
type CompactTranscriptConfig = {
|
|
28
|
-
|
|
29
|
-
compactCustomTools: boolean;
|
|
30
|
-
showFailedTools: boolean;
|
|
31
|
-
showBashMutations: boolean;
|
|
32
|
-
alwaysShowTools: string[];
|
|
33
|
-
mutationTools: string[];
|
|
34
|
-
previewTemplates: Record<string, string>;
|
|
21
|
+
enabled: boolean;
|
|
35
22
|
};
|
|
36
23
|
|
|
37
24
|
type ToolInfo = {
|
|
@@ -39,92 +26,97 @@ type ToolInfo = {
|
|
|
39
26
|
name: string;
|
|
40
27
|
args: any;
|
|
41
28
|
preview: string;
|
|
42
|
-
kind: ToolKind;
|
|
43
29
|
hidden?: boolean;
|
|
30
|
+
running?: boolean;
|
|
44
31
|
burstCount?: number;
|
|
45
|
-
|
|
32
|
+
startedAt?: number;
|
|
33
|
+
durationMs?: number;
|
|
34
|
+
burstDurationMs?: number;
|
|
46
35
|
result?: string;
|
|
47
36
|
isError?: boolean;
|
|
48
37
|
invalidate?: () => void;
|
|
49
38
|
};
|
|
50
39
|
|
|
40
|
+
type RunStats = {
|
|
41
|
+
startedAt: number;
|
|
42
|
+
toolCount: number;
|
|
43
|
+
readFiles: Set<string>;
|
|
44
|
+
editFiles: Set<string>;
|
|
45
|
+
commandCount: number;
|
|
46
|
+
otherCount: number;
|
|
47
|
+
failedCount: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type SummaryData = {
|
|
51
|
+
reads: number;
|
|
52
|
+
edits: number;
|
|
53
|
+
commands: number;
|
|
54
|
+
others: number;
|
|
55
|
+
failed: number;
|
|
56
|
+
durationMs: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
51
59
|
type RuntimeState = {
|
|
52
60
|
config: CompactTranscriptConfig;
|
|
53
61
|
toolsById: Map<string, ToolInfo>;
|
|
54
|
-
|
|
62
|
+
currentBurst: ToolInfo[];
|
|
55
63
|
hiddenToolIds: Set<string>;
|
|
64
|
+
runningToolIds: Set<string>;
|
|
56
65
|
agentActive: boolean;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
blinkOn: boolean;
|
|
67
|
+
blinkTimer?: ReturnType<typeof setInterval>;
|
|
68
|
+
runStats: RunStats;
|
|
69
|
+
// Live transcript components, so toggling can re-render existing rows.
|
|
70
|
+
toolComponents: Set<any>;
|
|
71
|
+
assistantComponents: Set<any>;
|
|
60
72
|
currentTheme?: Theme;
|
|
61
73
|
};
|
|
62
74
|
|
|
63
75
|
const DEFAULT_CONFIG: CompactTranscriptConfig = {
|
|
64
|
-
|
|
65
|
-
compactCustomTools: true,
|
|
66
|
-
showFailedTools: true,
|
|
67
|
-
showBashMutations: true,
|
|
68
|
-
alwaysShowTools: [],
|
|
69
|
-
mutationTools: [],
|
|
70
|
-
previewTemplates: {},
|
|
76
|
+
enabled: true,
|
|
71
77
|
};
|
|
72
78
|
|
|
73
79
|
const STATE_KEY = Symbol.for("pi-compact-transcript.state");
|
|
74
80
|
const TOOL_PATCH_KEY = Symbol.for("pi-compact-transcript.tool-patch");
|
|
75
81
|
const ASSISTANT_PATCH_KEY = Symbol.for("pi-compact-transcript.assistant-patch");
|
|
76
82
|
|
|
77
|
-
function
|
|
83
|
+
function newRunStats(): RunStats {
|
|
78
84
|
return {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
startedAt: Date.now(),
|
|
86
|
+
toolCount: 0,
|
|
87
|
+
readFiles: new Set(),
|
|
88
|
+
editFiles: new Set(),
|
|
89
|
+
commandCount: 0,
|
|
90
|
+
otherCount: 0,
|
|
91
|
+
failedCount: 0,
|
|
86
92
|
};
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
function normalizeConfig(input: unknown): CompactTranscriptConfig {
|
|
90
|
-
const source = (input && typeof input === "object" ? input : {}) as
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
: "balanced";
|
|
100
|
-
return {
|
|
101
|
-
mode,
|
|
102
|
-
compactCustomTools: typeof source.compactCustomTools === "boolean" ? source.compactCustomTools : DEFAULT_CONFIG.compactCustomTools,
|
|
103
|
-
showFailedTools: typeof source.showFailedTools === "boolean" ? source.showFailedTools : DEFAULT_CONFIG.showFailedTools,
|
|
104
|
-
showBashMutations: typeof source.showBashMutations === "boolean" ? source.showBashMutations : DEFAULT_CONFIG.showBashMutations,
|
|
105
|
-
alwaysShowTools: Array.isArray(source.alwaysShowTools) ? source.alwaysShowTools.filter(isNonEmptyString) : [],
|
|
106
|
-
mutationTools: Array.isArray(source.mutationTools) ? source.mutationTools.filter(isNonEmptyString) : [],
|
|
107
|
-
previewTemplates:
|
|
108
|
-
source.previewTemplates && typeof source.previewTemplates === "object" && !Array.isArray(source.previewTemplates)
|
|
109
|
-
? Object.fromEntries(
|
|
110
|
-
Object.entries(source.previewTemplates).filter(
|
|
111
|
-
([key, value]) => isNonEmptyString(key) && typeof value === "string",
|
|
112
|
-
),
|
|
113
|
-
)
|
|
114
|
-
: {},
|
|
115
|
-
};
|
|
96
|
+
const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
|
|
97
|
+
let enabled = DEFAULT_CONFIG.enabled;
|
|
98
|
+
if (typeof source.enabled === "boolean") {
|
|
99
|
+
enabled = source.enabled;
|
|
100
|
+
} else if (typeof source.mode === "string") {
|
|
101
|
+
// Pre-0.5 config persisted a mode string instead of an enabled flag.
|
|
102
|
+
enabled = source.mode !== "disabled" && source.mode !== "off";
|
|
103
|
+
}
|
|
104
|
+
return { enabled };
|
|
116
105
|
}
|
|
117
106
|
|
|
118
107
|
function getState(): RuntimeState {
|
|
119
108
|
const globalWithState = globalThis as typeof globalThis & { [STATE_KEY]?: RuntimeState };
|
|
120
109
|
globalWithState[STATE_KEY] ??= {
|
|
121
|
-
config:
|
|
110
|
+
config: { ...DEFAULT_CONFIG },
|
|
122
111
|
toolsById: new Map(),
|
|
123
|
-
|
|
112
|
+
currentBurst: [],
|
|
124
113
|
hiddenToolIds: new Set(),
|
|
114
|
+
runningToolIds: new Set(),
|
|
125
115
|
agentActive: false,
|
|
126
|
-
|
|
127
|
-
|
|
116
|
+
blinkOn: true,
|
|
117
|
+
runStats: newRunStats(),
|
|
118
|
+
toolComponents: new Set(),
|
|
119
|
+
assistantComponents: new Set(),
|
|
128
120
|
};
|
|
129
121
|
return globalWithState[STATE_KEY]!;
|
|
130
122
|
}
|
|
@@ -132,11 +124,7 @@ function getState(): RuntimeState {
|
|
|
132
124
|
const state = getState();
|
|
133
125
|
|
|
134
126
|
function isEnabled(): boolean {
|
|
135
|
-
return state.config.
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function isDebugMode(): boolean {
|
|
139
|
-
return state.config.mode === "debug";
|
|
127
|
+
return state.config.enabled;
|
|
140
128
|
}
|
|
141
129
|
|
|
142
130
|
function isNonEmptyString(value: unknown): value is string {
|
|
@@ -156,13 +144,7 @@ function oneLine(value: unknown): string {
|
|
|
156
144
|
}
|
|
157
145
|
|
|
158
146
|
function previewWidth(base = process.stdout.columns || 100): number {
|
|
159
|
-
|
|
160
|
-
state.config.mode === "aggressive"
|
|
161
|
-
? AGGRESSIVE_PREVIEW_WIDTH
|
|
162
|
-
: state.config.mode === "debug"
|
|
163
|
-
? DEBUG_PREVIEW_WIDTH
|
|
164
|
-
: DEFAULT_PREVIEW_WIDTH;
|
|
165
|
-
return Math.max(MIN_PREVIEW_WIDTH, Math.min(modeMax, base - PREVIEW_MARGIN));
|
|
147
|
+
return Math.max(MIN_PREVIEW_WIDTH, Math.min(MAX_PREVIEW_WIDTH, base - PREVIEW_MARGIN));
|
|
166
148
|
}
|
|
167
149
|
|
|
168
150
|
function limitPlain(text: string, max = previewWidth()): string {
|
|
@@ -183,91 +165,36 @@ function safeJson(value: unknown): string {
|
|
|
183
165
|
}
|
|
184
166
|
}
|
|
185
167
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (!
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
function isMutatingBash(command: unknown): boolean {
|
|
215
|
-
if (typeof command !== "string") return false;
|
|
216
|
-
const compact = command.replace(/#[^\n]*/g, "").trim();
|
|
217
|
-
if (!compact) return false;
|
|
218
|
-
|
|
219
|
-
// Obvious file/process mutations and package/install actions. This is intentionally
|
|
220
|
-
// conservative enough to keep anchors for risky commands without treating every
|
|
221
|
-
// shell pipeline as destructive.
|
|
222
|
-
return /(^|[;&|]\s*)(rm\b|mv\b|cp\b|mkdir\b|rmdir\b|touch\b|chmod\b|chown\b|ln\b|truncate\b|tee\b|npm\s+(i|install|uninstall|remove|publish)\b|pnpm\s+(i|install|add|remove)\b|yarn\s+(add|remove|install|publish)\b|bun\s+(add|remove|install)\b|pip\s+(install|uninstall)\b|git\s+(add|apply|commit|checkout|clean|merge|mv|pull|push|rebase|reset|restore|stash|switch)\b)/.test(
|
|
223
|
-
compact,
|
|
224
|
-
) || /(^|[^2])>>?\s*[^&\s]/.test(compact);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function classifyTool(name: string, args: any): ToolKind {
|
|
228
|
-
if (matchesAnyRule(state.config.alwaysShowTools, name)) return "always";
|
|
229
|
-
if (!BUILT_INS.has(name) && !state.config.compactCustomTools) return "always";
|
|
230
|
-
if (name === "edit" || name === "write" || name.endsWith(".edit") || name.endsWith(".write")) return "mutation";
|
|
231
|
-
if (matchesAnyRule(state.config.mutationTools, name)) return "mutation";
|
|
232
|
-
if (state.config.showBashMutations && (name === "bash" || name.endsWith(".bash")) && isMutatingBash(args?.command)) {
|
|
233
|
-
return "mutation";
|
|
234
|
-
}
|
|
235
|
-
return "noise";
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function findTemplate(name: string): string | undefined {
|
|
239
|
-
if (state.config.previewTemplates[name]) return state.config.previewTemplates[name];
|
|
240
|
-
for (const [rule, template] of Object.entries(state.config.previewTemplates)) {
|
|
241
|
-
if (matchRule(rule, name)) return template;
|
|
242
|
-
}
|
|
243
|
-
return undefined;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function getByPath(value: any, path: string): unknown {
|
|
247
|
-
let current = value;
|
|
248
|
-
for (const part of path.split(".")) {
|
|
249
|
-
if (!part) continue;
|
|
250
|
-
if (current == null || typeof current !== "object") return undefined;
|
|
251
|
-
current = current[part];
|
|
252
|
-
}
|
|
253
|
-
return current;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function renderTemplate(template: string, name: string, args: any): string {
|
|
257
|
-
return template.replace(/\{(?:arg\.)?([a-zA-Z0-9_.-]+)\}/g, (_match, key: string) => {
|
|
258
|
-
if (key === "name") return name;
|
|
259
|
-
if (key === "args") return safeJson(args);
|
|
260
|
-
const value = getByPath(args, key);
|
|
261
|
-
if (value === undefined || value === null) return "";
|
|
262
|
-
if (typeof value === "string") return value;
|
|
263
|
-
return safeJson(value);
|
|
264
|
-
});
|
|
265
|
-
}
|
|
168
|
+
// Sub-second durations render as "" so fast tools stay clutter-free.
|
|
169
|
+
function formatDuration(ms: number): string {
|
|
170
|
+
if (!Number.isFinite(ms) || ms < 1000) return "";
|
|
171
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
172
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
173
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
174
|
+
const seconds = totalSeconds % 60;
|
|
175
|
+
return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Checked in order; earlier keys are more likely to be the argument a human
|
|
179
|
+
// would recognize the call by.
|
|
180
|
+
const PREFERRED_ARG_KEYS = [
|
|
181
|
+
"command",
|
|
182
|
+
"code",
|
|
183
|
+
"query",
|
|
184
|
+
"pattern",
|
|
185
|
+
"path",
|
|
186
|
+
"file_path",
|
|
187
|
+
"filePath",
|
|
188
|
+
"file",
|
|
189
|
+
"url",
|
|
190
|
+
"prompt",
|
|
191
|
+
"text",
|
|
192
|
+
"description",
|
|
193
|
+
"name",
|
|
194
|
+
];
|
|
195
|
+
const PATH_ARG_KEYS = new Set(["path", "file_path", "filePath", "file"]);
|
|
266
196
|
|
|
267
197
|
function previewFor(name: string, args: any): string {
|
|
268
|
-
const template = findTemplate(name);
|
|
269
|
-
if (template) return renderTemplate(template, name, args);
|
|
270
|
-
|
|
271
198
|
switch (name) {
|
|
272
199
|
case "bash":
|
|
273
200
|
return `$ ${oneLine(args?.command || "...")}`;
|
|
@@ -297,8 +224,22 @@ function previewFor(name: string, args: any): string {
|
|
|
297
224
|
return `find ${args?.pattern ? quote(String(args.pattern)) : "..."} ${shortenPath(args?.path) || "."}`;
|
|
298
225
|
case "ls":
|
|
299
226
|
return `ls ${shortenPath(args?.path) || "."}`;
|
|
300
|
-
default:
|
|
227
|
+
default: {
|
|
228
|
+
// Unknown tools: show the most meaningful string argument instead of
|
|
229
|
+
// dumping the whole args object as JSON.
|
|
230
|
+
if (args && typeof args === "object") {
|
|
231
|
+
for (const key of PREFERRED_ARG_KEYS) {
|
|
232
|
+
const value = (args as Record<string, unknown>)[key];
|
|
233
|
+
if (isNonEmptyString(value)) {
|
|
234
|
+
const rendered = PATH_ARG_KEYS.has(key) ? shortenPath(value) : oneLine(value);
|
|
235
|
+
return `${name} ${rendered}`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const firstString = Object.values(args).find(isNonEmptyString);
|
|
239
|
+
if (firstString) return `${name} ${oneLine(firstString)}`;
|
|
240
|
+
}
|
|
301
241
|
return `${name} ${safeJson(args ?? {})}`;
|
|
242
|
+
}
|
|
302
243
|
}
|
|
303
244
|
}
|
|
304
245
|
|
|
@@ -313,26 +254,12 @@ function resultPreview(result: any, isPartial = false): string {
|
|
|
313
254
|
return `${lines.length} lines`;
|
|
314
255
|
}
|
|
315
256
|
|
|
316
|
-
function summarizeBurst(tools: ToolInfo[]): string {
|
|
317
|
-
const counts = new Map<string, number>();
|
|
318
|
-
for (const tool of tools) {
|
|
319
|
-
counts.set(tool.name, (counts.get(tool.name) ?? 0) + 1);
|
|
320
|
-
}
|
|
321
|
-
return [...counts.entries()]
|
|
322
|
-
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
323
|
-
.map(([name, count]) => `${count} ${name}`)
|
|
324
|
-
.join(", ");
|
|
325
|
-
}
|
|
326
|
-
|
|
327
257
|
function resetToolRun() {
|
|
328
258
|
state.toolsById = new Map();
|
|
329
|
-
state.
|
|
259
|
+
state.currentBurst = [];
|
|
330
260
|
state.hiddenToolIds = new Set();
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
function resetThinkingSignals() {
|
|
334
|
-
state.lastThinkingSignalComponent = undefined;
|
|
335
|
-
state.thinkingSignalCount = 0;
|
|
261
|
+
state.runningToolIds = new Set();
|
|
262
|
+
stopBlinkTimer();
|
|
336
263
|
}
|
|
337
264
|
|
|
338
265
|
function captureTheme(ctx: ExtensionContext) {
|
|
@@ -350,51 +277,139 @@ function applyResult(info: ToolInfo, result: any, isError: boolean, isPartial: b
|
|
|
350
277
|
if (suffix) info.result = suffix;
|
|
351
278
|
if (isError) {
|
|
352
279
|
info.isError = true;
|
|
280
|
+
// A failed tool always gets its own visible row, even if a burst had
|
|
281
|
+
// hidden it; render it as itself rather than as a burst summary.
|
|
282
|
+
info.burstCount = 1;
|
|
353
283
|
setToolHidden(info, false);
|
|
354
284
|
}
|
|
355
285
|
}
|
|
356
286
|
|
|
287
|
+
function ensureBlinkTimer() {
|
|
288
|
+
if (state.blinkTimer || state.runningToolIds.size === 0) return;
|
|
289
|
+
state.blinkTimer = setInterval(() => {
|
|
290
|
+
if (state.runningToolIds.size === 0) {
|
|
291
|
+
stopBlinkTimer();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
state.blinkOn = !state.blinkOn;
|
|
295
|
+
for (const id of state.runningToolIds) state.toolsById.get(id)?.invalidate?.();
|
|
296
|
+
}, BLINK_INTERVAL_MS);
|
|
297
|
+
state.blinkTimer.unref?.();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function stopBlinkTimer() {
|
|
301
|
+
if (state.blinkTimer) clearInterval(state.blinkTimer);
|
|
302
|
+
state.blinkTimer = undefined;
|
|
303
|
+
state.blinkOn = true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function statusMarker(theme: Theme, opts: { running?: boolean; isError?: boolean; hasResult?: boolean }): string {
|
|
307
|
+
if (opts.isError) return theme.fg("error", "◆ ");
|
|
308
|
+
if (opts.running) return theme.fg("dim", state.blinkOn ? "◆ " : "◇ ");
|
|
309
|
+
if (opts.hasResult) return theme.fg("success", "◆ ");
|
|
310
|
+
return theme.fg("dim", "◆ ");
|
|
311
|
+
}
|
|
312
|
+
|
|
357
313
|
function upsertToolInfo(id: string, name: string, args: any, invalidate?: () => void): ToolInfo {
|
|
358
314
|
let info = state.toolsById.get(id);
|
|
359
315
|
if (!info) {
|
|
360
|
-
info = { id, name, args, preview: previewFor(name, args)
|
|
316
|
+
info = { id, name, args, preview: previewFor(name, args) };
|
|
361
317
|
state.toolsById.set(id, info);
|
|
362
318
|
}
|
|
363
319
|
info.name = name;
|
|
364
320
|
info.args = args;
|
|
365
321
|
info.preview = previewFor(name, args);
|
|
366
|
-
info.kind = classifyTool(name, args);
|
|
367
322
|
if (invalidate) info.invalidate = invalidate;
|
|
368
323
|
return info;
|
|
369
324
|
}
|
|
370
325
|
|
|
371
|
-
function
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
326
|
+
function recordToolStart(name: string, args: any) {
|
|
327
|
+
const base = name.split(".").pop() ?? name;
|
|
328
|
+
state.runStats.toolCount++;
|
|
329
|
+
if (base === "read") {
|
|
330
|
+
if (isNonEmptyString(args?.path)) state.runStats.readFiles.add(args.path);
|
|
331
|
+
} else if (base === "edit" || base === "write") {
|
|
332
|
+
if (isNonEmptyString(args?.path)) state.runStats.editFiles.add(args.path);
|
|
333
|
+
} else if (base === "bash") {
|
|
334
|
+
state.runStats.commandCount++;
|
|
335
|
+
} else {
|
|
336
|
+
state.runStats.otherCount++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
377
339
|
|
|
378
|
-
|
|
379
|
-
|
|
340
|
+
function joinBurst(info: ToolInfo) {
|
|
341
|
+
const previous = state.currentBurst[state.currentBurst.length - 1];
|
|
342
|
+
|
|
343
|
+
if (!isEnabled()) {
|
|
344
|
+
state.currentBurst = [];
|
|
380
345
|
return;
|
|
381
346
|
}
|
|
382
347
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
348
|
+
// Bursts only group repeats of the same tool; a different tool starts a
|
|
349
|
+
// fresh row (and leaves the previous row visible with its count).
|
|
350
|
+
if (state.currentBurst.length && state.currentBurst[state.currentBurst.length - 1].name !== info.name) {
|
|
351
|
+
state.currentBurst = [];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!state.currentBurst.some((tool) => tool.id === info.id)) state.currentBurst.push(info);
|
|
355
|
+
for (const tool of state.currentBurst.slice(0, -1)) setToolHidden(tool, true);
|
|
356
|
+
info.burstCount = state.currentBurst.length;
|
|
357
|
+
previous?.invalidate?.();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function beginTool(id: string, name: string, args: any) {
|
|
361
|
+
const info = upsertToolInfo(id, name, args);
|
|
362
|
+
setToolHidden(info, false);
|
|
363
|
+
info.burstCount = 1;
|
|
364
|
+
info.running = true;
|
|
365
|
+
info.isError = false;
|
|
366
|
+
info.startedAt = Date.now();
|
|
367
|
+
state.runningToolIds.add(id);
|
|
368
|
+
ensureBlinkTimer();
|
|
369
|
+
recordToolStart(name, args);
|
|
370
|
+
joinBurst(info);
|
|
371
|
+
// The component may already be on screen from argument streaming; repaint
|
|
372
|
+
// now so the running marker and burst count appear immediately instead of
|
|
373
|
+
// waiting for the next result event or blink tick.
|
|
374
|
+
info.invalidate?.();
|
|
375
|
+
}
|
|
386
376
|
|
|
387
|
-
|
|
388
|
-
|
|
377
|
+
// A tool row rendered without a live tool_execution_start event — pi is
|
|
378
|
+
// rebuilding chat history after resume/reload. Reconstruct burst grouping so
|
|
379
|
+
// scrollback coalesces the same way the live view did, but skip timers,
|
|
380
|
+
// stats, and running state.
|
|
381
|
+
function hydrateTool(id: string, name: string, args: any, isError: boolean): ToolInfo {
|
|
382
|
+
const info = upsertToolInfo(id, name, args);
|
|
383
|
+
setToolHidden(info, false);
|
|
384
|
+
info.burstCount = 1;
|
|
385
|
+
if (isError) {
|
|
386
|
+
info.isError = true;
|
|
387
|
+
state.currentBurst = [];
|
|
388
|
+
return info;
|
|
389
389
|
}
|
|
390
|
-
info
|
|
391
|
-
|
|
390
|
+
joinBurst(info);
|
|
391
|
+
return info;
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
function updateToolResult(toolCallId: string, result: any, isError = false, isPartial = false) {
|
|
395
|
+
if (!isPartial) {
|
|
396
|
+
state.runningToolIds.delete(toolCallId);
|
|
397
|
+
if (state.runningToolIds.size === 0) stopBlinkTimer();
|
|
398
|
+
}
|
|
395
399
|
const info = state.toolsById.get(toolCallId);
|
|
396
400
|
if (!info) return;
|
|
401
|
+
if (!isPartial) {
|
|
402
|
+
info.running = false;
|
|
403
|
+
if (info.startedAt) info.durationMs = Date.now() - info.startedAt;
|
|
404
|
+
if (state.currentBurst.includes(info) && state.currentBurst.length > 1) {
|
|
405
|
+
info.burstDurationMs = state.currentBurst.reduce((total, tool) => total + (tool.durationMs ?? 0), 0);
|
|
406
|
+
}
|
|
407
|
+
if (isError) state.runStats.failedCount++;
|
|
408
|
+
}
|
|
397
409
|
applyResult(info, result, isError, isPartial);
|
|
410
|
+
// A failure ends the current burst so the red row stays visible and the
|
|
411
|
+
// next tool starts a fresh count.
|
|
412
|
+
if (isError) state.currentBurst = [];
|
|
398
413
|
info.invalidate?.();
|
|
399
414
|
}
|
|
400
415
|
|
|
@@ -408,45 +423,28 @@ function compactToolLine(
|
|
|
408
423
|
isError = false,
|
|
409
424
|
isPartial = false,
|
|
410
425
|
): string {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
// Hydration/resume path: compact the individual row, but do not mutate burst
|
|
414
|
-
// state or invalidate older rows while pi is rebuilding chat history.
|
|
415
|
-
if (!state.agentActive && !info) {
|
|
416
|
-
const suffix = resultPreview(result, isPartial);
|
|
417
|
-
const preview = previewFor(name, args);
|
|
418
|
-
const details = suffix ? `${preview} {${oneLine(suffix)}}` : preview;
|
|
419
|
-
const marker = classifyTool(name, args) === "mutation" ? theme.fg("warning", "◆ ") : "";
|
|
420
|
-
return marker + theme.fg("muted", limitPlain(details));
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
info = upsertToolInfo(toolCallId, name, args, invalidate);
|
|
426
|
+
if (!state.toolsById.has(toolCallId)) hydrateTool(toolCallId, name, args, isError);
|
|
427
|
+
const info = upsertToolInfo(toolCallId, name, args, invalidate);
|
|
424
428
|
applyResult(info, result, isError, isPartial);
|
|
425
429
|
if (info.hidden) return "";
|
|
426
430
|
|
|
427
|
-
const
|
|
431
|
+
const isBurst = (info.burstCount ?? 1) > 1;
|
|
432
|
+
const durationText = formatDuration((isBurst ? (info.burstDurationMs ?? info.durationMs) : info.durationMs) ?? 0);
|
|
433
|
+
const inner = [info.result ? oneLine(info.result) : "", durationText].filter(Boolean).join(" · ");
|
|
434
|
+
const status = inner ? ` {${inner}}` : info.running ? " {running}" : "";
|
|
428
435
|
const details = `${info.preview}${status}`;
|
|
429
|
-
|
|
430
|
-
|
|
436
|
+
const marker = statusMarker(theme, {
|
|
437
|
+
running: info.running,
|
|
438
|
+
isError: info.isError,
|
|
439
|
+
hasResult: result != null || !!info.result,
|
|
440
|
+
});
|
|
441
|
+
if (!isBurst) {
|
|
431
442
|
return marker + theme.fg("muted", limitPlain(details));
|
|
432
443
|
}
|
|
433
444
|
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
const prefixText = `${prefix}${middle}`;
|
|
438
|
-
return (
|
|
439
|
-
theme.fg("toolTitle", prefixText) +
|
|
440
|
-
theme.fg("muted", limitPlain(details, previewWidth((process.stdout.columns || 100) - prefixText.length)))
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function shouldUseOriginalToolRow(row: any, info: ToolInfo): boolean {
|
|
445
|
-
if (!isEnabled()) return true;
|
|
446
|
-
if (row.expanded) return true;
|
|
447
|
-
if (info.kind === "always") return true;
|
|
448
|
-
if (info.isError && state.config.showFailedTools) return true;
|
|
449
|
-
return false;
|
|
445
|
+
const prefix = `${info.burstCount}× `;
|
|
446
|
+
const budget = previewWidth((process.stdout.columns || 100) - prefix.length - MARKER_WIDTH);
|
|
447
|
+
return marker + theme.fg("muted", prefix + limitPlain(details, budget));
|
|
450
448
|
}
|
|
451
449
|
|
|
452
450
|
function patchToolExecutionComponent() {
|
|
@@ -461,24 +459,32 @@ function patchToolExecutionComponent() {
|
|
|
461
459
|
proto.updateDisplay = function patchedUpdateDisplay() {
|
|
462
460
|
if (!this.toolCallId || !this.toolName || !this.selfRenderContainer || typeof this.selfRenderContainer.clear !== "function") {
|
|
463
461
|
this.__compactTranscriptForceSelf = false;
|
|
462
|
+
this.__compactTranscriptHidden = false;
|
|
464
463
|
return originalUpdateDisplay.call(this);
|
|
465
464
|
}
|
|
465
|
+
state.toolComponents.add(this);
|
|
466
466
|
|
|
467
467
|
const invalidate = () => {
|
|
468
468
|
this.invalidate();
|
|
469
469
|
this.ui?.requestRender?.();
|
|
470
470
|
};
|
|
471
|
+
// Must run before upsertToolInfo, which would make the row look
|
|
472
|
+
// already-known and skip burst reconstruction for rehydrated history.
|
|
473
|
+
if (!state.toolsById.has(this.toolCallId)) {
|
|
474
|
+
hydrateTool(this.toolCallId, this.toolName, this.args, this.result?.isError ?? false);
|
|
475
|
+
}
|
|
471
476
|
const info = upsertToolInfo(this.toolCallId, this.toolName, this.args, invalidate);
|
|
472
477
|
applyResult(info, this.result, this.result?.isError ?? false, this.isPartial);
|
|
473
478
|
|
|
474
|
-
if (
|
|
479
|
+
if (!isEnabled() || this.expanded) {
|
|
475
480
|
setToolHidden(info, false);
|
|
476
481
|
this.__compactTranscriptForceSelf = false;
|
|
482
|
+
this.__compactTranscriptHidden = false;
|
|
477
483
|
return originalUpdateDisplay.call(this);
|
|
478
484
|
}
|
|
479
485
|
|
|
480
486
|
this.__compactTranscriptForceSelf = true;
|
|
481
|
-
this.
|
|
487
|
+
this.__compactTranscriptHidden = false;
|
|
482
488
|
this.selfRenderContainer.clear();
|
|
483
489
|
for (const image of this.imageComponents ?? []) this.removeChild?.(image);
|
|
484
490
|
for (const spacer of this.imageSpacers ?? []) this.removeChild?.(spacer);
|
|
@@ -488,6 +494,7 @@ function patchToolExecutionComponent() {
|
|
|
488
494
|
const theme = state.currentTheme;
|
|
489
495
|
if (!theme) {
|
|
490
496
|
this.__compactTranscriptForceSelf = false;
|
|
497
|
+
this.__compactTranscriptHidden = false;
|
|
491
498
|
return originalUpdateDisplay.call(this);
|
|
492
499
|
}
|
|
493
500
|
|
|
@@ -503,7 +510,7 @@ function patchToolExecutionComponent() {
|
|
|
503
510
|
);
|
|
504
511
|
|
|
505
512
|
if (!line) {
|
|
506
|
-
this.
|
|
513
|
+
this.__compactTranscriptHidden = true;
|
|
507
514
|
return;
|
|
508
515
|
}
|
|
509
516
|
|
|
@@ -511,7 +518,7 @@ function patchToolExecutionComponent() {
|
|
|
511
518
|
};
|
|
512
519
|
|
|
513
520
|
proto.render = function patchedRender(width: number) {
|
|
514
|
-
if (this.hideComponent) return [];
|
|
521
|
+
if (this.hideComponent || this.__compactTranscriptHidden) return [];
|
|
515
522
|
if (this.__compactTranscriptForceSelf) return this.selfRenderContainer.render(width);
|
|
516
523
|
return originalRender.call(this, width);
|
|
517
524
|
};
|
|
@@ -526,82 +533,30 @@ function patchAssistantMessageComponent() {
|
|
|
526
533
|
const originalUpdateContent = existing?.originalUpdateContent ?? proto.updateContent;
|
|
527
534
|
|
|
528
535
|
proto.updateContent = function patchedUpdateContent(message: any) {
|
|
536
|
+
state.assistantComponents.add(this);
|
|
529
537
|
if (!isEnabled() || !this.hideThinkingBlock || !Array.isArray(message?.content)) {
|
|
530
|
-
this.__compactTranscriptHiddenThinkingSignal = false;
|
|
531
538
|
return originalUpdateContent.call(this, message);
|
|
532
539
|
}
|
|
533
540
|
if (!this.contentContainer || typeof this.contentContainer.clear !== "function") {
|
|
534
|
-
this.__compactTranscriptHiddenThinkingSignal = false;
|
|
535
541
|
return originalUpdateContent.call(this, message);
|
|
536
542
|
}
|
|
537
543
|
|
|
538
544
|
this.lastMessage = message;
|
|
539
545
|
this.contentContainer.clear();
|
|
540
|
-
|
|
541
|
-
const hasText = message.content.some((c: any) => c.type === "text" && c.text?.trim());
|
|
542
|
-
const hasThinking = message.content.some((c: any) => c.type === "thinking" && c.thinking?.trim());
|
|
543
546
|
this.hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
|
|
544
547
|
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const thinkingSignal = state.agentActive && hasThinking && !hasText;
|
|
549
|
-
const coalesceThinkingSignals = state.config.mode === "aggressive";
|
|
550
|
-
if (!coalesceThinkingSignals) {
|
|
551
|
-
this.__compactTranscriptHiddenThinkingSignal = false;
|
|
552
|
-
if (!thinkingSignal && hasText) resetThinkingSignals();
|
|
553
|
-
} else {
|
|
554
|
-
if (this.__compactTranscriptHiddenThinkingSignal) return;
|
|
555
|
-
if (thinkingSignal && state.lastThinkingSignalComponent !== this) {
|
|
556
|
-
if (state.lastThinkingSignalComponent) {
|
|
557
|
-
state.lastThinkingSignalComponent.__compactTranscriptHiddenThinkingSignal = true;
|
|
558
|
-
state.lastThinkingSignalComponent.invalidate?.();
|
|
559
|
-
}
|
|
560
|
-
state.lastThinkingSignalComponent = this;
|
|
561
|
-
state.thinkingSignalCount++;
|
|
562
|
-
} else if (!thinkingSignal && hasText) {
|
|
563
|
-
resetThinkingSignals();
|
|
564
|
-
}
|
|
565
|
-
}
|
|
548
|
+
// Thinking blocks are suppressed entirely; only real text is rendered.
|
|
549
|
+
const texts = message.content.filter((c: any) => c.type === "text" && c.text?.trim());
|
|
550
|
+
if (texts.length === 0) return;
|
|
566
551
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
let renderedThinkingSignal = false;
|
|
573
|
-
for (let i = 0; i < message.content.length; i++) {
|
|
574
|
-
const content = message.content[i];
|
|
575
|
-
if (content.type === "text" && content.text?.trim()) {
|
|
576
|
-
this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme));
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
579
|
-
if (content.type !== "thinking" || !content.thinking?.trim()) continue;
|
|
552
|
+
// Assistant text ends a tool burst. The live path also does this via
|
|
553
|
+
// message_update events; doing it here too keeps hydrated history from
|
|
554
|
+
// grouping tool rows across turn boundaries.
|
|
555
|
+
state.currentBurst = [];
|
|
580
556
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
}
|
|
585
|
-
i += count - 1;
|
|
586
|
-
|
|
587
|
-
if (coalesceThinkingSignals && thinkingSignal) {
|
|
588
|
-
if (renderedThinkingSignal) continue;
|
|
589
|
-
count = Math.max(count, state.thinkingSignalCount);
|
|
590
|
-
renderedThinkingSignal = true;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const label = count > 1 ? `${LABEL} (${count}x)` : LABEL;
|
|
594
|
-
const theme = state.currentTheme;
|
|
595
|
-
if (theme) {
|
|
596
|
-
this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", label)), this.outputPad, 0));
|
|
597
|
-
} else {
|
|
598
|
-
this.contentContainer.addChild(new Text(label, this.outputPad, 0));
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const hasVisibleAfter = message.content
|
|
602
|
-
.slice(i + 1)
|
|
603
|
-
.some((c: any) => (c.type === "text" && c.text?.trim()) || (c.type === "thinking" && c.thinking?.trim()));
|
|
604
|
-
if (hasVisibleAfter) this.contentContainer.addChild(new Spacer(1));
|
|
557
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
558
|
+
for (const content of texts) {
|
|
559
|
+
this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme));
|
|
605
560
|
}
|
|
606
561
|
};
|
|
607
562
|
|
|
@@ -613,20 +568,76 @@ function patchRenderers() {
|
|
|
613
568
|
patchAssistantMessageComponent();
|
|
614
569
|
}
|
|
615
570
|
|
|
616
|
-
|
|
617
|
-
|
|
571
|
+
// Re-render every transcript row we have touched so toggling applies to the
|
|
572
|
+
// visible transcript immediately instead of only to future rows.
|
|
573
|
+
function refreshTranscript() {
|
|
574
|
+
let ui: any;
|
|
575
|
+
for (const component of state.toolComponents) {
|
|
576
|
+
try {
|
|
577
|
+
// ToolExecutionComponent.invalidate() re-runs updateDisplay.
|
|
578
|
+
component.invalidate?.();
|
|
579
|
+
ui ??= component.ui;
|
|
580
|
+
} catch {
|
|
581
|
+
state.toolComponents.delete(component);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
for (const component of state.assistantComponents) {
|
|
585
|
+
try {
|
|
586
|
+
if (component.lastMessage) component.updateContent?.(component.lastMessage);
|
|
587
|
+
component.invalidate?.();
|
|
588
|
+
} catch {
|
|
589
|
+
state.assistantComponents.delete(component);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
ui?.requestRender?.();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function summaryLine(data: SummaryData): string {
|
|
596
|
+
const plural = (count: number) => (count === 1 ? "" : "s");
|
|
597
|
+
const parts: string[] = [];
|
|
598
|
+
if (data.reads) parts.push(`read ${data.reads} file${plural(data.reads)}`);
|
|
599
|
+
if (data.edits) parts.push(`edited ${data.edits} file${plural(data.edits)}`);
|
|
600
|
+
if (data.commands) parts.push(`ran ${data.commands} command${plural(data.commands)}`);
|
|
601
|
+
if (data.others) parts.push(`${data.others} other tool${plural(data.others)}`);
|
|
602
|
+
if (data.failed) parts.push(`${data.failed} failed`);
|
|
603
|
+
if (parts.length === 0) return "";
|
|
604
|
+
const text = parts.join(", ");
|
|
605
|
+
const capitalized = text[0].toUpperCase() + text.slice(1);
|
|
606
|
+
const duration = formatDuration(data.durationMs);
|
|
607
|
+
return duration ? `${capitalized} · ${duration}` : capitalized;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function normalizeSummary(input: unknown): SummaryData {
|
|
611
|
+
const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
|
|
612
|
+
const num = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
|
|
613
|
+
return {
|
|
614
|
+
reads: num(source.reads),
|
|
615
|
+
edits: num(source.edits),
|
|
616
|
+
commands: num(source.commands),
|
|
617
|
+
others: num(source.others),
|
|
618
|
+
failed: num(source.failed),
|
|
619
|
+
durationMs: num(source.durationMs),
|
|
620
|
+
};
|
|
618
621
|
}
|
|
619
622
|
|
|
620
|
-
function
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
623
|
+
function appendRunSummary(pi: ExtensionAPI) {
|
|
624
|
+
const stats = state.runStats;
|
|
625
|
+
// Single-tool runs are self-evident from the transcript; a summary line
|
|
626
|
+
// would just repeat the row above it.
|
|
627
|
+
if (!isEnabled() || stats.toolCount < 2) return;
|
|
628
|
+
const data: SummaryData = {
|
|
629
|
+
reads: stats.readFiles.size,
|
|
630
|
+
edits: stats.editFiles.size,
|
|
631
|
+
commands: stats.commandCount,
|
|
632
|
+
others: stats.otherCount,
|
|
633
|
+
failed: stats.failedCount,
|
|
634
|
+
durationMs: Date.now() - stats.startedAt,
|
|
635
|
+
};
|
|
636
|
+
pi.appendEntry(SUMMARY_ENTRY_TYPE, data);
|
|
626
637
|
}
|
|
627
638
|
|
|
628
639
|
function restoreConfigFromBranch(ctx: ExtensionContext) {
|
|
629
|
-
let nextConfig =
|
|
640
|
+
let nextConfig = { ...DEFAULT_CONFIG };
|
|
630
641
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
631
642
|
if (entry.type === "custom" && entry.customType === CONFIG_ENTRY_TYPE) {
|
|
632
643
|
nextConfig = normalizeConfig(entry.data);
|
|
@@ -635,8 +646,11 @@ function restoreConfigFromBranch(ctx: ExtensionContext) {
|
|
|
635
646
|
state.config = nextConfig;
|
|
636
647
|
}
|
|
637
648
|
|
|
638
|
-
function
|
|
639
|
-
|
|
649
|
+
function setEnabled(enabled: boolean, pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
650
|
+
state.config.enabled = enabled;
|
|
651
|
+
pi.appendEntry(CONFIG_ENTRY_TYPE, { ...state.config });
|
|
652
|
+
refreshTranscript();
|
|
653
|
+
ctx.ui.notify(`Compact transcript: ${enabled ? "on" : "off"}`, "info");
|
|
640
654
|
}
|
|
641
655
|
|
|
642
656
|
function parseBoolean(value: string | undefined): boolean | undefined {
|
|
@@ -647,372 +661,33 @@ function parseBoolean(value: string | undefined): boolean | undefined {
|
|
|
647
661
|
return undefined;
|
|
648
662
|
}
|
|
649
663
|
|
|
650
|
-
function splitList(value: string): string[] {
|
|
651
|
-
return value
|
|
652
|
-
.split(/[\s,]+/)
|
|
653
|
-
.map((item) => item.trim())
|
|
654
|
-
.filter(Boolean);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function formatList(values: string[]): string {
|
|
658
|
-
return values.length ? values.join(", ") : "(none)";
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
function formatTemplatesInput(templates = state.config.previewTemplates): string {
|
|
662
|
-
return Object.entries(templates)
|
|
663
|
-
.map(([tool, template]) => `${tool}=${template}`)
|
|
664
|
-
.join("; ");
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function formatTemplatesDisplay(): string {
|
|
668
|
-
const keys = Object.keys(state.config.previewTemplates);
|
|
669
|
-
return keys.length ? keys.join(", ") : "(none)";
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function parseTemplatesInput(value: string): Record<string, string> {
|
|
673
|
-
const trimmed = value.trim();
|
|
674
|
-
if (!trimmed || trimmed === "clear") return {};
|
|
675
|
-
const entries: Record<string, string> = {};
|
|
676
|
-
for (const part of trimmed.split(/;\s*/)) {
|
|
677
|
-
const index = part.indexOf("=");
|
|
678
|
-
if (index <= 0) continue;
|
|
679
|
-
const key = part.slice(0, index).trim();
|
|
680
|
-
const template = part.slice(index + 1).trim();
|
|
681
|
-
if (key && template) entries[key] = template;
|
|
682
|
-
}
|
|
683
|
-
return entries;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
function describeConfig(): string {
|
|
687
|
-
return [
|
|
688
|
-
`mode: ${state.config.mode}`,
|
|
689
|
-
`custom tools: ${state.config.compactCustomTools ? "on" : "off"}`,
|
|
690
|
-
`failed tools visible: ${state.config.showFailedTools ? "on" : "off"}`,
|
|
691
|
-
`bash mutation anchors: ${state.config.showBashMutations ? "on" : "off"}`,
|
|
692
|
-
`always show: ${formatList(state.config.alwaysShowTools)}`,
|
|
693
|
-
`mutation tools: ${formatList(state.config.mutationTools)}`,
|
|
694
|
-
`templates: ${formatTemplatesDisplay()}`,
|
|
695
|
-
].join("\n");
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
function commandHelp(): string {
|
|
699
|
-
return [
|
|
700
|
-
"/compact-transcript [status]",
|
|
701
|
-
"/compact-transcript disabled|balanced|aggressive|debug",
|
|
702
|
-
"/compact-transcript custom-tools on|off",
|
|
703
|
-
"/compact-transcript failed on|off",
|
|
704
|
-
"/compact-transcript bash-mutations on|off",
|
|
705
|
-
"/compact-transcript always-show <tool[,tool]|/regex/|clear>",
|
|
706
|
-
"/compact-transcript mutation-tools <tool[,tool]|/regex/|clear>",
|
|
707
|
-
"/compact-transcript template <tool|/regex/> <template|clear>",
|
|
708
|
-
" template placeholders: {name}, {args}, {path}, {command}, {arg.foo}",
|
|
709
|
-
].join("\n");
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// Mode only selects the rendering style; it must not silently rewrite the
|
|
713
|
-
// user's other toggles, or switching modes becomes destructive.
|
|
714
|
-
function setMode(mode: ModeInput) {
|
|
715
|
-
state.config.mode = mode === "off" ? "disabled" : mode;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function onOff(value: boolean): "on" | "off" {
|
|
719
|
-
return value ? "on" : "off";
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function createInputSubmenu(
|
|
723
|
-
title: string,
|
|
724
|
-
prefill: string,
|
|
725
|
-
done: (selectedValue?: string) => void,
|
|
726
|
-
help: string,
|
|
727
|
-
): { render(width: number): string[]; handleInput(data: string): void; invalidate(): void } {
|
|
728
|
-
const input = new Input();
|
|
729
|
-
input.setValue(prefill);
|
|
730
|
-
// Input intentionally keeps cursor position when setValue() is called; for
|
|
731
|
-
// edit-in-place settings, start at the end like a normal command-line field.
|
|
732
|
-
(input as any).cursor = prefill.length;
|
|
733
|
-
input.onSubmit = (value) => done(value.trim());
|
|
734
|
-
input.onEscape = () => done(undefined);
|
|
735
|
-
return {
|
|
736
|
-
render(width: number) {
|
|
737
|
-
const theme = state.currentTheme;
|
|
738
|
-
const container = new Container();
|
|
739
|
-
container.addChild(new Text(theme ? theme.fg("accent", theme.bold(title)) : title, 0, 0));
|
|
740
|
-
container.addChild(new Text(theme ? theme.fg("dim", help) : help, 0, 0));
|
|
741
|
-
container.addChild(new Spacer(1));
|
|
742
|
-
container.addChild(input);
|
|
743
|
-
container.addChild(new Spacer(1));
|
|
744
|
-
container.addChild(new Text(theme ? theme.fg("dim", "Enter saves · Esc cancels") : "Enter saves · Esc cancels", 0, 0));
|
|
745
|
-
return container.render(width);
|
|
746
|
-
},
|
|
747
|
-
handleInput(data: string) {
|
|
748
|
-
input.handleInput(data);
|
|
749
|
-
},
|
|
750
|
-
invalidate() {
|
|
751
|
-
input.invalidate();
|
|
752
|
-
},
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
function buildSettingsItems(): SettingItem[] {
|
|
757
|
-
return [
|
|
758
|
-
{
|
|
759
|
-
id: "mode",
|
|
760
|
-
label: "Mode",
|
|
761
|
-
currentValue: state.config.mode,
|
|
762
|
-
values: ["balanced", "aggressive", "debug", "disabled"],
|
|
763
|
-
description:
|
|
764
|
-
"balanced compacts normal tool bursts; aggressive uses shorter previews; debug keeps rows visible while tuning; disabled turns compact-transcript rendering off.",
|
|
765
|
-
},
|
|
766
|
-
{
|
|
767
|
-
id: "customTools",
|
|
768
|
-
label: "Custom tools",
|
|
769
|
-
currentValue: onOff(state.config.compactCustomTools),
|
|
770
|
-
values: ["on", "off"],
|
|
771
|
-
description: "When on, compact custom/external tools added by extensions. When off, only built-in tools are compacted.",
|
|
772
|
-
},
|
|
773
|
-
{
|
|
774
|
-
id: "failedTools",
|
|
775
|
-
label: "Show failures",
|
|
776
|
-
currentValue: onOff(state.config.showFailedTools),
|
|
777
|
-
values: ["on", "off"],
|
|
778
|
-
description: "Keep failed tools visible even when they are part of an otherwise compacted burst.",
|
|
779
|
-
},
|
|
780
|
-
{
|
|
781
|
-
id: "bashMutations",
|
|
782
|
-
label: "Bash anchors",
|
|
783
|
-
currentValue: onOff(state.config.showBashMutations),
|
|
784
|
-
values: ["on", "off"],
|
|
785
|
-
description: "Keep destructive-looking bash commands visible as anchors and break compact tool bursts around them.",
|
|
786
|
-
},
|
|
787
|
-
{
|
|
788
|
-
id: "alwaysShowTools",
|
|
789
|
-
label: "Always show",
|
|
790
|
-
currentValue: formatList(state.config.alwaysShowTools),
|
|
791
|
-
description: "Press Enter to edit comma-separated tool names, wildcards, or /regex/ rules that should always stay visible.",
|
|
792
|
-
submenu: (_currentValue, done) =>
|
|
793
|
-
createInputSubmenu(
|
|
794
|
-
"Always show tools",
|
|
795
|
-
state.config.alwaysShowTools.join(", "),
|
|
796
|
-
done,
|
|
797
|
-
"Comma-separated names, wildcards, or /regex/. Empty input clears the list.",
|
|
798
|
-
),
|
|
799
|
-
},
|
|
800
|
-
{
|
|
801
|
-
id: "mutationTools",
|
|
802
|
-
label: "Mutation tools",
|
|
803
|
-
currentValue: formatList(state.config.mutationTools),
|
|
804
|
-
description: "Press Enter to edit comma-separated tool names, wildcards, or /regex/ rules that act as mutation anchors.",
|
|
805
|
-
submenu: (_currentValue, done) =>
|
|
806
|
-
createInputSubmenu(
|
|
807
|
-
"Mutation tools",
|
|
808
|
-
state.config.mutationTools.join(", "),
|
|
809
|
-
done,
|
|
810
|
-
"Comma-separated names, wildcards, or /regex/. Empty input clears the list.",
|
|
811
|
-
),
|
|
812
|
-
},
|
|
813
|
-
{
|
|
814
|
-
id: "templates",
|
|
815
|
-
label: "Templates",
|
|
816
|
-
currentValue: formatTemplatesDisplay(),
|
|
817
|
-
description: "Press Enter to edit preview templates as semicolon-separated tool=template pairs.",
|
|
818
|
-
submenu: (_currentValue, done) =>
|
|
819
|
-
createInputSubmenu(
|
|
820
|
-
"Preview templates",
|
|
821
|
-
formatTemplatesInput(),
|
|
822
|
-
done,
|
|
823
|
-
"Use tool=template; /regex/=template. Placeholders: {name}, {args}, {path}, {command}, {arg.foo}. `;` separates pairs, so templates cannot contain it.",
|
|
824
|
-
),
|
|
825
|
-
},
|
|
826
|
-
{
|
|
827
|
-
id: "reset",
|
|
828
|
-
label: "Reset defaults",
|
|
829
|
-
currentValue: "press enter",
|
|
830
|
-
values: ["press enter"],
|
|
831
|
-
description: "Restore the default compact-transcript configuration.",
|
|
832
|
-
},
|
|
833
|
-
];
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function applySettingsItem(id: string, value: string, pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
837
|
-
switch (id) {
|
|
838
|
-
case "mode":
|
|
839
|
-
setMode(value as ModeInput);
|
|
840
|
-
break;
|
|
841
|
-
case "customTools":
|
|
842
|
-
state.config.compactCustomTools = value === "on";
|
|
843
|
-
break;
|
|
844
|
-
case "failedTools":
|
|
845
|
-
state.config.showFailedTools = value === "on";
|
|
846
|
-
break;
|
|
847
|
-
case "bashMutations":
|
|
848
|
-
state.config.showBashMutations = value === "on";
|
|
849
|
-
break;
|
|
850
|
-
case "alwaysShowTools":
|
|
851
|
-
state.config.alwaysShowTools = splitList(value);
|
|
852
|
-
break;
|
|
853
|
-
case "mutationTools":
|
|
854
|
-
state.config.mutationTools = splitList(value);
|
|
855
|
-
break;
|
|
856
|
-
case "templates":
|
|
857
|
-
state.config.previewTemplates = parseTemplatesInput(value);
|
|
858
|
-
break;
|
|
859
|
-
case "reset":
|
|
860
|
-
state.config = cloneConfig(DEFAULT_CONFIG);
|
|
861
|
-
break;
|
|
862
|
-
default:
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
persistConfig(pi);
|
|
866
|
-
applyThinkingLabel(ctx);
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
async function showSettingsPanel(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
870
|
-
if (ctx.mode !== "tui") {
|
|
871
|
-
ctx.ui.notify(describeConfig(), "info");
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
state.currentTheme = ctx.ui.theme;
|
|
875
|
-
|
|
876
|
-
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
877
|
-
let settingsList: SettingsList;
|
|
878
|
-
const refreshValues = () => {
|
|
879
|
-
for (const item of buildSettingsItems()) {
|
|
880
|
-
settingsList.updateValue(item.id, item.currentValue);
|
|
881
|
-
}
|
|
882
|
-
};
|
|
883
|
-
|
|
884
|
-
settingsList = new SettingsList(
|
|
885
|
-
buildSettingsItems(),
|
|
886
|
-
SETTINGS_LIST_HEIGHT,
|
|
887
|
-
getSettingsListTheme(),
|
|
888
|
-
(id, value) => {
|
|
889
|
-
applySettingsItem(id, value, pi, ctx);
|
|
890
|
-
refreshValues();
|
|
891
|
-
tui.requestRender();
|
|
892
|
-
},
|
|
893
|
-
() => done(undefined),
|
|
894
|
-
);
|
|
895
|
-
|
|
896
|
-
return {
|
|
897
|
-
render(width: number) {
|
|
898
|
-
state.currentTheme = theme;
|
|
899
|
-
const title = theme.fg("accent", theme.bold("Compact Transcript"));
|
|
900
|
-
const subtitle = theme.fg("dim", "Configure transcript compaction. Enter/Space changes selected item; Esc closes.");
|
|
901
|
-
const container = new Container();
|
|
902
|
-
container.addChild(new Text(title, 0, 0));
|
|
903
|
-
container.addChild(new Text(subtitle, 0, 0));
|
|
904
|
-
container.addChild(new Spacer(1));
|
|
905
|
-
container.addChild(settingsList);
|
|
906
|
-
return container.render(width);
|
|
907
|
-
},
|
|
908
|
-
invalidate() {
|
|
909
|
-
settingsList.invalidate();
|
|
910
|
-
},
|
|
911
|
-
handleInput(data: string) {
|
|
912
|
-
settingsList.handleInput?.(data);
|
|
913
|
-
tui.requestRender();
|
|
914
|
-
},
|
|
915
|
-
};
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
|
|
919
664
|
function registerCommand(pi: ExtensionAPI) {
|
|
920
665
|
pi.registerCommand("compact-transcript", {
|
|
921
|
-
description: "
|
|
666
|
+
description: "Toggle compact transcript rendering (on/off).",
|
|
922
667
|
getArgumentCompletions(prefix: string) {
|
|
923
|
-
|
|
924
|
-
"status",
|
|
925
|
-
"help",
|
|
926
|
-
"reset",
|
|
927
|
-
"disabled",
|
|
928
|
-
"off",
|
|
929
|
-
"balanced",
|
|
930
|
-
"aggressive",
|
|
931
|
-
"debug",
|
|
932
|
-
"custom-tools on",
|
|
933
|
-
"custom-tools off",
|
|
934
|
-
"failed on",
|
|
935
|
-
"failed off",
|
|
936
|
-
"bash-mutations on",
|
|
937
|
-
"bash-mutations off",
|
|
938
|
-
"always-show clear",
|
|
939
|
-
"mutation-tools clear",
|
|
940
|
-
"template ",
|
|
941
|
-
];
|
|
942
|
-
return commands
|
|
668
|
+
return ["on", "off", "status"]
|
|
943
669
|
.filter((value) => value.startsWith(prefix.trimStart()))
|
|
944
670
|
.map((value) => ({ value, label: value }));
|
|
945
671
|
},
|
|
946
672
|
handler: async (args, ctx) => {
|
|
947
673
|
captureTheme(ctx);
|
|
948
|
-
const trimmed = args.trim();
|
|
949
|
-
if (trimmed === "
|
|
950
|
-
ctx.ui.notify(
|
|
951
|
-
return;
|
|
952
|
-
}
|
|
953
|
-
if (!trimmed || trimmed === "status" || trimmed === "help") {
|
|
954
|
-
await showSettingsPanel(pi, ctx);
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
if (trimmed === "reset") {
|
|
958
|
-
state.config = cloneConfig(DEFAULT_CONFIG);
|
|
959
|
-
persistConfig(pi);
|
|
960
|
-
applyThinkingLabel(ctx);
|
|
961
|
-
await showSettingsPanel(pi, ctx);
|
|
674
|
+
const trimmed = args.trim().toLowerCase();
|
|
675
|
+
if (trimmed === "status") {
|
|
676
|
+
ctx.ui.notify(`Compact transcript: ${isEnabled() ? "on" : "off"}`, "info");
|
|
962
677
|
return;
|
|
963
678
|
}
|
|
964
|
-
if (
|
|
965
|
-
|
|
966
|
-
persistConfig(pi);
|
|
967
|
-
applyThinkingLabel(ctx);
|
|
968
|
-
await showSettingsPanel(pi, ctx);
|
|
679
|
+
if (!trimmed) {
|
|
680
|
+
setEnabled(!isEnabled(), pi, ctx);
|
|
969
681
|
return;
|
|
970
682
|
}
|
|
971
|
-
|
|
972
|
-
const [
|
|
973
|
-
const
|
|
974
|
-
if (
|
|
975
|
-
|
|
976
|
-
if (value === undefined) {
|
|
977
|
-
ctx.ui.notify(`Usage: /compact-transcript ${command} on|off`, "error");
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
if (command === "custom-tools") state.config.compactCustomTools = value;
|
|
981
|
-
if (command === "failed") state.config.showFailedTools = value;
|
|
982
|
-
if (command === "bash-mutations") state.config.showBashMutations = value;
|
|
983
|
-
persistConfig(pi);
|
|
984
|
-
await showSettingsPanel(pi, ctx);
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
if (command === "always-show" || command === "mutation-tools") {
|
|
989
|
-
if (!restText) {
|
|
990
|
-
ctx.ui.notify(`Usage: /compact-transcript ${command} <tool[,tool]|/regex/|clear>`, "error");
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
const values = restText === "clear" ? [] : splitList(restText);
|
|
994
|
-
if (command === "always-show") state.config.alwaysShowTools = values;
|
|
995
|
-
else state.config.mutationTools = values;
|
|
996
|
-
persistConfig(pi);
|
|
997
|
-
await showSettingsPanel(pi, ctx);
|
|
683
|
+
// on/off, plus pre-0.5 mode names as legacy aliases.
|
|
684
|
+
const legacyOn = ["balanced", "aggressive", "debug"].includes(trimmed);
|
|
685
|
+
const parsed = legacyOn ? true : parseBoolean(trimmed);
|
|
686
|
+
if (parsed !== undefined) {
|
|
687
|
+
setEnabled(parsed, pi, ctx);
|
|
998
688
|
return;
|
|
999
689
|
}
|
|
1000
|
-
|
|
1001
|
-
if (command === "template") {
|
|
1002
|
-
const [tool, ...templateParts] = rest;
|
|
1003
|
-
const template = templateParts.join(" ").trim();
|
|
1004
|
-
if (!tool || !template) {
|
|
1005
|
-
ctx.ui.notify("Usage: /compact-transcript template <tool|/regex/> <template|clear>", "error");
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
if (template === "clear") delete state.config.previewTemplates[tool];
|
|
1009
|
-
else state.config.previewTemplates[tool] = template;
|
|
1010
|
-
persistConfig(pi);
|
|
1011
|
-
await showSettingsPanel(pi, ctx);
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
ctx.ui.notify(`Unknown option "${trimmed}".\n${commandHelp()}`, "error");
|
|
690
|
+
ctx.ui.notify(`Unknown option "${trimmed}". Usage: /compact-transcript [on|off|status]`, "error");
|
|
1016
691
|
},
|
|
1017
692
|
});
|
|
1018
693
|
}
|
|
@@ -1021,17 +696,29 @@ export default function compactTranscript(pi: ExtensionAPI) {
|
|
|
1021
696
|
patchRenderers();
|
|
1022
697
|
registerCommand(pi);
|
|
1023
698
|
|
|
699
|
+
pi.registerEntryRenderer<SummaryData>(SUMMARY_ENTRY_TYPE, (entry, _options, theme) => {
|
|
700
|
+
const line = summaryLine(normalizeSummary(entry.data));
|
|
701
|
+
if (!line) return undefined;
|
|
702
|
+
return new Text(theme.fg("muted", line), 0, 0);
|
|
703
|
+
});
|
|
704
|
+
|
|
1024
705
|
pi.on("session_start", async (_event, ctx) => {
|
|
1025
706
|
restoreConfigFromBranch(ctx);
|
|
1026
707
|
captureTheme(ctx);
|
|
1027
|
-
|
|
708
|
+
// Drop all per-tool state and component registries from the previous
|
|
709
|
+
// session; stale burst counts otherwise corrupt the rebuilt transcript
|
|
710
|
+
// after resume or /reload.
|
|
711
|
+
resetToolRun();
|
|
712
|
+
state.runStats = newRunStats();
|
|
713
|
+
state.toolComponents = new Set();
|
|
714
|
+
state.assistantComponents = new Set();
|
|
1028
715
|
ctx.ui.setWorkingMessage();
|
|
1029
716
|
// Clear any footer status left behind by pre-0.4 versions of this extension.
|
|
1030
717
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
1031
718
|
});
|
|
1032
719
|
|
|
1033
720
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1034
|
-
|
|
721
|
+
stopBlinkTimer();
|
|
1035
722
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
1036
723
|
ctx.ui.setWorkingMessage();
|
|
1037
724
|
});
|
|
@@ -1040,38 +727,27 @@ export default function compactTranscript(pi: ExtensionAPI) {
|
|
|
1040
727
|
state.agentActive = true;
|
|
1041
728
|
captureTheme(ctx);
|
|
1042
729
|
resetToolRun();
|
|
1043
|
-
|
|
1044
|
-
state.consecutiveThinking = 0;
|
|
1045
|
-
applyThinkingLabel(ctx);
|
|
730
|
+
state.runStats = newRunStats();
|
|
1046
731
|
});
|
|
1047
732
|
|
|
1048
733
|
pi.on("agent_end", (_event, _ctx) => {
|
|
1049
734
|
state.agentActive = false;
|
|
1050
|
-
state.
|
|
1051
|
-
|
|
735
|
+
state.currentBurst = [];
|
|
736
|
+
state.runningToolIds.clear();
|
|
737
|
+
stopBlinkTimer();
|
|
738
|
+
appendRunSummary(pi);
|
|
1052
739
|
});
|
|
1053
740
|
|
|
1054
741
|
pi.on("turn_start", (_event, ctx) => {
|
|
1055
|
-
state.consecutiveThinking = 0;
|
|
1056
742
|
captureTheme(ctx);
|
|
1057
|
-
applyThinkingLabel(ctx);
|
|
1058
743
|
});
|
|
1059
744
|
|
|
1060
745
|
pi.on("message_update", (event, ctx) => {
|
|
1061
746
|
captureTheme(ctx);
|
|
1062
747
|
const type = event.assistantMessageEvent?.type;
|
|
1063
|
-
if (type === "
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
if (type && !type.startsWith("thinking_")) {
|
|
1069
|
-
state.consecutiveThinking = 0;
|
|
1070
|
-
applyThinkingLabel(ctx);
|
|
1071
|
-
if (type === "text_delta" || type === "text_start") {
|
|
1072
|
-
resetToolRun();
|
|
1073
|
-
resetThinkingSignals();
|
|
1074
|
-
}
|
|
748
|
+
if (type === "text_delta" || type === "text_start") {
|
|
749
|
+
// Visible assistant text ends the current tool burst.
|
|
750
|
+
state.currentBurst = [];
|
|
1075
751
|
}
|
|
1076
752
|
});
|
|
1077
753
|
|