@ian-pascoe/pi-dap 0.1.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,512 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+ import {
3
+ keyText,
4
+ type AgentToolResult,
5
+ type Theme,
6
+ type ThemeColor,
7
+ type ToolRenderResultOptions,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ Container,
11
+ sliceByColumn,
12
+ Spacer,
13
+ stripTerminalSequences,
14
+ Text,
15
+ visibleWidth,
16
+ type Component,
17
+ } from "@earendil-works/pi-tui";
18
+ import { Value } from "typebox/value";
19
+ import {
20
+ DapToolProgressDetailsSchema,
21
+ DapToolResultDetailsSchema,
22
+ type DapPresentationDetails,
23
+ type DapToolParameters,
24
+ type DapToolProgressDetails,
25
+ type DapToolRenderDetails,
26
+ type DapToolResultDetails,
27
+ } from "./dap-tool-contract.js";
28
+
29
+ /** Theme operations used by Pi DAP transcript rendering. */
30
+ export type DapRenderTheme = Pick<Theme, "bold" | "fg">;
31
+
32
+ interface DapResultSummary {
33
+ readonly color: ThemeColor;
34
+ readonly text: string;
35
+ }
36
+
37
+ function humanizeDapOperation(operation: DapToolParameters["operation"]): string {
38
+ const labels = {
39
+ launch: "Launch",
40
+ set_breakpoints: "Set breakpoints",
41
+ continue: "Continue",
42
+ next: "Step over",
43
+ step_in: "Step in",
44
+ step_out: "Step out",
45
+ pause: "Pause",
46
+ stack: "Stack",
47
+ variables: "Variables",
48
+ evaluate: "Evaluate",
49
+ status: "Status",
50
+ stop: "Stop",
51
+ } as const;
52
+ return labels[operation];
53
+ }
54
+
55
+ function progressingDapOperation(operation: DapToolParameters["operation"]): string {
56
+ switch (operation) {
57
+ case "launch":
58
+ return "Launching";
59
+ case "continue":
60
+ return "Continuing";
61
+ case "next":
62
+ return "Stepping over";
63
+ case "step_in":
64
+ return "Stepping in";
65
+ case "step_out":
66
+ return "Stepping out";
67
+ default:
68
+ return humanizeDapOperation(operation);
69
+ }
70
+ }
71
+
72
+ /** Render an absolute workspace path as relative while retaining paths outside the workspace. */
73
+ export function workspaceRelativeDapPath(cwd: string, filePath: string): string {
74
+ if (!isAbsolute(filePath)) return filePath;
75
+ const relativePath = relative(cwd, filePath);
76
+ return relativePath !== "" && !relativePath.startsWith("..") ? relativePath : filePath;
77
+ }
78
+
79
+ /** Remove terminal sequences and unsafe controls from one human-visible Observer UI string. */
80
+ export function sanitizeDapObserverText(text: string): string {
81
+ const normalized = stripTerminalSequences(text).replaceAll("\r\n", "\n").replaceAll("\r", "\n");
82
+ let safe = "";
83
+ for (const character of normalized) {
84
+ const code = character.codePointAt(0) ?? 0;
85
+ if (
86
+ character === "\n" ||
87
+ character === "\t" ||
88
+ code >= 0xa0 ||
89
+ (code >= 0x20 && code <= 0x7e)
90
+ ) {
91
+ safe += character;
92
+ }
93
+ }
94
+ return safe;
95
+ }
96
+
97
+ function boundedDapPreview(text: string, width = 160): string {
98
+ const singleLine = sanitizeDapObserverText(text).replace(/\s+/g, " ").trim();
99
+ if (visibleWidth(singleLine) <= width) return singleLine;
100
+ return `${sliceByColumn(singleLine, 0, width - 1, true).trimEnd()}…`;
101
+ }
102
+
103
+ function dapCallTarget(parameters: DapToolParameters, cwd: string): string | undefined {
104
+ switch (parameters.operation) {
105
+ case "launch":
106
+ return [
107
+ parameters.profile,
108
+ parameters.program === undefined
109
+ ? undefined
110
+ : workspaceRelativeDapPath(cwd, parameters.program),
111
+ ]
112
+ .filter((value): value is string => value !== undefined)
113
+ .join(" · ");
114
+ case "set_breakpoints":
115
+ return `${workspaceRelativeDapPath(cwd, parameters.file_path)} · ${parameters.breakpoints.length}`;
116
+ case "stack":
117
+ return parameters.thread_id === undefined ? undefined : `thread #${parameters.thread_id}`;
118
+ case "variables":
119
+ return "frame_id" in parameters
120
+ ? `frame #${parameters.frame_id}`
121
+ : `reference #${parameters.variables_reference}`;
122
+ case "evaluate":
123
+ return boundedDapPreview(parameters.expression, 72);
124
+ default:
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ function appendField(
130
+ container: Container,
131
+ theme: DapRenderTheme,
132
+ label: string,
133
+ value: string | number,
134
+ ): void {
135
+ container.addChild(new Text(`${theme.fg("muted", `${label}:`)} ${String(value)}`, 0, 0));
136
+ }
137
+
138
+ function appendExpandedCall(
139
+ container: Container,
140
+ parameters: DapToolParameters,
141
+ theme: DapRenderTheme,
142
+ cwd: string,
143
+ ): void {
144
+ switch (parameters.operation) {
145
+ case "launch":
146
+ if (parameters.profile !== undefined)
147
+ appendField(container, theme, "Profile", parameters.profile);
148
+ if (parameters.program !== undefined)
149
+ appendField(container, theme, "Program", workspaceRelativeDapPath(cwd, parameters.program));
150
+ if (parameters.args !== undefined)
151
+ appendField(
152
+ container,
153
+ theme,
154
+ "Arguments",
155
+ parameters.args.map((argument) => boundedDapPreview(argument)).join(" · ") || "(none)",
156
+ );
157
+ if (parameters.cwd !== undefined)
158
+ appendField(
159
+ container,
160
+ theme,
161
+ "Working directory",
162
+ workspaceRelativeDapPath(cwd, parameters.cwd),
163
+ );
164
+ return;
165
+ case "set_breakpoints":
166
+ appendField(container, theme, "File", workspaceRelativeDapPath(cwd, parameters.file_path));
167
+ appendField(container, theme, "Breakpoints", parameters.breakpoints.length);
168
+ for (const breakpoint of parameters.breakpoints.slice(0, 20)) {
169
+ container.addChild(
170
+ new Text(
171
+ ` ${breakpoint.line}${breakpoint.condition === undefined ? "" : ` ${boundedDapPreview(breakpoint.condition)}`}`,
172
+ 0,
173
+ 0,
174
+ ),
175
+ );
176
+ }
177
+ if (parameters.breakpoints.length > 20)
178
+ appendField(container, theme, "Omitted", parameters.breakpoints.length - 20);
179
+ return;
180
+ case "stack":
181
+ if (parameters.thread_id !== undefined)
182
+ appendField(container, theme, "Thread", `#${parameters.thread_id}`);
183
+ if (parameters.start !== undefined) appendField(container, theme, "Start", parameters.start);
184
+ if (parameters.count !== undefined) appendField(container, theme, "Count", parameters.count);
185
+ return;
186
+ case "variables":
187
+ appendField(
188
+ container,
189
+ theme,
190
+ "Source",
191
+ "frame_id" in parameters
192
+ ? `frame #${parameters.frame_id}`
193
+ : `reference #${parameters.variables_reference}`,
194
+ );
195
+ if (parameters.start !== undefined) appendField(container, theme, "Start", parameters.start);
196
+ if (parameters.count !== undefined) appendField(container, theme, "Count", parameters.count);
197
+ return;
198
+ case "evaluate":
199
+ appendField(container, theme, "Expression", boundedDapPreview(parameters.expression));
200
+ if (parameters.frame_id !== undefined)
201
+ appendField(container, theme, "Frame", `#${parameters.frame_id}`);
202
+ return;
203
+ default:
204
+ return;
205
+ }
206
+ }
207
+
208
+ /** Render one DAP call with only the arguments explicitly supplied to the tool. */
209
+ export function renderDapToolCall(
210
+ parameters: DapToolParameters,
211
+ theme: DapRenderTheme,
212
+ expanded: boolean,
213
+ cwd: string,
214
+ ): Component {
215
+ const container = new Container();
216
+ const target = dapCallTarget(parameters, cwd);
217
+ container.addChild(
218
+ new Text(
219
+ [
220
+ theme.fg("toolTitle", theme.bold("DAP")),
221
+ theme.fg("accent", humanizeDapOperation(parameters.operation)),
222
+ target ? theme.fg("muted", target) : undefined,
223
+ ]
224
+ .filter((part): part is string => part !== undefined)
225
+ .join(" "),
226
+ 0,
227
+ 0,
228
+ ),
229
+ );
230
+ if (expanded) {
231
+ container.addChild(new Spacer(1));
232
+ appendExpandedCall(container, parameters, theme, cwd);
233
+ }
234
+ return container;
235
+ }
236
+
237
+ function toolResultText(result: AgentToolResult<unknown>): string {
238
+ return result.content
239
+ .filter((item) => item.type === "text")
240
+ .map((item) => item.text)
241
+ .join("");
242
+ }
243
+
244
+ function pluralizedCount(count: number, noun: string): string {
245
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
246
+ }
247
+
248
+ function stateSummary(details: DapToolResultDetails): DapResultSummary {
249
+ switch (details.state) {
250
+ case "launching":
251
+ case "running":
252
+ return { color: "accent", text: `▶ ${details.state}` };
253
+ case "stopped":
254
+ return {
255
+ color: "accent",
256
+ text: `● stopped${details.stop_reason === undefined ? "" : ` · ${details.stop_reason}`}`,
257
+ };
258
+ case "terminated":
259
+ return {
260
+ color: "success",
261
+ text: `■ terminated${details.exit_code === undefined ? "" : ` · exit ${details.exit_code}`}`,
262
+ };
263
+ case "idle":
264
+ return { color: "success", text: "✓ idle" };
265
+ }
266
+ }
267
+
268
+ function collapsedSummary(details: DapToolResultDetails, cwd: string): DapResultSummary {
269
+ const presentation = details.presentation;
270
+ if (presentation?.kind === "execution_wait") {
271
+ return {
272
+ color: "warning",
273
+ text: `! ${presentation.operation.replaceAll("_", " ")} wait cancelled · Debug Session still ${details.state}`,
274
+ };
275
+ }
276
+ if (presentation?.kind === "breakpoints") {
277
+ const verified = presentation.rows.filter((row) => row.verified).length;
278
+ const unverified = presentation.rows.length - verified;
279
+ return unverified === 0
280
+ ? { color: "success", text: `✓ ${pluralizedCount(verified, "breakpoint")} verified` }
281
+ : { color: "warning", text: `! ${verified} verified · ${unverified} unverified` };
282
+ }
283
+ if (presentation?.kind === "stack_frames") {
284
+ const first = presentation.rows[0];
285
+ const source = first?.source_path ?? first?.source_name;
286
+ const location =
287
+ source === undefined || first === undefined
288
+ ? undefined
289
+ : `${workspaceRelativeDapPath(cwd, source)}:${first.line}`;
290
+ return {
291
+ color: "toolOutput",
292
+ text: `${pluralizedCount(presentation.total_count, "stack frame")}${location === undefined ? "" : ` · ${location}`}`,
293
+ };
294
+ }
295
+ if (presentation?.kind === "variables") {
296
+ const visible = presentation.rows.filter((row) => row.kind === "variable").length;
297
+ return {
298
+ color: "toolOutput",
299
+ text: pluralizedCount(visible + presentation.omitted_count, "variable"),
300
+ };
301
+ }
302
+ if (presentation?.kind === "evaluation") {
303
+ return {
304
+ color: "toolOutput",
305
+ text: `result = ${boundedDapPreview(presentation.value, 80)}${presentation.type === undefined ? "" : ` · ${presentation.type}`}`,
306
+ };
307
+ }
308
+ return stateSummary(details);
309
+ }
310
+
311
+ function appendHeading(container: Container, theme: DapRenderTheme, label: string): void {
312
+ container.addChild(new Spacer(1));
313
+ container.addChild(new Text(theme.fg("muted", theme.bold(label)), 0, 0));
314
+ }
315
+
316
+ function rowLocation(
317
+ row: { readonly line?: number; readonly source_name?: string; readonly source_path?: string },
318
+ cwd: string,
319
+ ): string {
320
+ const source = row.source_path ?? row.source_name;
321
+ const path = source === undefined ? "(unknown source)" : workspaceRelativeDapPath(cwd, source);
322
+ return row.line === undefined ? path : `${path}:${row.line}`;
323
+ }
324
+
325
+ function appendPresentation(
326
+ container: Container,
327
+ presentation: DapPresentationDetails,
328
+ theme: DapRenderTheme,
329
+ cwd: string,
330
+ ): void {
331
+ switch (presentation.kind) {
332
+ case "breakpoints":
333
+ appendHeading(container, theme, "Breakpoints");
334
+ for (const row of presentation.rows.slice(0, 20)) {
335
+ const symbol = row.verified ? theme.fg("success", "✓") : theme.fg("warning", "!");
336
+ const id = row.id === undefined ? "" : theme.fg("dim", ` #${row.id}`);
337
+ const message =
338
+ row.message === undefined
339
+ ? ""
340
+ : theme.fg("warning", ` — ${sanitizeDapObserverText(row.message)}`);
341
+ container.addChild(new Text(`${symbol} ${rowLocation(row, cwd)}${id}${message}`, 0, 0));
342
+ }
343
+ break;
344
+ case "stack_frames":
345
+ appendHeading(container, theme, "Stack Frames");
346
+ for (const row of presentation.rows.slice(0, 20)) {
347
+ container.addChild(
348
+ new Text(
349
+ `${theme.fg("dim", `#${row.id}`)} ${sanitizeDapObserverText(row.name)} ${rowLocation(row, cwd)}:${row.column}`,
350
+ 0,
351
+ 0,
352
+ ),
353
+ );
354
+ }
355
+ break;
356
+ case "variables":
357
+ appendHeading(container, theme, "Variables");
358
+ for (const row of presentation.rows.slice(0, 20)) {
359
+ if (row.kind === "group") {
360
+ container.addChild(
361
+ new Text(
362
+ `${theme.bold(sanitizeDapObserverText(row.name))} ${theme.fg("dim", `#${row.variables_reference}`)}`,
363
+ 0,
364
+ 0,
365
+ ),
366
+ );
367
+ } else {
368
+ const type = row.type === undefined ? "" : ` · ${sanitizeDapObserverText(row.type)}`;
369
+ const reference =
370
+ row.variables_reference === 0 ? "" : theme.fg("dim", ` #${row.variables_reference}`);
371
+ container.addChild(
372
+ new Text(
373
+ `${sanitizeDapObserverText(row.name)} = ${sanitizeDapObserverText(row.value)}${type}${reference}`,
374
+ 0,
375
+ 0,
376
+ ),
377
+ );
378
+ }
379
+ }
380
+ break;
381
+ case "evaluation":
382
+ appendHeading(container, theme, "Evaluation");
383
+ appendField(container, theme, "Value", sanitizeDapObserverText(presentation.value));
384
+ if (presentation.type !== undefined)
385
+ appendField(container, theme, "Type", sanitizeDapObserverText(presentation.type));
386
+ appendField(container, theme, "Variables reference", `#${presentation.variables_reference}`);
387
+ return;
388
+ case "execution_wait":
389
+ return;
390
+ }
391
+ if (presentation.omitted_count > 0) {
392
+ container.addChild(
393
+ new Text(theme.fg("muted", `${presentation.omitted_count} more rows omitted`), 0, 0),
394
+ );
395
+ }
396
+ }
397
+
398
+ function visibleDebuggeeOutput(output: string): string | undefined {
399
+ const heading = output.indexOf("\n\nDebuggee output");
400
+ if (heading < 0) return undefined;
401
+ const content = output.indexOf(":\n", heading);
402
+ return content < 0 ? undefined : sanitizeDapObserverText(output.slice(content + 2));
403
+ }
404
+
405
+ function appendExpandedResult(
406
+ container: Container,
407
+ details: DapToolResultDetails,
408
+ theme: DapRenderTheme,
409
+ output: string,
410
+ cwd: string,
411
+ ): void {
412
+ appendField(container, theme, "State", details.state);
413
+ if (details.adapter_id !== undefined)
414
+ appendField(container, theme, "Adapter", details.adapter_id);
415
+ if (details.profile_id !== undefined)
416
+ appendField(container, theme, "Profile", details.profile_id);
417
+ if (details.stop_reason !== undefined)
418
+ appendField(container, theme, "Stop reason", sanitizeDapObserverText(details.stop_reason));
419
+ if (details.thread_id !== undefined)
420
+ appendField(container, theme, "Thread", `#${details.thread_id}`);
421
+ if (details.exit_code !== undefined)
422
+ appendField(container, theme, "Exit code", details.exit_code);
423
+ if (details.termination_reason !== undefined)
424
+ appendField(
425
+ container,
426
+ theme,
427
+ "Termination",
428
+ sanitizeDapObserverText(details.termination_reason),
429
+ );
430
+ if (details.presentation !== undefined)
431
+ appendPresentation(container, details.presentation, theme, cwd);
432
+ if (
433
+ details.output_discarded_bytes > 0 ||
434
+ details.output_truncated ||
435
+ details.spill_path !== undefined
436
+ ) {
437
+ appendHeading(container, theme, "Output");
438
+ if (details.output_discarded_bytes > 0)
439
+ container.addChild(
440
+ new Text(
441
+ theme.fg(
442
+ "warning",
443
+ `${details.output_discarded_bytes} older Debuggee output bytes discarded`,
444
+ ),
445
+ 0,
446
+ 0,
447
+ ),
448
+ );
449
+ if (details.output_truncated)
450
+ container.addChild(new Text(theme.fg("warning", "Visible output truncated"), 0, 0));
451
+ if (details.spill_path !== undefined)
452
+ appendField(container, theme, "Result Spill", details.spill_path);
453
+ }
454
+ const debuggeeOutput = visibleDebuggeeOutput(output);
455
+ if (debuggeeOutput !== undefined) {
456
+ appendHeading(container, theme, "Debuggee output");
457
+ container.addChild(new Text(theme.fg("toolOutput", debuggeeOutput || "(no output)"), 0, 0));
458
+ }
459
+ }
460
+
461
+ function expansionHint(theme: DapRenderTheme): string {
462
+ return `${theme.fg("dim", ` · ${keyText("app.tools.expand")}`)}${theme.fg("muted", " to expand")}`;
463
+ }
464
+
465
+ function renderProgress(details: DapToolProgressDetails, theme: DapRenderTheme): Component {
466
+ return new Text(
467
+ theme.fg(
468
+ "accent",
469
+ `${progressingDapOperation(details.operation)}… ${Math.floor(details.elapsed_ms / 1_000)}s`,
470
+ ),
471
+ 0,
472
+ 0,
473
+ );
474
+ }
475
+
476
+ /** Render a semantic DAP result while preserving raw agent-facing content outside the Observer UI. */
477
+ export function renderDapToolResult(
478
+ result: AgentToolResult<DapToolRenderDetails | undefined>,
479
+ options: ToolRenderResultOptions,
480
+ theme: DapRenderTheme,
481
+ isError: boolean,
482
+ cwd: string,
483
+ ): Component {
484
+ const output = toolResultText(result);
485
+ if (options.isPartial && Value.Check(DapToolProgressDetailsSchema, result.details)) {
486
+ return renderProgress(result.details, theme);
487
+ }
488
+ if (isError || !Value.Check(DapToolResultDetailsSchema, result.details)) {
489
+ const safeOutput = sanitizeDapObserverText(output);
490
+ const visibleOutput = options.expanded
491
+ ? safeOutput
492
+ : (safeOutput.split("\n").find((line) => line.trim().length > 0) ?? "DAP failed");
493
+ const failurePrefix = isError ? "× " : "";
494
+ return new Text(
495
+ theme.fg(
496
+ isError ? "error" : "toolOutput",
497
+ `${failurePrefix}${visibleOutput}${!options.expanded && safeOutput.includes("\n") ? expansionHint(theme) : ""}`,
498
+ ),
499
+ 0,
500
+ 0,
501
+ );
502
+ }
503
+ const summary = collapsedSummary(result.details, cwd);
504
+ if (!options.expanded) {
505
+ return new Text(`${theme.fg(summary.color, summary.text)}${expansionHint(theme)}`, 0, 0);
506
+ }
507
+ const container = new Container();
508
+ container.addChild(new Text(theme.fg(summary.color, summary.text), 0, 0));
509
+ container.addChild(new Spacer(1));
510
+ appendExpandedResult(container, result.details, theme, output, cwd);
511
+ return container;
512
+ }