pi-context-view 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry Makarov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # pi-context-view
2
+
3
+ ![Context usage map](doc/images/pi-context-view.png)
4
+
5
+ A pi extension that visualizes what fills the model's context and lets
6
+ you inspect the parts you normally can't see: the base prompt, tool
7
+ definitions, and instructions injected by other extensions.
8
+
9
+ ## Features
10
+
11
+ - **Context usage map** - visualize used and free context space, grouped by
12
+ category (tools, skills, messages, and more).
13
+
14
+ - **Context injections** - explore the hidden pieces of the context: the
15
+ initial prompt, tool definitions, and extension injections.
16
+
17
+ ## Commands
18
+
19
+ - `/context` - shorthand for `/context usage`.
20
+ - `/context usage` - open the context usage visualization.
21
+ - `/context injections` - show the hidden content of the context at session
22
+ start or resume.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pi install npm:pi-context-view
28
+ ```
29
+
30
+ ### Usage Examples
31
+
32
+ - Inspect context composition after compaction:
33
+
34
+ ![Context usage view and category preview](doc/images/context-usage.png)
35
+
36
+ - Inspect hidden parts of the context, such as tool definitions:
37
+
38
+ ![Context injections view and item preview](doc/images/context-injections.png)
39
+
40
+ ## Context
41
+
42
+ `pi-context-view` does not add any instructions or messages to the model
43
+ context.
44
+
45
+ ## License
46
+
47
+ MIT
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "pi-context-view",
3
+ "version": "0.2.0",
4
+ "description": "Pi extension to visualize context token usage and inspect hidden prompt, tool, and extension injections via /context",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Dmitry Makarov",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dimk90/pi-context-view.git"
11
+ },
12
+ "homepage": "https://github.com/dimk90/pi-context-view#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/dimk90/pi-context-view/issues"
15
+ },
16
+ "keywords": [
17
+ "pi",
18
+ "pi-extension",
19
+ "pi-package",
20
+ "coding-agent",
21
+ "context",
22
+ "tokens",
23
+ "tui"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22.19.0"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "doc/images",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --test test/*.test.ts",
35
+ "check": "tsc --noEmit && npm test"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/index.ts"
40
+ ],
41
+ "image": "https://media.githubusercontent.com/media/dimk90/pi-context-view/c1ff8949d340d94d6c9b40819ad4ff78aa2114cc/doc/images/pi-context-view.png"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*"
46
+ },
47
+ "devDependencies": {
48
+ "@earendil-works/pi-coding-agent": "0.80.6",
49
+ "@earendil-works/pi-tui": "0.80.6",
50
+ "@types/node": "^22.20.1",
51
+ "typescript": "^7.0.2"
52
+ }
53
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Initial capture state and conversion from pi event data to the semantic
3
+ * model. Event registration remains in index.ts; this module is independently
4
+ * unit-testable.
5
+ */
6
+ import {
7
+ type BuildSystemPromptOptions,
8
+ type ContextEvent,
9
+ estimateTokens,
10
+ type InputSource,
11
+ type ToolInfo,
12
+ } from "@earendil-works/pi-coding-agent";
13
+
14
+ import { analyzeSystemPrompt, type PromptOptionsSlice, type ToolSlice } from "./measure.ts";
15
+ import {
16
+ AGGREGATE_SOURCE_ID,
17
+ buildSnapshot,
18
+ type CaptureOrigin,
19
+ type InitialSnapshot,
20
+ type InjectionItem,
21
+ type InjectionSource,
22
+ } from "./model.ts";
23
+
24
+ const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
25
+ const AGGREGATE_SOURCE: InjectionSource = {
26
+ id: AGGREGATE_SOURCE_ID,
27
+ label: "extensions (aggregate)",
28
+ native: false,
29
+ };
30
+
31
+ /** Everything available when the first context event finalizes a snapshot. */
32
+ export interface CaptureFinalization {
33
+ systemPrompt: string;
34
+ messages: ContextEvent["messages"];
35
+ baselineMessages: ContextEvent["messages"];
36
+ allTools: readonly ToolInfo[];
37
+ activeToolNames: readonly string[];
38
+ origin: CaptureOrigin;
39
+ capturedAt?: Date;
40
+ }
41
+
42
+ /** Inputs for an on-demand pi-native prompt/tool snapshot. */
43
+ export interface NativeSnapshotInput {
44
+ systemPrompt: string;
45
+ options: BuildSystemPromptOptions;
46
+ allTools: readonly ToolInfo[];
47
+ activeToolNames: readonly string[];
48
+ capturedAt?: Date;
49
+ }
50
+
51
+ /** Result of the one allowed silent-probe attempt. */
52
+ export type ProbeOutcome =
53
+ | { readonly status: "captured" }
54
+ | { readonly status: "failed"; readonly reason: string };
55
+
56
+ /** A probe start request; concurrent callers share `completion`. */
57
+ export interface ProbeAttempt {
58
+ readonly started: boolean;
59
+ readonly completion: Promise<ProbeOutcome>;
60
+ }
61
+
62
+ /** Exact identity used to remove only synthetic probe messages. */
63
+ export interface SyntheticMessageIdentity {
64
+ readonly role: "user" | "assistant";
65
+ readonly timestamp: number;
66
+ }
67
+
68
+ /** Owned structured inputs prepared before later extension handlers can mutate shared event data. */
69
+ interface CapturePreparation {
70
+ readonly promptOptions: PromptOptionsSlice;
71
+ readonly toolSnippets?: Readonly<Record<string, string>>;
72
+ }
73
+
74
+ /** Lifecycle of the single probe attempt, including ownership retained after its completion times out. */
75
+ type ProbePhase =
76
+ | "idle"
77
+ | "waiting"
78
+ | "waiting-after-timeout"
79
+ | "running"
80
+ | "running-after-timeout"
81
+ | "failed"
82
+ | "settled";
83
+
84
+ /**
85
+ * Capture-once state machine. `prepare()` refreshes the structured options on
86
+ * every run until `finalize()` succeeds; subsequent finalizations return the
87
+ * original snapshot unchanged.
88
+ */
89
+ export class InitialCaptureState {
90
+ private pendingPreparation: CapturePreparation | undefined;
91
+ private initialSnapshot: InitialSnapshot | undefined;
92
+
93
+ /** The frozen Initial snapshot, or undefined until `finalize()` succeeds. */
94
+ public get snapshot(): InitialSnapshot | undefined {
95
+ return this.initialSnapshot;
96
+ }
97
+
98
+ /** Own the structured prompt inputs from `before_agent_start`; no-op once frozen. */
99
+ public prepare(options: BuildSystemPromptOptions): void {
100
+ if (this.initialSnapshot !== undefined) return;
101
+ this.pendingPreparation = {
102
+ promptOptions: copyPromptOptions(options),
103
+ toolSnippets: options.toolSnippets === undefined ? undefined : { ...options.toolSnippets },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Freeze the Initial snapshot from the first context event. Returns the
109
+ * existing snapshot on repeat calls, or undefined when `prepare()` never ran.
110
+ */
111
+ public finalize(input: CaptureFinalization): InitialSnapshot | undefined {
112
+ if (this.initialSnapshot !== undefined) return this.initialSnapshot;
113
+ if (this.pendingPreparation === undefined) return undefined;
114
+
115
+ const preparation = this.pendingPreparation;
116
+ const tools = captureActiveTools(input.allTools, input.activeToolNames, {
117
+ toolSnippets: preparation.toolSnippets,
118
+ });
119
+ const items = [
120
+ ...analyzeSystemPrompt(input.systemPrompt, preparation.promptOptions, tools),
121
+ ...measureInjectedMessages(input.messages, input.baselineMessages),
122
+ ];
123
+ this.initialSnapshot = buildSnapshot(items, input.origin, input.capturedAt ?? new Date());
124
+ this.pendingPreparation = undefined;
125
+ return this.initialSnapshot;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * State for one on-demand silent probe. It owns the timeout and exact synthetic
131
+ * message identities, but leaves pi API calls and UI restoration to index.ts.
132
+ */
133
+ export class SilentProbeState {
134
+ private phase: ProbePhase = "idle";
135
+ private inputObserved = false;
136
+ private readonly identities = new Map<string, SyntheticMessageIdentity>();
137
+ private completion: Promise<ProbeOutcome> | undefined;
138
+ private resolveCompletion: ((outcome: ProbeOutcome) => void) | undefined;
139
+ private outcome: ProbeOutcome | undefined;
140
+ private timeout: NodeJS.Timeout | undefined;
141
+
142
+ /** True while the probe owns the in-flight agent run (including after a timeout). */
143
+ public get isCurrentRun(): boolean {
144
+ return this.phase === "running" || this.phase === "running-after-timeout";
145
+ }
146
+
147
+ /** Defensive copies of the recorded probe message identities. */
148
+ public get syntheticMessages(): readonly SyntheticMessageIdentity[] {
149
+ return [...this.identities.values()].map((identity) => ({ ...identity }));
150
+ }
151
+
152
+ /**
153
+ * Begin the one allowed probe attempt with a failure timeout. Repeat calls
154
+ * return the original attempt's completion with `started: false`.
155
+ */
156
+ public start(timeoutMs = DEFAULT_PROBE_TIMEOUT_MS): ProbeAttempt {
157
+ if (this.completion !== undefined) {
158
+ return { started: false, completion: this.completion };
159
+ }
160
+
161
+ this.phase = "waiting";
162
+ this.completion = new Promise<ProbeOutcome>((resolve) => {
163
+ this.resolveCompletion = resolve;
164
+ });
165
+ this.timeout = setTimeout(() => {
166
+ if (this.phase === "waiting") {
167
+ this.phase = "waiting-after-timeout";
168
+ } else if (this.phase === "running") {
169
+ this.phase = "running-after-timeout";
170
+ }
171
+ this.resolve({ status: "failed", reason: "Silent probe timed out." });
172
+ }, timeoutMs);
173
+ return { started: true, completion: this.completion };
174
+ }
175
+
176
+ /** Mark the exact extension-originated empty input that starts the probe. */
177
+ public observeInput(source: InputSource, text: string): void {
178
+ const awaitingInput = this.phase === "waiting" || this.phase === "waiting-after-timeout";
179
+ if (awaitingInput && source === "extension" && text === "") this.inputObserved = true;
180
+ }
181
+
182
+ /** Associate the next matching lifecycle with the probe, not a real turn. */
183
+ public beginRun(prompt: string): boolean {
184
+ const ownsPendingInput = this.phase === "waiting" || this.phase === "waiting-after-timeout";
185
+ if (!ownsPendingInput || !this.inputObserved || prompt !== "") return false;
186
+ this.phase = this.phase === "waiting-after-timeout" ? "running-after-timeout" : "running";
187
+ return true;
188
+ }
189
+
190
+ /** Record probe user/assistant identities as their message events arrive. */
191
+ public recordMessage(message: ContextEvent["messages"][number]): void {
192
+ if (!this.isCurrentRun || (message.role !== "user" && message.role !== "assistant")) return;
193
+ const identity = { role: message.role, timestamp: message.timestamp } satisfies SyntheticMessageIdentity;
194
+ this.identities.set(identityKey(identity), identity);
195
+ }
196
+
197
+ /**
198
+ * Replace only the probe's aborted assistant with an empty successful message
199
+ * so pi does not render an "Operation aborted" transcript row.
200
+ */
201
+ public sanitizeAssistant(
202
+ message: ContextEvent["messages"][number],
203
+ ): ContextEvent["messages"][number] | undefined {
204
+ if (!this.isCurrentRun || message.role !== "assistant" || message.stopReason !== "aborted") {
205
+ return undefined;
206
+ }
207
+ this.recordMessage(message);
208
+ const identity = { role: "assistant", timestamp: message.timestamp } satisfies SyntheticMessageIdentity;
209
+ if (!this.identities.has(identityKey(identity))) return undefined;
210
+ return { ...message, content: [], stopReason: "stop", errorMessage: undefined };
211
+ }
212
+
213
+ /** Remove only messages whose exact role+timestamp identity belongs to the probe. */
214
+ public filterMessages(messages: ContextEvent["messages"]): ContextEvent["messages"] {
215
+ if (this.identities.size === 0) return messages;
216
+ return messages.filter((message) => {
217
+ if (message.role !== "user" && message.role !== "assistant") return true;
218
+ return !this.identities.has(identityKey(message));
219
+ });
220
+ }
221
+
222
+ /** Resolve a running attempt from `agent_settled`. */
223
+ public settle(captured: boolean): boolean {
224
+ if (!this.isCurrentRun) return false;
225
+ this.phase = "settled";
226
+ if (this.outcome === undefined) {
227
+ this.resolve(
228
+ captured
229
+ ? { status: "captured" }
230
+ : { status: "failed", reason: "Silent probe settled without a context snapshot." },
231
+ );
232
+ }
233
+ return true;
234
+ }
235
+
236
+ /** End a pending attempt during shutdown or a synchronous startup failure. */
237
+ public fail(reason: string): void {
238
+ if (this.completion === undefined || this.phase === "failed" || this.phase === "settled") return;
239
+ this.phase = "failed";
240
+ if (this.outcome === undefined) this.resolve({ status: "failed", reason });
241
+ }
242
+
243
+ /** Settle the completion promise exactly once and clear the timeout. */
244
+ private resolve(outcome: ProbeOutcome): void {
245
+ if (this.outcome !== undefined) return;
246
+ if (this.timeout !== undefined) clearTimeout(this.timeout);
247
+ this.timeout = undefined;
248
+ this.outcome = outcome;
249
+ const resolve = this.resolveCompletion;
250
+ this.resolveCompletion = undefined;
251
+ resolve?.(outcome);
252
+ }
253
+ }
254
+
255
+ /** Build a view-local pi-native snapshot without freezing the main capture state. */
256
+ export function buildNativeSnapshot(input: NativeSnapshotInput): InitialSnapshot {
257
+ const options = copyPromptOptions(input.options);
258
+ const tools = captureActiveTools(input.allTools, input.activeToolNames, input.options);
259
+ const items = analyzeSystemPrompt(input.systemPrompt, options, tools);
260
+ return buildSnapshot(items, "synthetic-probe", input.capturedAt ?? new Date());
261
+ }
262
+
263
+ /** Add frozen context-only messages to a current prompt/tool snapshot for Usage. */
264
+ export function mergeContextOnlyMessages(
265
+ snapshot: InitialSnapshot,
266
+ initial: InitialSnapshot,
267
+ ): InitialSnapshot {
268
+ const contextOnly = initial.groups.flatMap((group) =>
269
+ group.items.filter((item) => item.kind === "message" && item.contextOnly === true)
270
+ );
271
+ if (contextOnly.length === 0) return snapshot;
272
+ const items = [
273
+ ...snapshot.groups.flatMap((group) => group.items),
274
+ ...contextOnly,
275
+ ];
276
+ return buildSnapshot(items, snapshot.origin, snapshot.capturedAt);
277
+ }
278
+
279
+ /** Copy the prompt-options slice used by measurement, without shared nested references. */
280
+ export function copyPromptOptions(options: BuildSystemPromptOptions): PromptOptionsSlice {
281
+ return {
282
+ cwd: options.cwd,
283
+ homeDir: process.env.HOME,
284
+ customPrompt: options.customPrompt,
285
+ appendSystemPrompt: options.appendSystemPrompt,
286
+ contextFilePaths: options.contextFiles?.map((file) => file.path),
287
+ skills: options.skills
288
+ ?.filter((skill) => !skill.disableModelInvocation)
289
+ .map((skill) => ({
290
+ name: skill.name,
291
+ description: skill.description,
292
+ filePath: skill.filePath,
293
+ })),
294
+ };
295
+ }
296
+
297
+ /** Snapshot the final active tool set with provenance and payload definitions. */
298
+ export function captureActiveTools(
299
+ allTools: readonly ToolInfo[],
300
+ activeToolNames: readonly string[],
301
+ options: { readonly toolSnippets?: Readonly<Record<string, string>> },
302
+ ): ToolSlice[] {
303
+ const active = new Set(activeToolNames);
304
+ return allTools
305
+ .filter((tool) => active.has(tool.name))
306
+ .map((tool) => ({
307
+ name: tool.name,
308
+ description: tool.description,
309
+ parametersJson: JSON.stringify(tool.parameters ?? {}),
310
+ snippet: options.toolSnippets?.[tool.name],
311
+ guidelines: normalizeGuidelines(tool.promptGuidelines),
312
+ source: tool.sourceInfo.source,
313
+ }));
314
+ }
315
+
316
+ /**
317
+ * Measure extension messages while excluding ordinary session history. Custom
318
+ * messages remain attributable by customType; other roles are captured only
319
+ * when they differ from the session-branch baseline.
320
+ */
321
+ export function measureInjectedMessages(
322
+ messages: ContextEvent["messages"],
323
+ baselineMessages: ContextEvent["messages"],
324
+ ): InjectionItem[] {
325
+ const baseline = messageSignatureCounts(baselineMessages);
326
+ const occurrences = new Map<string, number>();
327
+ const items: InjectionItem[] = [];
328
+ for (const message of messages) {
329
+ const contextOnly = !consumeMessageSignature(baseline, message);
330
+ if (message.role !== "custom" && !contextOnly) continue;
331
+
332
+ const identity = message.role === "custom" ? message.customType : message.role;
333
+ const occurrence = occurrences.get(identity) ?? 0;
334
+ occurrences.set(identity, occurrence + 1);
335
+ const text = messageText(message);
336
+ items.push({
337
+ id: message.role === "custom"
338
+ ? `message:${message.customType}:${occurrence}`
339
+ : `message:context:${message.role}:${occurrence}`,
340
+ phase: "initial",
341
+ kind: "message",
342
+ source: message.role === "custom" ? messageSource(message.customType) : AGGREGATE_SOURCE,
343
+ label: message.role === "custom" ? "message" : `${message.role} message`,
344
+ chars: text.length,
345
+ tokens: estimateTokens(message),
346
+ text,
347
+ contextOnly: contextOnly || undefined,
348
+ });
349
+ }
350
+ return items;
351
+ }
352
+
353
+ /** Count structurally identical baseline messages for order-independent diffing. */
354
+ function messageSignatureCounts(messages: ContextEvent["messages"]): Map<string, number> {
355
+ const counts = new Map<string, number>();
356
+ for (const message of messages) {
357
+ const signature = JSON.stringify(message);
358
+ counts.set(signature, (counts.get(signature) ?? 0) + 1);
359
+ }
360
+ return counts;
361
+ }
362
+
363
+ /** Consume one matching baseline occurrence, returning false for a context-only message. */
364
+ function consumeMessageSignature(
365
+ counts: Map<string, number>,
366
+ message: ContextEvent["messages"][number],
367
+ ): boolean {
368
+ const signature = JSON.stringify(message);
369
+ const count = counts.get(signature) ?? 0;
370
+ if (count === 0) return false;
371
+ if (count === 1) counts.delete(signature);
372
+ else counts.set(signature, count - 1);
373
+ return true;
374
+ }
375
+
376
+ /** Extract provider-bound message content for raw preview. */
377
+ function messageText(message: ContextEvent["messages"][number]): string {
378
+ if (!("content" in message)) return JSON.stringify(message);
379
+ return typeof message.content === "string" ? message.content : JSON.stringify(message.content);
380
+ }
381
+
382
+ /** Map key uniquely identifying one probe message by role and timestamp. */
383
+ function identityKey(identity: SyntheticMessageIdentity): string {
384
+ return `${identity.role}:${identity.timestamp}`;
385
+ }
386
+
387
+ /** Attribute a custom-role message to its customType; the actual injector is unknowable. */
388
+ function messageSource(customType: string): InjectionSource {
389
+ return { id: `message-type:${customType}`, label: customType, native: false };
390
+ }
391
+
392
+ /** Normalize the string-or-array promptGuidelines field to an owned array. */
393
+ function normalizeGuidelines(guidelines: string | string[] | undefined): string[] {
394
+ if (guidelines === undefined) return [];
395
+ return Array.isArray(guidelines) ? [...guidelines] : [guidelines];
396
+ }
package/src/command.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `/context` command grammar, argument completions, and Initial capture
3
+ * resolution shared by the Usage and Injections views.
4
+ */
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
7
+
8
+ import {
9
+ buildNativeSnapshot,
10
+ type InitialCaptureState,
11
+ type SilentProbeState,
12
+ } from "./capture.ts";
13
+ import type { InitialSnapshot } from "./model.ts";
14
+
15
+ const COMMAND_USAGE = "Usage: /context [usage|injections]";
16
+ const DEFAULT_VIEW: ContextView = "usage";
17
+ const ARGUMENT_OPTIONS = [
18
+ { value: "usage", label: "usage", description: "Show estimated context usage" },
19
+ { value: "injections", label: "injections", description: "Explore initial context injections" },
20
+ ] satisfies AutocompleteItem[];
21
+
22
+ /** The focused view a `/context` invocation requests. */
23
+ export type ContextView = "usage" | "injections";
24
+
25
+ /** Parsed `/context` argument grammar. */
26
+ export type ContextCommand =
27
+ | { readonly type: "view"; readonly view: ContextView }
28
+ | { readonly type: "invalid"; readonly message: string };
29
+
30
+ /** Resolved Initial capture, possibly degraded to the pi-native fallback. */
31
+ export interface InitialCaptureResult {
32
+ readonly snapshot: InitialSnapshot;
33
+ readonly degradedReason?: string;
34
+ }
35
+
36
+ /** Parse the complete, intentionally small `/context` argument grammar. */
37
+ export function parseContextCommand(argumentsText: string): ContextCommand {
38
+ const words = argumentsText.trim().toLowerCase().split(/\s+/).filter(Boolean);
39
+ if (words.length === 0) {
40
+ return { type: "view", view: DEFAULT_VIEW };
41
+ }
42
+ if (words.length === 1 && words[0] === "usage") {
43
+ return { type: "view", view: "usage" };
44
+ }
45
+ if (words.length === 1 && words[0] === "injections") {
46
+ return { type: "view", view: "injections" };
47
+ }
48
+ return { type: "invalid", message: COMMAND_USAGE };
49
+ }
50
+
51
+ /** Complete full argument values for the supported `/context` grammar. */
52
+ export function getContextArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
53
+ const normalizedPrefix = argumentPrefix.trimStart().toLowerCase();
54
+ const matches = ARGUMENT_OPTIONS.filter((option) => option.value.startsWith(normalizedPrefix));
55
+ return matches.length > 0 ? matches.map((option) => ({ ...option })) : null;
56
+ }
57
+
58
+ /** Obtain Initial through passive capture, one silent probe, or a pi-native fallback. */
59
+ export async function resolveInitialCapture(
60
+ pi: ExtensionAPI,
61
+ capture: InitialCaptureState,
62
+ probe: SilentProbeState,
63
+ context: ExtensionCommandContext,
64
+ ): Promise<InitialCaptureResult> {
65
+ if (capture.snapshot !== undefined) return { snapshot: capture.snapshot };
66
+
67
+ await context.waitForIdle();
68
+ if (capture.snapshot !== undefined) return { snapshot: capture.snapshot };
69
+
70
+ const unavailableReason = getProbeUnavailableReason(context);
71
+ if (unavailableReason !== undefined) {
72
+ return createFallback(pi, context, unavailableReason);
73
+ }
74
+
75
+ const attempt = probe.start();
76
+ if (attempt.started) {
77
+ context.ui.setWorkingVisible(false);
78
+ try {
79
+ pi.sendUserMessage("");
80
+ } catch (error) {
81
+ probe.fail(error instanceof Error ? error.message : String(error));
82
+ }
83
+ }
84
+
85
+ try {
86
+ const outcome = await attempt.completion;
87
+ if (outcome.status === "captured" && capture.snapshot !== undefined) {
88
+ return { snapshot: capture.snapshot };
89
+ }
90
+ const reason = outcome.status === "failed" ? outcome.reason : "Silent probe did not capture Initial.";
91
+ return createFallback(pi, context, reason);
92
+ } finally {
93
+ if (attempt.started) context.ui.setWorkingVisible(true);
94
+ }
95
+ }
96
+
97
+ /** Report command errors in both interactive and headless modes. */
98
+ export function reportCommandMessage(
99
+ context: ExtensionCommandContext,
100
+ message: string,
101
+ type: "info" | "warning" | "error",
102
+ ): void {
103
+ if (context.hasUI) {
104
+ context.ui.notify(message, type);
105
+ return;
106
+ }
107
+ process.stderr.write(`${message}\n`);
108
+ }
109
+
110
+ /** Explain why a silent probe cannot run now, or undefined when it can. */
111
+ function getProbeUnavailableReason(context: ExtensionCommandContext): string | undefined {
112
+ if (context.model === undefined) return "Silent probe unavailable: no model is selected.";
113
+ if (!context.modelRegistry.hasConfiguredAuth(context.model)) {
114
+ return `Silent probe unavailable: ${context.model.provider} has no configured authentication.`;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ /** Build a degraded pi-native snapshot when passive capture and probing both failed. */
120
+ function createFallback(
121
+ pi: ExtensionAPI,
122
+ context: ExtensionCommandContext,
123
+ reason: string,
124
+ ): InitialCaptureResult {
125
+ return {
126
+ snapshot: buildNativeSnapshot({
127
+ systemPrompt: context.getSystemPrompt(),
128
+ options: context.getSystemPromptOptions(),
129
+ allTools: pi.getAllTools(),
130
+ activeToolNames: pi.getActiveTools(),
131
+ }),
132
+ degradedReason: `${reason} Extension additions were not observed.`,
133
+ };
134
+ }