@prjct.app/pi-activity 0.1.3 → 0.2.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,448 @@
1
+ import {
2
+ getLanguageFromPath,
3
+ highlightCode,
4
+ keyHint,
5
+ renderDiff,
6
+ type KeybindingsManager,
7
+ type Theme,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ Container,
11
+ Image,
12
+ Key,
13
+ matchesKey,
14
+ Text,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ wrapTextWithAnsi,
18
+ type Component,
19
+ } from "@earendil-works/pi-tui";
20
+ import { cleanDisplayText, formatBytes, formatDuration, resultText, TOOL_VERBS } from "./format.ts";
21
+ import type {
22
+ ActivityDensity,
23
+ ActivityRecord,
24
+ ActivityRecordSnapshot,
25
+ ActivityStatus,
26
+ ActivitySummaryData,
27
+ FileChange,
28
+ LegacyActivitySummaryData,
29
+ } from "./types.ts";
30
+
31
+ const STATUS_PRESENTATION: Record<ActivityStatus, { symbol: string; color: "accent" | "success" | "error" | "warning" }> = {
32
+ running: { symbol: "◆", color: "accent" },
33
+ success: { symbol: "✓", color: "success" },
34
+ error: { symbol: "✕", color: "error" },
35
+ cancelled: { symbol: "◇", color: "warning" },
36
+ };
37
+
38
+ function safeLine(line: string, width: number): string {
39
+ if (width <= 0) return "";
40
+ return truncateToWidth(line, width, "…");
41
+ }
42
+
43
+ function joinColumns(left: string, right: string, width: number, minimumLeft = 18): string {
44
+ if (!right) return safeLine(left, width);
45
+ const rightWidth = visibleWidth(right);
46
+ if (width < minimumLeft + rightWidth + 2) return safeLine(`${left} ${right}`, width);
47
+ const leftWidth = Math.max(minimumLeft, width - rightWidth - 2);
48
+ const clippedLeft = truncateToWidth(left, leftWidth, "…");
49
+ const padding = " ".repeat(Math.max(2, width - visibleWidth(clippedLeft) - rightWidth));
50
+ return safeLine(`${clippedLeft}${padding}${right}`, width);
51
+ }
52
+
53
+ function recordDuration(record: ActivityRecord | ActivityRecordSnapshot): number {
54
+ if ("durationMs" in record && record.durationMs !== undefined) return record.durationMs;
55
+ return Math.max(0, Date.now() - record.startedAt);
56
+ }
57
+
58
+ function metadataFor(record: ActivityRecord, density: ActivityDensity): string {
59
+ const duration = formatDuration(recordDuration(record));
60
+ const outcome = record.status === "running" ? duration : record.outcome;
61
+ if (density === "minimal") {
62
+ return record.status === "error" || record.status === "cancelled" ? outcome ?? "" : "";
63
+ }
64
+ const parts = [outcome];
65
+ if (record.status !== "running" && duration) parts.push(duration);
66
+ if (density === "forensic") {
67
+ parts.push(record.category);
68
+ if (record.truncated) parts.push("truncated");
69
+ }
70
+ return parts.filter(Boolean).join(" · ");
71
+ }
72
+
73
+ export class ActivityRowComponent implements Component {
74
+ private record: ActivityRecord;
75
+ private theme: Theme;
76
+ private density: () => ActivityDensity;
77
+
78
+ constructor(record: ActivityRecord, theme: Theme, density: () => ActivityDensity) {
79
+ this.record = record;
80
+ this.theme = theme;
81
+ this.density = density;
82
+ }
83
+
84
+ update(record: ActivityRecord, theme: Theme): void {
85
+ this.record = record;
86
+ this.theme = theme;
87
+ }
88
+
89
+ render(width: number): string[] {
90
+ const presentation = STATUS_PRESENTATION[this.record.status];
91
+ const symbol = this.theme.fg(presentation.color, presentation.symbol);
92
+ const verbText = (TOOL_VERBS[this.record.name] ?? this.record.name.toUpperCase()).slice(0, 8).padEnd(8);
93
+ const verb = this.theme.fg("toolTitle", this.theme.bold(verbText));
94
+ const target = this.theme.fg("toolOutput", this.record.target);
95
+ const left = `${symbol} ${verb}${target}`;
96
+ const metadata = metadataFor(this.record, this.density());
97
+ const rightColor = this.record.status === "error"
98
+ ? "error"
99
+ : this.record.status === "cancelled"
100
+ ? "warning"
101
+ : "dim";
102
+ const right = metadata ? this.theme.fg(rightColor, metadata) : "";
103
+ return [joinColumns(left, right, width, 22)];
104
+ }
105
+
106
+ invalidate(): void {}
107
+ }
108
+
109
+ export class ActiveToolsWidget implements Component {
110
+ private readonly getRecords: () => ActivityRecord[];
111
+ private readonly getCompleted: () => number;
112
+ private readonly isWaiting: () => boolean;
113
+ private theme: Theme;
114
+
115
+ constructor(
116
+ getRecords: () => ActivityRecord[],
117
+ getCompleted: () => number,
118
+ isWaiting: () => boolean,
119
+ theme: Theme,
120
+ ) {
121
+ this.getRecords = getRecords;
122
+ this.getCompleted = getCompleted;
123
+ this.isWaiting = isWaiting;
124
+ this.theme = theme;
125
+ }
126
+
127
+ render(width: number): string[] {
128
+ if (this.isWaiting()) {
129
+ return [safeLine(`${this.theme.fg("warning", "!")} ${this.theme.fg("muted", "Waiting for input")}`, width)];
130
+ }
131
+ const records = this.getRecords();
132
+ if (!records.length) return [];
133
+ const header = `${this.theme.fg("accent", "◆")} ${this.theme.bold("Working")} ${this.theme.fg("dim", `· ${records.length} active · ${this.getCompleted()} completed`)}`;
134
+ const lines = [safeLine(header, width)];
135
+ for (const record of records.slice(0, 3)) {
136
+ const verb = (TOOL_VERBS[record.name] ?? record.name.toUpperCase()).padEnd(8);
137
+ const elapsed = this.theme.fg("dim", formatDuration(Date.now() - record.startedAt));
138
+ lines.push(joinColumns(` ${this.theme.fg("toolTitle", verb)}${this.theme.fg("toolOutput", record.target)}`, elapsed, width, 18));
139
+ }
140
+ if (records.length > 3) lines.push(safeLine(this.theme.fg("dim", ` … ${records.length - 3} more active`), width));
141
+ return lines;
142
+ }
143
+
144
+ invalidate(): void {}
145
+ }
146
+
147
+ function count(value: number, singular: string, plural = `${singular}s`): string {
148
+ return `${value} ${value === 1 ? singular : plural}`;
149
+ }
150
+
151
+ function changeStats(change: FileChange): string {
152
+ if (change.kind === "write") {
153
+ const parts = [change.lines === undefined ? undefined : count(change.lines, "line")];
154
+ if (change.bytes !== undefined) parts.push(formatBytes(change.bytes));
155
+ return parts.filter(Boolean).join(" · ");
156
+ }
157
+ return `+${change.additions ?? 0} −${change.deletions ?? 0}`;
158
+ }
159
+
160
+ function isVersionTwo(data: ActivitySummaryData | LegacyActivitySummaryData): data is ActivitySummaryData {
161
+ return (data as { version?: unknown }).version === 2;
162
+ }
163
+
164
+ export class ActivitySummaryComponent implements Component {
165
+ constructor(
166
+ private readonly data: ActivitySummaryData | LegacyActivitySummaryData,
167
+ private readonly expanded: boolean,
168
+ private readonly theme: Theme,
169
+ ) {}
170
+
171
+ render(width: number): string[] {
172
+ if (!isVersionTwo(this.data)) return this.renderLegacy(width);
173
+ const data = this.data;
174
+ const issueCount = data.errorCount + data.cancelledCount;
175
+ const successfulVerification = data.records.some((record) => record.category === "verify" && record.status === "success");
176
+ const symbol = issueCount ? "!" : "✓";
177
+ const symbolColor = issueCount ? "warning" : "success";
178
+ const middle = [
179
+ count(data.actionCount, "action"),
180
+ count(data.modifiedFiles.length, "observed file"),
181
+ issueCount ? count(issueCount, "issue") : successfulVerification ? "verified" : "clean",
182
+ formatDuration(data.durationMs),
183
+ ].filter(Boolean).join(" · ");
184
+ const title = `${this.theme.fg(symbolColor, symbol)} ${this.theme.fg("customMessageLabel", this.theme.bold(this.expanded ? "Activity report" : "Activity"))} ${this.theme.fg("muted", `· ${middle}`)}`;
185
+ if (!this.expanded) return [safeLine(title, width)];
186
+
187
+ const lines = [safeLine(title, width)];
188
+ if (data.modifiedFiles.length) {
189
+ lines.push("", safeLine(this.theme.fg("accent", this.theme.bold("Changed · observed through edit/write")), width));
190
+ for (const change of data.modifiedFiles) {
191
+ const marker = this.theme.fg("warning", change.kind === "write" ? "W" : "M");
192
+ lines.push(joinColumns(` ${marker} ${this.theme.fg("text", cleanDisplayText(change.path))}`, this.theme.fg("dim", changeStats(change)), width, 20));
193
+ }
194
+ }
195
+
196
+ const verifications = data.records.filter((record) => record.category === "verify");
197
+ if (verifications.length) {
198
+ lines.push("", safeLine(this.theme.fg("accent", this.theme.bold("Verified")), width));
199
+ for (const record of verifications) {
200
+ const presentation = STATUS_PRESENTATION[record.status];
201
+ const left = ` ${this.theme.fg(presentation.color, presentation.symbol)} ${this.theme.fg("text", record.target)}`;
202
+ const right = this.theme.fg("dim", [record.outcome, formatDuration(record.durationMs)].filter(Boolean).join(" · "));
203
+ lines.push(joinColumns(left, right, width, 20));
204
+ }
205
+ }
206
+
207
+ const issues = data.records.filter((record) => record.status === "error" || record.status === "cancelled");
208
+ if (issues.length) {
209
+ lines.push("", safeLine(this.theme.fg("error", this.theme.bold("Issues")), width));
210
+ for (const record of issues) {
211
+ const presentation = STATUS_PRESENTATION[record.status];
212
+ lines.push(joinColumns(` ${this.theme.fg(presentation.color, presentation.symbol)} ${this.theme.fg("text", record.target)}`, this.theme.fg(presentation.color, record.outcome ?? record.status), width, 20));
213
+ if (record.errorMessage) lines.push(safeLine(` ${this.theme.fg("dim", record.errorMessage)}`, width));
214
+ }
215
+ }
216
+
217
+ if (data.truncatedCount) {
218
+ lines.push("", safeLine(this.theme.fg("warning", `${count(data.truncatedCount, "result")} truncated; full paths remain in expanded tool output.`), width));
219
+ }
220
+
221
+ const categories = [
222
+ data.categoryCounts.inspect ? `${data.categoryCounts.inspect} inspected` : undefined,
223
+ data.categoryCounts.change ? `${data.categoryCounts.change} changed` : undefined,
224
+ data.categoryCounts.execute ? `${data.categoryCounts.execute} executed` : undefined,
225
+ data.categoryCounts.verify ? `${data.categoryCounts.verify} verified` : undefined,
226
+ ].filter(Boolean).join(" · ");
227
+ if (categories) lines.push("", safeLine(this.theme.fg("dim", `${categories} · ${keyHint("app.tools.expand", "collapse details")}`), width));
228
+ return lines;
229
+ }
230
+
231
+ private renderLegacy(width: number): string[] {
232
+ const files = this.data.modifiedFiles ?? [];
233
+ const failures = this.data.failedActions ?? [];
234
+ const actions = this.data.actionCount === undefined ? "previous run" : count(this.data.actionCount, "action");
235
+ const errors = this.data.errorCount === undefined ? count(failures.length, "error category", "error categories") : count(this.data.errorCount, "error");
236
+ const title = `${this.theme.fg(failures.length ? "error" : "muted", "Activity")} ${this.theme.fg("muted", `· ${actions} · ${count(files.length, "file")} · ${errors}`)}`;
237
+ if (!this.expanded) return [safeLine(title, width)];
238
+ return [safeLine(title, width), ...files.map((path) => safeLine(` • ${path}`, width))];
239
+ }
240
+
241
+ invalidate(): void {}
242
+ }
243
+
244
+ type InspectorFilter = "all" | "changed" | "commands" | "issues";
245
+ type InspectableRecord = ActivityRecord | ActivityRecordSnapshot;
246
+
247
+ function isChanged(record: InspectableRecord): boolean {
248
+ return record.category === "change";
249
+ }
250
+
251
+ function isIssue(record: InspectableRecord): boolean {
252
+ return record.status === "error" || record.status === "cancelled";
253
+ }
254
+
255
+ export class ActivityInspectorComponent implements Component {
256
+ private filter: InspectorFilter = "all";
257
+ private selected = 0;
258
+ private showDetail = false;
259
+ private readonly filters: InspectorFilter[] = ["all", "changed", "commands", "issues"];
260
+
261
+ constructor(
262
+ private readonly records: InspectableRecord[],
263
+ private readonly theme: Theme,
264
+ private readonly keybindings: KeybindingsManager,
265
+ private readonly onClose: () => void,
266
+ private readonly onRender: () => void,
267
+ ) {}
268
+
269
+ private filtered(): InspectableRecord[] {
270
+ const newestFirst = [...this.records].reverse();
271
+ switch (this.filter) {
272
+ case "changed": return newestFirst.filter(isChanged);
273
+ case "commands": return newestFirst.filter((record) => record.name === "bash");
274
+ case "issues": return newestFirst.filter(isIssue);
275
+ default: return newestFirst;
276
+ }
277
+ }
278
+
279
+ private countFor(filter: InspectorFilter): number {
280
+ switch (filter) {
281
+ case "changed": return this.records.filter(isChanged).length;
282
+ case "commands": return this.records.filter((record) => record.name === "bash").length;
283
+ case "issues": return this.records.filter(isIssue).length;
284
+ default: return this.records.length;
285
+ }
286
+ }
287
+
288
+ private changeFilter(direction: number): void {
289
+ const index = this.filters.indexOf(this.filter);
290
+ this.filter = this.filters[(index + direction + this.filters.length) % this.filters.length]!;
291
+ this.selected = 0;
292
+ this.showDetail = false;
293
+ }
294
+
295
+ handleInput(data: string): void {
296
+ const items = this.filtered();
297
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
298
+ this.onClose();
299
+ return;
300
+ }
301
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab"))) this.changeFilter(-1);
302
+ else if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) this.changeFilter(1);
303
+ else if (this.keybindings.matches(data, "tui.select.up")) this.selected = Math.max(0, this.selected - 1);
304
+ else if (this.keybindings.matches(data, "tui.select.down")) this.selected = Math.min(Math.max(0, items.length - 1), this.selected + 1);
305
+ else if (this.keybindings.matches(data, "tui.select.confirm") && items.length) this.showDetail = !this.showDetail;
306
+ else if (["1", "2", "3", "4"].includes(data)) {
307
+ this.filter = this.filters[Number(data) - 1]!;
308
+ this.selected = 0;
309
+ this.showDetail = false;
310
+ }
311
+ this.onRender();
312
+ }
313
+
314
+ render(width: number): string[] {
315
+ const lines: string[] = [safeLine(this.theme.fg("accent", this.theme.bold("Activity inspector")), width)];
316
+ const tabs = this.filters.map((filter, index) => {
317
+ const label = `${index + 1} ${filter[0]!.toUpperCase()}${filter.slice(1)} ${this.countFor(filter)}`;
318
+ return filter === this.filter ? this.theme.fg("accent", this.theme.bold(`[${label}]`)) : this.theme.fg("dim", label);
319
+ }).join(" ");
320
+ lines.push(safeLine(tabs, width), "");
321
+
322
+ const items = this.filtered();
323
+ if (!items.length) {
324
+ lines.push(safeLine(this.theme.fg("dim", " No activity in this filter."), width));
325
+ } else {
326
+ const start = Math.max(0, Math.min(this.selected - 5, Math.max(0, items.length - 10)));
327
+ for (let index = start; index < Math.min(items.length, start + 10); index++) {
328
+ const record = items[index]!;
329
+ const selected = index === this.selected;
330
+ const presentation = STATUS_PRESENTATION[record.status];
331
+ const cursor = selected ? this.theme.fg("accent", "›") : " ";
332
+ const verb = (TOOL_VERBS[record.name] ?? record.name.toUpperCase()).padEnd(8);
333
+ const left = `${cursor} ${this.theme.fg(presentation.color, presentation.symbol)} ${this.theme.fg("toolTitle", verb)}${this.theme.fg("toolOutput", record.target)}`;
334
+ const right = this.theme.fg("dim", [record.outcome, formatDuration(recordDuration(record))].filter(Boolean).join(" · "));
335
+ lines.push(joinColumns(left, right, width, 24));
336
+ }
337
+ if (items.length > 10) lines.push(safeLine(this.theme.fg("dim", ` ${this.selected + 1}/${items.length}`), width));
338
+
339
+ const selectedRecord = items[this.selected];
340
+ if (this.showDetail && selectedRecord) lines.push(...this.renderDetail(selectedRecord, width));
341
+ }
342
+
343
+ const keys = (id: "tui.select.up" | "tui.select.confirm" | "tui.select.cancel") => this.keybindings.getKeys(id).join("/");
344
+ const help = `${keys("tui.select.up")} navigate · left/right filter · ${keys("tui.select.confirm")} inspect · ${keys("tui.select.cancel")} close`;
345
+ lines.push("", safeLine(this.theme.fg("dim", help), width));
346
+ return lines;
347
+ }
348
+
349
+ private renderDetail(record: InspectableRecord, width: number): string[] {
350
+ const lines = ["", safeLine(this.theme.fg("borderMuted", "─".repeat(Math.max(1, width))), width)];
351
+ lines.push(joinColumns(` ${this.theme.fg("accent", "Target")} ${this.theme.fg("text", record.target)}`, this.theme.fg("dim", record.category), width, 20));
352
+ lines.push(safeLine(` ${this.theme.fg("accent", "Status")} ${record.status}${record.outcome ? ` · ${record.outcome}` : ""} · ${formatDuration(recordDuration(record))}`, width));
353
+ if (record.change) lines.push(safeLine(` ${this.theme.fg("accent", "Change")} ${cleanDisplayText(record.change.path)} · ${changeStats(record.change)}`, width));
354
+ if (record.truncated) lines.push(safeLine(` ${this.theme.fg("warning", "Output was truncated")}`, width));
355
+ if (record.errorMessage) lines.push(...wrapTextWithAnsi(` ${this.theme.fg("error", record.errorMessage)}`, Math.max(1, width)));
356
+ if ("outputPreview" in record && record.outputPreview && !record.errorMessage) {
357
+ lines.push(safeLine(` ${this.theme.fg("accent", "Result")}`, width));
358
+ for (const outputLine of record.outputPreview.split("\n").slice(0, 5)) {
359
+ lines.push(safeLine(` ${this.theme.fg("dim", cleanDisplayText(outputLine))}`, width));
360
+ }
361
+ }
362
+ return lines;
363
+ }
364
+
365
+ invalidate(): void {}
366
+ }
367
+
368
+ function numberedCode(text: string, path: string, startLine: number, theme: Theme): string {
369
+ const normalized = text.replace(/\t/g, " ");
370
+ const language = getLanguageFromPath(path);
371
+ const lines = language ? highlightCode(normalized, language) : normalized.split("\n").map((line) => theme.fg("toolOutput", line));
372
+ const lastLine = startLine + Math.max(0, lines.length - 1);
373
+ const digits = String(lastLine).length;
374
+ return lines.map((line, index) => `${theme.fg("dim", String(startLine + index).padStart(digits))} ${theme.fg("borderMuted", "│")} ${line}`).join("\n");
375
+ }
376
+
377
+ function truncationWarnings(result: unknown): string[] {
378
+ if (!result || typeof result !== "object") return [];
379
+ const details = (result as { details?: Record<string, unknown> }).details;
380
+ if (!details) return [];
381
+ const warnings: string[] = [];
382
+ const truncation = details.truncation as { truncated?: boolean; outputLines?: number; totalLines?: number } | undefined;
383
+ if (truncation?.truncated) {
384
+ warnings.push(truncation.totalLines
385
+ ? `Truncated: showing ${truncation.outputLines ?? "some"} of ${truncation.totalLines} lines`
386
+ : "Output truncated");
387
+ }
388
+ if (typeof details.fullOutputPath === "string") warnings.push(`Full output: ${details.fullOutputPath}`);
389
+ if (details.matchLimitReached) warnings.push(`${details.matchLimitReached} match limit reached`);
390
+ if (details.resultLimitReached) warnings.push(`${details.resultLimitReached} result limit reached`);
391
+ if (details.entryLimitReached) warnings.push(`${details.entryLimitReached} entry limit reached`);
392
+ if (details.linesTruncated) warnings.push("Some matching lines were truncated");
393
+ return warnings;
394
+ }
395
+
396
+ export function renderExpandedToolResult(
397
+ name: string,
398
+ args: Record<string, unknown>,
399
+ result: unknown,
400
+ expanded: boolean,
401
+ isError: boolean,
402
+ showImages: boolean,
403
+ theme: Theme,
404
+ record?: ActivityRecord,
405
+ ): Component {
406
+ if (!expanded) return new Container();
407
+ const text = resultText(result);
408
+ const sections: string[] = [];
409
+ const typedResult = result && typeof result === "object"
410
+ ? result as { content?: Array<{ type?: string; data?: string; mimeType?: string }>; details?: Record<string, unknown> }
411
+ : undefined;
412
+ const details = typedResult?.details;
413
+ const images = typedResult?.content?.filter((item) => item.type === "image" && typeof item.data === "string" && typeof item.mimeType === "string") ?? [];
414
+
415
+ if (isError) {
416
+ sections.push(theme.fg("error", text || "Tool execution failed."));
417
+ } else if (name === "edit" && typeof details?.diff === "string") {
418
+ sections.push(renderDiff(details.diff, { filePath: typeof args.path === "string" ? args.path : undefined }));
419
+ } else if (name === "write" && typeof args.content === "string") {
420
+ sections.push(numberedCode(args.content, typeof args.path === "string" ? args.path : "", 1, theme));
421
+ } else if (name === "read" && text) {
422
+ sections.push(numberedCode(text, typeof args.path === "string" ? args.path : "", typeof args.offset === "number" ? args.offset : 1, theme));
423
+ } else if (text) {
424
+ sections.push(text.split("\n").map((line) => theme.fg("toolOutput", line)).join("\n"));
425
+ } else if (!images.length) {
426
+ sections.push(theme.fg("dim", "No textual output."));
427
+ }
428
+
429
+ const warnings = truncationWarnings(result);
430
+ if (warnings.length) sections.push(theme.fg("warning", warnings.map((warning) => `[${warning}]`).join("\n")));
431
+ if (record?.durationMs !== undefined) sections.push(theme.fg("dim", `Took ${formatDuration(record.durationMs)}`));
432
+
433
+ const component = new Container();
434
+ if (sections.length) component.addChild(new Text(`\n${sections.join("\n\n")}`, 0, 0));
435
+ if (images.length && showImages) {
436
+ for (const image of images) {
437
+ component.addChild(new Image(
438
+ image.data!,
439
+ image.mimeType!,
440
+ { fallbackColor: (value) => theme.fg("muted", value) },
441
+ { maxWidthCells: 80, maxHeightCells: 24 },
442
+ ));
443
+ }
444
+ } else if (images.length) {
445
+ component.addChild(new Text(`\n${theme.fg("dim", `[${images.length} image result${images.length === 1 ? "" : "s"} hidden]`)}`, 0, 0));
446
+ }
447
+ return component;
448
+ }