@mrclrchtr/supi-debug 4.9.0 → 5.0.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.
@@ -0,0 +1,336 @@
1
+ import type { TruncationResult } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type DebugAgentAccess,
4
+ type DebugEventView,
5
+ isDebugLevel,
6
+ } from "@mrclrchtr/supi-core/debug";
7
+
8
+ const MAX_RENDER_EVENTS = 12;
9
+ const MAX_EVENT_VALUE_DEPTH = 6;
10
+ const MAX_EVENT_VALUE_CHARS = 1_200;
11
+ const MAX_EVENT_VALUE_ITEMS = 256;
12
+ const MAX_EVENT_STRING_CHARS = 300;
13
+ const MAX_EVENT_TEXT_CHARS = 1_000;
14
+
15
+ /** Small truncation facts used by transcript renderers. */
16
+ export interface DebugOutputTruncation {
17
+ truncated: true;
18
+ outputLines: number;
19
+ totalLines: number;
20
+ outputBytes: number;
21
+ totalBytes: number;
22
+ }
23
+
24
+ /** Stable, bounded facts used by the debug tool and its transcript renderers. */
25
+ export type DebugRenderEvent = Omit<DebugEventView, "rawData">;
26
+
27
+ export interface DebugRenderDetails {
28
+ enabled?: boolean;
29
+ agentAccess?: DebugAgentAccess;
30
+ sessionFile?: string;
31
+ rawAccessDenied: boolean;
32
+ rawDataUnavailable: boolean;
33
+ events: DebugRenderEvent[];
34
+ eventCount: number;
35
+ omittedEventCount: number;
36
+ eventDataTruncated: boolean;
37
+ persistedEventCount?: number;
38
+ truncation?: DebugOutputTruncation;
39
+ emptyReason?: "no-persisted-events" | "no-matches";
40
+ }
41
+
42
+ interface DetailValueState {
43
+ remainingChars: number;
44
+ remainingItems: number;
45
+ truncated: boolean;
46
+ seen: WeakSet<object>;
47
+ }
48
+
49
+ interface CreateDebugRenderDetailsOptions {
50
+ enabled?: boolean;
51
+ agentAccess?: DebugAgentAccess;
52
+ sessionFile?: string;
53
+ rawAccessDenied?: boolean;
54
+ rawDataUnavailable?: boolean;
55
+ persistedEventCount?: number;
56
+ truncation?: TruncationResult;
57
+ eventCount?: number;
58
+ emptyReason?: "no-persisted-events" | "no-matches";
59
+ }
60
+
61
+ function boundedText(value: string, maxChars: number): { value: string; truncated: boolean } {
62
+ if (value.length <= maxChars) return { value, truncated: false };
63
+ return { value: `${value.slice(0, Math.max(0, maxChars - 1))}…`, truncated: true };
64
+ }
65
+
66
+ function boundedCount(value: unknown, fallback: number): number {
67
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
68
+ ? Math.floor(value)
69
+ : fallback;
70
+ }
71
+
72
+ function takeItem(state: DetailValueState): boolean {
73
+ if (state.remainingItems <= 0) {
74
+ state.truncated = true;
75
+ return false;
76
+ }
77
+ state.remainingItems--;
78
+ return true;
79
+ }
80
+
81
+ function takeChars(state: DetailValueState, value: string): string {
82
+ if (state.remainingChars <= 0) {
83
+ state.truncated = true;
84
+ return "[Truncated]";
85
+ }
86
+
87
+ const maxChars = Math.min(MAX_EVENT_STRING_CHARS, state.remainingChars);
88
+ state.remainingChars -= maxChars;
89
+ if (value.length <= maxChars) return value;
90
+
91
+ state.truncated = true;
92
+ return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
93
+ }
94
+
95
+ function boundArray(value: unknown[], state: DetailValueState, depth: number): unknown[] {
96
+ const items: unknown[] = [];
97
+ for (const item of value) {
98
+ if (state.remainingChars <= 0 || state.remainingItems <= 0) {
99
+ state.truncated = true;
100
+ break;
101
+ }
102
+ items.push(boundValue(item, state, depth + 1));
103
+ }
104
+ if (items.length < value.length) {
105
+ state.truncated = true;
106
+ items.push("[Truncated]");
107
+ }
108
+ return items;
109
+ }
110
+
111
+ function boundObject(
112
+ value: Record<string, unknown>,
113
+ state: DetailValueState,
114
+ depth: number,
115
+ ): Record<string, unknown> | string {
116
+ const objectValue: Record<string, unknown> = {};
117
+ let omittedEntries = false;
118
+
119
+ try {
120
+ for (const key in value) {
121
+ if (!Object.hasOwn(value, key)) continue;
122
+ if (state.remainingChars <= 0 || state.remainingItems <= 0) {
123
+ state.truncated = true;
124
+ omittedEntries = true;
125
+ break;
126
+ }
127
+ const boundedKey = takeChars(state, key);
128
+ objectValue[boundedKey] = boundValue(value[key], state, depth + 1);
129
+ }
130
+ } catch {
131
+ state.truncated = true;
132
+ return "[Unserializable]";
133
+ }
134
+
135
+ if (omittedEntries) {
136
+ state.truncated = true;
137
+ objectValue["[Truncated]"] = true;
138
+ }
139
+ return objectValue;
140
+ }
141
+
142
+ function boundReference(value: object, state: DetailValueState, depth: number): unknown {
143
+ if (depth >= MAX_EVENT_VALUE_DEPTH) {
144
+ state.truncated = true;
145
+ return "[MaxDepth]";
146
+ }
147
+ if (state.seen.has(value)) {
148
+ state.truncated = true;
149
+ return "[Circular]";
150
+ }
151
+ state.seen.add(value);
152
+ return Array.isArray(value)
153
+ ? boundArray(value, state, depth)
154
+ : boundObject(value as Record<string, unknown>, state, depth);
155
+ }
156
+
157
+ function boundValue(value: unknown, state: DetailValueState, depth: number): unknown {
158
+ if (!takeItem(state)) return "[Truncated]";
159
+ if (value === undefined || value === null) return value;
160
+ if (typeof value === "string") return takeChars(state, value);
161
+ if (typeof value === "boolean") return value;
162
+ if (typeof value === "number")
163
+ return Number.isFinite(value) ? value : takeChars(state, String(value));
164
+ if (typeof value === "bigint") return takeChars(state, `${value}n`);
165
+ if (typeof value === "function" || typeof value === "symbol") {
166
+ return takeChars(state, String(value));
167
+ }
168
+ return boundReference(value, state, depth);
169
+ }
170
+
171
+ function boundEvent(event: DebugEventView): { event: DebugRenderEvent; truncated: boolean } {
172
+ const state: DetailValueState = {
173
+ remainingChars: MAX_EVENT_VALUE_CHARS,
174
+ remainingItems: MAX_EVENT_VALUE_ITEMS,
175
+ truncated: false,
176
+ seen: new WeakSet<object>(),
177
+ };
178
+ const source = boundedText(String(event.source), MAX_EVENT_STRING_CHARS);
179
+ const category = boundedText(String(event.category), MAX_EVENT_STRING_CHARS);
180
+ const message = boundedText(String(event.message), MAX_EVENT_TEXT_CHARS);
181
+ const cwd =
182
+ event.cwd === undefined ? undefined : boundedText(String(event.cwd), MAX_EVENT_STRING_CHARS);
183
+ const operationId =
184
+ event.operationId === undefined
185
+ ? undefined
186
+ : boundedText(String(event.operationId), MAX_EVENT_STRING_CHARS);
187
+
188
+ const boundedEvent: DebugRenderEvent = {
189
+ id: Number.isFinite(event.id) ? event.id : 0,
190
+ timestamp: Number.isFinite(event.timestamp) ? event.timestamp : 0,
191
+ ...(operationId ? { operationId: operationId.value } : {}),
192
+ source: source.value,
193
+ level: event.level,
194
+ category: category.value,
195
+ message: message.value,
196
+ ...(cwd ? { cwd: cwd.value } : {}),
197
+ ...(event.data === undefined ? {} : { data: boundValue(event.data, state, 0) }),
198
+ };
199
+
200
+ return {
201
+ event: boundedEvent,
202
+ truncated:
203
+ source.truncated ||
204
+ category.truncated ||
205
+ message.truncated ||
206
+ Boolean(cwd?.truncated) ||
207
+ Boolean(operationId?.truncated) ||
208
+ state.truncated,
209
+ };
210
+ }
211
+
212
+ function summarizeTruncation(
213
+ truncation: TruncationResult | undefined,
214
+ ): DebugOutputTruncation | undefined {
215
+ if (!truncation?.truncated) return undefined;
216
+ return {
217
+ truncated: true,
218
+ outputLines: truncation.outputLines,
219
+ totalLines: truncation.totalLines,
220
+ outputBytes: truncation.outputBytes,
221
+ totalBytes: truncation.totalBytes,
222
+ };
223
+ }
224
+
225
+ /** Build bounded, JSON-safe details for tool and message transcript surfaces. */
226
+ export function createDebugRenderDetails(
227
+ events: readonly DebugEventView[],
228
+ options: CreateDebugRenderDetailsOptions = {},
229
+ ): DebugRenderDetails {
230
+ const boundedEvents = events.slice(0, MAX_RENDER_EVENTS).map(boundEvent);
231
+ const eventDataTruncated = boundedEvents.some((entry) => entry.truncated);
232
+ const eventCount = Math.max(events.length, boundedCount(options.eventCount, events.length));
233
+
234
+ return {
235
+ ...(options.enabled === undefined ? {} : { enabled: options.enabled }),
236
+ ...(options.agentAccess === undefined ? {} : { agentAccess: options.agentAccess }),
237
+ ...(options.sessionFile === undefined
238
+ ? {}
239
+ : { sessionFile: boundedText(options.sessionFile, MAX_EVENT_STRING_CHARS).value }),
240
+ rawAccessDenied: options.rawAccessDenied === true,
241
+ rawDataUnavailable: options.rawDataUnavailable === true,
242
+ events: boundedEvents.map((entry) => entry.event),
243
+ eventCount,
244
+ omittedEventCount: Math.max(0, eventCount - boundedEvents.length),
245
+ eventDataTruncated,
246
+ ...(options.persistedEventCount === undefined
247
+ ? {}
248
+ : { persistedEventCount: boundedCount(options.persistedEventCount, 0) }),
249
+ ...(options.emptyReason === undefined ? {} : { emptyReason: options.emptyReason }),
250
+ ...(summarizeTruncation(options.truncation)
251
+ ? { truncation: summarizeTruncation(options.truncation) }
252
+ : {}),
253
+ };
254
+ }
255
+
256
+ function parseEvent(value: unknown): DebugEventView | undefined {
257
+ if (typeof value !== "object" || value === null) return undefined;
258
+ const record = value as Record<string, unknown>;
259
+ if (
260
+ typeof record.id !== "number" ||
261
+ !Number.isFinite(record.id) ||
262
+ typeof record.timestamp !== "number" ||
263
+ !Number.isFinite(record.timestamp) ||
264
+ typeof record.source !== "string" ||
265
+ !isDebugLevel(record.level) ||
266
+ typeof record.category !== "string" ||
267
+ typeof record.message !== "string"
268
+ ) {
269
+ return undefined;
270
+ }
271
+
272
+ return {
273
+ id: record.id,
274
+ timestamp: record.timestamp,
275
+ source: record.source,
276
+ level: record.level,
277
+ category: record.category,
278
+ message: record.message,
279
+ ...(typeof record.operationId === "string" ? { operationId: record.operationId } : {}),
280
+ ...(typeof record.cwd === "string" ? { cwd: record.cwd } : {}),
281
+ ...(record.data === undefined ? {} : { data: record.data }),
282
+ };
283
+ }
284
+
285
+ function parseTruncation(value: unknown): DebugOutputTruncation | undefined {
286
+ if (typeof value !== "object" || value === null) return undefined;
287
+ const record = value as Record<string, unknown>;
288
+ if (record.truncated !== true) return undefined;
289
+ return {
290
+ truncated: true,
291
+ outputLines: boundedCount(record.outputLines, 0),
292
+ totalLines: boundedCount(record.totalLines, 0),
293
+ outputBytes: boundedCount(record.outputBytes, 0),
294
+ totalBytes: boundedCount(record.totalBytes, 0),
295
+ };
296
+ }
297
+
298
+ /** Read old or malformed message details without allowing the renderer to throw. */
299
+ export function readDebugRenderDetails(value: unknown): DebugRenderDetails {
300
+ const record =
301
+ typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
302
+ const events = Array.isArray(record.events)
303
+ ? record.events.flatMap((event) => {
304
+ const parsed = parseEvent(event);
305
+ return parsed ? [parsed] : [];
306
+ })
307
+ : [];
308
+ const details = createDebugRenderDetails(events, {
309
+ enabled: typeof record.enabled === "boolean" ? record.enabled : undefined,
310
+ agentAccess:
311
+ record.agentAccess === "off" ||
312
+ record.agentAccess === "sanitized" ||
313
+ record.agentAccess === "raw"
314
+ ? record.agentAccess
315
+ : undefined,
316
+ sessionFile: typeof record.sessionFile === "string" ? record.sessionFile : undefined,
317
+ rawAccessDenied: record.rawAccessDenied === true,
318
+ rawDataUnavailable: record.rawDataUnavailable === true,
319
+ emptyReason:
320
+ record.emptyReason === "no-persisted-events" || record.emptyReason === "no-matches"
321
+ ? record.emptyReason
322
+ : undefined,
323
+ persistedEventCount:
324
+ typeof record.persistedEventCount === "number" ? record.persistedEventCount : undefined,
325
+ eventCount: boundedCount(record.eventCount, events.length),
326
+ });
327
+
328
+ const parsedTruncation = parseTruncation(record.truncation);
329
+ if (parsedTruncation) details.truncation = parsedTruncation;
330
+ details.eventDataTruncated = details.eventDataTruncated || record.eventDataTruncated === true;
331
+ details.omittedEventCount = Math.max(
332
+ details.omittedEventCount,
333
+ boundedCount(record.omittedEventCount, details.omittedEventCount),
334
+ );
335
+ return details;
336
+ }
package/src/renderer.ts CHANGED
@@ -1,16 +1,73 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ type ExtensionAPI,
3
+ formatSize,
4
+ keyHint,
5
+ type Theme,
6
+ type ToolRenderResultOptions,
7
+ truncateHead,
8
+ } from "@earendil-works/pi-coding-agent";
2
9
  import { Text } from "@earendil-works/pi-tui";
3
10
  import type { DebugEventView } from "@mrclrchtr/supi-core/debug";
4
11
  import { formatDataLines } from "./format.ts";
12
+ import {
13
+ createDebugRenderDetails,
14
+ type DebugRenderDetails,
15
+ type DebugRenderEvent,
16
+ readDebugRenderDetails,
17
+ } from "./render-details.ts";
5
18
 
6
19
  const DEBUG_REPORT_TYPE = "supi-debug-report";
20
+ const MAX_RENDER_LINES = 240;
21
+ const MAX_RENDER_BYTES = 16 * 1024;
7
22
 
8
- interface DebugReportDetails {
9
- events?: DebugEventView[];
10
- rawAccessDenied?: boolean;
23
+ interface DebugToolResult {
24
+ content?: Array<{ type: string; text?: string }>;
25
+ details?: unknown;
11
26
  }
12
27
 
13
- type Theme = Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2];
28
+ interface DebugCallArgs {
29
+ operationId?: unknown;
30
+ source?: unknown;
31
+ level?: unknown;
32
+ category?: unknown;
33
+ limit?: unknown;
34
+ sessionFile?: unknown;
35
+ includeRaw?: unknown;
36
+ }
37
+
38
+ function boundedCallValue(value: unknown, maxLength = 80): string | undefined {
39
+ if (typeof value !== "string" && typeof value !== "number") return undefined;
40
+ const text = String(value);
41
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
42
+ }
43
+
44
+ function formatCallArgs(args: unknown): string {
45
+ const input = (args ?? {}) as DebugCallArgs;
46
+ const filters: string[] = [];
47
+ const operationId = boundedCallValue(input.operationId);
48
+ const source = boundedCallValue(input.source);
49
+ const level = boundedCallValue(input.level);
50
+ const category = boundedCallValue(input.category);
51
+ const limit = boundedCallValue(input.limit);
52
+ const sessionFile = boundedCallValue(input.sessionFile);
53
+
54
+ if (operationId) filters.push(`operationId=${operationId}`);
55
+ if (source) filters.push(`source=${source}`);
56
+ if (level) filters.push(`level=${level}`);
57
+ if (category) filters.push(`category=${category}`);
58
+ if (limit) filters.push(`limit=${limit}`);
59
+ if (sessionFile) filters.push(`sessionFile=${sessionFile}`);
60
+ if (input.includeRaw === true) filters.push("raw");
61
+ return filters.join(" ");
62
+ }
63
+
64
+ /** Render the compact human-facing call header for `supi_debug`. */
65
+ export function renderDebugToolCall(args: unknown, theme: Theme): Text {
66
+ const callArgs = formatCallArgs(args);
67
+ let content = theme.fg("toolTitle", "supi_debug");
68
+ if (callArgs) content += ` ${theme.fg("dim", callArgs)}`;
69
+ return new Text(content, 0, 0);
70
+ }
14
71
 
15
72
  function formatLevel(theme: Theme, level: string): string {
16
73
  const color =
@@ -24,81 +81,199 @@ function formatLevel(theme: Theme, level: string): string {
24
81
  return theme.fg(color, level.toUpperCase());
25
82
  }
26
83
 
27
- function pushEventLines(lines: string[], event: DebugEventView, theme: Theme): void {
28
- const timestamp = theme.fg("dim", `[${new Date(event.timestamp).toISOString()}]`);
84
+ function formatTimestamp(timestamp: number): string {
85
+ const date = new Date(timestamp);
86
+ return Number.isNaN(date.getTime()) ? String(timestamp) : date.toISOString();
87
+ }
88
+
89
+ function pushEventLines(lines: string[], event: DebugRenderEvent, theme: Theme): void {
90
+ const timestamp = theme.fg("dim", `[${formatTimestamp(event.timestamp)}]`);
29
91
  const level = formatLevel(theme, event.level);
30
92
  const source = theme.fg("toolTitle", `${event.source}/${event.category}`);
31
93
 
32
94
  lines.push(`${timestamp} ${level} ${source}: ${event.message}`);
95
+ if (event.operationId) lines.push(theme.fg("dim", ` operationId: ${event.operationId}`));
96
+ if (event.cwd) lines.push(theme.fg("dim", ` cwd: ${event.cwd}`));
97
+ pushDataLines(lines, "data", event.data, theme);
98
+ }
33
99
 
34
- if (event.cwd) {
35
- lines.push(theme.fg("dim", ` cwd: ${event.cwd}`));
100
+ function pushDataLines(lines: string[], label: string, value: unknown, theme: Theme): void {
101
+ const dataLines = formatDataLines(value);
102
+ if (dataLines.length === 0) return;
103
+ if (dataLines.length === 1) {
104
+ lines.push(theme.fg("dim", ` ${label}: ${dataLines[0]}`));
105
+ return;
36
106
  }
107
+ lines.push(theme.fg("dim", ` ${label}:`));
108
+ for (const line of dataLines) lines.push(theme.fg("dim", ` ${line}`));
109
+ }
37
110
 
38
- const dataLines = formatDataLines(event.data);
39
- if (dataLines.length > 0) {
40
- if (dataLines.length === 1) {
41
- lines.push(theme.fg("dim", ` data: ${dataLines[0]}`));
42
- } else {
43
- lines.push(theme.fg("dim", " data:"));
44
- for (const dl of dataLines) {
45
- lines.push(theme.fg("dim", ` ${dl}`));
46
- }
47
- }
48
- }
111
+ function formatTruncation(truncation: DebugRenderDetails["truncation"]): string | undefined {
112
+ if (!truncation?.truncated) return undefined;
113
+ const omittedLines = truncation.totalLines - truncation.outputLines;
114
+ const omittedBytes = truncation.totalBytes - truncation.outputBytes;
115
+ return `Agent output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}); ${omittedLines} lines (${formatSize(omittedBytes)}) omitted.`;
116
+ }
49
117
 
50
- const rawLines = formatDataLines(event.rawData);
51
- if (rawLines.length > 0) {
52
- if (rawLines.length === 1) {
53
- lines.push(theme.fg("dim", ` rawData: ${rawLines[0]}`));
54
- } else {
55
- lines.push(theme.fg("dim", " rawData:"));
56
- for (const rl of rawLines) {
57
- lines.push(theme.fg("dim", ` ${rl}`));
58
- }
59
- }
118
+ function buildDetailNotes(details: DebugRenderDetails, theme: Theme): string[] {
119
+ const notes: string[] = [];
120
+ if (details.omittedEventCount > 0) {
121
+ notes.push(
122
+ theme.fg(
123
+ "warning",
124
+ `${details.omittedEventCount} event${details.omittedEventCount === 1 ? "" : "s"} omitted from the transcript view.`,
125
+ ),
126
+ );
127
+ }
128
+ if (details.eventDataTruncated) {
129
+ notes.push(theme.fg("warning", "Some event data was bounded for the transcript view."));
130
+ }
131
+ const truncation = formatTruncation(details.truncation);
132
+ if (truncation) notes.push(theme.fg("warning", truncation));
133
+ if (details.rawDataUnavailable) {
134
+ notes.push(theme.fg("warning", "Raw debug data is not persisted for historical sessions."));
135
+ } else if (details.rawAccessDenied) {
136
+ notes.push(
137
+ theme.fg(
138
+ "warning",
139
+ "Raw debug data was requested but is not enabled in SuPi Debug settings.",
140
+ ),
141
+ );
60
142
  }
143
+ return notes;
61
144
  }
62
145
 
63
- function renderExpandedReport(details: DebugReportDetails, theme: Theme): string {
146
+ function buildEventLines(details: DebugRenderDetails, theme: Theme): string[] {
64
147
  const lines: string[] = [];
65
- for (const event of details.events ?? []) {
148
+ for (const event of details.events) {
66
149
  if (lines.length > 0) lines.push("");
67
150
  pushEventLines(lines, event, theme);
68
151
  }
69
-
70
- if (details.rawAccessDenied) {
71
- lines.push("");
152
+ if (lines.length === 0) {
72
153
  lines.push(
73
154
  theme.fg(
74
- "warning",
75
- "Raw debug data was requested but is not enabled in SuPi Debug settings.",
155
+ "muted",
156
+ details.emptyReason === "no-persisted-events"
157
+ ? "This session has no persisted debug events."
158
+ : "No matching debug events available.",
76
159
  ),
77
160
  );
78
161
  }
162
+ return lines;
163
+ }
79
164
 
80
- return lines.join("\n");
165
+ function renderExpandedReport(details: DebugRenderDetails, theme: Theme): string {
166
+ const notes = buildDetailNotes(details, theme);
167
+ const noteText = notes.join("\n");
168
+ const noteBytes = new TextEncoder().encode(noteText).byteLength;
169
+ const marker = "[Transcript view truncated: more output omitted.]";
170
+ const markerBytes = new TextEncoder().encode(marker).byteLength;
171
+ const result = truncateHead(buildEventLines(details, theme).join("\n"), {
172
+ maxLines: Math.max(1, MAX_RENDER_LINES - notes.length - 1),
173
+ maxBytes: Math.max(1, MAX_RENDER_BYTES - noteBytes - markerBytes - 2),
174
+ });
175
+ const bodyLines = result.content ? result.content.split("\n") : [];
176
+ if (result.truncated) bodyLines.push(marker);
177
+ return [...bodyLines, ...notes].join("\n");
178
+ }
179
+
180
+ function renderToolSummary(details: DebugRenderDetails, theme: Theme): string {
181
+ if (details.eventCount === 0) {
182
+ return details.emptyReason === "no-persisted-events"
183
+ ? theme.fg("muted", "No persisted debug events")
184
+ : theme.fg("muted", "No matching debug events");
185
+ }
186
+
187
+ let summary = `${details.eventCount} event${details.eventCount === 1 ? "" : "s"}`;
188
+ if (details.omittedEventCount > 0) summary += ` · ${details.omittedEventCount} omitted`;
189
+ if (details.eventDataTruncated) summary += " · data bounded";
190
+ if (details.truncation?.truncated) summary += " · output truncated";
191
+ if (details.rawDataUnavailable || details.rawAccessDenied) summary += " · raw unavailable";
192
+ return theme.fg("muted", summary);
193
+ }
194
+
195
+ function renderProgress(details: unknown, expanded: boolean, theme: Theme): Text {
196
+ const progress =
197
+ typeof details === "object" && details !== null ? (details as Record<string, unknown>) : {};
198
+ const scannedLines =
199
+ typeof progress.scannedLines === "number" ? progress.scannedLines.toLocaleString("en-US") : "?";
200
+ const matchedEvents =
201
+ typeof progress.matchedEvents === "number"
202
+ ? progress.matchedEvents.toLocaleString("en-US")
203
+ : "?";
204
+ const line = `Reading persisted debug events… ${scannedLines} lines scanned · ${matchedEvents} matches`;
205
+ return new Text(theme.fg("warning", expanded ? line : "Reading persisted debug events…"), 0, 0);
206
+ }
207
+
208
+ /** Render the compact or expanded result for the `supi_debug` tool. */
209
+ export function renderDebugToolResult(
210
+ result: DebugToolResult,
211
+ options: ToolRenderResultOptions,
212
+ theme: Theme,
213
+ context: { isError: boolean },
214
+ ): Text {
215
+ if (options.isPartial) return renderProgress(result.details, options.expanded, theme);
216
+ if (context.isError) return new Text(theme.fg("error", "supi_debug failed"), 0, 0);
217
+
218
+ const details = readDebugRenderDetails(result.details);
219
+ if (!options.expanded) {
220
+ let summary = renderToolSummary(details, theme);
221
+ if (details.eventCount > 0)
222
+ summary += theme.fg("dim", ` ${keyHint("app.tools.expand", "to expand")}`);
223
+ return new Text(summary, 0, 0);
224
+ }
225
+
226
+ return new Text(renderExpandedReport(details, theme), 0, 0);
227
+ }
228
+
229
+ function renderEmptyMessage(content: unknown, details: DebugRenderDetails, theme: Theme): Text {
230
+ const text =
231
+ typeof content === "string"
232
+ ? content
233
+ : details.emptyReason === "no-persisted-events"
234
+ ? "This session has no persisted debug events."
235
+ : "No debug events.";
236
+ return new Text(theme.fg("muted", text), 0, 0);
237
+ }
238
+
239
+ function renderCollapsedMessage(details: DebugRenderDetails, theme: Theme): Text {
240
+ const first = details.events[0];
241
+ let summary = `${details.eventCount} event${details.eventCount === 1 ? "" : "s"} — ${first?.source ?? "debug"}/${first?.category ?? "event"}`;
242
+ if (details.eventCount > 1) summary += ` +${details.eventCount - 1} more`;
243
+ if (details.omittedEventCount > 0) summary += ` · ${details.omittedEventCount} omitted`;
244
+ if (details.eventDataTruncated) summary += " · data bounded";
245
+ if (details.truncation?.truncated) summary += " · output truncated";
246
+ if (details.rawDataUnavailable || details.rawAccessDenied) summary += " · raw unavailable";
247
+ return new Text(theme.fg("muted", summary), 0, 0);
248
+ }
249
+
250
+ function renderDebugMessage(
251
+ content: unknown,
252
+ details: DebugRenderDetails,
253
+ expanded: boolean,
254
+ theme: Theme,
255
+ ): Text {
256
+ if (details.eventCount === 0) return renderEmptyMessage(content, details, theme);
257
+ if (!expanded) return renderCollapsedMessage(details, theme);
258
+ return new Text(renderExpandedReport(details, theme), 0, 0);
81
259
  }
82
260
 
83
261
  /** Register the TUI message renderer for supi-debug-report custom messages. */
84
262
  export function registerDebugMessageRenderer(pi: ExtensionAPI): void {
85
- pi.registerMessageRenderer(DEBUG_REPORT_TYPE, (message, options, theme) => {
86
- const { expanded } = options;
87
- const details = (message.details ?? {}) as DebugReportDetails;
88
- const events = details.events ?? [];
89
-
90
- if (events.length === 0) {
91
- const text = typeof message.content === "string" ? message.content : "No debug events.";
92
- return new Text(theme.fg("muted", text), 0, 0);
93
- }
94
-
95
- if (!expanded) {
96
- const first = events[0];
97
- const more = events.length > 1 ? ` +${events.length - 1} more` : "";
98
- const summary = `${events.length} event${events.length === 1 ? "" : "s"} — ${first.source}/${first.category}${more}`;
99
- return new Text(theme.fg("muted", summary), 0, 0);
100
- }
101
-
102
- return new Text(renderExpandedReport(details, theme), 0, 0);
103
- });
263
+ pi.registerMessageRenderer(DEBUG_REPORT_TYPE, (message, options, theme) =>
264
+ renderDebugMessage(
265
+ message.content,
266
+ readDebugRenderDetails(message.details),
267
+ options.expanded,
268
+ theme,
269
+ ),
270
+ );
271
+ }
272
+
273
+ /** Create bounded details for a debug report message. */
274
+ export function createDebugMessageDetails(
275
+ events: readonly DebugEventView[],
276
+ options: Parameters<typeof createDebugRenderDetails>[1] = {},
277
+ ): DebugRenderDetails {
278
+ return createDebugRenderDetails(events, options);
104
279
  }