@mrclrchtr/supi-review 1.12.1 → 1.14.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.
@@ -21,6 +21,7 @@ pnpm add @mrclrchtr/supi-core
21
21
  ## Package surfaces
22
22
 
23
23
  - `@mrclrchtr/supi-core/api` — reusable helpers for other packages and extensions
24
+ - `@mrclrchtr/supi-core/report` — shared text/report rendering helpers for TUI and plain-text summaries
24
25
 
25
26
  ## What you get from the API
26
27
 
@@ -71,6 +72,14 @@ The built-in settings UI supports:
71
72
  - active-branch session helper: `getActiveBranchEntries()`
72
73
  - terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
73
74
 
75
+ ### Report helpers
76
+
77
+ - `clampReportWidth()` — enforce a minimum readable report width
78
+ - `formatReportTitle()` / `formatSectionHeader()` — shared themed headers
79
+ - `formatDimLine()` / `formatKeyValueLine()` — common summary rows
80
+ - `formatOverflowHint()` — consistent preview-overflow hints
81
+ - `wrapReportText()` — ANSI-aware wrapped report blocks with optional indentation
82
+
74
83
  ## Example
75
84
 
76
85
  ```ts
@@ -101,3 +110,4 @@ const message = wrapExtensionContext("my-extension", "hello", {
101
110
  - `src/config.ts` — shared config loading and writing
102
111
  - `src/config-settings.ts` — config-backed settings registration helper
103
112
  - `src/settings-ui.ts` — shared settings overlay
113
+ - `src/report.ts` — shared text/report rendering helpers
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.12.1",
3
+ "version": "1.14.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,9 +44,11 @@
44
44
  "./config": "./src/config.ts",
45
45
  "./context": "./src/context.ts",
46
46
  "./debug": "./src/debug-registry.ts",
47
+ "./footer-registry": "./src/footer-registry.ts",
47
48
  "./llm": "./src/llm.ts",
48
49
  "./package.json": "./package.json",
49
50
  "./path": "./src/path.ts",
51
+ "./report": "./src/report.ts",
50
52
  "./progress-widget": "./src/progress-widget.ts",
51
53
  "./project": "./src/project.ts",
52
54
  "./session": "./src/session.ts",
@@ -13,12 +13,16 @@ export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
14
  export * from "./debug-registry.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
+ export * from "./footer-registry.ts";
17
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
18
  export * from "./llm.ts";
17
19
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
20
  export * from "./path.ts";
19
21
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
20
22
  export * from "./project.ts";
21
23
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
24
+ export * from "./report.ts";
25
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
22
26
  export * from "./session.ts";
23
27
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
24
28
  export * from "./settings.ts";
@@ -0,0 +1,57 @@
1
+ // Shared footer contribution registry for SuPi extensions.
2
+ //
3
+ // Extensions register pre-styled text chunks with a placement hint
4
+ // ("stats" for the metrics line, "status" for the extension status line).
5
+ // The custom footer in supi-extras (or PI's built-in footer) reads these
6
+ // contributions and renders them alongside the built-in metrics.
7
+
8
+ import { createRegistry } from "./registry-utils.ts";
9
+
10
+ /** Where the contribution should appear in the footer. */
11
+ export type FooterPlacement = "stats" | "status";
12
+
13
+ /** A single footer contribution registered by an extension. */
14
+ export interface FooterContribution {
15
+ /** Unique key for this contribution. Re-registering with the same key replaces it. */
16
+ key: string;
17
+ /** Which footer line this belongs on. */
18
+ placement: FooterPlacement;
19
+ /**
20
+ * Sort order within the placement (lower values render further left). Default: 100.
21
+ * Priority 0 is reserved for the turn cache-hit part so it stays adjacent to CH.
22
+ */
23
+ priority?: number;
24
+ /** Return the pre-styled text for this contribution. Called on every render. */
25
+ render: () => string;
26
+ }
27
+
28
+ const registry = createRegistry<FooterContribution>("footer-contributions");
29
+
30
+ function sortByPriority(a: FooterContribution, b: FooterContribution): number {
31
+ return (a.priority ?? 100) - (b.priority ?? 100);
32
+ }
33
+
34
+ export const footerContributions = {
35
+ /** Register or replace a footer contribution. */
36
+ register(contribution: FooterContribution): void {
37
+ registry.register(contribution.key, contribution);
38
+ },
39
+
40
+ /** Remove a contribution (e.g. on session_shutdown or when disabled). */
41
+ unregister(key: string): void {
42
+ registry.unregister(key);
43
+ },
44
+
45
+ /** Get contributions for a specific placement, sorted by priority. */
46
+ getByPlacement(placement: FooterPlacement): FooterContribution[] {
47
+ return registry
48
+ .getAll()
49
+ .filter((c) => c.placement === placement)
50
+ .sort(sortByPriority);
51
+ },
52
+
53
+ /** Remove all contributions (primarily for tests). */
54
+ clear(): void {
55
+ registry.clear();
56
+ },
57
+ };
@@ -13,10 +13,14 @@ export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
14
  export * from "./debug-registry.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
+ export * from "./footer-registry.ts";
17
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
18
  export * from "./path.ts";
17
19
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
20
  export * from "./project.ts";
19
21
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
22
+ export * from "./report.ts";
23
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
20
24
  export * from "./session.ts";
21
25
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
22
26
  export * from "./settings.ts";
@@ -8,16 +8,38 @@ import { CancellableLoader, Container, Text } from "@earendil-works/pi-tui";
8
8
 
9
9
  // ── Types ──────────────────────────────────────────────────────────────────
10
10
 
11
+ /** What the reviewer is currently doing and on what. */
12
+ export interface CurrentFocus {
13
+ /** Display label for the active tool (e.g. "Reading", "Searching", "Finding"). */
14
+ label: string;
15
+ /** Context detail (e.g. file path, search pattern, directory). */
16
+ detail: string;
17
+ }
18
+
11
19
  /** Progress state for widget display, compatible with child-session updates. */
12
20
  export interface WidgetProgress {
13
21
  /** Number of agent turns completed. */
14
22
  turns: number;
15
23
  /** Number of tool executions started. */
16
24
  toolUses: number;
17
- /** Human-readable active tool descriptions. */
18
- activities: string[];
19
25
  /** Token usage stats, if available. */
20
- tokens?: { input: number; output: number; total: number };
26
+ tokens?: {
27
+ input: number;
28
+ output: number;
29
+ total: number;
30
+ cacheRead?: number;
31
+ cacheWrite?: number;
32
+ };
33
+ /** Per-tool execution counts keyed by short display label (e.g. "diffs", "reads", "greps"). */
34
+ toolCounts?: Record<string, number>;
35
+ /** Number of distinct files inspected so far (via read_snapshot_diff / read_snapshot_file). */
36
+ filesInspected?: number;
37
+ /** Total files in the review snapshot. */
38
+ filesTotal?: number;
39
+ /** Current tool + context for the progress narrative line. */
40
+ currentFocus?: CurrentFocus;
41
+ /** Elapsed time in milliseconds since the operation started. */
42
+ elapsedMs?: number;
21
43
  }
22
44
 
23
45
  // ── Widget ─────────────────────────────────────────────────────────────────
@@ -25,12 +47,12 @@ export interface WidgetProgress {
25
47
  /**
26
48
  * TUI progress widget for long-running operations.
27
49
  *
28
- * Shows an animated loader, turn count, tool uses, token count, and any active
29
- * tool descriptions while the child session or operation is running.
50
+ * Two-line layout: top line shows the narrative (current focus + file progress),
51
+ * bottom line shows stats (tokens, elapsed time, turns, tool counts).
30
52
  */
31
53
  export class ProgressWidget extends Container {
32
54
  private message: string;
33
- private progress: WidgetProgress = { turns: 0, toolUses: 0, activities: [] };
55
+ private progress: WidgetProgress = { turns: 0, toolUses: 0 };
34
56
  private loader: CancellableLoader;
35
57
  private tui: { requestRender(): void };
36
58
  private theme: Theme;
@@ -79,30 +101,89 @@ export class ProgressWidget extends Container {
79
101
 
80
102
  private renderContent(): void {
81
103
  this.clear();
104
+ this.renderTopLine();
105
+ this.renderBottomLine();
106
+ }
82
107
 
83
- const stats: string[] = [];
84
- if (this.progress.turns > 0) stats.push(`⟳${this.progress.turns}`);
85
- if (this.progress.toolUses > 0) stats.push(`${this.progress.toolUses} tool uses`);
86
- if (this.progress.tokens) stats.push(`${formatTokens(this.progress.tokens.total)} tokens`);
108
+ private renderTopLine(): void {
109
+ const topParts: string[] = [];
87
110
 
88
- const loaderMessage =
89
- stats.length > 0 ? `${this.message} · ${stats.join(" · ")}` : this.message;
111
+ if (this.progress.currentFocus) {
112
+ const { label, detail } = this.progress.currentFocus;
113
+ topParts.push(detail ? `${label}: ${detail}` : label);
114
+ }
115
+
116
+ if (this.progress.filesTotal && this.progress.filesTotal > 0) {
117
+ const inspected = this.progress.filesInspected ?? 0;
118
+ topParts.push(`${inspected}/${this.progress.filesTotal} files`);
119
+ }
90
120
 
121
+ const loaderMessage =
122
+ topParts.length > 0 ? `${this.message} · ${topParts.join(" · ")}` : this.message;
91
123
  this.loader.setMessage(loaderMessage);
92
124
  this.addChild(this.loader);
125
+ }
126
+
127
+ private renderBottomLine(): void {
128
+ const stats: string[] = [];
129
+
130
+ this.appendTokenStats(stats);
131
+
132
+ if (this.progress.elapsedMs !== undefined && this.progress.elapsedMs >= 1000) {
133
+ stats.push(formatElapsed(this.progress.elapsedMs));
134
+ }
93
135
 
94
- if (this.progress.activities.length > 0) {
95
- this.addChild(
96
- new Text(this.theme.fg("dim", ` ⎿ ${this.progress.activities.join(", ")}…`), 1, 0),
97
- );
136
+ if (this.progress.turns > 0) {
137
+ stats.push(`⟳ ${this.progress.turns}`);
98
138
  }
139
+
140
+ if (this.progress.toolCounts) {
141
+ const parts = Object.entries(this.progress.toolCounts)
142
+ .filter(([, count]) => count > 0)
143
+ .sort(([, a], [, b]) => b - a)
144
+ .map(([label, count]) => `${count} ${label}`);
145
+ if (parts.length > 0) stats.push(parts.join(" · "));
146
+ }
147
+
148
+ if (stats.length > 0) {
149
+ this.addChild(new Text(this.theme.fg("dim", ` ${stats.join(" · ")}`), 1, 0));
150
+ }
151
+ }
152
+
153
+ private appendTokenStats(stats: string[]): void {
154
+ const tokens = this.progress.tokens;
155
+ if (!tokens) return;
156
+
157
+ stats.push(`↑ ${formatTokens(tokens.input)}`);
158
+ if (tokens.cacheRead !== undefined && tokens.cacheRead > 0) {
159
+ stats.push(`↲ ${formatTokens(tokens.cacheRead)}`);
160
+ }
161
+ if (tokens.cacheWrite !== undefined && tokens.cacheWrite > 0) {
162
+ stats.push(`↱ ${formatTokens(tokens.cacheWrite)}`);
163
+ }
164
+ stats.push(`↓ ${formatTokens(tokens.output)}`);
99
165
  }
100
166
  }
101
167
 
102
168
  // ── Helpers ────────────────────────────────────────────────────────────────
103
169
 
104
- function formatTokens(count: number): string {
170
+ export function formatTokens(count: number): string {
105
171
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
106
172
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
107
173
  return String(count);
108
174
  }
175
+
176
+ export function formatElapsed(ms: number): string {
177
+ const totalSec = Math.floor(ms / 1000);
178
+ const hours = Math.floor(totalSec / 3600);
179
+ const minutes = Math.floor((totalSec % 3600) / 60);
180
+ const seconds = totalSec % 60;
181
+
182
+ if (hours > 0) {
183
+ return `${hours}h ${minutes}m ${seconds}s`;
184
+ }
185
+ if (minutes > 0) {
186
+ return `${minutes}m ${seconds}s`;
187
+ }
188
+ return `${seconds}s`;
189
+ }
@@ -27,7 +27,7 @@ function getGlobalRegistryMap<T>(name: string): Map<string, T> {
27
27
  *
28
28
  * @typeParam T - The value type stored in the registry.
29
29
  * @param name - Unique registry name (used to construct the `Symbol.for` key).
30
- * @returns An object with `register`, `getAll`, and `clear` functions.
30
+ * @returns An object with `register`, `unregister`, `getAll`, and `clear` functions.
31
31
  */
32
32
  export function createRegistry<T>(name: string) {
33
33
  const getMap = (): Map<string, T> => getGlobalRegistryMap<T>(name);
@@ -40,6 +40,13 @@ export function createRegistry<T>(name: string) {
40
40
  getMap().set(id, value);
41
41
  },
42
42
 
43
+ /**
44
+ * Remove a registration by id. No-op if not registered.
45
+ */
46
+ unregister: (id: string): void => {
47
+ getMap().delete(id);
48
+ },
49
+
43
50
  /**
44
51
  * Get all registered values in registration order.
45
52
  */
@@ -0,0 +1,121 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+
4
+ /** Minimal theme surface required by the shared report helpers. */
5
+ export type ReportTheme = Pick<Theme, "fg">;
6
+
7
+ /** Color keys accepted by the report helpers. */
8
+ export type ReportColor = Parameters<Theme["fg"]>[0];
9
+
10
+ /** Options for rendering a themed key/value report row. */
11
+ export interface KeyValueLineOptions {
12
+ /** The label shown on the left. */
13
+ label: string;
14
+ /** The value shown on the right. */
15
+ value: string;
16
+ /** Theme used for color formatting. */
17
+ theme: ReportTheme;
18
+ /** Maximum rendered width. */
19
+ width: number;
20
+ /** Left indentation in spaces. */
21
+ indent?: number;
22
+ /** Theme color applied to the label. Defaults to `"text"`. */
23
+ labelColor?: ReportColor;
24
+ /** Theme color applied to the value. Defaults to `"dim"`. */
25
+ valueColor?: ReportColor;
26
+ /** Separator placed between label and value. Defaults to `": "`. */
27
+ separator?: string;
28
+ }
29
+
30
+ /** Options for rendering a preview-overflow hint. */
31
+ export interface OverflowHintOptions {
32
+ /** Optional follow-up hint such as `run /supi-context full`. */
33
+ hint?: string | null;
34
+ /** Left indentation in spaces. Defaults to `2`. */
35
+ indent?: number;
36
+ }
37
+
38
+ /** Ensure a report width never drops below the minimum readable width. */
39
+ export function clampReportWidth(width: number, minWidth = 24): number {
40
+ return Math.max(minWidth, width);
41
+ }
42
+
43
+ /** Render a top-level report title line with theme color and truncation. */
44
+ export function formatReportTitle(
45
+ title: string,
46
+ theme: ReportTheme,
47
+ width: number,
48
+ color: ReportColor = "accent",
49
+ ): string {
50
+ return truncateToWidth(theme.fg(color, title), width);
51
+ }
52
+
53
+ /**
54
+ * Render a section header with optional dimmed metadata.
55
+ *
56
+ * Example: `Usage by category 42.3k tokens`
57
+ */
58
+ export function formatSectionHeader(
59
+ title: string,
60
+ meta: string | null,
61
+ theme: ReportTheme,
62
+ width: number,
63
+ ): string {
64
+ const left = theme.fg("text", title);
65
+ const content = meta ? `${left}${theme.fg("dim", ` ${meta}`)}` : left;
66
+ return truncateToWidth(content, width);
67
+ }
68
+
69
+ /** Render a single dimmed report line with optional left indentation. */
70
+ export function formatDimLine(text: string, theme: ReportTheme, width: number, indent = 0): string {
71
+ const safeIndent = Math.max(0, indent);
72
+ return truncateToWidth(`${" ".repeat(safeIndent)}${theme.fg("dim", text)}`, width);
73
+ }
74
+
75
+ /** Render a dimmed preview-overflow hint such as `… and 3 more — run /foo full`. */
76
+ export function formatOverflowHint(
77
+ hiddenCount: number,
78
+ theme: ReportTheme,
79
+ width: number,
80
+ options: OverflowHintOptions = {},
81
+ ): string {
82
+ const { hint = null, indent = 2 } = options;
83
+ const suffix = hint ? ` — ${hint}` : "";
84
+ return formatDimLine(`… and ${hiddenCount} more${suffix}`, theme, width, indent);
85
+ }
86
+
87
+ /** Render a single themed `label: value` row with truncation. */
88
+ export function formatKeyValueLine(options: KeyValueLineOptions): string {
89
+ const {
90
+ label,
91
+ value,
92
+ theme,
93
+ width,
94
+ indent = 2,
95
+ labelColor = "text",
96
+ valueColor = "dim",
97
+ separator = ": ",
98
+ } = options;
99
+ const safeIndent = Math.max(0, indent);
100
+
101
+ return truncateToWidth(
102
+ `${" ".repeat(safeIndent)}${theme.fg(labelColor, label)}${separator}${theme.fg(valueColor, value)}`,
103
+ width,
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Wrap a report text block to the available width and optionally prefix each line.
109
+ *
110
+ * This is useful for wrapped bullets or explanatory notes that should align
111
+ * under an existing report indent.
112
+ */
113
+ export function wrapReportText(
114
+ text: string,
115
+ width: number,
116
+ options: { indent?: string } = {},
117
+ ): string[] {
118
+ const indent = options.indent ?? "";
119
+ const wrapped = wrapTextWithAnsi(text, Math.max(1, width - indent.length));
120
+ return indent ? wrapped.map((line) => `${indent}${line}`) : wrapped;
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-review",
3
- "version": "1.12.1",
3
+ "version": "1.14.0",
4
4
  "description": "SuPi Review extension — structured code review via /supi-review command",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@mrclrchtr/supi-core": "1.12.1"
23
+ "@mrclrchtr/supi-core": "1.14.0"
24
24
  },
25
25
  "bundledDependencies": [
26
26
  "@mrclrchtr/supi-core"
@@ -84,7 +84,8 @@ function emitBriefProgress(
84
84
  invocation: BriefSynthesisInvocation,
85
85
  ): void {
86
86
  ctx.progress.tokens = buildProgressTokens(() => ctx.session.getSessionStats());
87
- invocation.onProgress?.({ ...ctx.progress, activities: [...ctx.progress.activities] });
87
+ ctx.progress.elapsedMs = Date.now() - ctx.startTime;
88
+ invocation.onProgress?.(ctx.progress);
88
89
  }
89
90
 
90
91
  function handleAgentEnd(options: {
@@ -145,13 +146,14 @@ export async function runBriefSynthesis(
145
146
  break;
146
147
  case "tool_execution_start":
147
148
  ctx.progress.toolUses++;
148
- ctx.progress.activities = [
149
- event.toolName === "submit_review_brief" ? "submitting brief" : event.toolName,
150
- ];
149
+ ctx.progress.currentFocus = {
150
+ label: event.toolName === "submit_review_brief" ? "Submitting brief" : event.toolName,
151
+ detail: "",
152
+ };
151
153
  emitBriefProgress(ctx, invocation);
152
154
  break;
153
155
  case "tool_execution_end":
154
- ctx.progress.activities = [];
156
+ ctx.progress.currentFocus = undefined;
155
157
  emitBriefProgress(ctx, invocation);
156
158
  break;
157
159
  case "agent_end": {
@@ -107,7 +107,6 @@ export function buildFailureDebug(input: BuildFailureDebugInput): ReviewFailureD
107
107
  return {
108
108
  turns: input.progress.turns,
109
109
  toolUses: input.progress.toolUses,
110
- activities: input.progress.activities.length > 0 ? [...input.progress.activities] : undefined,
111
110
  tokens: input.progress.tokens,
112
111
  recentEvents: input.recentEvents.length > 0 ? [...input.recentEvents] : undefined,
113
112
  lastAssistantText: lastAssistant?.text,
@@ -38,27 +38,113 @@ export interface RunnerContext {
38
38
  timeoutSteered: boolean;
39
39
  graceTurnsRemaining: number | undefined;
40
40
  debug: { recentEvents: string[] };
41
+ /** Accumulated per-tool-label execution counts. */
42
+ toolCounts: Record<string, number>;
43
+ /** Set of distinct file paths inspected via read_snapshot_diff / read_snapshot_file. */
44
+ inspectedFiles: Set<string>;
45
+ /** Timestamp (ms) when the runner started, for elapsed-time display. */
46
+ startTime: number;
41
47
  }
42
48
 
43
49
  // ---------------------------------------------------------------------------
44
50
  // Tool and progress helpers
45
51
  // ---------------------------------------------------------------------------
46
52
 
47
- /** Maps tool names to human-readable activity descriptions. */
48
- function toolNameToActivity(name: string, phase: "start" | "end"): string {
49
- if (phase === "end") return "";
53
+ /** Maps tool names to compact display labels for the progress line. */
54
+ function toolNameToLabel(name: string): string {
50
55
  const map: Record<string, string> = {
51
- read: "reading",
52
- grep: "searching",
53
- find: "finding files",
54
- ls: "listing files",
55
- submit_review: "submitting review",
56
- read_snapshot_diff: "reading diff",
57
- read_snapshot_file: "reading file",
56
+ read: "reads",
57
+ grep: "greps",
58
+ find: "finds",
59
+ ls: "ls",
60
+ submit_review: "submits",
61
+ read_snapshot_diff: "diffs",
62
+ read_snapshot_file: "file-reads",
58
63
  };
59
64
  return map[name] ?? name;
60
65
  }
61
66
 
67
+ /** Maps tool names to focus labels for the progress narrative line. */
68
+ function toolNameToFocusLabel(name: string): string {
69
+ const map: Record<string, string> = {
70
+ read: "Reading",
71
+ grep: "Searching",
72
+ find: "Finding",
73
+ ls: "Listing",
74
+ submit_review: "Submitting review",
75
+ read_snapshot_diff: "Reading",
76
+ read_snapshot_file: "Reading",
77
+ };
78
+ return map[name] ?? name;
79
+ }
80
+
81
+ /** Extract the basename from a file path, or return the path unchanged. */
82
+ function extractBasename(file: string): string {
83
+ const lastSlash = file.lastIndexOf("/");
84
+ return lastSlash >= 0 ? file.slice(lastSlash + 1) : file;
85
+ }
86
+
87
+ /** Truncate a focus detail string to a display-friendly length. */
88
+ function truncateFocusDetail(value: string): string {
89
+ return value.length > 40 ? `${value.slice(0, 40)}…` : value;
90
+ }
91
+
92
+ /** Extract focus detail from a read-type tool (read, read_snapshot_diff, read_snapshot_file). */
93
+ function tryExtractReadDetail(toolName: string, a: Record<string, unknown>): string | undefined {
94
+ const file = (a.file ?? a.path) as string | undefined;
95
+ if (!file) return undefined;
96
+ const name = extractBasename(file);
97
+ if (toolName === "read_snapshot_diff") return `${name} (diff)`;
98
+ if (toolName === "read_snapshot_file") return `${name} (full)`;
99
+ return name;
100
+ }
101
+
102
+ /** Extract a human-readable detail string from tool args for the focus display. */
103
+ function tryExtractFocusDetail(toolName: string, args: unknown): string | undefined {
104
+ if (typeof args !== "object" || args === null) return undefined;
105
+ const a = args as Record<string, unknown>;
106
+
107
+ // submit_review is self-explanatory — no detail needed
108
+ if (toolName === "submit_review") return undefined;
109
+
110
+ // Read-type tools: extract file path, show basename only
111
+ if (
112
+ toolName === "read" ||
113
+ toolName === "read_snapshot_diff" ||
114
+ toolName === "read_snapshot_file"
115
+ ) {
116
+ return tryExtractReadDetail(toolName, a);
117
+ }
118
+
119
+ // grep: show the search pattern
120
+ if (toolName === "grep") {
121
+ const pattern = a.pattern as string | undefined;
122
+ return pattern ? truncateFocusDetail(pattern) : undefined;
123
+ }
124
+
125
+ // find: show the glob / pattern
126
+ if (toolName === "find") {
127
+ const pattern = (a.pattern ?? a.glob) as string | undefined;
128
+ return pattern ? truncateFocusDetail(pattern) : undefined;
129
+ }
130
+
131
+ // ls: show the listing path
132
+ if (toolName === "ls") {
133
+ const path = a.path as string | undefined;
134
+ return path ? truncateFocusDetail(path) : undefined;
135
+ }
136
+
137
+ return undefined;
138
+ }
139
+
140
+ /** Extract a file path from tool args when the tool is file-inspecting (for inspectedFiles tracking). */
141
+ function tryExtractFileArg(toolName: string, args: unknown): string | undefined {
142
+ if (toolName !== "read_snapshot_diff" && toolName !== "read_snapshot_file") return undefined;
143
+ if (typeof args !== "object" || args === null) return undefined;
144
+ const file = (args as Record<string, unknown>).file;
145
+ return typeof file === "string" ? file : undefined;
146
+ }
147
+
62
148
  export function createSubmitReviewTool(resultHolder: {
63
149
  value: ReviewOutputEvent | undefined;
64
150
  }): ReturnType<typeof defineTool> {
@@ -81,7 +167,11 @@ export function createSubmitReviewTool(resultHolder: {
81
167
 
82
168
  export function emitProgress(ctx: RunnerContext): void {
83
169
  ctx.progress.tokens = buildProgressTokens(() => ctx.session.getSessionStats());
84
- ctx.invocation.onProgress?.({ ...ctx.progress, activities: [...ctx.progress.activities] });
170
+ ctx.progress.toolCounts = { ...ctx.toolCounts };
171
+ ctx.progress.filesInspected = ctx.inspectedFiles.size;
172
+ ctx.progress.filesTotal = ctx.invocation.snapshot.stats.files;
173
+ ctx.progress.elapsedMs = Date.now() - ctx.startTime;
174
+ ctx.invocation.onProgress?.(ctx.progress);
85
175
  }
86
176
 
87
177
  // ---------------------------------------------------------------------------
@@ -90,7 +180,6 @@ export function emitProgress(ctx: RunnerContext): void {
90
180
 
91
181
  export function handleTurnEnd(ctx: RunnerContext): void {
92
182
  ctx.progress.turns++;
93
- ctx.progress.activities = [];
94
183
 
95
184
  if (!ctx.state.settled && ctx.timeoutSteered && ctx.graceTurnsRemaining !== undefined) {
96
185
  ctx.graceTurnsRemaining--;
@@ -108,8 +197,19 @@ export function handleToolStart(
108
197
  ctx: RunnerContext,
109
198
  ): void {
110
199
  ctx.progress.toolUses++;
111
- const activity = toolNameToActivity(event.toolName, "start");
112
- if (activity) ctx.progress.activities.push(activity);
200
+
201
+ const label = toolNameToLabel(event.toolName);
202
+ ctx.toolCounts[label] = (ctx.toolCounts[label] ?? 0) + 1;
203
+
204
+ const file = tryExtractFileArg(event.toolName, event.args);
205
+ if (file) ctx.inspectedFiles.add(file);
206
+
207
+ const focusDetail = tryExtractFocusDetail(event.toolName, event.args);
208
+ ctx.progress.currentFocus = {
209
+ label: toolNameToFocusLabel(event.toolName),
210
+ detail: focusDetail ?? "",
211
+ };
212
+
113
213
  ctx.invocation.onToolActivity?.({ toolName: event.toolName, phase: "start" });
114
214
  emitProgress(ctx);
115
215
  }
@@ -118,11 +218,7 @@ export function handleToolEnd(
118
218
  event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
119
219
  ctx: RunnerContext,
120
220
  ): void {
121
- const activity = toolNameToActivity(event.toolName, "start");
122
- if (activity) {
123
- const index = ctx.progress.activities.indexOf(activity);
124
- if (index !== -1) ctx.progress.activities.splice(index, 1);
125
- }
221
+ ctx.progress.currentFocus = undefined;
126
222
  ctx.invocation.onToolActivity?.({ toolName: event.toolName, phase: "end" });
127
223
  emitProgress(ctx);
128
224
  }
@@ -201,6 +201,8 @@ function buildRunnerCtx(runner: ReviewerRunnerState): RunnerContext {
201
201
  ctx.timeoutSteered = runner.timeoutSteered;
202
202
  ctx.graceTurnsRemaining = runner.graceTurnsRemaining;
203
203
  ctx.debug = runner.debug;
204
+ ctx.toolCounts = {};
205
+ ctx.inspectedFiles = new Set();
204
206
  return ctx;
205
207
  }
206
208
 
@@ -214,6 +216,7 @@ function syncCtxFromLifecycle(
214
216
  ctx.resolve = lcCtx.resolve;
215
217
  ctx.cleanup = lcCtx.cleanup;
216
218
  ctx.state = lcCtx.state;
219
+ ctx.startTime = lcCtx.startTime;
217
220
  ctx.submitSteered = runner.submitSteered;
218
221
  ctx.timeoutSteered = runner.timeoutSteered;
219
222
  ctx.graceTurnsRemaining = runner.graceTurnsRemaining;
@@ -42,8 +42,18 @@ export function extractAssistantText(content: unknown): string | undefined {
42
42
 
43
43
  /** Build a truncated string representation of session stats for progress. */
44
44
  export function buildProgressTokens(
45
- getSessionStats: () => { tokens?: { input?: number; output?: number; total?: number } },
46
- ): { input: number; output: number; total: number } | undefined {
45
+ getSessionStats: () => {
46
+ tokens?: {
47
+ input?: number;
48
+ output?: number;
49
+ total?: number;
50
+ cacheRead?: number;
51
+ cacheWrite?: number;
52
+ };
53
+ },
54
+ ):
55
+ | { input: number; output: number; total: number; cacheRead?: number; cacheWrite?: number }
56
+ | undefined {
47
57
  try {
48
58
  const stats = getSessionStats();
49
59
  return stats?.tokens
@@ -51,6 +61,8 @@ export function buildProgressTokens(
51
61
  input: stats.tokens.input ?? 0,
52
62
  output: stats.tokens.output ?? 0,
53
63
  total: stats.tokens.total ?? 0,
64
+ cacheRead: stats.tokens.cacheRead,
65
+ cacheWrite: stats.tokens.cacheWrite,
54
66
  }
55
67
  : undefined;
56
68
  } catch {
@@ -27,6 +27,8 @@ export interface LifecycleCtx<TResult> {
27
27
  * Useful for custom timers or resources set up by `onTimeout`.
28
28
  */
29
29
  addTeardown: (fn: () => void) => void;
30
+ /** Timestamp (ms) when the lifecycle started, for elapsed-time display. */
31
+ startTime: number;
30
32
  }
31
33
 
32
34
  /** Configuration for `runWithLifecycle`. */
@@ -90,7 +92,6 @@ export function runWithLifecycle<TResult>(
90
92
  const progress: ReviewProgress = {
91
93
  turns: 0,
92
94
  toolUses: 0,
93
- activities: [],
94
95
  tokens: undefined,
95
96
  };
96
97
  const state: { settled: boolean; aborting: boolean } = {
@@ -122,6 +123,8 @@ export function runWithLifecycle<TResult>(
122
123
  return result;
123
124
  };
124
125
 
126
+ const startTime = Date.now();
127
+
125
128
  return new Promise<TResult>((resolve) => {
126
129
  const ctx: LifecycleCtx<TResult> = {
127
130
  resolve,
@@ -130,6 +133,7 @@ export function runWithLifecycle<TResult>(
130
133
  state,
131
134
  session,
132
135
  addTeardown,
136
+ startTime,
133
137
  };
134
138
 
135
139
  session.subscribe((event: AgentSessionEvent) => {
package/src/types.ts CHANGED
@@ -128,11 +128,12 @@ export interface ReviewPacket {
128
128
  export interface ReviewFailureDebugInfo {
129
129
  turns: number;
130
130
  toolUses: number;
131
- activities?: string[];
132
131
  tokens?: {
133
132
  input: number;
134
133
  output: number;
135
134
  total: number;
135
+ cacheRead?: number;
136
+ cacheWrite?: number;
136
137
  };
137
138
  recentEvents?: string[];
138
139
  lastAssistantText?: string;
@@ -200,10 +201,24 @@ export interface ReviewProgress {
200
201
  turns: number;
201
202
  /** Number of tool executions started. */
202
203
  toolUses: number;
203
- /** Human-readable active tool descriptions. */
204
- activities: string[];
205
204
  /** Token usage stats, if available. */
206
- tokens?: { input: number; output: number; total: number };
205
+ tokens?: {
206
+ input: number;
207
+ output: number;
208
+ total: number;
209
+ cacheRead?: number;
210
+ cacheWrite?: number;
211
+ };
212
+ /** Per-tool execution counts keyed by short display label (e.g. "diffs", "reads", "greps"). */
213
+ toolCounts?: Record<string, number>;
214
+ /** Number of distinct files inspected so far (via read_snapshot_diff / read_snapshot_file). */
215
+ filesInspected?: number;
216
+ /** Total files in the review snapshot. */
217
+ filesTotal?: number;
218
+ /** Current tool + context for the progress narrative line. */
219
+ currentFocus?: { label: string; detail: string };
220
+ /** Elapsed time in milliseconds since the operation started. */
221
+ elapsedMs?: number;
207
222
  }
208
223
 
209
224
  export type BriefSynthesisRunResult =
@@ -87,9 +87,6 @@ function appendDebugContent(parts: string[], debug: ReviewFailureDebugInfo | und
87
87
  const lines = [
88
88
  `- Turns: ${debug.turns}`,
89
89
  `- Tool uses: ${debug.toolUses}`,
90
- debug.activities && debug.activities.length > 0
91
- ? `- Active: ${debug.activities.join(", ")}`
92
- : undefined,
93
90
  debug.tokens
94
91
  ? `- Tokens: ${debug.tokens.input} in / ${debug.tokens.output} out / ${debug.tokens.total} total`
95
92
  : undefined,
@@ -210,9 +210,6 @@ function renderFailureDebug(
210
210
 
211
211
  const lines = [
212
212
  `Turns: ${debug.turns} · Tool uses: ${debug.toolUses}`,
213
- debug.activities && debug.activities.length > 0
214
- ? `Active: ${debug.activities.join(", ")}`
215
- : undefined,
216
213
  debug.tokens
217
214
  ? `Tokens: ${debug.tokens.input} in / ${debug.tokens.output} out / ${debug.tokens.total} total`
218
215
  : undefined,