@mrclrchtr/supi-context 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  # @mrclrchtr/supi-context
10
10
 
11
- Adds a `/supi-context` command to the [pi coding agent](https://github.com/earendil-works/pi) so you can see where your context window is going.
11
+ Adds a `/supi-context` command to the [pi coding agent](https://github.com/earendil-works/pi) so you can inspect how the current session is spending its context window.
12
12
 
13
13
  ## Install
14
14
 
@@ -26,41 +26,64 @@ pi install ./packages/supi-context
26
26
 
27
27
  ## What you get
28
28
 
29
- After install, pi gets one command:
29
+ After install, pi gets one user command:
30
30
 
31
31
  - `/supi-context` — render a detailed context-usage report for the current session
32
+ - `/supi-context full` — render the same report with the full guideline and tool-definition lists instead of previews
32
33
 
33
- The report includes:
34
+ The command sends a custom `supi-context` message, and this package registers a dedicated renderer so the report shows up as a structured TUI view instead of plain text.
34
35
 
35
- - model name and context-window size
36
- - estimated or scaled total token usage
37
- - a visual grid of used space, free space, and autocompact buffer
38
- - token usage by category: system prompt, user messages, assistant messages, tool calls, tool results, and other
39
- - system-prompt breakdown for context files, skills, guidelines, tool snippets, and append text
36
+ ## What the report shows
40
37
 
41
- - **Guidelines attribution** breaks down guideline bullets by source: PI built-in defaults, known built-in tools (`read`, `write`, `edit`), and other (extensions/custom tools). Each source shows its token count and bullet count.
42
- - **Tool snippet breakdown** — shows per-tool one-line snippet tokens alongside definition tokens in the tool definitions section.
38
+ The report is meant to answer questions like:
43
39
 
44
- - injected subdirectory context files from `supi-claude-md`, including turn number and token cost
45
- - active skills and their token cost
46
- - tool-definition count and token cost, with per-tool snippet token column
47
- - guideline source summary (e.g., "2 bullets from default · 1 bullet from read · 3 bullets from edit")
48
- - compaction summary when older turns were summarized
49
- - extra provider sections from extensions registered through the shared context-provider registry
40
+ - what is taking up space in the current context window?
41
+ - how much room is left before compaction pressure gets worse?
42
+ - which instruction files, context files, skills, guidelines, or tools are expensive?
43
+ - what extra context was injected by other SuPi extensions?
44
+
45
+ It includes:
46
+
47
+ - model name, context-window size, and total token usage
48
+ - approximation or pending-usage notes when exact usage data is not available yet
49
+ - a visual usage bar for system prompt, user messages, assistant messages, tool calls, tool results, other, autocompact buffer, and free space
50
+ - a category breakdown table for the same usage buckets
51
+ - a system-prompt composition breakdown for:
52
+ - base prompt content
53
+ - instruction files (`AGENTS.md`, `CLAUDE.md`, etc.)
54
+ - other context files loaded into the system prompt
55
+ - active skills
56
+ - guidelines
57
+ - tool snippets
58
+ - append text
59
+ - instruction-file details with token cost, line count, and detected origin (`project` vs `global`)
60
+ - injected subdirectory context files from `supi-claude-md`, including turn number, line count, and token cost
61
+ - active skill names with per-skill token counts
62
+ - guideline bullet previews, plus source attribution for PI defaults, known built-in tools (`read`, `write`, `edit`), and `other`
63
+ - active tool definitions with per-tool definition token counts and snippet-token columns when available
64
+ - a compaction note when older turns were summarized
65
+ - extra provider sections from extensions registered through the shared context-provider registry in `@mrclrchtr/supi-core`
66
+
67
+ ## Configuration
68
+
69
+ No settings are required.
70
+
71
+ This package does not add a model-callable tool; it adds a user command only.
50
72
 
51
73
  ## Notes
52
74
 
53
- - The command uses the latest cached `systemPromptOptions` captured before an agent run.
54
- - When exact usage data is not available yet, the report falls back to estimated token counts and shows an approximation note.
55
- - The command does not add a model-callable tool; it is a user command only.
75
+ - The command uses the latest cached `systemPromptOptions` captured during `before_agent_start`.
76
+ - If those prompt options are missing or incomplete, the package backfills context files and skills by re-parsing the current system prompt.
77
+ - Exact totals come from pi's current context-usage data when available. Otherwise the report falls back to rough estimates and/or scales estimated category totals to the latest measured total.
78
+ - If no model is selected yet, the report can still render, but the context-window bar cannot show capacity.
56
79
 
57
80
  ## Source
58
81
 
59
- - `src/context.ts` — command registration and cached prompt-option handling
60
- - `src/analysis.ts` — token accounting and report data
61
- - `src/format.ts` — formatted report output
62
- - `src/prompt-inference.ts` — model-specific context window detection
63
- - `src/renderer.ts` — custom message renderer for the report
64
- - `src/utils.ts` — token formatting helpers
82
+ - `src/context.ts` — command registration, cached prompt-option handling, and renderer wiring
83
+ - `src/analysis.ts` — token accounting, attribution, and report data assembly
84
+ - `src/format.ts` — formatted report output for the TUI view
85
+ - `src/prompt-inference.ts` — fallback recovery of context files, skills, and guideline sections from the live system prompt
86
+ - `src/renderer.ts` — custom renderer for `supi-context` messages
87
+ - `src/utils.ts` — token and plural-format helpers
65
88
 
66
89
  Tests live under `__tests__/unit/`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,11 +19,15 @@
19
19
  "!__tests__"
20
20
  ],
21
21
  "peerDependencies": {
22
+ "@earendil-works/pi-ai": "*",
22
23
  "@earendil-works/pi-coding-agent": "*",
23
24
  "@earendil-works/pi-tui": "*",
24
25
  "typebox": "*"
25
26
  },
26
27
  "peerDependenciesMeta": {
28
+ "@earendil-works/pi-ai": {
29
+ "optional": true
30
+ },
27
31
  "@earendil-works/pi-coding-agent": {
28
32
  "optional": true
29
33
  },
@@ -40,8 +44,10 @@
40
44
  "./config": "./src/config.ts",
41
45
  "./context": "./src/context.ts",
42
46
  "./debug": "./src/debug-registry.ts",
47
+ "./llm": "./src/llm.ts",
43
48
  "./package.json": "./package.json",
44
49
  "./path": "./src/path.ts",
50
+ "./progress-widget": "./src/progress-widget.ts",
45
51
  "./project": "./src/project.ts",
46
52
  "./session": "./src/session.ts",
47
53
  "./settings": "./src/settings.ts",
@@ -13,6 +13,8 @@ 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 "./llm.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";
@@ -1,11 +1,42 @@
1
1
  // Config-aware settings helper for SuPi config-backed settings sections.
2
2
  // Wraps registerSettings() and centralizes selected-scope loading + scoped persistence.
3
+ //
4
+ // Setting items can declare a `configType` ("boolean" | "number" | "stringList")
5
+ // to enable auto-generated persistChange. When all items have a configType,
6
+ // the persistChange callback can be omitted.
3
7
 
4
8
  import type { SettingItem } from "@earendil-works/pi-tui";
5
9
  import type { SettingsScope } from "../settings/settings-registry.ts";
6
10
  import { registerSettings } from "../settings/settings-registry.ts";
7
11
  import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
8
12
 
13
+ // ── Types ──────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Supported config value types for declarative persistChange.
17
+ *
18
+ * - `"boolean"`: maps "on" → true, "off" → false
19
+ * - `"number"`: parses integer via Number.parseInt, falls back to unset on invalid
20
+ * - `"stringList"`: splits on comma, trims whitespace, unsets on empty
21
+ */
22
+ export type ConfigSettingType = "boolean" | "number" | "stringList";
23
+
24
+ /**
25
+ * Extended setting item that can declare its config type for auto-generated
26
+ * persistence handling.
27
+ */
28
+ export interface ConfigSettingItem extends SettingItem {
29
+ /**
30
+ * When set, persistChange for this item is auto-generated.
31
+ * All items must declare a configType for auto-generation to activate.
32
+ */
33
+ configType?: ConfigSettingType;
34
+ }
35
+
36
+ /**
37
+ * Helpers provided to the persistChange callback for writing or removing
38
+ * scoped config values.
39
+ */
9
40
  export interface ConfigSettingsHelpers {
10
41
  /** Write a key to the selected scope's config section. */
11
42
  set(key: string, value: unknown): void;
@@ -22,10 +53,21 @@ export interface ConfigSettingsOptions<T> {
22
53
  section: string;
23
54
  /** Default config values */
24
55
  defaults: T;
25
- /** Build SettingItem[] from scoped config. Called by loadValues. */
26
- buildItems: (settings: T, scope: SettingsScope, cwd: string) => SettingItem[];
27
- /** Handle a settings change with scoped persistence helpers. */
28
- persistChange: (
56
+ /**
57
+ * Build SettingItem[] from scoped config. Called by loadValues.
58
+ *
59
+ * Items can include a `configType` property for auto-generated
60
+ * persistChange handling. When ALL items declare a configType,
61
+ * the `persistChange` callback can be omitted.
62
+ */
63
+ buildItems: (settings: T, scope: SettingsScope, cwd: string) => ConfigSettingItem[];
64
+ /**
65
+ * Handle a settings change with scoped persistence helpers.
66
+ *
67
+ * Optional when all items returned by `buildItems` declare a `configType`.
68
+ * Required when any item lacks a `configType`.
69
+ */
70
+ persistChange?: (
29
71
  scope: SettingsScope,
30
72
  cwd: string,
31
73
  settingId: string,
@@ -36,6 +78,52 @@ export interface ConfigSettingsOptions<T> {
36
78
  homeDir?: string;
37
79
  }
38
80
 
81
+ // ── Auto-generated persistChange ───────────────────────────────────────────
82
+
83
+ function autoPersistChange(
84
+ settingId: string,
85
+ value: string,
86
+ helpers: ConfigSettingsHelpers,
87
+ items: ConfigSettingItem[],
88
+ ): void {
89
+ const item = items.find((i) => i.id === settingId);
90
+ if (!item?.configType) return;
91
+
92
+ switch (item.configType) {
93
+ case "boolean": {
94
+ helpers.set(settingId, value === "on");
95
+ break;
96
+ }
97
+ case "number": {
98
+ const num = Number.parseInt(value, 10);
99
+ if (Number.isFinite(num) && num > 0) {
100
+ helpers.set(settingId, num);
101
+ } else {
102
+ helpers.unset(settingId);
103
+ }
104
+ break;
105
+ }
106
+ case "stringList": {
107
+ const names = value
108
+ .split(",")
109
+ .map((s) => s.trim())
110
+ .filter((s) => s.length > 0);
111
+ if (names.length > 0) {
112
+ helpers.set(settingId, names);
113
+ } else {
114
+ helpers.unset(settingId);
115
+ }
116
+ break;
117
+ }
118
+ }
119
+ }
120
+
121
+ function areAllItemsDeclarative(items: ConfigSettingItem[]): boolean {
122
+ return items.length > 0 && items.every((i) => i.configType !== undefined);
123
+ }
124
+
125
+ // ── Registration ───────────────────────────────────────────────────────────
126
+
39
127
  /**
40
128
  * Register a config-backed settings section.
41
129
  *
@@ -43,8 +131,13 @@ export interface ConfigSettingsOptions<T> {
43
131
  * instead of merged effective runtime config. Provides scoped `set` / `unset`
44
132
  * persistence helpers so extensions don't need to wire `writeSupiConfig` /
45
133
  * `removeSupiConfigKey` by hand.
134
+ *
135
+ * When every item returned by `buildItems` declares a `configType`, the
136
+ * `persistChange` callback is optional and will be auto-generated.
46
137
  */
47
138
  export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): void {
139
+ let cachedItems: ConfigSettingItem[] | undefined;
140
+
48
141
  registerSettings({
49
142
  id: options.id,
50
143
  label: options.label,
@@ -53,7 +146,9 @@ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): vo
53
146
  scope,
54
147
  homeDir: options.homeDir,
55
148
  });
56
- return options.buildItems(settings, scope, cwd);
149
+ const items = options.buildItems(settings, scope, cwd);
150
+ cachedItems = items;
151
+ return items;
57
152
  },
58
153
  persistChange: (scope, cwd, settingId, value) => {
59
154
  const helpers: ConfigSettingsHelpers = {
@@ -70,7 +165,18 @@ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): vo
70
165
  });
71
166
  },
72
167
  };
73
- options.persistChange(scope, cwd, settingId, value, helpers);
168
+
169
+ // Use manual persistChange when provided
170
+ if (options.persistChange) {
171
+ options.persistChange(scope, cwd, settingId, value, helpers);
172
+ return;
173
+ }
174
+
175
+ // Auto-generate when all items are declarative
176
+ const items = cachedItems ?? options.buildItems(options.defaults, scope, cwd);
177
+ if (areAllItemsDeclarative(items)) {
178
+ autoPersistChange(settingId, value, helpers, items);
179
+ }
74
180
  },
75
181
  });
76
182
  }
@@ -184,3 +184,23 @@ function extractSection(
184
184
  }
185
185
  return null;
186
186
  }
187
+
188
+ /**
189
+ * Shorthand for {@link loadSupiConfig} that infers the return type from defaults.
190
+ *
191
+ * Reduces boilerplate when a package only needs the merged runtime config.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const config = loadSectionConfig("my-ext", cwd, { enabled: true, timeout: 30 });
196
+ * // config is typed as { enabled: boolean; timeout: number }
197
+ * ```
198
+ */
199
+ export function loadSectionConfig<T extends Record<string, unknown>>(
200
+ section: string,
201
+ cwd: string,
202
+ defaults: T,
203
+ options?: SupiConfigOptions,
204
+ ): T {
205
+ return loadSupiConfig(section, cwd, defaults, options);
206
+ }
@@ -0,0 +1,211 @@
1
+ import { complete } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { TSchema } from "typebox";
4
+ import { Value } from "typebox/value";
5
+
6
+ // Shared LLM utilities for SuPi extensions.
7
+ //
8
+ // Provides retry logic, structured LLM call helpers, and other
9
+ // common patterns for extensions that interact with AI models.
10
+
11
+ /**
12
+ * Options for {@link withRetry}.
13
+ */
14
+ export interface WithRetryOptions {
15
+ /** Maximum number of retry attempts after the initial call. Default: 2 */
16
+ retries?: number;
17
+ /** Base delay in milliseconds for exponential backoff. Default: 1000 */
18
+ baseDelayMs?: number;
19
+ /** AbortSignal to cancel retry loops. */
20
+ signal?: AbortSignal;
21
+ /** Called with each failed attempt's attempt index and error. */
22
+ logger?: (attempt: number, error: unknown) => void;
23
+ /** Called before each retry delay with attempt index and computed delay. */
24
+ onRetry?: (attempt: number, delayMs: number) => void;
25
+ }
26
+
27
+ /**
28
+ * Attempt an async operation with retries and exponential backoff.
29
+ *
30
+ * If the signal is already aborted on entry, the operation is skipped entirely.
31
+ * If the signal aborts during a delay, the delay is cancelled immediately.
32
+ *
33
+ * @param fn - The async operation to retry.
34
+ * @param options - Optional configuration for retries, backoff, signal, and callbacks.
35
+ * @returns The result on success, or `null` if all attempts fail or the signal aborts.
36
+ */
37
+ /**
38
+ * Create a promise that resolves after `ms` milliseconds, or rejects if
39
+ * the signal fires before the timeout elapses.
40
+ */
41
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
42
+ return new Promise<void>((resolve, reject) => {
43
+ const timer = setTimeout(resolve, ms);
44
+ if (signal) {
45
+ const onAbort = () => {
46
+ clearTimeout(timer);
47
+ reject(new DOMException("Aborted", "AbortError"));
48
+ };
49
+ signal.addEventListener("abort", onAbort, { once: true });
50
+ }
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Attempt an async operation with retries and exponential backoff.
56
+ *
57
+ * If the signal is already aborted on entry, the operation is skipped entirely.
58
+ * If the signal aborts during a delay, the delay is cancelled immediately.
59
+ *
60
+ * @param fn - The async operation to retry.
61
+ * @param options - Optional configuration for retries, backoff, signal, and callbacks.
62
+ * @returns The result on success, or `null` if all attempts fail or the signal aborts.
63
+ */
64
+ export async function withRetry<T>(
65
+ fn: () => Promise<T>,
66
+ options?: WithRetryOptions,
67
+ ): Promise<T | null> {
68
+ const { retries = 2, baseDelayMs = 1000, signal, logger, onRetry } = options ?? {};
69
+
70
+ if (signal?.aborted) return null;
71
+
72
+ for (let attempt = 0; attempt <= retries; attempt++) {
73
+ try {
74
+ return await fn();
75
+ } catch (err) {
76
+ logger?.(attempt, err);
77
+ if (attempt >= retries || signal?.aborted) continue;
78
+
79
+ const delayMs = baseDelayMs * 2 ** attempt;
80
+ onRetry?.(attempt, delayMs);
81
+
82
+ try {
83
+ await delay(delayMs, signal);
84
+ } catch {
85
+ // delay() only rejects on abort
86
+ return null;
87
+ }
88
+ }
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Extract and validate JSON from LLM response content blocks.
96
+ *
97
+ * Finds the first JSON object `{...}` in the combined text content,
98
+ * parses it, and validates against a TypeBox schema.
99
+ *
100
+ * @param content - The LLM response content blocks.
101
+ * @param schema - TypeBox schema to validate against.
102
+ * @returns The parsed and validated result, or `null` if extraction or validation fails.
103
+ */
104
+ export function extractJsonFromResponse<T extends TSchema>(
105
+ content: ReadonlyArray<{ type: string; text?: string }>,
106
+ schema: T,
107
+ ): { parsed: import("typebox").Static<T> } | null {
108
+ const text = content
109
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
110
+ .map((c) => c.text)
111
+ .join("");
112
+
113
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
114
+ if (!jsonMatch) return null;
115
+
116
+ try {
117
+ const parsed = JSON.parse(jsonMatch[0]);
118
+ if (Value.Check(schema, parsed)) {
119
+ return { parsed } as { parsed: import("typebox").Static<T> };
120
+ }
121
+ return null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ // ── callWithJsonResponse ───────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Options for {@link callWithJsonResponse}.
131
+ */
132
+ export interface CallWithJsonResponseOptions {
133
+ /** The prompt to send to the LLM. */
134
+ prompt: string;
135
+ /** Optional data context appended to the prompt. */
136
+ dataContext?: string;
137
+ /** Maximum tokens for the response. Default: 4096 */
138
+ maxTokens?: number;
139
+ /** System prompt for the LLM call. Default: "" */
140
+ systemPrompt?: string;
141
+ /** Number of retries for the LLM call. Default: 2 */
142
+ retries?: number;
143
+ }
144
+
145
+ /**
146
+ * Call the LLM with a prompt and validate the JSON response against a TypeBox schema.
147
+ *
148
+ * Handles model resolution, auth, retry via `withRetry`, text extraction,
149
+ * JSON regex matching, and TypeBox validation.
150
+ *
151
+ * Returns `null` when:
152
+ * - No model is available
153
+ * - All retries fail
154
+ * - Response contains no valid JSON
155
+ * - JSON doesn't match the schema
156
+ * - The request is aborted
157
+ *
158
+ * @param ctx - The extension context for model resolution and auth.
159
+ * @param options - Call options including prompt, schema, and retry config.
160
+ * @param schema - TypeBox schema to validate the JSON response against.
161
+ * @returns The parsed and validated result, or `null`.
162
+ */
163
+ export async function callWithJsonResponse<T extends TSchema>(
164
+ ctx: ExtensionContext,
165
+ options: CallWithJsonResponseOptions,
166
+ schema: T,
167
+ ): Promise<{ parsed: import("typebox").Static<T> } | null> {
168
+ const { prompt, dataContext, maxTokens = 4096, systemPrompt = "", retries = 2 } = options;
169
+
170
+ const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0] ?? null;
171
+ if (!model) return null;
172
+
173
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
174
+ if (!auth.ok || !auth.apiKey) return null;
175
+
176
+ const fullPrompt = dataContext
177
+ ? `${prompt}
178
+
179
+ DATA:
180
+ ${dataContext}`
181
+ : prompt;
182
+
183
+ const response = await withRetry(
184
+ async () => {
185
+ return complete(
186
+ model,
187
+ {
188
+ systemPrompt,
189
+ messages: [
190
+ {
191
+ role: "user",
192
+ content: [{ type: "text", text: fullPrompt }],
193
+ timestamp: Date.now(),
194
+ },
195
+ ],
196
+ },
197
+ {
198
+ apiKey: auth.apiKey,
199
+ headers: auth.headers,
200
+ signal: ctx.signal,
201
+ maxTokens,
202
+ },
203
+ );
204
+ },
205
+ { retries, baseDelayMs: 1000, signal: ctx.signal },
206
+ );
207
+
208
+ if (!response) return null;
209
+
210
+ return extractJsonFromResponse(response.content, schema);
211
+ }
@@ -0,0 +1,108 @@
1
+ // Generic progress widget for SuPi long-running operations.
2
+ //
3
+ // Provides a TUI-based progress display with animated loader, turn counts,
4
+ // tool usage, and activity descriptions.
5
+
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+ import { CancellableLoader, Container, Text } from "@earendil-works/pi-tui";
8
+
9
+ // ── Types ──────────────────────────────────────────────────────────────────
10
+
11
+ /** Progress state for widget display, compatible with child-session updates. */
12
+ export interface WidgetProgress {
13
+ /** Number of agent turns completed. */
14
+ turns: number;
15
+ /** Number of tool executions started. */
16
+ toolUses: number;
17
+ /** Human-readable active tool descriptions. */
18
+ activities: string[];
19
+ /** Token usage stats, if available. */
20
+ tokens?: { input: number; output: number; total: number };
21
+ }
22
+
23
+ // ── Widget ─────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * TUI progress widget for long-running operations.
27
+ *
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.
30
+ */
31
+ export class ProgressWidget extends Container {
32
+ private message: string;
33
+ private progress: WidgetProgress = { turns: 0, toolUses: 0, activities: [] };
34
+ private loader: CancellableLoader;
35
+ private tui: { requestRender(): void };
36
+ private theme: Theme;
37
+
38
+ constructor(tui: { requestRender(): void }, theme: Theme, message: string) {
39
+ super();
40
+ this.tui = tui;
41
+ this.theme = theme;
42
+ this.message = message;
43
+ this.loader = new CancellableLoader(
44
+ tui as ConstructorParameters<typeof CancellableLoader>[0],
45
+ (text: string) => theme.fg("accent", text),
46
+ (text: string) => theme.fg("muted", text),
47
+ message,
48
+ );
49
+
50
+ this.renderContent();
51
+ }
52
+
53
+ /** AbortSignal that fires when the user presses Escape. */
54
+ get signal(): AbortSignal {
55
+ return this.loader.signal;
56
+ }
57
+
58
+ /** Callback invoked when the user presses Escape. */
59
+ set onAbort(fn: (() => void) | undefined) {
60
+ this.loader.onAbort = fn;
61
+ }
62
+
63
+ /** Delegate keyboard input to the loader. */
64
+ handleInput(data: string): void {
65
+ this.loader.handleInput(data);
66
+ }
67
+
68
+ /** Update progress state and request a re-render. */
69
+ updateProgress(progress: WidgetProgress): void {
70
+ this.progress = progress;
71
+ this.renderContent();
72
+ this.tui.requestRender();
73
+ }
74
+
75
+ /** Clean up the widget. */
76
+ dispose(): void {
77
+ this.loader.dispose();
78
+ }
79
+
80
+ private renderContent(): void {
81
+ this.clear();
82
+
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`);
87
+
88
+ const loaderMessage =
89
+ stats.length > 0 ? `${this.message} · ${stats.join(" · ")}` : this.message;
90
+
91
+ this.loader.setMessage(loaderMessage);
92
+ this.addChild(this.loader);
93
+
94
+ if (this.progress.activities.length > 0) {
95
+ this.addChild(
96
+ new Text(this.theme.fg("dim", ` ⎿ ${this.progress.activities.join(", ")}…`), 1, 0),
97
+ );
98
+ }
99
+ }
100
+ }
101
+
102
+ // ── Helpers ────────────────────────────────────────────────────────────────
103
+
104
+ function formatTokens(count: number): string {
105
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
106
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
107
+ return String(count);
108
+ }
@@ -8,9 +8,11 @@ import type {
8
8
  AgentToolResult,
9
9
  AgentToolUpdateCallback,
10
10
  ExtensionAPI,
11
+ ExtensionCommandContext,
11
12
  ExtensionContext,
12
13
  } from "@earendil-works/pi-coding-agent";
13
14
  import { type TSchema, Type } from "typebox";
15
+ import { ProgressWidget, type WidgetProgress } from "./progress-widget.ts";
14
16
 
15
17
  // ---------------------------------------------------------------------------
16
18
  // Types
@@ -114,3 +116,67 @@ export const SymbolParam = Type.String({
114
116
 
115
117
  /** Maximum results to return. */
116
118
  export const MaxResultsParam = Type.Number({ description: "Maximum results to return" });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Progress widget runner
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Run an async operation with a live TUI progress widget.
126
+ *
127
+ * Automatically manages:
128
+ * - The {@link ProgressWidget} lifecycle
129
+ * - `supi:working:start` / `supi:working:end` events for tab-spinner integration
130
+ * - Abort signal handling
131
+ * - Error catching (returns `null` on failure)
132
+ *
133
+ * Falls back to running without a widget when `ctx.hasUI` is false.
134
+ *
135
+ * @param pi - The extension API (for event emission).
136
+ * @param ctx - The command context (for UI access and hasUI check).
137
+ * @param title - The progress widget title.
138
+ * @param runner - Async function that receives (signal, onProgress).
139
+ * @returns The runner result, or `null` on cancel/error.
140
+ */
141
+ export async function runWithProgressWidget<T>(
142
+ pi: ExtensionAPI,
143
+ ctx: ExtensionCommandContext,
144
+ title: string,
145
+ runner: (signal: AbortSignal, onProgress: (p: WidgetProgress) => void) => Promise<T>,
146
+ ): Promise<T | null> {
147
+ if (!ctx.hasUI) {
148
+ // No UI — run without progress widget but still emit working events
149
+ pi.events.emit("supi:working:start", { source: "supi-core" });
150
+ try {
151
+ return await runner(new AbortController().signal, () => {});
152
+ } catch {
153
+ return null;
154
+ } finally {
155
+ pi.events.emit("supi:working:end", { source: "supi-core" });
156
+ }
157
+ }
158
+
159
+ return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
160
+ const widget = new ProgressWidget(tui, theme, title);
161
+ let finished = false;
162
+
163
+ const finish = (result: T | null) => {
164
+ if (finished) return;
165
+ finished = true;
166
+ pi.events.emit("supi:working:end", { source: "supi-core" });
167
+ widget.dispose();
168
+ done(result);
169
+ };
170
+
171
+ widget.onAbort = () => {
172
+ // Widget handles abort signal; runner resolves with cancel/error.
173
+ };
174
+
175
+ pi.events.emit("supi:working:start", { source: "supi-core" });
176
+ runner(widget.signal, (progress) => widget.updateProgress(progress))
177
+ .then((result) => finish(result))
178
+ .catch(() => finish(null));
179
+
180
+ return widget;
181
+ });
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-context",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "SuPi Context extension — detailed context usage report via /supi-context 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.10.0"
23
+ "@mrclrchtr/supi-core": "1.11.0"
24
24
  },
25
25
  "bundledDependencies": [
26
26
  "@mrclrchtr/supi-core"