@pi-archimedes/diff 2.12.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,10 +24,10 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0:
24
24
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent
25
25
  ```
26
26
 
27
- After installing Pi, choose one installation command above, then `cd` into your project and run `pi`. Inside the session, `/login` signs you in and `/model` picks a model — the [setup section](https://github.com/danielcherubini/pi-archimedes#setup) walks through the first run. A running session picks the diff renderer up with `/reload`.
27
+ After installing Pi, choose one installation command above, then `cd` into your project and run `pi`. Inside the session, `/login` signs you in and `/model` picks a model — the [setup section](https://github.com/archimedes-codes/pi-archimedes#setup) walks through the first run. A running session picks the diff renderer up with `/reload`.
28
28
 
29
29
  <div align="center">
30
- <img src="https://raw.githubusercontent.com/danielcherubini/pi-archimedes/main/docs/images/diff-edit.png" width="700" alt="Shiki syntax-highlighted split diff">
30
+ <img src="https://raw.githubusercontent.com/archimedes-codes/pi-archimedes/main/docs/images/diff-edit.png" width="700" alt="Shiki syntax-highlighted split diff">
31
31
  </div>
32
32
 
33
33
  ## What you get
@@ -43,7 +43,7 @@ The renderer displays `edit` and `write` tool changes in the tool UI, including
43
43
 
44
44
  ## Appearance
45
45
 
46
- Standalone, the renderer runs on fixed defaults — the `github-dark` theme, a 150-column minimum for split view, 60 code columns per side, and line-wrap safety checks enabled — and it does **not** read settings, because standalone has no config reader. In the [suite](https://github.com/danielcherubini/pi-archimedes), its `archimedes.diff` namespace supplies the settings through the suite's config reader:
46
+ Standalone, the renderer runs on fixed defaults — the `github-dark` theme, a 150-column minimum for split view, 60 code columns per side, and line-wrap safety checks enabled — and it does **not** read settings, because standalone has no config reader. In the [suite](https://github.com/archimedes-codes/pi-archimedes), its `archimedes.diff` namespace supplies the settings through the suite's config reader:
47
47
 
48
48
  | Setting | Type | Default | Description |
49
49
  |---------|------|---------|-------------|
@@ -56,4 +56,4 @@ The palette is derived from the chosen Shiki theme; it does **not** automaticall
56
56
 
57
57
  On/off is managed by the suite: toggle via `/plugins` (`archimedes.diff.enabled`, default on).
58
58
 
59
- ← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
59
+ ← [Back to pi-archimedes](https://github.com/archimedes-codes/pi-archimedes)
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@pi-archimedes/diff",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
- "url": "https://github.com/danielcherubini/pi-archimedes.git"
6
+ "url": "https://github.com/archimedes-codes/pi-archimedes.git"
7
7
  },
8
8
  "type": "module",
9
9
  "keywords": [
@@ -37,6 +37,6 @@
37
37
  "extensions": [
38
38
  "./src/index.ts"
39
39
  ],
40
- "image": "https://raw.githubusercontent.com/danielcherubini/pi-archimedes/main/docs/images/diff-edit.png"
40
+ "image": "https://raw.githubusercontent.com/archimedes-codes/pi-archimedes/main/docs/images/diff-edit.png"
41
41
  }
42
42
  }
@@ -19,7 +19,6 @@ import {
19
19
  DEFAULT_DIFF_BG,
20
20
  themeCacheKey,
21
21
  resolveDiffColors,
22
- resetDiffColors,
23
22
  } from "./codes.js";
24
23
 
25
24
  // ── Exported constants ──────────────────────────────────────────────────────
@@ -185,19 +184,16 @@ describe("themeCacheKey", () => {
185
184
 
186
185
  describe("resolveDiffColors", () => {
187
186
  it("returns DEFAULT_DIFF_COLORS when no theme", () => {
188
- resetDiffColors();
189
187
  const result = resolveDiffColors(undefined);
190
188
  expect(result).toEqual(DEFAULT_DIFF_COLORS);
191
189
  });
192
190
 
193
191
  it("returns DEFAULT_DIFF_COLORS when theme lacks getFgAnsi", () => {
194
- resetDiffColors();
195
192
  const result = resolveDiffColors({ fg: () => "" });
196
193
  expect(result).toEqual(DEFAULT_DIFF_COLORS);
197
194
  });
198
195
 
199
196
  it("returns theme-derived colors when theme has getFgAnsi", () => {
200
- resetDiffColors();
201
197
  const theme = {
202
198
  getFgAnsi: (token: string) => {
203
199
  if (token === "toolDiffAdded") return "\x1b[38;2;50;200;50m";
@@ -218,7 +214,6 @@ describe("resolveDiffColors", () => {
218
214
  // it (pre-0.99.0) substituted a colour where the theme had said "the
219
215
  // terminal's own". Both readings agree on the fix: a terminal default is
220
216
  // an answer, not a missing value.
221
- resetDiffColors();
222
217
  for (const token of ["", "\x1b[39m", "\x1b[0m"]) {
223
218
  const result = resolveDiffColors({ getFgAnsi: () => token });
224
219
  expect(result).toEqual({ fgAdd: token, fgDel: token, fgCtx: token });
@@ -226,7 +221,6 @@ describe("resolveDiffColors", () => {
226
221
  });
227
222
 
228
223
  it("substitutes the hardcoded fallback per token, only where the theme has no answer", () => {
229
- resetDiffColors();
230
224
  const theme = {
231
225
  getFgAnsi: (token: string) => {
232
226
  if (token === "toolDiffAdded") return "\x1b[38;2;50;200;50m";
@@ -242,44 +236,7 @@ describe("resolveDiffColors", () => {
242
236
  });
243
237
 
244
238
  it("keeps the hardcoded set for the no-theme-object path", () => {
245
- resetDiffColors();
246
239
  expect(resolveDiffColors(undefined)).toEqual(DEFAULT_DIFF_COLORS);
247
240
  expect(resolveDiffColors({})).toEqual(DEFAULT_DIFF_COLORS);
248
241
  });
249
242
  });
250
-
251
- // ── resetDiffColors ─────────────────────────────────────────────────────────
252
-
253
- describe("resetDiffColors", () => {
254
- it("clears cache so subsequent call re-derives", () => {
255
- const theme = {
256
- getFgAnsi: (token: string) => {
257
- if (token === "toolDiffAdded") return "\x1b[38;2;50;200;50m";
258
- if (token === "toolDiffRemoved") return "\x1b[38;2;200;50;50m";
259
- if (token === "toolDiffContext") return "\x1b[38;2;100;100;100m";
260
- return "";
261
- },
262
- };
263
-
264
- // First call derives colors
265
- const result1 = resolveDiffColors(theme);
266
- expect(result1.fgAdd).toBe("\x1b[38;2;50;200;50m");
267
-
268
- // Reset clears cache
269
- resetDiffColors();
270
-
271
- // Now change the theme
272
- const newTheme = {
273
- getFgAnsi: (token: string) => {
274
- if (token === "toolDiffAdded") return "\x1b[38;2;10;100;10m";
275
- if (token === "toolDiffRemoved") return "\x1b[38;2;100;10;10m";
276
- if (token === "toolDiffContext") return "\x1b[38;2;50;50;50m";
277
- return "";
278
- },
279
- };
280
-
281
- // After reset, new theme should be picked up
282
- const result2 = resolveDiffColors(newTheme);
283
- expect(result2.fgAdd).toBe("\x1b[38;2;10;100;10m");
284
- });
285
- });
package/src/ansi/codes.ts CHANGED
@@ -56,9 +56,6 @@ export const DEFAULT_DIFF_BG: DiffBg = {
56
56
  divider: `${FG_RULE}│\x1b[0m`,
57
57
  };
58
58
 
59
- // Theme cache key state — lives here since it's used by resolveDiffColors.
60
- let _lastThemeKey: string | undefined;
61
-
62
59
  // ---------------------------------------------------------------------------
63
60
  // Regex patterns
64
61
  // ---------------------------------------------------------------------------
@@ -84,11 +81,6 @@ export const DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, f
84
81
  // Theme-aware color resolution
85
82
  // ---------------------------------------------------------------------------
86
83
 
87
- /** Reset auto-derived colors — call when theme changes. */
88
- export function resetDiffColors(): void {
89
- _lastThemeKey = undefined;
90
- }
91
-
92
84
  export function themeCacheKey(theme?: any): string {
93
85
  if (!theme?.fg) return "no-theme";
94
86
  const fgKeys = [
@@ -130,12 +122,6 @@ function themeFgAnsi(theme: any, token: string): string | undefined {
130
122
  }
131
123
 
132
124
  export function resolveDiffColors(theme?: any): DiffColors {
133
- const themeKey = themeCacheKey(theme);
134
-
135
- // Re-derive theme key cache (bg colors are now passed explicitly via DiffBg)
136
- if (themeKey !== _lastThemeKey) {
137
- _lastThemeKey = themeKey;
138
- }
139
125
  if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
140
126
  // Per token, so one unknown token cannot drag the whole row back to the
141
127
  // hardcoded set.
package/src/ansi/index.ts CHANGED
@@ -7,7 +7,7 @@ export {
7
7
  DEFAULT_DIFF_BG,
8
8
  ANSI_RE, ANSI_CAPTURE_RE, ANSI_PARAM_CAPTURE_RE,
9
9
  DEFAULT_DIFF_COLORS,
10
- resetDiffColors, themeCacheKey, resolveDiffColors,
10
+ themeCacheKey, resolveDiffColors,
11
11
  deriveBgFromTheme,
12
12
  type DiffBg,
13
13
  type DiffColors,
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Config threading (issue #163, the #156 shape-parity card).
3
+ *
4
+ * `registerDiffTools(pi, getTheme, readConfig)` used to park `readConfig` in a
5
+ * module-scope binding and re-point two submodule getters at it. Two sessions in
6
+ * one process therefore shared ONE config slot: after the second registration,
7
+ * the first session's rows rendered with the second session's `diffTheme` and
8
+ * split thresholds — measured flipping a 200-column render from split to unified.
9
+ *
10
+ * The pin drives INSTALLED RENDERERS, never a leaf function. Passing a config
11
+ * straight to `shouldUseSplit` cannot observe the shared holder, so a test
12
+ * written against the leaf reads green on both the broken and the fixed tree and
13
+ * pins nothing.
14
+ *
15
+ * Red on the base tree: session A's reader stops firing the moment session B
16
+ * registers (`expected N to be greater than N`). Green once the reader is
17
+ * captured per registration.
18
+ */
19
+
20
+ import { describe, it, expect } from "vitest";
21
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import path from "node:path";
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { registerDiffTools } from "./index.js";
26
+ import type { DiffRenderConfig } from "./render/index.js";
27
+
28
+ /**
29
+ * A `pi` handle that collects every resolver, the way pi collects a LIST of them
30
+ * per extension (same shape as `src/tools/register.test.ts`).
31
+ */
32
+ function fakePi() {
33
+ const renderers: ((name: string, next: () => any) => any)[] = [];
34
+ const pi = {
35
+ registerTool: () => {},
36
+ registerToolRenderer: (r: (name: string, next: () => any) => any) => {
37
+ renderers.push(r);
38
+ },
39
+ on: () => () => {},
40
+ } as unknown as ExtensionAPI;
41
+ return { pi, renderers };
42
+ }
43
+
44
+ /**
45
+ * Resolve the way pi does: fold every registered resolver over the previous
46
+ * answer. Both installers push a resolver, so capturing only the last one would
47
+ * overwrite the `edit` answer — the fold is what keeps it.
48
+ */
49
+ function resolveTool(
50
+ resolvers: readonly ((name: string, next: () => any) => any)[],
51
+ toolName: string,
52
+ ) {
53
+ let out: any = { renderShell: "default" };
54
+ for (const r of resolvers) out = r(toolName, () => out) ?? out;
55
+ return out;
56
+ }
57
+
58
+ // The theme stub from `src/tools/edit.test.ts` — a token-addressable fake, so
59
+ // nothing here decides a colour by reading an escape sequence.
60
+ const theme = {
61
+ fg: (token: string, text?: string) =>
62
+ text === undefined ? `[${token}]` : `[${token}:${text}]`,
63
+ bold: (text: string) => `**${text}**`,
64
+ bg: (_token: string, text: string) => text,
65
+ italic: (text: string) => text,
66
+ } as any;
67
+
68
+ /**
69
+ * A FRESH context per call, exactly as pi hands one to a renderer. A reused ctx
70
+ * would let the `ctx.state._pk` memo short-circuit the row before it ever built
71
+ * a component, and the read we are counting would not happen.
72
+ */
73
+ function freshCtx() {
74
+ return {
75
+ state: {},
76
+ outputPad: 1,
77
+ argsComplete: true,
78
+ lastComponent: undefined,
79
+ invalidate: () => {},
80
+ isError: false,
81
+ expanded: false,
82
+ } as any;
83
+ }
84
+
85
+ /** Render a row the way pi does: build it, then draw it at a real width. */
86
+ function drawRow(renderers: any, args: any, width: number): string[] {
87
+ const component = renderers.renderCall(args, theme, freshCtx());
88
+ return component.render(width);
89
+ }
90
+
91
+ /** Let the component's async render promise run to completion. */
92
+ async function settle(): Promise<void> {
93
+ for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0));
94
+ }
95
+
96
+ function workspace(dir: string, name: string, contents: string): string {
97
+ const file = path.join(dir, name);
98
+ writeFileSync(file, contents);
99
+ return file;
100
+ }
101
+
102
+ describe("config threading across two sessions in one process", () => {
103
+ it("keeps session A reading its own config after session B registers (#163)", async () => {
104
+ const dir = mkdtempSync(path.join(tmpdir(), "diff-threading-"));
105
+ try {
106
+ let readsA = 0;
107
+ let readsB = 0;
108
+
109
+ // A wants a wide-enough terminal for the split view and a light theme.
110
+ const configA: DiffRenderConfig = {
111
+ diffTheme: "github-light",
112
+ diffSplitMinWidth: 100,
113
+ diffSplitMinCodeWidth: 30,
114
+ diffSplitWrapCheck: false,
115
+ };
116
+ // B wants a terminal wider than any real one: every render falls back to
117
+ // unified. If A inherits B's reader, A's split view disappears.
118
+ const configB: DiffRenderConfig = {
119
+ diffTheme: "dracula",
120
+ diffSplitMinWidth: 4000,
121
+ diffSplitMinCodeWidth: 30,
122
+ diffSplitWrapCheck: false,
123
+ };
124
+
125
+ const readerA = () => {
126
+ readsA++;
127
+ return configA;
128
+ };
129
+ const readerB = () => {
130
+ readsB++;
131
+ return configB;
132
+ };
133
+
134
+ const sessionA = fakePi();
135
+ registerDiffTools(sessionA.pi, () => theme, readerA);
136
+
137
+ const editA = resolveTool(sessionA.renderers, "edit");
138
+ expect(typeof editA?.renderCall).toBe("function");
139
+
140
+ const first = workspace(dir, "first.ts", "const a = 1;\n");
141
+ drawRow(
142
+ editA,
143
+ { path: first, oldText: "const a = 1;\n", newText: "const b = 2;\n" },
144
+ 200,
145
+ );
146
+ await settle();
147
+
148
+ const readsAfterFirst = readsA;
149
+ expect(readsAfterFirst).toBeGreaterThan(0);
150
+
151
+ // A second session, on a DIFFERENT `pi` handle, with a different reader.
152
+ const sessionB = fakePi();
153
+ registerDiffTools(sessionB.pi, () => theme, readerB);
154
+ expect(resolveTool(sessionB.renderers, "edit")?.renderCall).toBeTypeOf("function");
155
+
156
+ // A brand new `edit` row, still rendered by A's resolvers.
157
+ const second = workspace(dir, "second.ts", "const c = 3;\n");
158
+ drawRow(
159
+ editA,
160
+ { path: second, oldText: "const c = 3;\n", newText: "const d = 4;\n" },
161
+ 200,
162
+ );
163
+ await settle();
164
+
165
+ // A's rows must still ask A. On the shared-holder tree this reads
166
+ // `expected N to be greater than N`: B's registration re-pointed the
167
+ // module binding and A's reader never fires again.
168
+ expect(readsA).toBeGreaterThan(readsAfterFirst);
169
+ // A's rows must not have been rendered by B's reader either.
170
+ expect(readsB).toBe(0);
171
+ } finally {
172
+ rmSync(dir, { recursive: true, force: true });
173
+ }
174
+ });
175
+ });
@@ -6,7 +6,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import * as Ansi from "./ansi/index.js";
7
7
  import type { DiffBg, DiffColors } from "./ansi/index.js";
8
8
  import { DEFAULT_DIFF_COLORS, DEFAULT_DIFF_BG, deriveBgFromTheme } from "./ansi/index.js";
9
- import { renderSplitLines, MAX_RENDER_LINES } from "./render/index.js";
9
+ import { renderSplitLines, MAX_RENDER_LINES, type DiffRenderConfig } from "./render/index.js";
10
10
  import type { ParsedDiff } from "./core/diff.js";
11
11
 
12
12
  // ---------------------------------------------------------------------------
@@ -31,9 +31,10 @@ import type { ParsedDiff } from "./core/diff.js";
31
31
  *
32
32
  * ```ts
33
33
  * renderResult(result, options, theme, context) {
34
+ * const cfg = readConfig();
34
35
  * const d = result.details;
35
36
  * if (d?._type === "diff") {
36
- * const comp = context.lastComponent ?? new DiffComponent(d.diff, d.language, theme);
37
+ * const comp = context.lastComponent ?? new DiffComponent(d.diff, d.language, theme, cfg);
37
38
  * return comp;
38
39
  * }
39
40
  * }
@@ -43,6 +44,8 @@ export class DiffComponent implements Component {
43
44
  private diff: ParsedDiff;
44
45
  private language: BundledLanguage | undefined;
45
46
  private theme: Theme;
47
+ /** The config the row was registered with — never read from module state. */
48
+ private config: DiffRenderConfig;
46
49
  private maxLines: number;
47
50
 
48
51
  /** Box wrapper with base background — makes the diff self-contained. */
@@ -64,11 +67,13 @@ export class DiffComponent implements Component {
64
67
  diff: ParsedDiff,
65
68
  language: BundledLanguage | undefined,
66
69
  theme: Theme,
70
+ config: DiffRenderConfig,
67
71
  maxLines: number = MAX_RENDER_LINES,
68
72
  ) {
69
73
  this.diff = diff;
70
74
  this.language = language;
71
75
  this.theme = theme;
76
+ this.config = config;
72
77
  this.maxLines = maxLines;
73
78
 
74
79
  // Box provides base background so the diff is self-contained.
@@ -122,6 +127,7 @@ export class DiffComponent implements Component {
122
127
  this.diff,
123
128
  this.language,
124
129
  width,
130
+ this.config,
125
131
  this.maxLines,
126
132
  dc,
127
133
  dbg,
package/src/index.ts CHANGED
@@ -7,11 +7,9 @@
7
7
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
8
8
  import type { Component, SettingItem } from "@earendil-works/pi-tui";
9
9
 
10
- import { setConfigGetter as setShikiConfig } from "./shiki.js";
11
- import { setConfigGetter as setRenderConfig } from "./render/index.js";
10
+ import type { DiffRenderConfig } from "./render/index.js";
12
11
  import { DiffComponent } from "./diff-component.js";
13
12
  import type { DiffBg, DiffColors } from "./ansi/index.js";
14
- import { resetDiffColors } from "./ansi/index.js";
15
13
  import { installWriteTool, installEditTool } from "./tools/index.js";
16
14
  import { diag } from "./log.js";
17
15
  import { reportPiSupport } from "./pi-support.js";
@@ -27,12 +25,10 @@ export type { DiffBg, DiffColors } from "./ansi/index.js";
27
25
  // Config
28
26
  // ---------------------------------------------------------------------------
29
27
 
30
- export interface DiffConfig {
31
- diffTheme: string;
32
- diffSplitMinWidth: number;
33
- diffSplitMinCodeWidth: number;
34
- diffSplitWrapCheck?: boolean;
35
- }
28
+ // The shape lives in the render layer (`render/shared.ts`), which is what
29
+ // actually consumes it — aliased here so the public name survives and the shape
30
+ // has exactly one declaration.
31
+ export type DiffConfig = DiffRenderConfig;
36
32
 
37
33
  const DEFAULT_DIFF_CONFIG: DiffConfig = {
38
34
  diffTheme: "github-dark",
@@ -41,15 +37,11 @@ const DEFAULT_DIFF_CONFIG: DiffConfig = {
41
37
  diffSplitWrapCheck: true,
42
38
  };
43
39
 
44
- let _readConfig: () => DiffConfig = () => DEFAULT_DIFF_CONFIG;
45
- function getConfig(): DiffConfig { return _readConfig(); }
46
-
47
40
  // ---------------------------------------------------------------------------
48
41
  // Settings
49
42
  // ---------------------------------------------------------------------------
50
43
 
51
- export function getDiffSettingsItems(overrideConfig?: DiffConfig): SettingItem[] {
52
- const config = overrideConfig ?? getConfig();
44
+ export function getDiffSettingsItems(config: DiffConfig): SettingItem[] {
53
45
  return [
54
46
  {
55
47
  id: "diffTheme",
@@ -117,8 +109,10 @@ try {
117
109
  * builds a fresh extension and hands registration a new handle. A module-wide
118
110
  * flag would then install for the first session only, and every session after
119
111
  * it would get pi's native `edit`/`write` rows and none of the result details
120
- * the rows read. The theme and config readers are per-session, so those are
121
- * refreshed on every call.
112
+ * the rows read. The config reader is not module state: it is handed to the
113
+ * installers and captured by the rows they register, so a second call on an
114
+ * already-installed handle is a no-op and cannot re-point the first session's
115
+ * rows at another session's config (#163).
122
116
  */
123
117
  const _installedExtensions = new WeakSet<object>();
124
118
 
@@ -135,15 +129,6 @@ export function registerDiffTools(
135
129
  // runs again on every session_start.
136
130
  reportPiSupport("diff", "1.2.0");
137
131
 
138
- _readConfig = readConfig;
139
-
140
- // Reset theme-derived colors so they are re-derived from the current theme
141
- resetDiffColors();
142
-
143
- // Wire config getters into submodules
144
- setShikiConfig(() => getConfig());
145
- setRenderConfig(() => getConfig());
146
-
147
132
  // Already installed on this extension: re-registering would stack a duplicate
148
133
  // resolver and handler set. A different handle is a different extension, so
149
134
  // it installs even when this module instance is a reused one.
@@ -168,8 +153,8 @@ export function registerDiffTools(
168
153
  // The rows are chosen by pi's renderer resolution, and the result details
169
154
  // each row reads arrive through `tool_result` (ADR 0029). Neither registers a
170
155
  // tool, so pi's own `edit` and `write` stay in place.
171
- installEditTool(pi, cwd, home);
172
- installWriteTool(pi, cwd, home, _TextComponent);
156
+ installEditTool(pi, cwd, home, readConfig);
157
+ installWriteTool(pi, cwd, home, _TextComponent, readConfig);
173
158
  _installedExtensions.add(pi);
174
159
  }
175
160
 
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Module-state shape pin for `packages/diff` (issue #163).
3
+ *
4
+ * `packages/core/src/session-state-discipline.test.ts` is the cross-package
5
+ * guard, but it is INTRA-FILE: it follows a session handler's body and the
6
+ * functions that body calls in the same file, so it was satisfied here by
7
+ * assigning `_readConfig` one line away from the handler that wrote it. This
8
+ * file asserts the SOURCE SHAPE instead, so it cannot be satisfied by moving an
9
+ * assignment to another file or one line further down.
10
+ *
11
+ * What is banned: a module-scope `let`/`var` holding a config getter, a config
12
+ * value, or a theme key, and any exported `setConfigGetter` — the setter being
13
+ * the tell that the binding is re-pointed per session rather than owned by the
14
+ * process.
15
+ *
16
+ * What stays allowed, deliberately: `_TextComponent` (`index.ts` — loaded once
17
+ * at module evaluation, never re-pointed) and `_shikiReady` (`shiki.ts` — an
18
+ * honest "the Shiki warm-up has run" flag). Both are pinned below so this file
19
+ * cannot quietly become a blanket "no module state" rule.
20
+ */
21
+
22
+ import { describe, it, expect } from "vitest";
23
+ import { readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { join, relative } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const SRC_ROOT = fileURLToPath(new URL("./", import.meta.url));
28
+
29
+ /** Every non-test `.ts` file under `packages/diff/src`. */
30
+ function sourceFiles(dir: string = SRC_ROOT): string[] {
31
+ const out: string[] = [];
32
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
33
+ const full = join(dir, entry.name);
34
+ if (entry.isDirectory()) {
35
+ out.push(...sourceFiles(full));
36
+ continue;
37
+ }
38
+ if (!entry.isFile() || !entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) continue;
39
+ out.push(full);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** A module-scope binding holding config, a config reader, or a theme key. */
45
+ const CONFIG_HOLDER = /^\s*(?:export\s+)?(?:let|var)\s+_?(?:getConfig|_readConfig|_lastThemeKey)\b/;
46
+
47
+ /** The exported setter that re-points such a binding per session. */
48
+ const CONFIG_SETTER = /^\s*export\s+(?:function|const)\s+setConfigGetter\b/;
49
+
50
+ function violations(files: readonly string[]): string[] {
51
+ const found: string[] = [];
52
+ for (const file of files) {
53
+ const rel = relative(SRC_ROOT, file).replace(/\\/g, "/");
54
+ const lines = readFileSync(file, "utf8").split("\n");
55
+ lines.forEach((line, i) => {
56
+ if (CONFIG_HOLDER.test(line)) {
57
+ found.push(`${rel}:${i + 1} [config holder at module scope] ${line.trim()}`);
58
+ }
59
+ if (CONFIG_SETTER.test(line)) {
60
+ found.push(`${rel}:${i + 1} [per-session config setter exported] ${line.trim()}`);
61
+ }
62
+ });
63
+ }
64
+ return found;
65
+ }
66
+
67
+ describe("diff module state (#163)", () => {
68
+ it("scans a non-empty file set (so the guard cannot go vacuous)", () => {
69
+ const files = sourceFiles();
70
+ expect(files.length).toBeGreaterThan(5);
71
+ for (const file of files) expect(statSync(file).isFile()).toBe(true);
72
+ expect(files.map((f) => relative(SRC_ROOT, f).replace(/\\/g, "/"))).toContain("shiki.ts");
73
+ });
74
+
75
+ it("declares no module-scope config holder and exports no setConfigGetter", () => {
76
+ expect(violations(sourceFiles())).toEqual([]);
77
+ });
78
+
79
+ it("still allows the two honest process-wide bindings", () => {
80
+ const index = readFileSync(join(SRC_ROOT, "index.ts"), "utf8");
81
+ const shiki = readFileSync(join(SRC_ROOT, "shiki.ts"), "utf8");
82
+ expect(index).toMatch(/^let _TextComponent:/m);
83
+ expect(shiki).toMatch(/^let _shikiReady = false;$/m);
84
+ });
85
+
86
+ it("keeps shiki.ts free of any import from index.js or render/ (no cycle)", () => {
87
+ const shiki = readFileSync(join(SRC_ROOT, "shiki.ts"), "utf8");
88
+ expect(shiki).not.toMatch(/from\s+["']\.\/index\.js["']/);
89
+ expect(shiki).not.toMatch(/from\s+["']\.\/render\//);
90
+ });
91
+ });
@@ -7,8 +7,7 @@ export {
7
7
  MAX_HL_CHARS,
8
8
  WORD_DIFF_MIN_SIM,
9
9
  DEFAULT_TERM_WIDTH,
10
- getConfig,
11
- setConfigGetter,
10
+ type DiffRenderConfig,
12
11
  adaptiveWrapRows,
13
12
  wrapAnsi,
14
13
  shouldUseSplit,
@@ -1,9 +1,7 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
1
+ import { describe, it, expect } from "vitest";
2
2
  import { parseDiff } from "../core/diff.js";
3
3
  import {
4
4
  shouldUseSplit,
5
- setConfigGetter,
6
- getConfig,
7
5
  adaptiveWrapRows,
8
6
  wrapAnsi,
9
7
  } from "./shared.js";
@@ -48,44 +46,41 @@ describe("wrapAnsi", () => {
48
46
  });
49
47
 
50
48
  describe("render shared / shouldUseSplit", () => {
51
- beforeEach(() => {
52
- setConfigGetter(() => ({
53
- diffSplitMinWidth: 150,
54
- diffSplitMinCodeWidth: 60,
55
- diffSplitWrapCheck: true,
56
- }));
57
- });
49
+ // The config used to arrive through a module-scope getter; it is now a
50
+ // parameter, so each case passes the object it previously installed.
51
+ const cfg = {
52
+ diffTheme: "github-dark",
53
+ diffSplitMinWidth: 150,
54
+ diffSplitMinCodeWidth: 60,
55
+ diffSplitWrapCheck: true,
56
+ };
58
57
 
59
58
  it("returns false for empty diff", () => {
60
59
  const diff = parseDiff("", "");
61
- expect(shouldUseSplit(diff, 200)).toBe(false);
60
+ expect(shouldUseSplit(diff, 200, cfg)).toBe(false);
62
61
  });
63
62
 
64
63
  it("returns false if terminal width < diffSplitMinWidth", () => {
65
64
  const old = "const x = 1;\n";
66
65
  const updated = "const x = 2;\n";
67
66
  const diff = parseDiff(old, updated);
68
- expect(shouldUseSplit(diff, 140)).toBe(false);
67
+ expect(shouldUseSplit(diff, 140, cfg)).toBe(false);
69
68
  });
70
69
 
71
70
  it("returns false if available code column width < diffSplitMinCodeWidth", () => {
72
- setConfigGetter(() => ({
73
- diffSplitMinWidth: 100,
74
- diffSplitMinCodeWidth: 60,
75
- diffSplitWrapCheck: true,
76
- }));
71
+ const narrow = { ...cfg, diffSplitMinWidth: 100, diffSplitMinCodeWidth: 60 };
77
72
  const old = "const x = 1;\n";
78
73
  const updated = "const x = 2;\n";
79
74
  const diff = parseDiff(old, updated);
80
75
  // With tw=110, half=54, nw=2, gw=7, cw=47 < 60 -> false
81
- expect(shouldUseSplit(diff, 110)).toBe(false);
76
+ expect(shouldUseSplit(diff, 110, narrow)).toBe(false);
82
77
  });
83
78
 
84
79
  it("returns true for normal short lines when terminal width and code width fit", () => {
85
80
  const old = "const x = 1;\n";
86
81
  const updated = "const x = 2;\n";
87
82
  const diff = parseDiff(old, updated);
88
- expect(shouldUseSplit(diff, 200)).toBe(true);
83
+ expect(shouldUseSplit(diff, 200, cfg)).toBe(true);
89
84
  });
90
85
 
91
86
  it("falls back to false when lines wrap and diffSplitWrapCheck is true", () => {
@@ -93,18 +88,14 @@ describe("render shared / shouldUseSplit", () => {
93
88
  const longLineNew = "const longLine = '" + "b".repeat(150) + "';\n";
94
89
  const diff = parseDiff(longLineOld, longLineNew);
95
90
  // In a 200 col terminal, cw ~ 92, line length > 150, all lines wrap (ratio = 1.0 >= 0.2)
96
- expect(shouldUseSplit(diff, 200)).toBe(false);
91
+ expect(shouldUseSplit(diff, 200, cfg)).toBe(false);
97
92
  });
98
93
 
99
94
  it("returns true when lines wrap but diffSplitWrapCheck is false", () => {
100
- setConfigGetter(() => ({
101
- diffSplitMinWidth: 150,
102
- diffSplitMinCodeWidth: 60,
103
- diffSplitWrapCheck: false,
104
- }));
95
+ const noWrapCheck = { ...cfg, diffSplitWrapCheck: false };
105
96
  const longLineOld = "const longLine = '" + "a".repeat(150) + "';\n";
106
97
  const longLineNew = "const longLine = '" + "b".repeat(150) + "';\n";
107
98
  const diff = parseDiff(longLineOld, longLineNew);
108
- expect(shouldUseSplit(diff, 200)).toBe(true);
99
+ expect(shouldUseSplit(diff, 200, noWrapCheck)).toBe(true);
109
100
  });
110
101
  });
@@ -24,25 +24,12 @@ export const DEFAULT_TERM_WIDTH = 200;
24
24
  // ---------------------------------------------------------------------------
25
25
 
26
26
  export interface DiffRenderConfig {
27
+ diffTheme: string;
27
28
  diffSplitMinWidth: number;
28
29
  diffSplitMinCodeWidth: number;
29
30
  diffSplitWrapCheck?: boolean;
30
31
  }
31
32
 
32
- export function getConfig(): DiffRenderConfig {
33
- return typeof _getConfig === "function" ? _getConfig() : DEFAULT_CONFIG;
34
- }
35
-
36
- const DEFAULT_CONFIG: DiffRenderConfig = {
37
- diffSplitMinWidth: 150,
38
- diffSplitMinCodeWidth: 60,
39
- diffSplitWrapCheck: true,
40
- };
41
- let _getConfig: (() => DiffRenderConfig) | undefined;
42
- export function setConfigGetter(fn: () => DiffRenderConfig): void {
43
- _getConfig = fn;
44
- }
45
-
46
33
  // ---------------------------------------------------------------------------
47
34
  // Helpers
48
35
  // ---------------------------------------------------------------------------
@@ -109,9 +96,8 @@ export function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(w), fi
109
96
  return rows;
110
97
  }
111
98
 
112
- export function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
99
+ export function shouldUseSplit(diff: ParsedDiff, tw: number, cfg: DiffRenderConfig, maxRows = MAX_PREVIEW_LINES): boolean {
113
100
  if (!diff.lines.length) return false;
114
- const cfg = getConfig();
115
101
  if (tw < cfg.diffSplitMinWidth) return false;
116
102
 
117
103
  const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
@@ -13,6 +13,7 @@ import {
13
13
  MAX_RENDER_LINES,
14
14
  WORD_DIFF_MIN_SIM,
15
15
  DEFAULT_TERM_WIDTH,
16
+ type DiffRenderConfig,
16
17
  adaptiveWrapRows,
17
18
  wrapAnsi,
18
19
  shouldUseSplit,
@@ -30,11 +31,12 @@ import { renderUnifiedLines } from "./unified.js";
30
31
  export async function renderSplit(
31
32
  diff: ParsedDiff,
32
33
  language: BundledLanguage | undefined,
34
+ cfg: DiffRenderConfig,
33
35
  max = MAX_PREVIEW_LINES,
34
36
  dc: DiffColors = DEFAULT_DIFF_COLORS,
35
37
  ): Promise<string> {
36
38
  const width = process.stdout.columns ?? DEFAULT_TERM_WIDTH;
37
- const lines = await renderSplitLines(diff, language, width, max, dc, DEFAULT_DIFF_BG);
39
+ const lines = await renderSplitLines(diff, language, width, cfg, max, dc, DEFAULT_DIFF_BG);
38
40
  return lines.join("\n");
39
41
  }
40
42
 
@@ -42,6 +44,7 @@ export async function renderSplitLines(
42
44
  diff: ParsedDiff,
43
45
  language: BundledLanguage | undefined,
44
46
  width: number,
47
+ cfg: DiffRenderConfig,
45
48
  max = MAX_PREVIEW_LINES,
46
49
  dc: DiffColors = DEFAULT_DIFF_COLORS,
47
50
  dbg: DiffBg = DEFAULT_DIFF_BG,
@@ -49,7 +52,7 @@ export async function renderSplitLines(
49
52
  const rst = dbg.rst;
50
53
  const bgBase = dbg.bgBase;
51
54
 
52
- if (!shouldUseSplit(diff, width, max)) return renderUnifiedLines(diff, language, width, max, dc, dbg);
55
+ if (!shouldUseSplit(diff, width, cfg, max)) return renderUnifiedLines(diff, language, width, cfg, max, dc, dbg);
53
56
  if (!diff.lines.length) return [];
54
57
 
55
58
  type Row = { left: DiffLine | null; right: DiffLine | null };
@@ -80,7 +83,7 @@ export async function renderSplitLines(
80
83
  if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
81
84
  }
82
85
  const [leftHL, rightHL] = canHL
83
- ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
86
+ ? await Promise.all([hlBlock(leftSrc.join("\n"), language, cfg.diffTheme), hlBlock(rightSrc.join("\n"), language, cfg.diffTheme)])
84
87
  : [leftSrc, rightSrc];
85
88
 
86
89
  let lI = 0, rI = 0;
@@ -12,6 +12,7 @@ import {
12
12
  MAX_RENDER_LINES,
13
13
  WORD_DIFF_MIN_SIM,
14
14
  DEFAULT_TERM_WIDTH,
15
+ type DiffRenderConfig,
15
16
  adaptiveWrapRows,
16
17
  wrapAnsi,
17
18
  } from "./shared.js";
@@ -27,11 +28,12 @@ import {
27
28
  export async function renderUnified(
28
29
  diff: ParsedDiff,
29
30
  language: BundledLanguage | undefined,
31
+ cfg: DiffRenderConfig,
30
32
  max = MAX_RENDER_LINES,
31
33
  dc: DiffColors = DEFAULT_DIFF_COLORS,
32
34
  ): Promise<string> {
33
35
  const width = process.stdout.columns ?? DEFAULT_TERM_WIDTH;
34
- const lines = await renderUnifiedLines(diff, language, width, max, dc, DEFAULT_DIFF_BG);
36
+ const lines = await renderUnifiedLines(diff, language, width, cfg, max, dc, DEFAULT_DIFF_BG);
35
37
  return lines.join("\n");
36
38
  }
37
39
 
@@ -39,6 +41,7 @@ export async function renderUnifiedLines(
39
41
  diff: ParsedDiff,
40
42
  language: BundledLanguage | undefined,
41
43
  width: number,
44
+ cfg: DiffRenderConfig,
42
45
  max = MAX_RENDER_LINES,
43
46
  dc: DiffColors = DEFAULT_DIFF_COLORS,
44
47
  dbg: DiffBg = DEFAULT_DIFF_BG,
@@ -61,7 +64,7 @@ export async function renderUnifiedLines(
61
64
  if (l.type === "ctx" || l.type === "add") newSrc.push(l.content);
62
65
  }
63
66
  const [oldHL, newHL] = canHL
64
- ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
67
+ ? await Promise.all([hlBlock(oldSrc.join("\n"), language, cfg.diffTheme), hlBlock(newSrc.join("\n"), language, cfg.diffTheme)])
65
68
  : [oldSrc, newSrc];
66
69
 
67
70
  let oI = 0, nI = 0, idx = 0;
package/src/shiki.test.ts CHANGED
@@ -15,7 +15,7 @@ vi.mock("./ansi/index.js", () => ({
15
15
 
16
16
  // ── Imports ──────────────────────────────────────────────────────────────────
17
17
 
18
- import { lang, setConfigGetter } from "./shiki.js";
18
+ import { lang } from "./shiki.js";
19
19
 
20
20
  // ── lang ─────────────────────────────────────────────────────────────────────
21
21
 
@@ -231,33 +231,21 @@ describe("lang", () => {
231
231
  });
232
232
  });
233
233
 
234
- // ── setConfigGetter ──────────────────────────────────────────────────────────
234
+ // ── hlBlock ──────────────────────────────────────────────────────────────────
235
235
 
236
- describe("setConfigGetter", () => {
237
- it("changes the config getter", async () => {
238
- // Import hlBlock to test config getter effect
236
+ // The theme used to arrive through a module-scope config getter that these tests
237
+ // re-pointed; `hlBlock` now takes it directly (#163), so the two cases that
238
+ // exercised real highlighting survive as explicit-theme calls.
239
+ describe("hlBlock", () => {
240
+ it("returns an array of lines for a highlighted block", async () => {
239
241
  const { hlBlock } = await import("./shiki.js");
240
-
241
- setConfigGetter(() => ({ diffTheme: "github-light" }));
242
-
243
- // The config getter should now return the new theme
244
- // We can't directly inspect _getConfig, but we can verify hlBlock
245
- // doesn't throw and uses the new config
246
- const result = await hlBlock("const x = 1;", "javascript");
242
+ const result = await hlBlock("const x = 1;", "javascript", "github-dark");
247
243
  expect(Array.isArray(result)).toBe(true);
248
244
  });
249
245
 
250
- it("accepts custom theme", async () => {
251
- setConfigGetter(() => ({ diffTheme: "nord" }));
252
-
246
+ it("highlights a second language under its own theme", async () => {
253
247
  const { hlBlock } = await import("./shiki.js");
254
- const result = await hlBlock("print('hello')", "python");
248
+ const result = await hlBlock("print('hello')", "python", "github-dark");
255
249
  expect(Array.isArray(result)).toBe(true);
256
250
  });
257
-
258
- it("restores default behavior after setting", () => {
259
- setConfigGetter(() => ({ diffTheme: "github-dark" }));
260
- // Should not throw
261
- expect(() => setConfigGetter(() => ({ diffTheme: "one-dark" }))).not.toThrow();
262
- });
263
251
  });
package/src/shiki.ts CHANGED
@@ -6,15 +6,6 @@ import type { BundledLanguage, BundledTheme } from "shiki";
6
6
 
7
7
  import { normalizeShikiContrast } from "./ansi/index.js";
8
8
 
9
- // ---------------------------------------------------------------------------
10
- // Config access
11
- // ---------------------------------------------------------------------------
12
-
13
- let _getConfig: () => { diffTheme: string } = () => ({ diffTheme: "github-dark" });
14
- export function setConfigGetter(fn: () => { diffTheme: string }): void {
15
- _getConfig = fn;
16
- }
17
-
18
9
  // ---------------------------------------------------------------------------
19
10
  // Language detection
20
11
  // ---------------------------------------------------------------------------
@@ -43,10 +34,10 @@ const MAX_HL_CHARS = 80_000;
43
34
  const CACHE_LIMIT = 192;
44
35
 
45
36
  let _shikiReady = false;
46
- async function ensureShiki(): Promise<void> {
37
+ async function ensureShiki(theme: string): Promise<void> {
47
38
  if (_shikiReady) return;
48
39
  _shikiReady = true;
49
- codeToANSI("", "typescript", _getConfig().diffTheme as BundledTheme).catch(() => {});
40
+ codeToANSI("", "typescript", theme as BundledTheme).catch(() => {});
50
41
  }
51
42
 
52
43
  const _cache = new Map<string, string[]>();
@@ -63,13 +54,12 @@ function _touch(k: string, v: string[]): string[] {
63
54
  return v;
64
55
  }
65
56
 
66
- export async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
57
+ export async function hlBlock(code: string, language: BundledLanguage | undefined, theme: string): Promise<string[]> {
67
58
  if (!code) return [""];
68
59
  if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
69
60
 
70
- await ensureShiki();
61
+ await ensureShiki(theme);
71
62
 
72
- const theme = _getConfig().diffTheme;
73
63
  const k = `${theme}\0${language}\0${code}`;
74
64
  const hit = _cache.get(k);
75
65
  if (hit) return _touch(k, hit); // Promote to most-recently-used
@@ -14,6 +14,15 @@ import path from "node:path";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { createEditTool } from "@earendil-works/pi-coding-agent";
16
16
  import { createEditRenderers, installEditTool } from "./edit.js";
17
+ import type { DiffRenderConfig } from "../render/index.js";
18
+
19
+ /** The config the rows read — now threaded per registration rather than held per module (#163). */
20
+ const DIFF_TEST_CONFIG: DiffRenderConfig = {
21
+ diffTheme: "github-dark",
22
+ diffSplitMinWidth: 150,
23
+ diffSplitMinCodeWidth: 60,
24
+ diffSplitWrapCheck: true,
25
+ };
17
26
 
18
27
  // `diff` is standalone and must not import `@pi-archimedes/core` (see
19
28
  // `src/pi-support.ts`), so the tests strip escape sequences themselves.
@@ -39,6 +48,7 @@ function captureTool(cwd: string, home: string) {
39
48
  } as unknown as ExtensionAPI,
40
49
  cwd,
41
50
  home,
51
+ () => DIFF_TEST_CONFIG,
42
52
  );
43
53
  return resolved;
44
54
  }
@@ -185,7 +195,7 @@ describe("edit row padding", () => {
185
195
  // The row draws its own shell (`renderShell: "self"`), so the inset is ours to
186
196
  // choose — but pi still tells the renderer how much tool output is padded
187
197
  // here, and that number is what the row must respect.
188
- const renderers = createEditRenderers("/project", "/home");
198
+ const renderers = createEditRenderers("/project", "/home", () => DIFF_TEST_CONFIG);
189
199
  const callArgs = { path: "/project/src/a.ts", edits: [{ oldText: "x", newText: "y" }] };
190
200
 
191
201
  function padXFor(outputPad: number): number {
package/src/tools/edit.ts CHANGED
@@ -7,7 +7,7 @@ import { parseDiff } from "../core/diff.js";
7
7
  import * as Ansi from "../ansi/index.js";
8
8
  import { themeCacheKey } from "../ansi/index.js";
9
9
  import { lang } from "../shiki.js";
10
- import { MAX_PREVIEW_LINES } from "../render/index.js";
10
+ import { MAX_PREVIEW_LINES, type DiffRenderConfig } from "../render/index.js";
11
11
  import { DiffComponent } from "../diff-component.js";
12
12
 
13
13
  interface EditOperation { oldText: string; newText: string }
@@ -75,13 +75,17 @@ export function editResultDetails(event: {
75
75
  }
76
76
 
77
77
  /** The Archimedes `edit` renderers, closed over the paths they shorten against. */
78
- export function createEditRenderers(cwd: string, home: string): ToolRenderers {
78
+ export function createEditRenderers(cwd: string, home: string, readConfig: () => DiffRenderConfig): ToolRenderers {
79
79
  const sp = (p: string) => Ansi.shortPath(cwd, home, p);
80
80
 
81
81
  return {
82
82
  renderShell: "self",
83
83
 
84
84
  renderCall(args: any, theme: any, ctx: any) {
85
+ // Resolved once per invocation, not per row: the reader belongs to the
86
+ // session that registered this row, so it is captured here rather than
87
+ // read back out of module state (#163).
88
+ const cfg = readConfig();
85
89
  const fp = args?.path ?? args?.file_path ?? "";
86
90
  const operations = getEditOperations(args);
87
91
 
@@ -126,7 +130,7 @@ export function createEditRenderers(cwd: string, home: string): ToolRenderers {
126
130
  const diff = diffs[0];
127
131
  if (diff) {
128
132
  shell.addChild(new Spacer(1));
129
- shell.addChild(new DiffComponent(diff, lg, theme, MAX_PREVIEW_LINES));
133
+ shell.addChild(new DiffComponent(diff, lg, theme, cfg, MAX_PREVIEW_LINES));
130
134
  }
131
135
  } else {
132
136
  const maxShown = Math.min(operations.length, 3);
@@ -139,7 +143,7 @@ export function createEditRenderers(cwd: string, home: string): ToolRenderers {
139
143
  theme.fg("muted", `Edit ${i + 1}/${operations.length}`),
140
144
  0, 0,
141
145
  ));
142
- shell.addChild(new DiffComponent(diff, lg, theme, previewLines));
146
+ shell.addChild(new DiffComponent(diff, lg, theme, cfg, previewLines));
143
147
  }
144
148
  const remainder = operations.length - maxShown;
145
149
  if (remainder > 0) {
@@ -213,8 +217,8 @@ export function createEditRenderers(cwd: string, home: string): ToolRenderers {
213
217
  * handlers per extension, so calling it from `session_start` would stack a
214
218
  * duplicate on every `/reload`.
215
219
  */
216
- export function installEditTool(pi: ExtensionAPI, cwd: string, home: string): void {
217
- const renderers = createEditRenderers(cwd, home);
220
+ export function installEditTool(pi: ExtensionAPI, cwd: string, home: string, readConfig: () => DiffRenderConfig): void {
221
+ const renderers = createEditRenderers(cwd, home, readConfig);
218
222
  pi.registerToolRenderer((toolName, next) =>
219
223
  toolName === "edit" ? { ...next(), ...renderers } : next(),
220
224
  );
@@ -18,6 +18,19 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import { Text } from "@earendil-works/pi-tui";
19
19
  import { installEditTool } from "./edit.js";
20
20
  import { installWriteTool } from "./write.js";
21
+ import type { DiffRenderConfig } from "../render/index.js";
22
+
23
+ /**
24
+ * The config reader both installers now take as their last argument (#163): it
25
+ * is captured by the rows each registration builds, never parked in module
26
+ * state where a second session could re-point it.
27
+ */
28
+ const cfg = (): DiffRenderConfig => ({
29
+ diffTheme: "github-dark",
30
+ diffSplitMinWidth: 150,
31
+ diffSplitMinCodeWidth: 60,
32
+ diffSplitWrapCheck: true,
33
+ });
21
34
 
22
35
  /** A `pi` handle that records what was registered. */
23
36
  function fakePi() {
@@ -69,7 +82,7 @@ const emit = (
69
82
  describe("edit registration", () => {
70
83
  it("registers no tool and resolves only `edit`", () => {
71
84
  const { pi, renderers, registered } = fakePi();
72
- installEditTool(pi, "/work", "/home");
85
+ installEditTool(pi, "/work", "/home", cfg);
73
86
 
74
87
  expect(registered).toHaveLength(0);
75
88
  expect(renderers).toHaveLength(1);
@@ -79,7 +92,7 @@ describe("edit registration", () => {
79
92
 
80
93
  it("keeps the built-in's own renderShell while replacing the renderers", () => {
81
94
  const { pi, renderers } = fakePi();
82
- installEditTool(pi, "/work", "/home");
95
+ installEditTool(pi, "/work", "/home", cfg);
83
96
 
84
97
  const { result } = resolve(renderers[0], "edit");
85
98
  expect(typeof result.renderCall).toBe("function");
@@ -92,7 +105,7 @@ describe("edit registration", () => {
92
105
  const { dir, file, done } = makeWorkspace("one\ntwo\nthree\n");
93
106
  try {
94
107
  const { pi, handlers } = fakePi();
95
- installEditTool(pi, dir, dir);
108
+ installEditTool(pi, dir, dir, cfg);
96
109
  writeFileSync(file, "one\n2\nthree\n");
97
110
 
98
111
  const out = emit(handlers, "tool_result", {
@@ -117,7 +130,7 @@ describe("edit registration", () => {
117
130
  const { dir, file, done } = makeWorkspace();
118
131
  try {
119
132
  const { pi, handlers } = fakePi();
120
- installEditTool(pi, dir, dir);
133
+ installEditTool(pi, dir, dir, cfg);
121
134
 
122
135
  const out = emit(handlers, "tool_result", {
123
136
  type: "tool_result",
@@ -146,7 +159,7 @@ describe("edit registration", () => {
146
159
  const { dir, file, done } = makeWorkspace();
147
160
  try {
148
161
  const { pi, handlers } = fakePi();
149
- installEditTool(pi, dir, dir);
162
+ installEditTool(pi, dir, dir, cfg);
150
163
 
151
164
  const out = emit(handlers, "tool_result", {
152
165
  type: "tool_result",
@@ -168,7 +181,7 @@ describe("edit registration", () => {
168
181
  describe("write registration", () => {
169
182
  it("registers no tool and resolves only `write`", () => {
170
183
  const { pi, renderers, registered } = fakePi();
171
- installWriteTool(pi, "/work", "/home", Text as any);
184
+ installWriteTool(pi, "/work", "/home", Text as any, cfg);
172
185
 
173
186
  expect(registered).toHaveLength(0);
174
187
  expect(resolve(renderers[0], "write").result?.renderShell).toBe("default");
@@ -179,7 +192,7 @@ describe("write registration", () => {
179
192
  const { dir, file, done } = makeWorkspace("one\ntwo\n");
180
193
  try {
181
194
  const { pi, handlers } = fakePi();
182
- installWriteTool(pi, dir, dir, Text as any);
195
+ installWriteTool(pi, dir, dir, Text as any, cfg);
183
196
 
184
197
  emit(handlers, "tool_call", {
185
198
  type: "tool_call",
@@ -210,7 +223,7 @@ describe("write registration", () => {
210
223
  try {
211
224
  const fresh = path.join(dir, "fresh.txt");
212
225
  const { pi, handlers } = fakePi();
213
- installWriteTool(pi, dir, dir, Text as any);
226
+ installWriteTool(pi, dir, dir, Text as any, cfg);
214
227
 
215
228
  emit(handlers, "tool_call", {
216
229
  type: "tool_call",
@@ -239,7 +252,7 @@ describe("write registration", () => {
239
252
  const { dir, file, done } = makeWorkspace("same\n");
240
253
  try {
241
254
  const { pi, handlers } = fakePi();
242
- installWriteTool(pi, dir, dir, Text as any);
255
+ installWriteTool(pi, dir, dir, Text as any, cfg);
243
256
 
244
257
  emit(handlers, "tool_call", {
245
258
  type: "tool_call",
@@ -15,6 +15,15 @@ import path from "node:path";
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import { Text } from "@earendil-works/pi-tui";
17
17
  import { installWriteTool } from "./write.js";
18
+ import type { DiffRenderConfig } from "../render/index.js";
19
+
20
+ /** The config the rows read — now threaded per registration rather than held per module (#163). */
21
+ const DIFF_TEST_CONFIG: DiffRenderConfig = {
22
+ diffTheme: "github-dark",
23
+ diffSplitMinWidth: 150,
24
+ diffSplitMinCodeWidth: 60,
25
+ diffSplitWrapCheck: true,
26
+ };
18
27
 
19
28
  // `diff` is standalone and must not import `@pi-archimedes/core` (see
20
29
  // `src/pi-support.ts`), so the tests strip escape sequences themselves.
@@ -41,6 +50,7 @@ function captureTool(cwd: string, home: string) {
41
50
  cwd,
42
51
  home,
43
52
  Text as any,
53
+ () => DIFF_TEST_CONFIG,
44
54
  );
45
55
  return resolved;
46
56
  }
@@ -8,7 +8,7 @@ import * as Ansi from "../ansi/index.js";
8
8
  import { themeCacheKey } from "../ansi/index.js";
9
9
  import { lang, hlBlock } from "../shiki.js";
10
10
  import { DiffComponent } from "../diff-component.js";
11
- import { MAX_RENDER_LINES } from "../render/index.js";
11
+ import { MAX_RENDER_LINES, type DiffRenderConfig } from "../render/index.js";
12
12
 
13
13
  /**
14
14
  * The content a `write` call displaces, read while the call is still about to
@@ -67,11 +67,14 @@ export function createWriteRenderers(
67
67
  cwd: string,
68
68
  home: string,
69
69
  TextComponent: new (text?: string, paddingX?: number, paddingY?: number) => Component,
70
+ readConfig: () => DiffRenderConfig,
70
71
  ): ToolRenderers {
71
72
  const sp = (p: string) => Ansi.shortPath(cwd, home, p);
72
73
 
73
74
  return {
74
75
  renderCall(args: any, theme: any, ctx: any) {
76
+ // Once per invocation — see the same note in `edit.ts` (#163).
77
+ const cfg = readConfig();
75
78
  const fp = args?.path ?? args?.file_path ?? "";
76
79
  const isNew = !fp || !existsSync(fp);
77
80
  const label = isNew ? "create" : "write";
@@ -90,7 +93,7 @@ export function createWriteRenderers(
90
93
  ctx.state._previewKey = previewKey;
91
94
  ctx.state._previewText = hdr;
92
95
  const lg = lang(fp);
93
- hlBlock(args.content, lg)
96
+ hlBlock(args.content, lg, cfg.diffTheme)
94
97
  .then((lines: string[]) => {
95
98
  if (ctx.state._previewKey !== previewKey) return;
96
99
  const maxShow = ctx.expanded ? lines.length : 16;
@@ -112,6 +115,7 @@ export function createWriteRenderers(
112
115
  },
113
116
 
114
117
  renderResult(result: any, _opt: any, theme: any, ctx: any) {
118
+ const cfg = readConfig();
115
119
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
116
120
  if (ctx.isError) {
117
121
  const e = result.content
@@ -125,7 +129,7 @@ export function createWriteRenderers(
125
129
  if (d?._type === "diff") {
126
130
  const comp = ctx.lastComponent instanceof DiffComponent
127
131
  ? ctx.lastComponent
128
- : new DiffComponent(d.diff, d.language, theme, MAX_RENDER_LINES);
132
+ : new DiffComponent(d.diff, d.language, theme, cfg, MAX_RENDER_LINES);
129
133
  return comp;
130
134
  }
131
135
  if (d?._type === "noChange") {
@@ -140,7 +144,7 @@ export function createWriteRenderers(
140
144
  ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
141
145
  const lg = lang(fp);
142
146
  if (rawContent) {
143
- hlBlock(rawContent, lg)
147
+ hlBlock(rawContent, lg, cfg.diffTheme)
144
148
  .then((hlLines: string[]) => {
145
149
  if (ctx.state._nfk !== pk) return;
146
150
  const maxShow = ctx.expanded ? hlLines.length : 12;
@@ -178,8 +182,9 @@ export function installWriteTool(
178
182
  cwd: string,
179
183
  home: string,
180
184
  TextComponent: new (text?: string, paddingX?: number, paddingY?: number) => Component,
185
+ readConfig: () => DiffRenderConfig,
181
186
  ): void {
182
- const renderers = createWriteRenderers(cwd, home, TextComponent);
187
+ const renderers = createWriteRenderers(cwd, home, TextComponent, readConfig);
183
188
  pi.registerToolRenderer((toolName, next) =>
184
189
  toolName === "write" ? { ...next(), ...renderers } : next(),
185
190
  );