@xprnvd/pi-context-view 0.0.0-stage → 0.7.2
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 +21 -0
- package/README.md +112 -2
- package/package.json +55 -5
- package/src/capture/adjustments.ts +649 -0
- package/src/capture/attribution.ts +25 -0
- package/src/capture/builder.ts +128 -0
- package/src/capture/diff.ts +455 -0
- package/src/capture/dispatch.ts +36 -0
- package/src/capture/guard.ts +287 -0
- package/src/capture/messages.ts +246 -0
- package/src/capture/payload.ts +584 -0
- package/src/capture/redact.ts +58 -0
- package/src/capture/register.ts +129 -0
- package/src/capture/request.ts +218 -0
- package/src/capture/tools.ts +69 -0
- package/src/capture/tracker.ts +71 -0
- package/src/command.ts +164 -0
- package/src/compaction.ts +57 -0
- package/src/config.ts +408 -0
- package/src/index.ts +191 -0
- package/src/injections.ts +670 -0
- package/src/measure.ts +887 -0
- package/src/message-preview.ts +72 -0
- package/src/model-identity.ts +48 -0
- package/src/model.ts +548 -0
- package/src/pi-version.ts +29 -0
- package/src/probe/failure.ts +34 -0
- package/src/probe/filter.ts +126 -0
- package/src/probe/silent-probe.ts +349 -0
- package/src/probe/token.ts +28 -0
- package/src/probe/trigger.ts +178 -0
- package/src/probe/view.ts +26 -0
- package/src/projection.ts +122 -0
- package/src/prompt-additions.ts +226 -0
- package/src/prompt-blocks.ts +80 -0
- package/src/replay.ts +234 -0
- package/src/settings.ts +62 -0
- package/src/snapshot.ts +188 -0
- package/src/system-prompt-rows.ts +311 -0
- package/src/text.ts +46 -0
- package/src/token-diff.ts +211 -0
- package/src/transcript.ts +19 -0
- package/src/ui/color.ts +52 -0
- package/src/ui/diff-preview.ts +181 -0
- package/src/ui/injections-model.ts +378 -0
- package/src/ui/injections-view.ts +729 -0
- package/src/ui/json-preview.ts +41 -0
- package/src/ui/layout.ts +148 -0
- package/src/ui/markers.ts +253 -0
- package/src/ui/notes.ts +117 -0
- package/src/ui/section-preview.ts +267 -0
- package/src/ui/skill-preview.ts +83 -0
- package/src/ui/status-bar.ts +94 -0
- package/src/ui/usage-map.ts +157 -0
- package/src/ui/usage-preview.ts +194 -0
- package/src/ui/usage-view.ts +1255 -0
- package/src/ui/wheel.ts +65 -0
- package/src/usage.ts +461 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction lifecycle tracking for the silent-probe preconditions and the
|
|
3
|
+
* `/context` refusal: a probe must not start while an observed compaction can
|
|
4
|
+
* still reject prompts, and no view opens on a projection about to be replaced.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
/** Tracks the observable compaction lifecycle that makes a silent probe unsafe. */
|
|
9
|
+
export class CompactionState {
|
|
10
|
+
private currentSignal: AbortSignal | undefined;
|
|
11
|
+
|
|
12
|
+
/** Whether a compaction observed through `session_before_compact` is still active. */
|
|
13
|
+
public get isActive(): boolean {
|
|
14
|
+
return this.currentSignal !== undefined && !this.currentSignal.aborted;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Track the current compaction until Pi reports its end or its signal aborts. */
|
|
18
|
+
public begin(signal: AbortSignal): void {
|
|
19
|
+
if (signal.aborted) {
|
|
20
|
+
this.currentSignal = undefined;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.currentSignal = signal;
|
|
24
|
+
signal.addEventListener("abort", () => {
|
|
25
|
+
if (this.currentSignal === signal) this.currentSignal = undefined;
|
|
26
|
+
}, { once: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Clear the current lifecycle after compaction can no longer reject prompts. */
|
|
30
|
+
public finish(): void {
|
|
31
|
+
this.currentSignal = undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Follow compaction through Pi's session events; a new or ending session clears it. */
|
|
36
|
+
export function registerCompactionTracking(pi: ExtensionAPI, compaction: CompactionState): void {
|
|
37
|
+
pi.on("session_start", () => {
|
|
38
|
+
compaction.finish();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
pi.on("session_before_compact", (event) => {
|
|
42
|
+
compaction.begin(event.signal);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Pi ends every observed compaction with exactly one of these two events.
|
|
46
|
+
pi.on("session_compact", () => {
|
|
47
|
+
compaction.finish();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pi.on("session_compact_failed", () => {
|
|
51
|
+
compaction.finish();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.on("session_shutdown", () => {
|
|
55
|
+
compaction.finish();
|
|
56
|
+
});
|
|
57
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Override-only user configuration.
|
|
3
|
+
*
|
|
4
|
+
* Defaults live in this module; the global
|
|
5
|
+
* `<agent dir>/extensions/pi-context-view.json` carries overrides only. The
|
|
6
|
+
* file is never auto-created and never backfilled with missing defaults, so
|
|
7
|
+
* later default changes still reach users who did not override them. An
|
|
8
|
+
* absent file or omitted value is silent; unreadable, unparseable, and invalid
|
|
9
|
+
* configuration warns and degrades to defaults instead of failing a view.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { getAgentDir, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
/** Global override file; project-local configuration is intentionally unsupported. */
|
|
17
|
+
export const CONFIG_FILE_NAME = "pi-context-view.json";
|
|
18
|
+
|
|
19
|
+
/** Category id of the legend and map rows tracking the auto-compaction reserve. */
|
|
20
|
+
export const AUTO_COMPACT_BUFFER_CATEGORY_ID = "auto-compact-buffer";
|
|
21
|
+
/** Category id of the legend and map rows tracking unoccupied context. */
|
|
22
|
+
export const FREE_SPACE_CATEGORY_ID = "free-space";
|
|
23
|
+
|
|
24
|
+
/** Color of a usage category without its own configurable entry, such as a tool-output child. */
|
|
25
|
+
const FALLBACK_CATEGORY_COLOR: ThemeColor = "muted";
|
|
26
|
+
|
|
27
|
+
/** Literal color values, accepted as `#rgb` or `#rrggbb` in either case. */
|
|
28
|
+
const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
29
|
+
|
|
30
|
+
/** Pi foreground theme color keys a configured category may name. */
|
|
31
|
+
export const THEME_COLOR_NAMES = [
|
|
32
|
+
"accent", "border", "borderAccent", "borderMuted", "success",
|
|
33
|
+
"error", "warning", "muted", "dim", "text", "thinkingText",
|
|
34
|
+
"scrollbarTrack", "scrollbarThumb",
|
|
35
|
+
"searchMatchText", "userMessageText", "customMessageText",
|
|
36
|
+
"customMessageLabel", "toolTitle", "toolOutput", "mdHeading",
|
|
37
|
+
"mdLink", "mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder",
|
|
38
|
+
"mdQuote", "mdQuoteBorder", "mdHr", "mdListBullet", "toolDiffAdded",
|
|
39
|
+
"toolDiffRemoved", "toolDiffContext", "syntaxComment", "syntaxKeyword",
|
|
40
|
+
"syntaxFunction", "syntaxVariable", "syntaxString", "syntaxNumber",
|
|
41
|
+
"syntaxType", "syntaxOperator", "syntaxPunctuation", "thinkingOff",
|
|
42
|
+
"thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh",
|
|
43
|
+
"thinkingXhigh", "thinkingMax", "bashMode",
|
|
44
|
+
] as const satisfies readonly ThemeColor[];
|
|
45
|
+
|
|
46
|
+
/** Resolves to its argument only for `never`, turning a non-empty type into a compile error. */
|
|
47
|
+
type AssertNever<T extends never> = T;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compile-time proof that the list above stays complete. `satisfies` alone
|
|
51
|
+
* rejects only removed or renamed keys; this fails the build when pi adds a
|
|
52
|
+
* theme color, which would otherwise be silently rejected as unconfigurable.
|
|
53
|
+
*/
|
|
54
|
+
type _EveryThemeColorIsListed = AssertNever<Exclude<ThemeColor, (typeof THEME_COLOR_NAMES)[number]>>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every configurable usage color: the category id the view resolves, the flat
|
|
58
|
+
* config key overriding it, and the built-in default. Listed in legend order,
|
|
59
|
+
* which a created override file reproduces.
|
|
60
|
+
*/
|
|
61
|
+
const CATEGORY_COLOR_SPECS = {
|
|
62
|
+
"system-prompt": { key: "systemPromptColor", color: "mdHeading" },
|
|
63
|
+
"context-files": { key: "instructionFilesColor", color: "mdCodeBlock" },
|
|
64
|
+
"skills": { key: "skillsColor", color: "customMessageLabel" },
|
|
65
|
+
"built-in-tools": { key: "builtInToolsColor", color: "mdHeading" },
|
|
66
|
+
"custom-tools": { key: "customToolsColor", color: "accent" },
|
|
67
|
+
"mcp-tools": { key: "mcpToolsColor", color: "mdLink" },
|
|
68
|
+
"user-messages": { key: "userMessagesColor", color: "syntaxString" },
|
|
69
|
+
"assistant-messages": { key: "assistantMessagesColor", color: "syntaxFunction" },
|
|
70
|
+
"assistant-thinking": { key: "assistantThinkingColor", color: "thinkingXhigh" },
|
|
71
|
+
"tool-calls": { key: "toolCallsColor", color: "syntaxKeyword" },
|
|
72
|
+
"tool-output": { key: "toolOutputColor", color: "toolOutput" },
|
|
73
|
+
"extensions": { key: "extensionsColor", color: "syntaxType" },
|
|
74
|
+
"compacted-data": { key: "compactedDataColor", color: "thinkingHigh" },
|
|
75
|
+
[AUTO_COMPACT_BUFFER_CATEGORY_ID]: { key: "autoCompactBufferColor", color: "dim" },
|
|
76
|
+
[FREE_SPACE_CATEGORY_ID]: { key: "freeSpaceColor", color: "dim" },
|
|
77
|
+
} as const satisfies Record<string, { readonly key: string; readonly color: ThemeColor }>;
|
|
78
|
+
|
|
79
|
+
/** Flat config key of one configurable category color. */
|
|
80
|
+
type ConfigKey = (typeof CATEGORY_COLOR_SPECS)[keyof typeof CATEGORY_COLOR_SPECS]["key"];
|
|
81
|
+
|
|
82
|
+
/** Config keys mapped to the category they color. */
|
|
83
|
+
const CONFIG_KEY_CATEGORIES: ReadonlyMap<string, string> = new Map(
|
|
84
|
+
Object.entries(CATEGORY_COLOR_SPECS).map(([categoryId, spec]) => [spec.key, categoryId]),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/** Cell counts a configured map dimension may request; the viewport clamps further at render time. */
|
|
88
|
+
const MINIMUM_MAP_CELLS = 4;
|
|
89
|
+
const MAXIMUM_MAP_CELLS = 64;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Both configurable map dimensions: the geometry field, the flat config key
|
|
93
|
+
* overriding it, and the built-in default a created override file reproduces.
|
|
94
|
+
*/
|
|
95
|
+
const MAP_SIZE_SPECS = {
|
|
96
|
+
columns: { key: "mapCols", cells: 16 },
|
|
97
|
+
rows: { key: "mapRows", cells: 16 },
|
|
98
|
+
} as const satisfies Record<string, { readonly key: string; readonly cells: number }>;
|
|
99
|
+
|
|
100
|
+
/** Geometry field of one configurable map dimension. */
|
|
101
|
+
type MapSizeField = keyof typeof MAP_SIZE_SPECS;
|
|
102
|
+
|
|
103
|
+
/** Config keys mapped to the map dimension they size. */
|
|
104
|
+
const CONFIG_KEY_MAP_FIELDS: ReadonlyMap<string, MapSizeField> = new Map(
|
|
105
|
+
Object.entries(MAP_SIZE_SPECS).map(([field, spec]) => [spec.key, field as MapSizeField]),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/** Config key of the switch that lets a view start the silent probe on its own. */
|
|
109
|
+
const AUTO_PROBING_KEY = "autoProbing";
|
|
110
|
+
|
|
111
|
+
/** Built-in probing switch: enabled, so a view opened before any request can still capture one. */
|
|
112
|
+
const DEFAULT_AUTO_PROBING = true;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Renamed keys still honored, mapped to their current name, so a rename never
|
|
116
|
+
* silently drops an override an existing file already carries. The value type
|
|
117
|
+
* fails the build when a rename points at a key no category declares.
|
|
118
|
+
*/
|
|
119
|
+
const RENAMED_CONFIG_KEYS: ReadonlyMap<string, ConfigKey> = new Map([
|
|
120
|
+
["memoryColor", "instructionFilesColor"],
|
|
121
|
+
["systemToolsColor", "builtInToolsColor"],
|
|
122
|
+
["agentTextMessagesColor", "assistantMessagesColor"],
|
|
123
|
+
["agentThinkingMessagesColor", "assistantThinkingColor"],
|
|
124
|
+
["agentToolCallMessagesColor", "toolCallsColor"],
|
|
125
|
+
["extensionMessagesColor", "extensionsColor"],
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/** Fast runtime membership check for configured Pi foreground color names. */
|
|
129
|
+
const THEME_COLORS: ReadonlySet<string> = new Set(THEME_COLOR_NAMES);
|
|
130
|
+
|
|
131
|
+
/** Theme-independent color, always stored expanded to a lowercase `#rrggbb`. */
|
|
132
|
+
export type HexColor = `#${string}`;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One configured color: a pi theme color key, which follows theme changes, or
|
|
136
|
+
* a literal hex value, which stays fixed across themes.
|
|
137
|
+
*/
|
|
138
|
+
export type CategoryColor = ThemeColor | HexColor;
|
|
139
|
+
|
|
140
|
+
/** Resolved color of each configurable usage category, keyed by category id. */
|
|
141
|
+
export type CategoryColors = ReadonlyMap<string, CategoryColor>;
|
|
142
|
+
|
|
143
|
+
/** Requested context-map geometry in cells, before the viewport clamps it. */
|
|
144
|
+
export interface MapSize {
|
|
145
|
+
readonly columns: number;
|
|
146
|
+
readonly rows: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** All user-configurable state of one runtime. */
|
|
150
|
+
export interface ContextViewConfig {
|
|
151
|
+
readonly categoryColors: CategoryColors;
|
|
152
|
+
readonly mapSize: MapSize;
|
|
153
|
+
/** Whether a view opened before any captured request may start the silent probe. */
|
|
154
|
+
readonly autoProbing: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Configuration for one view open, with the problems that degraded it to defaults. */
|
|
158
|
+
export interface ConfigLoadResult {
|
|
159
|
+
readonly config: ContextViewConfig;
|
|
160
|
+
/** Empty when the file is absent or fully valid; reported once per file revision. */
|
|
161
|
+
readonly warnings: readonly string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Outcome of explicitly creating the defaults-populated override file. */
|
|
165
|
+
export type ConfigCreationResult =
|
|
166
|
+
| { readonly type: "created"; readonly filePath: string }
|
|
167
|
+
| { readonly type: "exists"; readonly filePath: string }
|
|
168
|
+
| { readonly type: "failed"; readonly filePath: string; readonly reason: string };
|
|
169
|
+
|
|
170
|
+
/** Built-in colors, used whenever the file omits or misconfigures a category. */
|
|
171
|
+
export const DEFAULT_CATEGORY_COLORS: CategoryColors = new Map(
|
|
172
|
+
Object.entries(CATEGORY_COLOR_SPECS).map(([categoryId, spec]) => [categoryId, spec.color] as const),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
/** Built-in map geometry, used whenever the file omits or misconfigures a dimension. */
|
|
176
|
+
export const DEFAULT_MAP_SIZE: MapSize = {
|
|
177
|
+
columns: MAP_SIZE_SPECS.columns.cells,
|
|
178
|
+
rows: MAP_SIZE_SPECS.rows.cells,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Configuration used when no override file exists. */
|
|
182
|
+
export const DEFAULT_CONFIG: ContextViewConfig = {
|
|
183
|
+
categoryColors: DEFAULT_CATEGORY_COLORS,
|
|
184
|
+
mapSize: DEFAULT_MAP_SIZE,
|
|
185
|
+
autoProbing: DEFAULT_AUTO_PROBING,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Absolute path of the global override file. */
|
|
189
|
+
export function getConfigFilePath(): string {
|
|
190
|
+
return join(getAgentDir(), "extensions", CONFIG_FILE_NAME);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create the global override file populated with every built-in default. The
|
|
195
|
+
* single `O_EXCL` write is atomic and never overwrites or modifies an existing
|
|
196
|
+
* path, so a concurrent creator loses the race instead of the file's content.
|
|
197
|
+
*/
|
|
198
|
+
export function createDefaultConfigFile(filePath: string = getConfigFilePath()): ConfigCreationResult {
|
|
199
|
+
try {
|
|
200
|
+
// Separate from the write below: mkdir also reports EEXIST, for a parent that is a file.
|
|
201
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return { type: "failed", filePath, reason: describeError(error) };
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
writeFileSync(filePath, serializeDefaultConfig(), { encoding: "utf8", flag: "wx" });
|
|
207
|
+
return { type: "created", filePath };
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (hasErrorCode(error, "EEXIST")) return { type: "exists", filePath };
|
|
210
|
+
return { type: "failed", filePath, reason: describeError(error) };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Per-runtime configuration cache.
|
|
216
|
+
*
|
|
217
|
+
* The file is read lazily on the first view open, then re-read only after its
|
|
218
|
+
* modification time changes, so edits apply without restarting pi.
|
|
219
|
+
*/
|
|
220
|
+
export class ConfigStore {
|
|
221
|
+
private readonly filePath: string;
|
|
222
|
+
private cached: ContextViewConfig = DEFAULT_CONFIG;
|
|
223
|
+
private cachedModifiedTime: number | undefined;
|
|
224
|
+
private pendingWarnings: readonly string[] = [];
|
|
225
|
+
private loaded = false;
|
|
226
|
+
|
|
227
|
+
/** Create a store over the global override file, or an explicit path in tests. */
|
|
228
|
+
public constructor(filePath: string = getConfigFilePath()) {
|
|
229
|
+
this.filePath = filePath;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Configuration for one view open. Warnings are returned once per file
|
|
234
|
+
* revision, so reopening a view never repeats a report for an unchanged file.
|
|
235
|
+
*/
|
|
236
|
+
public load(): ConfigLoadResult {
|
|
237
|
+
const modifiedTime = readModifiedTime(this.filePath);
|
|
238
|
+
if (!this.loaded || modifiedTime !== this.cachedModifiedTime) {
|
|
239
|
+
const result = loadConfigFile(this.filePath);
|
|
240
|
+
this.cached = result.config;
|
|
241
|
+
this.pendingWarnings = result.warnings;
|
|
242
|
+
this.cachedModifiedTime = modifiedTime;
|
|
243
|
+
this.loaded = true;
|
|
244
|
+
}
|
|
245
|
+
const warnings = this.pendingWarnings;
|
|
246
|
+
this.pendingWarnings = [];
|
|
247
|
+
return { config: this.cached, warnings };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Read and validate one override file; a missing file yields defaults without warnings. */
|
|
252
|
+
export function loadConfigFile(filePath: string): ConfigLoadResult {
|
|
253
|
+
let text: string;
|
|
254
|
+
try {
|
|
255
|
+
text = readFileSync(filePath, "utf8");
|
|
256
|
+
} catch (error) {
|
|
257
|
+
// An absent file is the expected state for users who never configured anything.
|
|
258
|
+
if (hasErrorCode(error, "ENOENT")) return { config: DEFAULT_CONFIG, warnings: [] };
|
|
259
|
+
return degraded(`Cannot read ${filePath}: ${describeError(error)}.`);
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
return applyOverrides(JSON.parse(text));
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return degraded(`Cannot parse ${filePath}: ${describeError(error)}.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Color of one usage category, falling back for categories without a configurable entry. */
|
|
269
|
+
export function resolveCategoryColor(colors: CategoryColors, categoryId: string | undefined): CategoryColor {
|
|
270
|
+
if (categoryId === undefined) return FALLBACK_CATEGORY_COLOR;
|
|
271
|
+
return colors.get(categoryId) ?? FALLBACK_CATEGORY_COLOR;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Whether a resolved color is a literal value rather than a theme color key. */
|
|
275
|
+
export function isHexColor(color: CategoryColor): color is HexColor {
|
|
276
|
+
return color.startsWith("#");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Serialize every built-in default as an editable, flat override file. */
|
|
280
|
+
function serializeDefaultConfig(): string {
|
|
281
|
+
const groups: Array<ReadonlyArray<readonly [string, CategoryColor | number | boolean]>> = [
|
|
282
|
+
Object.values(CATEGORY_COLOR_SPECS).map((spec) => [spec.key, spec.color]),
|
|
283
|
+
Object.values(MAP_SIZE_SPECS).map((spec) => [spec.key, spec.cells]),
|
|
284
|
+
[[AUTO_PROBING_KEY, DEFAULT_AUTO_PROBING]],
|
|
285
|
+
];
|
|
286
|
+
// Written by hand: JSON.stringify cannot leave the blank lines that separate the groups
|
|
287
|
+
const body = groups
|
|
288
|
+
.map((entries) => entries.map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(",\n"))
|
|
289
|
+
.join(",\n\n");
|
|
290
|
+
return `{\n${body}\n}\n`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Merge valid overrides onto the built-in defaults, reporting every ignored entry. */
|
|
294
|
+
function applyOverrides(raw: unknown): ConfigLoadResult {
|
|
295
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
296
|
+
return degraded(`${CONFIG_FILE_NAME} must contain a JSON object.`);
|
|
297
|
+
}
|
|
298
|
+
const config: ConfigDraft = {
|
|
299
|
+
categoryColors: new Map(DEFAULT_CATEGORY_COLORS),
|
|
300
|
+
mapSize: { ...DEFAULT_MAP_SIZE },
|
|
301
|
+
autoProbing: DEFAULT_AUTO_PROBING,
|
|
302
|
+
};
|
|
303
|
+
const warnings: string[] = [];
|
|
304
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
305
|
+
const currentKey = RENAMED_CONFIG_KEYS.get(key);
|
|
306
|
+
// The current name always wins, so a file carrying both names loads order-independently.
|
|
307
|
+
if (currentKey !== undefined && currentKey in raw) continue;
|
|
308
|
+
const warning = applyOverride(currentKey ?? key, key, value, config);
|
|
309
|
+
if (warning !== undefined) warnings.push(warning);
|
|
310
|
+
}
|
|
311
|
+
return { config, warnings };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Configuration being assembled from the defaults, before it is returned read-only. */
|
|
315
|
+
interface ConfigDraft {
|
|
316
|
+
categoryColors: Map<string, CategoryColor>;
|
|
317
|
+
mapSize: Record<MapSizeField, number>;
|
|
318
|
+
autoProbing: boolean;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Apply one entry onto the mutable defaults, returning the reason it was
|
|
323
|
+
* ignored. Reports name `configuredKey`, the name the file carries, which an
|
|
324
|
+
* accepted alias makes differ from the `resolvedKey` the entry configures.
|
|
325
|
+
*/
|
|
326
|
+
function applyOverride(
|
|
327
|
+
resolvedKey: string,
|
|
328
|
+
configuredKey: string,
|
|
329
|
+
value: unknown,
|
|
330
|
+
config: ConfigDraft,
|
|
331
|
+
): string | undefined {
|
|
332
|
+
const categoryId = CONFIG_KEY_CATEGORIES.get(resolvedKey);
|
|
333
|
+
if (categoryId !== undefined) {
|
|
334
|
+
const color = parseColor(value);
|
|
335
|
+
if (color === undefined) {
|
|
336
|
+
return `Ignoring invalid color for "${configuredKey}"; expected a theme color name or a hex value.`;
|
|
337
|
+
}
|
|
338
|
+
config.categoryColors.set(categoryId, color);
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
const field = CONFIG_KEY_MAP_FIELDS.get(resolvedKey);
|
|
342
|
+
if (field !== undefined) {
|
|
343
|
+
const cells = parseMapDimension(value);
|
|
344
|
+
if (cells === undefined) {
|
|
345
|
+
return `Ignoring invalid size for "${configuredKey}"; ` +
|
|
346
|
+
`expected an integer between ${MINIMUM_MAP_CELLS} and ${MAXIMUM_MAP_CELLS}.`;
|
|
347
|
+
}
|
|
348
|
+
config.mapSize[field] = cells;
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
if (resolvedKey === AUTO_PROBING_KEY) {
|
|
352
|
+
if (typeof value !== "boolean") return `Ignoring invalid value for "${configuredKey}"; expected true or false.`;
|
|
353
|
+
config.autoProbing = value;
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
return `Ignoring unknown ${CONFIG_FILE_NAME} key "${configuredKey}".`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Whole-file failure: built-in defaults plus one explanatory warning. */
|
|
360
|
+
function degraded(reason: string): ConfigLoadResult {
|
|
361
|
+
return { config: DEFAULT_CONFIG, warnings: [`${reason} Using default configuration.`] };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Normalize one configured color value, or reject it as unusable. */
|
|
365
|
+
function parseColor(value: unknown): CategoryColor | undefined {
|
|
366
|
+
if (typeof value !== "string") return undefined;
|
|
367
|
+
if (isThemeColor(value)) return value;
|
|
368
|
+
return parseHexColor(value);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Accept only whole in-range cell counts; the viewport still clamps what it cannot render. */
|
|
372
|
+
function parseMapDimension(value: unknown): number | undefined {
|
|
373
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return undefined;
|
|
374
|
+
if (value < MINIMUM_MAP_CELLS || value > MAXIMUM_MAP_CELLS) return undefined;
|
|
375
|
+
return value;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Accept only theme color keys the active theme is guaranteed to define. */
|
|
379
|
+
function isThemeColor(value: unknown): value is ThemeColor {
|
|
380
|
+
return typeof value === "string" && THEME_COLORS.has(value);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Expand shorthand and lowercase, so rendering always resolves six hex digits. */
|
|
384
|
+
function parseHexColor(value: string): HexColor | undefined {
|
|
385
|
+
if (!HEX_COLOR_PATTERN.test(value)) return undefined;
|
|
386
|
+
const digits = value.slice(1).toLowerCase();
|
|
387
|
+
if (digits.length === 6) return `#${digits}`;
|
|
388
|
+
return `#${[...digits].map((digit) => `${digit}${digit}`).join("")}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Modification time in milliseconds, or undefined while the file is absent or unreadable. */
|
|
392
|
+
function readModifiedTime(filePath: string): number | undefined {
|
|
393
|
+
try {
|
|
394
|
+
return statSync(filePath).mtimeMs;
|
|
395
|
+
} catch {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Recognize one errno code: an absent file to read, or a create-only write refusing to clobber. */
|
|
401
|
+
function hasErrorCode(error: unknown, code: "ENOENT" | "EEXIST"): boolean {
|
|
402
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Normalize an unknown throw into a short reportable reason. */
|
|
406
|
+
function describeError(error: unknown): string {
|
|
407
|
+
return error instanceof Error ? error.message : String(error);
|
|
408
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-context-view - inspect what occupies the model context.
|
|
3
|
+
*
|
|
4
|
+
* Structured capture publishes a snapshot of every request to SnapshotStore,
|
|
5
|
+
* which keeps the latest one. Injections shows that request as it was sent;
|
|
6
|
+
* Usage applies it to the current branch. Either view runs one on-demand
|
|
7
|
+
* silent probe when opened before any request, unless configuration disables it. On a Pi version older than the
|
|
8
|
+
* supported one, it registers no lifecycle handlers, captures nothing, and
|
|
9
|
+
* `/context` only reports the
|
|
10
|
+
* required version.
|
|
11
|
+
*/
|
|
12
|
+
import { type ExtensionAPI, type ExtensionCommandContext, VERSION } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
import { type ConfigLoadResult, ConfigStore, createDefaultConfigFile } from "./config.ts";
|
|
15
|
+
import {
|
|
16
|
+
CONTEXT_COMMAND_DESCRIPTION,
|
|
17
|
+
getContextArgumentCompletions,
|
|
18
|
+
parseContextCommand,
|
|
19
|
+
reportCommandMessage,
|
|
20
|
+
reportCompactionInProgress,
|
|
21
|
+
reportConfigCreation,
|
|
22
|
+
reportTuiOnly,
|
|
23
|
+
reportUnsupportedPi,
|
|
24
|
+
type RequestSnapshotResult,
|
|
25
|
+
resolveRequestSnapshot,
|
|
26
|
+
} from "./command.ts";
|
|
27
|
+
import { SnapshotBuilder } from "./capture/builder.ts";
|
|
28
|
+
import { registerCapture } from "./capture/register.ts";
|
|
29
|
+
import { CompactionState, registerCompactionTracking } from "./compaction.ts";
|
|
30
|
+
import { buildFallbackInjectionsSnapshot, buildInjectionsSnapshot } from "./injections.ts";
|
|
31
|
+
import { resolveNextRequestModel } from "./model-identity.ts";
|
|
32
|
+
import { isSupportedPiVersion } from "./pi-version.ts";
|
|
33
|
+
import { ProbeFilter, registerProbeFilter } from "./probe/filter.ts";
|
|
34
|
+
import { registerSilentProbe, SilentProbe } from "./probe/silent-probe.ts";
|
|
35
|
+
import { ProbeTrigger } from "./probe/trigger.ts";
|
|
36
|
+
import { createProbeView } from "./probe/view.ts";
|
|
37
|
+
import { applyRequestSnapshot } from "./projection.ts";
|
|
38
|
+
import { collectPromptSources } from "./prompt-additions.ts";
|
|
39
|
+
import { buildUsageSnapshot } from "./replay.ts";
|
|
40
|
+
import { readAutoCompactReserveTokens } from "./settings.ts";
|
|
41
|
+
import { SnapshotStore } from "./snapshot.ts";
|
|
42
|
+
import { showInjectionsView } from "./ui/injections-view.ts";
|
|
43
|
+
import { showUsageView } from "./ui/usage-view.ts";
|
|
44
|
+
import { computeUsage, toReportedUsage } from "./usage.ts";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extension factory. Pi passes only `pi`; in-process tests pass `snapshots` to
|
|
48
|
+
* read what capture publishes, and `configStore` to stay independent of the
|
|
49
|
+
* developer's configuration file.
|
|
50
|
+
*/
|
|
51
|
+
export default function (pi: ExtensionAPI, snapshots = new SnapshotStore(), configStore = new ConfigStore()) {
|
|
52
|
+
const probeFilter = new ProbeFilter();
|
|
53
|
+
const probe = new SilentProbe(probeFilter);
|
|
54
|
+
const probeView = createProbeView(probeFilter, probe);
|
|
55
|
+
const compaction = new CompactionState();
|
|
56
|
+
const trigger = new ProbeTrigger({ pi, probe, snapshots, compaction });
|
|
57
|
+
const supported = isSupportedPiVersion(VERSION);
|
|
58
|
+
|
|
59
|
+
/** Open Injections on the latest request snapshot, or on the pi-native fallback without one. */
|
|
60
|
+
async function openInjections(
|
|
61
|
+
ctx: ExtensionCommandContext,
|
|
62
|
+
latest: RequestSnapshotResult,
|
|
63
|
+
loadedConfig: ConfigLoadResult,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
if (latest.type !== "snapshot") {
|
|
66
|
+
await showInjectionsView(ctx, {
|
|
67
|
+
snapshot: buildFallbackInjectionsSnapshot({
|
|
68
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
69
|
+
options: ctx.getSystemPromptOptions(),
|
|
70
|
+
allTools: pi.getAllTools(),
|
|
71
|
+
activeToolNames: pi.getActiveTools(),
|
|
72
|
+
promptSources: collectPromptSources(pi.getAllTools(), pi.getCommands()),
|
|
73
|
+
}),
|
|
74
|
+
degradedReason: latest.type === "missing" ? latest.degradedReason : undefined,
|
|
75
|
+
probingDisabled: latest.type === "unprobed",
|
|
76
|
+
notices: loadedConfig.warnings,
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const snapshot = latest.snapshot;
|
|
81
|
+
await showInjectionsView(ctx, {
|
|
82
|
+
snapshot: buildInjectionsSnapshot({
|
|
83
|
+
snapshot,
|
|
84
|
+
entries: ctx.sessionManager.getEntries(),
|
|
85
|
+
filterMessages: (messages) => probeView.filterMessages(messages),
|
|
86
|
+
options: ctx.getSystemPromptOptions(),
|
|
87
|
+
allTools: pi.getAllTools(),
|
|
88
|
+
promptSources: collectPromptSources(pi.getAllTools(), pi.getCommands()),
|
|
89
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
90
|
+
activeToolNames: pi.getActiveTools(),
|
|
91
|
+
}),
|
|
92
|
+
probe: snapshot.origin === "synthetic-probe",
|
|
93
|
+
guard: snapshot.guard,
|
|
94
|
+
notices: loadedConfig.warnings,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Open Usage on the current branch with the latest request snapshot's changes applied. */
|
|
99
|
+
async function openUsage(
|
|
100
|
+
ctx: ExtensionCommandContext,
|
|
101
|
+
latest: RequestSnapshotResult,
|
|
102
|
+
loadedConfig: ConfigLoadResult,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const { messages, branchMessages, systemChanges, forcedPrompt } = applyRequestSnapshot({
|
|
105
|
+
snapshot: latest.type === "snapshot" ? latest.snapshot : undefined,
|
|
106
|
+
entries: ctx.sessionManager.getEntries(),
|
|
107
|
+
leafId: ctx.sessionManager.getLeafId(),
|
|
108
|
+
filterMessages: (projected) => probeView.filterMessages(projected),
|
|
109
|
+
});
|
|
110
|
+
const current = buildUsageSnapshot({
|
|
111
|
+
messages,
|
|
112
|
+
systemChanges,
|
|
113
|
+
forcedPrompt,
|
|
114
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
115
|
+
options: ctx.getSystemPromptOptions(),
|
|
116
|
+
allTools: pi.getAllTools(),
|
|
117
|
+
activeToolNames: pi.getActiveTools(),
|
|
118
|
+
promptSources: collectPromptSources(pi.getAllTools(), pi.getCommands()),
|
|
119
|
+
});
|
|
120
|
+
await showUsageView(ctx, {
|
|
121
|
+
usage: computeUsage({
|
|
122
|
+
snapshot: current,
|
|
123
|
+
messages,
|
|
124
|
+
reported: toReportedUsage(ctx.getContextUsage()),
|
|
125
|
+
modelLabel: ctx.model?.id,
|
|
126
|
+
// Pi's routed-model lookup reads its session messages, not the request's changes
|
|
127
|
+
nextRequestModel: resolveNextRequestModel(ctx.model, branchMessages),
|
|
128
|
+
autoCompactReserveTokens: readAutoCompactReserveTokens(pi, ctx.model),
|
|
129
|
+
}),
|
|
130
|
+
degradedReason: latest.type === "missing" ? latest.degradedReason : undefined,
|
|
131
|
+
probingDisabled: latest.type === "unprobed",
|
|
132
|
+
// Reported inside the view: a notification would stay hidden behind the fullscreen overlay.
|
|
133
|
+
notices: loadedConfig.warnings,
|
|
134
|
+
categoryColors: loadedConfig.config.categoryColors,
|
|
135
|
+
mapSize: loadedConfig.config.mapSize,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pi.registerCommand("context", {
|
|
140
|
+
description: CONTEXT_COMMAND_DESCRIPTION,
|
|
141
|
+
getArgumentCompletions: getContextArgumentCompletions,
|
|
142
|
+
handler: async (args, ctx) => {
|
|
143
|
+
// Every form, config included, would describe a lifecycle this Pi does not have
|
|
144
|
+
if (!supported) {
|
|
145
|
+
reportUnsupportedPi(ctx, VERSION);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const command = parseContextCommand(args);
|
|
149
|
+
if (command.type === "invalid") {
|
|
150
|
+
reportCommandMessage(ctx, command.message, "error");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Creating the file needs no UI, so it stays available in every run mode.
|
|
154
|
+
if (command.type === "config") {
|
|
155
|
+
reportConfigCreation(ctx, createDefaultConfigFile());
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (ctx.mode !== "tui") {
|
|
159
|
+
reportTuiOnly(ctx, command.view);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (compaction.isActive) {
|
|
163
|
+
reportCompactionInProgress(ctx, command.view);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Loaded only when a view opens: the factory also runs where no view can
|
|
167
|
+
const loadedConfig = configStore.load();
|
|
168
|
+
const latest = await resolveRequestSnapshot(snapshots, trigger, ctx, loadedConfig.config.autoProbing);
|
|
169
|
+
// Compaction can start while waiting for idle; refuse instead of showing its fallback
|
|
170
|
+
if (compaction.isActive) {
|
|
171
|
+
reportCompactionInProgress(ctx, command.view);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (command.view === "injections") await openInjections(ctx, latest, loadedConfig);
|
|
175
|
+
else await openUsage(ctx, latest, loadedConfig);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Older Pi lacks events the capture and the probe rely on, so observe nothing there
|
|
180
|
+
if (!supported) return;
|
|
181
|
+
|
|
182
|
+
// Probe layer first: ProbeFilter's context_with_system handler must run before capture's
|
|
183
|
+
registerProbeFilter(pi, probeFilter);
|
|
184
|
+
registerSilentProbe(pi, probe);
|
|
185
|
+
registerCapture(pi, probeView, new SnapshotBuilder(snapshots));
|
|
186
|
+
registerCompactionTracking(pi, compaction);
|
|
187
|
+
|
|
188
|
+
pi.on("session_shutdown", () => {
|
|
189
|
+
snapshots.clear();
|
|
190
|
+
});
|
|
191
|
+
}
|