@xprnvd/pi-context-view 0.7.2 → 0.8.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
@@ -35,28 +35,28 @@ injected by other extensions.
35
35
  defaults, useful for
36
36
  [customization](https://github.com/dimk90/pi-context-view#customization).
37
37
 
38
- ## Demo
39
-
40
-
41
- ### `/context`
38
+ ## Demo: `/context`
42
39
 
43
40
  See what fills your context, for example, what survives compaction:
44
41
 
45
- ![Context usage view showing estimated context composition](https://media.githubusercontent.com/media/dimk90/pi-context-view/e9f75e538ada31af0c1ba3517bad0a13f06050e6/doc/images/context-usage.gif)
42
+ ![Context usage view showing estimated context composition](https://media.githubusercontent.com/media/dimk90/pi-context-view/e9f75e538ada31af0c1ba3517bad0a13f06050e6/doc/images/context-usage.gif)
43
+ ➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
46
44
 
47
45
 
48
- ### `/context injections`
46
+ ## Demo: `/context injections`
49
47
 
50
48
  Inspect hidden parts of the context, such as tool definitions:
51
49
 
52
- ![Context injections view and item preview](https://media.githubusercontent.com/media/dimk90/pi-context-view/e9f75e538ada31af0c1ba3517bad0a13f06050e6/doc/images/context-injections.gif)
50
+ ![Context injections view and item preview](https://media.githubusercontent.com/media/dimk90/pi-context-view/0ee9a806e9c23bea642edabacfd6830755f3122d/doc/images/context-injections.gif)
51
+ ➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
53
52
 
54
- ### Zoom
53
+ ## Demo: Zoom
55
54
 
56
55
  Zoom in for a more detailed breakdown of large context windows, such as
57
56
  1M-token windows:
58
57
 
59
- ![Zoom feature](https://media.githubusercontent.com/media/dimk90/pi-context-view/e9f75e538ada31af0c1ba3517bad0a13f06050e6/doc/images/zoom.gif)
58
+ ![Zoom feature](https://media.githubusercontent.com/media/dimk90/pi-context-view/e9f75e538ada31af0c1ba3517bad0a13f06050e6/doc/images/zoom.gif)
59
+ ➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
60
60
 
61
61
  ## Install
62
62
 
@@ -73,7 +73,7 @@ To get started, create a configuration file populated with the current defaults:
73
73
  ```text
74
74
  /context config
75
75
  ```
76
- > This creates `~/.pi/agent/extensions/pi-context-view.json`.
76
+ ➥ This creates `~/.pi/agent/extensions/pi-context-view.json`.
77
77
 
78
78
  See the [configuration reference](https://github.com/dimk90/pi-context-view/blob/master/doc/CONFIG.md)
79
79
  for each parameter's meaning and default value.
@@ -85,7 +85,8 @@ colors that stay the same across themes. Here are examples of custom
85
85
  [terrain](https://github.com/dimk90/pi-context-view/blob/master/doc/palettes/terrain.json) and
86
86
  [rainbow](https://github.com/dimk90/pi-context-view/blob/master/doc/palettes/rainbow.json) palettes:
87
87
 
88
- ![Terrain and rainbow palettes](https://media.githubusercontent.com/media/dimk90/pi-context-view/e9f75e538ada31af0c1ba3517bad0a13f06050e6/doc/images/palettes.png)
88
+ ![Terrain and rainbow palettes](https://media.githubusercontent.com/media/dimk90/pi-context-view/e9f75e538ada31af0c1ba3517bad0a13f06050e6/doc/images/palettes.png)
89
+ ➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
89
90
 
90
91
  See the [theme color reference](https://github.com/dimk90/pi-context-view/blob/develop/doc/PI-THEME-COLORS.md)
91
92
  for color names that follow the current theme.
@@ -94,7 +95,8 @@ for color names that follow the current theme.
94
95
 
95
96
  You can configure the number of rows and columns in the `Context Usage` map:
96
97
 
97
- ![Map size demo](https://media.githubusercontent.com/media/dimk90/pi-context-view/2bc280f758d88fc0ac6396e921c7e697c9016086/doc/images/map-sizes.png)
98
+ ![Map size demo](https://media.githubusercontent.com/media/dimk90/pi-context-view/2bc280f758d88fc0ac6396e921c7e697c9016086/doc/images/map-sizes.png)
99
+ ➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
98
100
 
99
101
 
100
102
  ## Context Footprint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xprnvd/pi-context-view",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension to visualize context usage and inspect the hidden parts: base prompt, tools defs, and extension injections",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,9 +9,9 @@
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/xprnvd/pi-context-view.git"
11
11
  },
12
- "homepage": "https://github.com/dimk90/pi-context-view#readme",
12
+ "homepage": "https://github.com/xprnvd/pi-context-view",
13
13
  "bugs": {
14
- "url": "https://github.com/dimk90/pi-context-view/issues"
14
+ "url": "https://github.com/xprnvd/pi-context-view/issues"
15
15
  },
16
16
  "keywords": [
17
17
  "pi",
@@ -100,10 +100,23 @@ export function messageKey(message: RequestMessage): string {
100
100
  */
101
101
  export function sameMessage(a: RequestMessage, b: RequestMessage): boolean {
102
102
  if (a.role !== b.role || customTypeKey(a) !== customTypeKey(b)) return false;
103
- const result = compareJson(modelFacingPart(a), modelFacingPart(b), 0);
103
+ const result = compareJson(modelFacingPart(a), modelFacingPart(b), 0, "any");
104
104
  return result === "unknown" ? messageKey(a) === messageKey(b) : result === "equal";
105
105
  }
106
106
 
107
+ /**
108
+ * Whether two tools declare the same interface, as Pi's `declarationsEqual()`
109
+ * decides, without serializing plain JSON declarations. Only the fields Pi
110
+ * keeps in a declaration are compared; anything outside plain JSON data falls
111
+ * back to `declarationsEqual()`.
112
+ */
113
+ export function sameDeclaration(a: Tool, b: Tool): boolean {
114
+ if (a === b) return true;
115
+ // Pi compares declarations as plain JSON, so schema key order matters
116
+ const result = compareJson(declarationPart(a), declarationPart(b), 0, "same");
117
+ return result === "unknown" ? declarationsEqual(a, b) : result === "equal";
118
+ }
119
+
107
120
  /**
108
121
  * Remove the non-system messages that both sides share at the start and at
109
122
  * the end. Alignment matches these ends first, so `diffConversation()` over
@@ -158,7 +171,7 @@ function diffTools(before: readonly Tool[], after: readonly Tool[]): SystemChang
158
171
  const changes: SystemChange[] = [];
159
172
  for (const tool of after) {
160
173
  const previous = beforeByName.get(tool.name);
161
- if (previous === undefined || !declarationsEqual(previous, tool)) {
174
+ if (previous === undefined || !sameDeclaration(previous, tool)) {
162
175
  changes.push({ type: "tool", name: tool.name, declaration: tool });
163
176
  }
164
177
  }
@@ -168,6 +181,16 @@ function diffTools(before: readonly Tool[], after: readonly Tool[]): SystemChang
168
181
  return changes;
169
182
  }
170
183
 
184
+ /** The fields of a tool that Pi's `toToolDeclaration()` keeps; executable and display fields are left out. */
185
+ function declarationPart(tool: Tool): Tool {
186
+ return {
187
+ name: tool.name,
188
+ description: tool.description,
189
+ parameters: tool.parameters,
190
+ constrainedSampling: tool.constrainedSampling,
191
+ };
192
+ }
193
+
171
194
  /** Plain text of system content; text blocks join with a newline, as Pi renders them. */
172
195
  function contentText(content: SystemMessage["content"]): string {
173
196
  return typeof content === "string" ? content : content.map((block) => block.text).join("\n");
@@ -302,18 +325,25 @@ export function canonicalJson(value: unknown): string {
302
325
 
303
326
  /**
304
327
  * Result of comparing two values as JSON:
305
- * equal both encode to the same canonical JSON
306
- * different their canonical JSON differs
307
- * unknown a value is outside plain JSON data; compare the encoded keys
328
+ * equal both encode to the same JSON
329
+ * different their JSON differs
330
+ * unknown a value is outside plain JSON data; compare the encoded values
308
331
  */
309
332
  type JsonComparison = "equal" | "different" | "unknown";
310
333
 
334
+ /**
335
+ * Which JSON encoding a comparison agrees with:
336
+ * any `canonicalJson()`: object key order does not matter
337
+ * same `JSON.stringify()`: object keys must come in the same order
338
+ */
339
+ type KeyOrder = "any" | "same";
340
+
311
341
  /**
312
342
  * Compare plain JSON data (strings, booleans, null, finite numbers, arrays,
313
- * and plain objects) as `canonicalJson()` would, without encoding it. Object
314
- * key order and `undefined` properties do not matter, as in the keys.
343
+ * and plain objects) as its JSON encoding would, without encoding it.
344
+ * `undefined` properties do not matter, as in JSON.
315
345
  */
316
- function compareJson(a: unknown, b: unknown, depth: number): JsonComparison {
346
+ function compareJson(a: unknown, b: unknown, depth: number, keyOrder: KeyOrder): JsonComparison {
317
347
  if (typeof a === "string" || typeof a === "boolean") {
318
348
  if (typeof b !== typeof a) return "unknown";
319
349
  return a === b ? "equal" : "different";
@@ -325,17 +355,23 @@ function compareJson(a: unknown, b: unknown, depth: number): JsonComparison {
325
355
  if (a === null) return b === null ? "equal" : "unknown";
326
356
  if (typeof a !== "object" || typeof b !== "object" || b === null || depth >= MAX_COMPARE_DEPTH) return "unknown";
327
357
  if (Array.isArray(a) || Array.isArray(b)) {
328
- return Array.isArray(a) && Array.isArray(b) ? compareJsonArrays(a, b, depth) : "unknown";
358
+ return Array.isArray(a) && Array.isArray(b) ? compareJsonArrays(a, b, depth, keyOrder) : "unknown";
329
359
  }
330
360
  if (!isPlainObject(a) || !isPlainObject(b)) return "unknown";
331
- return compareJsonObjects(a as Record<string, unknown>, b as Record<string, unknown>, depth);
361
+ const compareObjects = keyOrder === "any" ? compareJsonObjects : compareOrderedJsonObjects;
362
+ return compareObjects(a as Record<string, unknown>, b as Record<string, unknown>, depth);
332
363
  }
333
364
 
334
365
  /** Compare two arrays element by element; JSON keeps their order. */
335
- function compareJsonArrays(a: readonly unknown[], b: readonly unknown[], depth: number): JsonComparison {
366
+ function compareJsonArrays(
367
+ a: readonly unknown[],
368
+ b: readonly unknown[],
369
+ depth: number,
370
+ keyOrder: KeyOrder,
371
+ ): JsonComparison {
336
372
  if (a.length !== b.length) return "different";
337
373
  for (let index = 0; index < a.length; index++) {
338
- const result = compareJson(a[index], b[index], depth + 1);
374
+ const result = compareJson(a[index], b[index], depth + 1, keyOrder);
339
375
  if (result !== "equal") return result;
340
376
  }
341
377
  return "equal";
@@ -351,7 +387,7 @@ function compareJsonObjects(
351
387
  for (const key in a) {
352
388
  if (a[key] === undefined) continue;
353
389
  unpaired++;
354
- const result = compareJson(a[key], b[key], depth + 1);
390
+ const result = compareJson(a[key], b[key], depth + 1, "any");
355
391
  if (result !== "equal") return result;
356
392
  }
357
393
  for (const key in b) {
@@ -364,6 +400,29 @@ function compareJsonObjects(
364
400
  return unpaired === 0 ? "equal" : "different";
365
401
  }
366
402
 
403
+ /** Compare two plain objects by their defined properties, which must come in the same order. */
404
+ function compareOrderedJsonObjects(
405
+ a: Readonly<Record<string, unknown>>,
406
+ b: Readonly<Record<string, unknown>>,
407
+ depth: number,
408
+ ): JsonComparison {
409
+ const aKeys = Object.keys(a).filter((key) => a[key] !== undefined);
410
+ const bKeys = Object.keys(b).filter((key) => b[key] !== undefined);
411
+ // JSON omits these, so the key lists alone cannot decide
412
+ if (aKeys.some((key) => isOmittedByJson(a[key])) || bKeys.some((key) => isOmittedByJson(b[key]))) return "unknown";
413
+ if (aKeys.length !== bKeys.length || aKeys.some((key, index) => key !== bKeys[index])) return "different";
414
+ for (const key of aKeys) {
415
+ const result = compareJson(a[key], b[key], depth + 1, "same");
416
+ if (result !== "equal") return result;
417
+ }
418
+ return "equal";
419
+ }
420
+
421
+ /** Whether JSON leaves out an object property with this value. */
422
+ function isOmittedByJson(value: unknown): boolean {
423
+ return typeof value === "function" || typeof value === "symbol";
424
+ }
425
+
367
426
  /** Whether a value is an object literal, not a class instance whose JSON may differ. */
368
427
  function isPlainObject(value: object): boolean {
369
428
  const prototype: unknown = Object.getPrototypeOf(value);
@@ -10,7 +10,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
 
11
11
  import type { ProbeView } from "../probe/view.ts";
12
12
  import type { CaptureOrigin, RequestMessage } from "../snapshot.ts";
13
- import { trimMatchedEnds, type UnmatchedMessages } from "./diff.ts";
13
+ import { sameDeclaration, trimMatchedEnds, type UnmatchedMessages } from "./diff.ts";
14
14
  import type { GuardModel } from "./guard.ts";
15
15
 
16
16
  /**
@@ -47,7 +47,9 @@ export interface SystemText {
47
47
 
48
48
  /**
49
49
  * The parts of a request the deferred diff and the payload guard need, as this
50
- * extension's handler saw them. Request-side values are owned copies.
50
+ * extension's handler saw them. Request-side objects are owned copies, except
51
+ * declarations equal to the baseline's: those are the baseline's own objects,
52
+ * which are only read. Strings are kept by reference.
51
53
  */
52
54
  export interface RequestCopy {
53
55
  /** The request's replayed system state; undefined when it has none. */
@@ -105,8 +107,8 @@ export interface CaptureInput {
105
107
 
106
108
  /**
107
109
  * Read the baseline, copy what differs from it, and keep the effective prompt
108
- * for deferred forced-prompt detection. Throws when a differing message cannot
109
- * be cloned, for example when a handler added a function value to it.
110
+ * for deferred forced-prompt detection. Throws when a differing message or tool
111
+ * cannot be cloned, for example when a handler added a function value to it.
110
112
  */
111
113
  export function captureRequest(input: CaptureInput): CapturedRequest {
112
114
  const baseline = readBaseline(input.sessionManager, input.probe);
@@ -125,21 +127,22 @@ export function captureRequest(input: CaptureInput): CapturedRequest {
125
127
 
126
128
  /**
127
129
  * Copy the request's replayed system state and the messages that differ from
128
- * the baseline. Messages equal to the baseline at both ends are compared in
129
- * place and never copied. System text positions and historical declarations
130
- * also belong to the copy: serializers may keep them instead of collapsing.
130
+ * the baseline. Messages equal to the baseline at both ends, and declarations
131
+ * equal to the baseline's of the same name, are compared in place and never
132
+ * copied. System text positions and historical declarations also belong to
133
+ * the copy: serializers may keep them instead of collapsing.
131
134
  */
132
135
  export function copyRequest(baseline: readonly BaselineMessage[], messages: readonly RequestMessage[]): RequestCopy {
133
136
  const unmatched = trimMatchedEnds(baseline, messages);
134
- // Later handlers share these objects, tool schemas included, and may edit them in place
135
- const copy = structuredClone({
136
- system: getCurrentSystemMessage(messages), declarations: getDeclaredTools(messages), request: unmatched.request,
137
- });
137
+ // Later handlers share these objects and may edit them in place
138
+ const request = structuredClone(unmatched.request);
139
+ const copyTool = createToolCopier(baseline);
140
+ const system = getCurrentSystemMessage(messages);
138
141
  return {
139
- ...(copy.system === undefined ? {} : { system: copy.system }),
142
+ ...(system === undefined ? {} : { system: copySystemState(system, copyTool) }),
140
143
  systemTexts: copySystemTexts(messages),
141
- declarations: copy.declarations,
142
- conversation: { prefix: unmatched.prefix, baseline: unmatched.baseline, request: copy.request },
144
+ declarations: getDeclaredTools(messages).map(copyTool),
145
+ conversation: { prefix: unmatched.prefix, baseline: unmatched.baseline, request },
143
146
  };
144
147
  }
145
148
 
@@ -181,6 +184,36 @@ export function detectForcedPrompt(effectivePrompt: string, baseline: readonly B
181
184
  return effectivePrompt === replayed ? undefined : effectivePrompt;
182
185
  }
183
186
 
187
+ /**
188
+ * A function that returns, for each request tool, the baseline's declaration
189
+ * of the same name when it is equal, else an owned copy. Each request object
190
+ * is compared and copied once, so the current and historical declarations
191
+ * keep sharing objects. Throws when a differing tool cannot be cloned.
192
+ */
193
+ function createToolCopier(baseline: readonly BaselineMessage[]): (tool: Tool) => Tool {
194
+ const baselineTools = new Map(getDeclaredTools(baseline.map(({ message }) => message))
195
+ .map((tool) => [tool.name, tool]));
196
+ const copies = new Map<Tool, Tool>();
197
+ return (tool) => {
198
+ let copy = copies.get(tool);
199
+ if (copy === undefined) {
200
+ const previous = baselineTools.get(tool.name);
201
+ // Later handlers share request tools, schemas included, and may edit them in place
202
+ copy = previous !== undefined && sameDeclaration(previous, tool) ? previous : structuredClone(tool);
203
+ copies.set(tool, copy);
204
+ }
205
+ return copy;
206
+ };
207
+ }
208
+
209
+ /**
210
+ * The replayed system state with copied tools. Replay builds new containers
211
+ * and strings are immutable, so content and sections need no copy.
212
+ */
213
+ function copySystemState(system: SystemMessage, copyTool: (tool: Tool) => Tool): SystemMessage {
214
+ return system.toolsAdded === undefined ? system : { ...system, toolsAdded: system.toolsAdded.map(copyTool) };
215
+ }
216
+
184
217
  /**
185
218
  * Copy the text of every system message with its position. Strings are
186
219
  * immutable, so a shallow copy of the sections suffices; tool changes are
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  type ChangeEditor,
8
8
  type ForcedEdit,
9
+ type InjectionGroup,
9
10
  type InjectionItem,
10
11
  type InjectionSnapshot,
11
12
  type RequestChange,
@@ -40,6 +41,30 @@ export type ChangeScope =
40
41
  | "request-new"
41
42
  | "payload";
42
43
 
44
+ /** What a list filter compares the query against. */
45
+ export type InjectionFilterMode = "name" | "content";
46
+
47
+ /** A non-blank list filter; the view owns how text is normalized and compared. */
48
+ export interface InjectionFilter {
49
+ readonly mode: InjectionFilterMode;
50
+ /** Whether a group or item label contains the query. */
51
+ readonly nameMatches: (label: string) => boolean;
52
+ /** Whether an item's content contains the query. */
53
+ readonly contentMatches: (item: InjectionItem) => boolean;
54
+ }
55
+
56
+ /**
57
+ * Rows a filter leaves, by item id and `source:<id>` group key. The snapshot
58
+ * itself stays whole, so estimates, diffs, and change scopes never depend on
59
+ * which rows a filter hides.
60
+ */
61
+ export interface InjectionFilterResult {
62
+ /** Rows to show: matches and the ancestors kept as their context. */
63
+ readonly kept: ReadonlySet<string>;
64
+ /** Rows that matched directly, not as context. */
65
+ readonly matched: ReadonlySet<string>;
66
+ }
67
+
43
68
  /** One flattened list row derived from the snapshot hierarchy. */
44
69
  export type InjectionRow =
45
70
  | {
@@ -51,6 +76,8 @@ export type InjectionRow =
51
76
  readonly diff?: TokenDiff;
52
77
  /** Where the changes of the rows it counts live; absent when none changed. */
53
78
  readonly scope?: ChangeScope;
79
+ /** Whether an active filter matched this row itself rather than keeping it as context. */
80
+ readonly matched?: boolean;
54
81
  }
55
82
  | {
56
83
  readonly kind: "item";
@@ -84,6 +111,8 @@ export type InjectionRow =
84
111
  readonly editors?: readonly ChangeEditor[];
85
112
  /** Stable preview target id from the snapshot. */
86
113
  readonly itemId: string;
114
+ /** Whether an active filter matched this row itself rather than keeping it as context. */
115
+ readonly matched?: boolean;
87
116
  }
88
117
  | {
89
118
  readonly kind: "separator";
@@ -110,10 +139,80 @@ export function collectItemsById(snapshot: InjectionSnapshot): Map<string, Injec
110
139
  return items;
111
140
  }
112
141
 
113
- /** Flatten snapshot groups into rows separated from the non-selectable snapshot total. */
114
- export function buildInjectionRows(snapshot: InjectionSnapshot): InjectionRow[] {
142
+ /**
143
+ * Rows the filter matches, plus each match's ancestors as context. In name
144
+ * mode a matching group or item keeps its whole subtree, since naming a
145
+ * container asks for its contents. In content mode an item with children
146
+ * matches through them: its own text concatenates theirs, so it stands alone
147
+ * only when no child matches.
148
+ */
149
+ export function filterInjections(snapshot: InjectionSnapshot, filter: InjectionFilter): InjectionFilterResult {
150
+ const kept = new Set<string>();
151
+ const matched = new Set<string>();
152
+ const matches = (item: InjectionItem): boolean => filter.mode === "name"
153
+ ? filter.nameMatches(item.label)
154
+ : filter.contentMatches(item);
155
+ for (const group of snapshot.groups) {
156
+ const wholeGroup = filter.mode === "name" && filter.nameMatches(group.source.label);
157
+ if (wholeGroup) matched.add(groupKey(group));
158
+ let groupKept = wholeGroup;
159
+ for (const item of group.items) {
160
+ if (filterItem(item, filter.mode, matches, wholeGroup, kept, matched)) groupKept = true;
161
+ }
162
+ if (groupKept) kept.add(groupKey(group));
163
+ }
164
+ return { kept, matched };
165
+ }
166
+
167
+ /**
168
+ * Record which rows of one item the filter keeps and matches; true when any
169
+ * is kept. `whole` keeps the full subtree, as inside a matching group.
170
+ */
171
+ function filterItem(
172
+ item: InjectionItem,
173
+ mode: InjectionFilterMode,
174
+ matches: (item: InjectionItem) => boolean,
175
+ whole: boolean,
176
+ kept: Set<string>,
177
+ matched: Set<string>,
178
+ ): boolean {
179
+ const children = item.children ?? [];
180
+ const matchedChildren = children.filter(matches);
181
+ for (const child of matchedChildren) matched.add(child.id);
182
+ const itemMatched = matches(item);
183
+ if (whole || (mode === "name" && itemMatched)) {
184
+ if (itemMatched) matched.add(item.id);
185
+ for (const entry of [item, ...children]) kept.add(entry.id);
186
+ return true;
187
+ }
188
+ if (matchedChildren.length > 0) {
189
+ for (const entry of [item, ...matchedChildren]) kept.add(entry.id);
190
+ return true;
191
+ }
192
+ if (mode === "content" && itemMatched) {
193
+ matched.add(item.id);
194
+ kept.add(item.id);
195
+ return true;
196
+ }
197
+ return false;
198
+ }
199
+
200
+ /** Match key of a group row, kept apart from item ids. */
201
+ function groupKey(group: InjectionGroup): string {
202
+ return `source:${group.source.id}`;
203
+ }
204
+
205
+ /**
206
+ * Flatten snapshot groups into rows separated from the non-selectable snapshot
207
+ * total. A filter result hides the rows it does not keep and flags the rows it
208
+ * matched; every shown row keeps the values and status of the whole snapshot.
209
+ */
210
+ export function buildInjectionRows(snapshot: InjectionSnapshot, filter?: InjectionFilterResult): InjectionRow[] {
211
+ const shown = (key: string): boolean => filter === undefined || filter.kept.has(key);
212
+ const matched = (key: string): boolean | undefined => filter?.matched.has(key);
115
213
  const rows: InjectionRow[] = [];
116
214
  for (const group of snapshot.groups) {
215
+ if (!shown(groupKey(group))) continue;
117
216
  rows.push({
118
217
  kind: "group",
119
218
  label: group.source.label,
@@ -121,9 +220,11 @@ export function buildInjectionRows(snapshot: InjectionSnapshot): InjectionRow[]
121
220
  depth: 0,
122
221
  diff: group.diff,
123
222
  scope: combineScopes(group.items.filter((item) => item.diffOnly !== true).map(itemScope)),
223
+ matched: matched(groupKey(group)),
124
224
  });
125
- group.items.forEach((item, itemIndex) => {
126
- const isLastItem = itemIndex === group.items.length - 1;
225
+ const items = group.items.filter((item) => shown(item.id));
226
+ items.forEach((item, itemIndex) => {
227
+ const isLastItem = itemIndex === items.length - 1;
127
228
  rows.push({
128
229
  kind: "item",
129
230
  label: item.label,
@@ -141,8 +242,9 @@ export function buildInjectionRows(snapshot: InjectionSnapshot): InjectionRow[]
141
242
  diffOnly: item.diffOnly,
142
243
  ...rowStatus(item),
143
244
  itemId: item.id,
245
+ matched: matched(item.id),
144
246
  });
145
- const children = item.children ?? [];
247
+ const children = (item.children ?? []).filter((child) => shown(child.id));
146
248
  children.forEach((child, childIndex) => {
147
249
  rows.push({
148
250
  kind: "item",
@@ -159,6 +261,7 @@ export function buildInjectionRows(snapshot: InjectionSnapshot): InjectionRow[]
159
261
  requestOnly: child.requestOnly,
160
262
  ...rowStatus(child),
161
263
  itemId: child.id,
264
+ matched: matched(child.id),
162
265
  });
163
266
  });
164
267
  });
@@ -372,6 +475,12 @@ export class PreviewScroller {
372
475
  return this.scrollBy(direction * Math.max(1, this.visibleCount - 1));
373
476
  }
374
477
 
478
+ /** Leave a visible line in place; otherwise scroll it near the top, below `context` lines. */
479
+ public reveal(line: number, context: number): boolean {
480
+ if (line >= this.offsetValue && line < this.offsetValue + this.visibleCount) return false;
481
+ return this.scrollTo(line - context);
482
+ }
483
+
375
484
  public reset(): void {
376
485
  this.offsetValue = 0;
377
486
  }