pi-umbra 0.2.0 → 0.3.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.
Files changed (60) hide show
  1. package/README.md +7 -5
  2. package/node_modules/pi-umbra-help/README.md +1 -0
  3. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +7 -0
  4. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +12 -1
  5. package/node_modules/pi-umbra-help/package.json +1 -1
  6. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +2 -1
  7. package/node_modules/pi-umbra-inputbar/package.json +1 -1
  8. package/node_modules/pi-umbra-shimmer/package.json +1 -1
  9. package/node_modules/pi-umbra-shimmer/patch.mjs +21 -7
  10. package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
  11. package/node_modules/pi-umbra-skill-matcher/patch.mjs +21 -7
  12. package/node_modules/pi-umbra-subagents/LICENSE +21 -0
  13. package/node_modules/pi-umbra-subagents/README.md +101 -0
  14. package/node_modules/pi-umbra-subagents/extensions/umbra-loop.ts +102 -0
  15. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +262 -0
  16. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +198 -0
  17. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.ts +229 -0
  18. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +141 -0
  19. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/models.ts +137 -0
  20. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +88 -0
  21. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +140 -0
  22. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +490 -0
  23. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.check.ts +237 -0
  24. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.ts +378 -0
  25. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
  26. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
  27. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
  28. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/report.md +15 -0
  29. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
  30. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
  31. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
  32. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
  33. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
  34. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents.ts +4 -0
  35. package/node_modules/pi-umbra-subagents/package.json +43 -0
  36. package/node_modules/pi-umbra-subagents/patch.mjs +97 -0
  37. package/node_modules/pi-umbra-theme/README.md +39 -16
  38. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +44 -1
  39. package/node_modules/pi-umbra-theme/checks/umbra-image-viewer.check.ts +85 -0
  40. package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +20 -1
  41. package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +17 -10
  42. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +52 -1
  43. package/node_modules/pi-umbra-theme/extensions/umbra-image-viewer.ts +230 -0
  44. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/compact-mode.ts +3 -3
  45. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/hover.ts +0 -4
  46. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/interaction.ts +20 -68
  47. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/layout.ts +0 -4
  48. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/scroll.ts +6 -190
  49. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/diff-palette.ts +44 -3
  50. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/shiki-highlight.ts +5 -3
  51. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/names.ts +1 -0
  52. package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +16 -6
  53. package/node_modules/pi-umbra-theme/package.json +1 -1
  54. package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +1 -1
  55. package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +4 -4
  56. package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +10 -10
  57. package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +5 -5
  58. package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +20 -20
  59. package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +4 -4
  60. package/package.json +16 -9
@@ -1,7 +1,8 @@
1
1
  # pi-umbra-theme
2
2
 
3
- Seven dark themes for pi, and five extensions for what a theme cannot colour: the terminal
4
- background, a gutter beside model text, the footer, the working line and the tool calls.
3
+ Seven dark themes for pi, and six extensions for what a theme cannot colour: the terminal
4
+ background, a gutter beside model text, the footer, the working line, the tool calls and a viewer
5
+ for the images they return.
5
6
 
6
7
  ![A pi session with tool cards, an edit diff, the gutter and the footer](https://raw.githubusercontent.com/grknbyk/pi-umbra/main/assets/cards.webp)
7
8
 
@@ -33,7 +34,12 @@ Pick one in `/settings`, or set `"theme"` in `settings.json`.
33
34
  pi colours its own text but leaves the window behind it alone. umbra-background sets the
34
35
  terminal background to the active theme's `export.pageBg` with OSC 11 when pi starts, and
35
36
  again whenever you switch theme. On exit it sends OSC 111, which gives the terminal its own
36
- colour back. A terminal that ignores OSC 11 keeps its colour.
37
+ colour back.
38
+
39
+ Some terminals ignore OSC 11 and keep their own colour. pi asks the terminal which colour it
40
+ has. If the answer is not the theme's colour, pi shows a warning once, and `/umb-doctor` tells
41
+ you where to set the colour in that terminal (VS Code, Windows Terminal, Terminal.app,
42
+ JetBrains, tmux).
37
43
 
38
44
  | `piUmbraTheme.background` | Effect |
39
45
  |---|---|
@@ -81,7 +87,7 @@ it. Numbers are shortened: `695`, `7.0k`, `224k`, `1M`.
81
87
  It replaces pi's fixed "Working…" with the tool that is running:
82
88
 
83
89
  ```
84
- ◴ Shell… (9s · ↓ 824 tokens)
90
+ ◴ Running… (9s · ↓ 824 tokens)
85
91
  ```
86
92
 
87
93
  The label follows the running tool, then the turn's elapsed time, then the output tokens so
@@ -91,12 +97,17 @@ While the model thinks, the line adds `thought for 12s`.
91
97
  | Key | Label | Frames |
92
98
  |---|---|---|
93
99
  | `read` | Reading | `◜◠◝◞◡◟` |
94
- | `ls`, `find`, `grep` | Listing | `⎺⎻⎼⎽⎼⎻` |
95
- | `write`, `edit` | Editing | `▖▘▝▗` |
96
- | `bash`, `powershell` | Shell | `◴◷◶◵` |
100
+ | `ls` | Listing | `⎺⎻⎼⎽⎼⎻` |
101
+ | `find`, `grep` | Searching | `⎺⎻⎼⎽⎼⎻` |
102
+ | `write` | Writing | `▖▘▝▗` |
103
+ | `edit` | Editing | `▖▘▝▗` |
104
+ | `bash`, `powershell` | Running | `◴◷◶◵` |
105
+ | `ask_user_question` | Asking | `◇◈◆◈` |
97
106
  | `thinking` | Thinking | `✶✸✹✺✻✼` |
98
107
  | `idle` | Working | `⣻⢿⡿⣟⣯⣷⣾⣽` |
99
- | `custom` (any other tool) | Tool call | `◇◈◆◈` |
108
+ | any other tool | the title its card shows, such as `Web Search` | `◇◈◆◈` |
109
+
110
+ Set `custom` to give every tool in the last row one label instead.
100
111
 
101
112
  Every entry can be changed, and any tool name can get its own:
102
113
 
@@ -157,7 +168,6 @@ The mode and every setting persist in `~/.pi/agent/umbra-toolbox.json`.
157
168
  | fullscreen | click the hint | expand that card |
158
169
  | fullscreen | click an expanded card | collapse it |
159
170
  | fullscreen | click a "show more" header | open the full text in a scrollable overlay |
160
- | fullscreen | `Ctrl+End` or the `Back to bottom` button | jump to the latest output |
161
171
  | text overlay | `↑` `↓` `PgUp` `PgDn` `Home` `End`, wheel | scroll |
162
172
  | text overlay | `Esc` or `Ctrl+C` | close |
163
173
  | settings panel | `Tab`, `Shift+Tab` | switch between Style, UI and Diff |
@@ -186,8 +196,8 @@ Change them in the panel, or edit `umbra-toolbox.json` while pi is closed.
186
196
  | `scrollStepLines` | `3` | 1 to 50 | lines per mouse wheel step in fullscreen |
187
197
  | `dimThinkingText` | `false` | | dim headings in compact mode |
188
198
 
189
- `/umb-toolbox status` also prints keys for pi-cc-extensions features that are not part of
190
- this package, such as its footer and startup header. They have no effect here.
199
+ `/umb-toolbox status` also prints keys for features that are not part of this package, such as
200
+ a footer and startup header. They have no effect here.
191
201
 
192
202
  ### The write tool
193
203
 
@@ -198,12 +208,24 @@ of the way when another extension already provides `write`.
198
208
  A write card can show no diff in these cases: the session was resumed (the record lives in
199
209
  memory), the old file was over 512 KB or not UTF-8 text, or the path was not a regular file.
200
210
 
201
- ### Credit
211
+ ## Image viewer
202
212
 
203
- The cards are the render layer of
204
- [pi-cc-extensions](https://github.com/minuque/pi-cc-extensions) 0.9.6 by minuque, MIT. The
205
- license is kept in `extensions/umbra-toolbox/LICENSE-pi-cc-extensions`. That package's footer,
206
- header and working line are left out, since the extensions above cover them.
213
+ Click an image under a tool card, such as a screenshot the model read, and it opens full screen.
214
+
215
+ | Input | Effect |
216
+ |---|---|
217
+ | wheel, or two fingers up and down on a touchpad | zoom toward the pointer |
218
+ | double click | zoom in 2× at that point |
219
+ | drag, `←` `↑` `→` `↓` | move |
220
+ | `+`, `-` | zoom toward the centre |
221
+ | `0` | fit to the screen; an image smaller than the screen stays at 100% |
222
+ | `Esc`, `q` | close |
223
+
224
+ It needs kitty, and fullscreen mode (`"tuiMode": "fullscreen"`), where pi reports mouse clicks.
225
+ Kitty crops and scales the image itself, so the image is sent to the terminal once and each zoom
226
+ step sends only the new crop. Kitty on Linux does not pass pinch gestures on to programs, so a
227
+ pinch does nothing. The click reaches the viewer through the tool cards; without them, nothing
228
+ opens it.
207
229
 
208
230
  ## Files
209
231
 
@@ -214,6 +236,7 @@ pi-umbra-theme/
214
236
  │ ├── umbra-background.ts
215
237
  │ ├── umbra-footer.ts
216
238
  │ ├── umbra-gutter.ts
239
+ │ ├── umbra-image-viewer.ts
217
240
  │ ├── umbra-toolbox/
218
241
  │ └── umbra-working.ts
219
242
  ├── lib/
@@ -29,7 +29,7 @@ process.stdout.write = ((chunk: string) => {
29
29
  return realWrite(chunk as never);
30
30
  }) as never;
31
31
 
32
- const { default: umbraBackground } = await import("../extensions/umbra-background.ts");
32
+ const { default: umbraBackground, backgroundHowTo, sameColour } = await import("../extensions/umbra-background.ts");
33
33
 
34
34
  type Handler = (event: unknown, ctx: unknown) => void;
35
35
  const handlers = new Map<string, Handler>();
@@ -43,6 +43,10 @@ const pi = {
43
43
  umbraBackground(pi as never);
44
44
 
45
45
  const notices: string[] = [];
46
+ // The terminal as the TUI sees it: it answers the OSC 11 query with this colour, or never.
47
+ let answer: { r: number; g: number; b: number } | undefined;
48
+ const setWidget = (_key: string, factory: (tui: unknown) => unknown) =>
49
+ factory({ queryTerminalBackgroundColor: async () => answer });
46
50
  // Real pi hands the extension a Theme with NO sourcePath; the file it was read from is only on
47
51
  // the list getAllThemes() returns, under the name `path`. The check used to invent a sourcePath
48
52
  // and then assert against its own invention, which is why it passed while nothing happened on
@@ -53,6 +57,7 @@ const ctx = (sourcePath: string | undefined, mode = "tui") => ({
53
57
  theme: { name: "violet-forge", sourcePath },
54
58
  getAllThemes: () => [],
55
59
  notify: (m: string) => notices.push(m),
60
+ setWidget,
56
61
  },
57
62
  });
58
63
  const piShapedCtx = (path: string | undefined, mode = "tui") => ({
@@ -61,6 +66,7 @@ const piShapedCtx = (path: string | undefined, mode = "tui") => ({
61
66
  theme: { name: "violet-forge" },
62
67
  getAllThemes: () => (path === undefined ? [] : [{ name: "violet-forge", path }]),
63
68
  notify: (m: string) => notices.push(m),
69
+ setWidget,
64
70
  },
65
71
  });
66
72
 
@@ -140,6 +146,7 @@ const liveCtx = {
140
146
  { name: "violet-forge", path: themePath },
141
147
  ],
142
148
  notify: (m: string) => notices.push(m),
149
+ setWidget,
143
150
  },
144
151
  };
145
152
  start()({}, liveCtx);
@@ -153,6 +160,42 @@ live.name = "bare";
153
160
  await new Promise((done) => setTimeout(done, 700));
154
161
  assert.deepEqual(written, [], "the watcher must stop at shutdown");
155
162
 
163
+ // Asking the terminal back. It took the colour: nothing to say. It kept its own: one warning
164
+ // that names the fix, and the status /umb-doctor reads. It never answered: unknown, so silence.
165
+ const status = () => (globalThis as { __umbraBackground?: { ignored: boolean } }).__umbraBackground;
166
+ const settle = () => new Promise((done) => setTimeout(done, 10));
167
+ setSetting(undefined);
168
+ notices.length = 0;
169
+ answer = { r: 0x19, g: 0x18, b: 0x31 };
170
+ start()({}, ctx(themePath));
171
+ await settle();
172
+ assert.equal(status()?.ignored, false, "a colour off by one step still counts as taken");
173
+ assert.deepEqual(notices, []);
174
+ shutdown()({}, ctx(themePath));
175
+
176
+ answer = { r: 0xff, g: 0xff, b: 0xff };
177
+ start()({}, ctx(themePath));
178
+ await settle();
179
+ assert.equal(status()?.ignored, true);
180
+ assert.equal(notices.length, 1, "a kept background is reported");
181
+ start()({}, ctx(themePath));
182
+ await settle();
183
+ assert.equal(notices.length, 1, "and only once per session");
184
+ shutdown()({}, ctx(themePath));
185
+
186
+ (globalThis as { __umbraBackground?: unknown }).__umbraBackground = undefined;
187
+ answer = undefined;
188
+ start()({}, ctx(themePath));
189
+ await settle();
190
+ assert.equal(status(), undefined, "no reply is not a verdict");
191
+ shutdown()({}, ctx(themePath));
192
+
193
+ assert.equal(sameColour("#191830", { r: 0x1b, g: 0x18, b: 0x30 }), true);
194
+ assert.equal(sameColour("#191830", { r: 0x1d, g: 0x18, b: 0x30 }), false);
195
+ assert.match(backgroundHowTo({ TERM_PROGRAM: "vscode" }, "#000000"), /terminal\.background.*#000000/);
196
+ assert.match(backgroundHowTo({ WT_SESSION: "x" }, "#000000"), /Windows Terminal/);
197
+ assert.match(backgroundHowTo({}, "#000000"), /terminal's own settings/);
198
+
156
199
  process.stdout.write = realWrite;
157
200
 
158
201
  console.log("umbra-background: all checks passed");
@@ -0,0 +1,85 @@
1
+ // The image viewer behind a click on a tool card's image: the map-style zoom keeps the point under
2
+ // the pointer in place, a click lands on the right image rows, frames crop through kitty controls,
3
+ // and closing puts pi's overlay compositing back and frees the image.
4
+ //
5
+ // Run it with: bun run umbra-image-viewer.check.ts
6
+ import assert from "node:assert/strict";
7
+ import { Image, setCapabilities, setCellDimensions } from "@earendil-works/pi-tui";
8
+ import viewer, { axis, openToolImageAt, zoomCenter } from "../extensions/umbra-image-viewer.ts";
9
+
10
+ // Loading the extension is what lets umbra-toolbox find it.
11
+ viewer();
12
+ assert.equal((globalThis as any).__umbraImageViewer?.openToolImageAt, openToolImageAt, "registered for umbra-toolbox");
13
+
14
+ // The zoom keeps the image point under the pointer where it was.
15
+ const before = axis(4000, 800, 0.2, 2000);
16
+ const at = 100;
17
+ const point = before.srcStart + (at - before.dispStart) / 0.2;
18
+ const after = axis(4000, 800, 0.4, zoomCenter(before, 800, at, 0.2, 0.4));
19
+ assert.equal(after.srcStart + (at - after.dispStart) / 0.4, point, "point under the pointer stays put");
20
+ assert.deepEqual(axis(100, 800, 1, 0), { srcStart: 0, srcSpan: 100, dispStart: 350, dispSpan: 100, center: 50 }, "a small image is centred");
21
+ assert.equal(axis(4000, 800, 1, -50).srcStart, 0, "panning stops at the left edge");
22
+ assert.equal(axis(4000, 800, 1, 99999).srcStart, 3200, "and at the right edge");
23
+
24
+ setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
25
+ setCellDimensions({ widthPx: 10, heightPx: 20 });
26
+ // 400x200 PNG header is enough: the viewer never decodes the pixels.
27
+ const png = Buffer.alloc(33);
28
+ Buffer.from("89504e470d0a1a0a0000000d49484452", "hex").copy(png);
29
+ png.writeUInt32BE(400, 16);
30
+ png.writeUInt32BE(200, 20);
31
+ const image = new Image(png.toString("base64"), "image/png", { fallbackColor: (s) => s }, { maxWidthCells: 20 });
32
+ const tool = {
33
+ imageComponents: [image],
34
+ render: (width: number) => ["", "title", "summary", "", ...image.render(width)],
35
+ };
36
+
37
+ const writes: string[] = [];
38
+ let overlay: any;
39
+ let hidden = false;
40
+ const ownComposite = function () {
41
+ return ["base"];
42
+ };
43
+ // compositeOverlays lives on the prototype in pi, so the viewer's override is an own property.
44
+ const tui: any = Object.assign(Object.create({ compositeOverlays: ownComposite }), {
45
+ terminal: { columns: 80, rows: 25, write: (s: string) => writes.push(s) },
46
+ requestRender() {},
47
+ showOverlay(component: any) {
48
+ overlay = component;
49
+ return { hide: () => (hidden = true) };
50
+ },
51
+ });
52
+
53
+ assert.equal(openToolImageAt(tui, tool, 2, 80), false, "a click on the summary is not the image");
54
+ assert.equal(openToolImageAt(tui, tool, 4, 80), true, "a click on the image's first row opens it");
55
+ assert.ok(overlay, "the viewer is an overlay");
56
+
57
+ const frame = () => tui.compositeOverlays(["x", "y"], 80, 25) as string[];
58
+ let lines = frame();
59
+ assert.equal(lines.length, 25, "the frame fills the terminal");
60
+ const controls = () => /\x1b_G([^;]*);/.exec(lines.find((l) => l.includes("\x1b_G"))!)![1]!;
61
+ assert.match(controls(), /x=0,y=0,w=400,h=200,c=40,r=10,/, "fit shows the whole image at its own size, 40x10 cells of 10x20 px");
62
+ assert.match(lines.at(-1)!, / 100% /, "and says so: fit never stretches a smaller image");
63
+ assert.match(controls(), /(^|,)i=\d+/, "under its own kitty id");
64
+
65
+ const wheelUp = () => overlay.handleMouse({ type: "wheel", wheelDelta: -3, screenX: 40, screenY: 12, x: 40, y: 12 });
66
+ wheelUp();
67
+ lines = frame();
68
+ assert.match(controls(), /w=400,h=200,c=48,/, "wheel up first grows a small image whole");
69
+ for (let i = 0; i < 4; i++) wheelUp();
70
+ lines = frame();
71
+ assert.doesNotMatch(controls(), /w=400/, "until it outgrows the window: then a narrower crop");
72
+
73
+ const cropX = () => Number(/x=(\d+)/.exec(controls())![1]);
74
+ const x0 = cropX();
75
+ overlay.handleMouse({ type: "press", button: "left", screenX: 40, screenY: 12 });
76
+ overlay.handleMouse({ type: "drag", button: "left", screenX: 30, screenY: 12 });
77
+ lines = frame();
78
+ assert.ok(cropX() > x0, "dragging left moves the view right");
79
+
80
+ overlay.handleInput("\x1b");
81
+ assert.ok(hidden, "esc closes");
82
+ assert.equal(tui.compositeOverlays, ownComposite, "pi's compositing is back");
83
+ assert.ok(writes.some((w) => w.includes("a=d,d=I")), "the viewer's copy of the image is freed");
84
+
85
+ console.log("umbra-image-viewer.check.ts ok - zoom holds the pointer, hit rows, kitty crop, close restores");
@@ -7,6 +7,7 @@
7
7
  import assert from "node:assert/strict";
8
8
  import toolbox from "../extensions/umbra-toolbox/index.ts";
9
9
  import { CONFIG_PATH } from "../extensions/umbra-toolbox/config/config.ts";
10
+ import { resolveShikiTheme } from "../extensions/umbra-toolbox/renderer/tool/diff/diff-palette.ts";
10
11
  import { ShikiHighlightCache } from "../extensions/umbra-toolbox/renderer/tool/diff/shiki-highlight.ts";
11
12
 
12
13
  const commands: string[] = [];
@@ -30,4 +31,22 @@ await Promise.race([ready, new Promise((_, reject) => setTimeout(() => reject(ne
30
31
  const lines = cache.get(fallback[0]!, "ts", "github-dark", fallback);
31
32
  assert.ok(lines?.[0]?.includes("\x1b["), "shiki resolved and coloured the line");
32
33
 
33
- console.log("umbra-toolbox.check.ts ok - loads, /umb-toolbox only, umbra-toolbox.json, shiki highlights");
34
+ // Code in a diff wears the pi theme's own syntax colours, not one github palette for every theme.
35
+ const rgb = (hex: string) => `\x1b[38;2;${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(";")}m`;
36
+ const piColours: Record<string, string> = { text: "#d5ced9", syntaxKeyword: "#c74ded", syntaxNumber: "#f39c12", syntaxOperator: "#e25822" };
37
+ const piTheme = {
38
+ fg: (_slot: string, text: string) => text,
39
+ getFgAnsi: (slot: string) => rgb(piColours[slot] ?? "#808080"),
40
+ getBgAnsi: () => "\x1b[48;2;15;17;26m",
41
+ };
42
+ const syntax = resolveShikiTheme(piTheme);
43
+ assert.equal(typeof syntax, "object", "a 24-bit theme gets its own shiki theme");
44
+ const code = ["const PORT = 3000;"];
45
+ await new Promise<void>((resolve) => cache.get(code[0]!, "ts", syntax, code, resolve));
46
+ const coloured = cache.get(code[0]!, "ts", syntax, code)![0]!;
47
+ assert.ok(coloured.startsWith(rgb("#c74ded") + "const"), "const takes the theme's keyword colour");
48
+ assert.ok(coloured.includes(rgb("#e25822") + "="), "= takes its operator colour");
49
+ assert.ok(coloured.includes(rgb("#f39c12") + "3000"), "3000 takes its number colour");
50
+ assert.equal(resolveShikiTheme({ fg: (_s: string, t: string) => t, getFgAnsi: () => "", getBgAnsi: () => "\x1b[48;2;15;17;26m" }), "github-dark", "colours pi cannot report: github-dark");
51
+
52
+ console.log("umbra-toolbox.check.ts ok - loads, /umb-toolbox only, umbra-toolbox.json, shiki highlights in the pi theme's colours");
@@ -3,6 +3,7 @@
3
3
  //
4
4
  // bun run umbra-working.check.ts
5
5
  import assert from "node:assert";
6
+ import { humanizeToolLabel } from "../extensions/umbra-toolbox/renderer/tool/names.ts";
6
7
  import { visibleWidth } from "@earendil-works/pi-tui";
7
8
  import { compact, elapsed } from "../lib/umbra-format.ts";
8
9
  import { type Turn, entryFor, indicatorFor, labelFor, newTurn, statusLine,
@@ -16,19 +17,25 @@ const KEYS = [...TOOLS, "thinking", "idle", "custom"];
16
17
  // every name in `ToolCallEvent` is named here rather than sampled.
17
18
  assert.equal(labelFor("read"), "Reading");
18
19
  assert.equal(labelFor("ls"), "Listing");
19
- assert.equal(labelFor("find"), "Listing");
20
- assert.equal(labelFor("grep"), "Listing");
21
- assert.equal(labelFor("write"), "Editing");
20
+ assert.equal(labelFor("find"), "Searching");
21
+ assert.equal(labelFor("grep"), "Searching");
22
+ assert.equal(labelFor("write"), "Writing");
22
23
  assert.equal(labelFor("edit"), "Editing");
23
- assert.equal(labelFor("bash"), "Shell");
24
- assert.equal(labelFor("powershell"), "Shell");
24
+ assert.equal(labelFor("bash"), "Running");
25
+ assert.equal(labelFor("powershell"), "Running");
25
26
  assert.equal(labelFor("thinking"), "Thinking");
26
27
  assert.equal(labelFor("idle"), "Working");
27
28
 
28
- // CustomToolCallEvent - anything an extension registered - lands on one entry, animation included.
29
- assert.equal(labelFor("fabric_exec"), "Tool call");
30
- assert.equal(labelFor("ask_user_question"), "Tool call");
29
+ // CustomToolCallEvent - anything an extension registered - shares one animation and shows the
30
+ // title its tool card shows, so the card and the line name the tool with the same words.
31
+ assert.equal(labelFor("fabric_exec"), "Fabric Exec");
32
+ assert.equal(labelFor("ask_user_question"), "Asking");
31
33
  assert.deepEqual(indicatorFor("fabric_exec").frames, indicatorFor("custom").frames);
34
+ for (const name of ["web_search", "fetch_content", "fabric_exec", "codeSearch"]) {
35
+ assert.equal(labelFor(name), humanizeToolLabel(name), `${name}: line and card agree`);
36
+ }
37
+ // Renaming `custom` still renames every tool umbra has no word for.
38
+ assert.equal(labelFor("web_search", { tools: { custom: { label: "Tool" } } }), "Tool");
32
39
 
33
40
  // --- frames ----------------------------------------------------------------------------------
34
41
  // Every glyph must measure one column: a two-column frame shifts the whole status line on every
@@ -90,8 +97,8 @@ const at = (turn: Partial<Turn>): Turn => ({ ...newTurn(0), ...turn });
90
97
  // A turn that has produced nothing yet shows no arrow, because "↓ 0 tokens" is noise.
91
98
  assert.equal(statusLine(at({}), 1_000, ON), "Working… (1s)");
92
99
  assert.equal(statusLine(at({ key: "read", settled: 180 }), 5_000, ON), "Reading… (5s · ↓ 180 tokens)");
93
- assert.equal(statusLine(at({ key: "bash", settled: 1_200 }), 12_000, ON), "Shell… (12s · ↓ 1.2k tokens)");
94
- assert.equal(statusLine(at({ key: "fabric_exec" }), 1_000, ON), "Tool call… (1s)");
100
+ assert.equal(statusLine(at({ key: "bash", settled: 1_200 }), 12_000, ON), "Running… (12s · ↓ 1.2k tokens)");
101
+ assert.equal(statusLine(at({ key: "fabric_exec" }), 1_000, ON), "Fabric Exec… (1s)");
95
102
  assert.equal(statusLine(at({ key: "fabric_exec" }), 1_000, ON, renamed), "Fabric… (1s)");
96
103
 
97
104
  // Streaming output counts before the message ends, or the number stands still mid-answer.
@@ -9,8 +9,13 @@
9
9
  // So this does it: on start it reads the active theme's own `export.pageBg` and sets the terminal
10
10
  // background to it, and on shutdown it puts the background back with OSC 111. Nothing is guessed —
11
11
  // a theme without an `export.pageBg` is left alone.
12
+ //
13
+ // Some terminals drop the OSC 11 set but still answer the query, with the colour they kept. So
14
+ // after each set the terminal is asked back. When the answer differs, the theme cannot show as
15
+ // intended: pi says so once, and /umb-doctor says how to set the colour in that terminal.
12
16
  import { readFileSync } from "node:fs";
13
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import type { TUI } from "@earendil-works/pi-tui";
14
19
 
15
20
  const SETTINGS_KEY = "piUmbraTheme";
16
21
  // How often the active theme's name is compared. It is an in-memory string, so this costs a
@@ -25,6 +30,28 @@ const setBackground = (hex: string) => process.stdout.write(`\x1b]11;${hex}\x07`
25
30
  const resetBackground = () => process.stdout.write("\x1b]111\x07");
26
31
 
27
32
  type Settings = { background?: string };
33
+ type Rgb = { r: number; g: number; b: number };
34
+
35
+ // Terminals round the colour they store, so a channel may come back one or two steps off.
36
+ export const sameColour = (hex: string, rgb: Rgb): boolean =>
37
+ [rgb.r, rgb.g, rgb.b].every((channel, i) => Math.abs(channel - parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)) <= 2);
38
+
39
+ /** Where the background lives in the settings of the terminal pi runs in, from its own variables. */
40
+ export const backgroundHowTo = (env: Record<string, string | undefined>, hex: string): string => {
41
+ if (env.TERM_PROGRAM === "vscode")
42
+ return `VS Code: add "workbench.colorCustomizations": { "terminal.background": "${hex}" } to settings.json.`;
43
+ if (env.WT_SESSION !== undefined)
44
+ return `Windows Terminal: Settings, your profile, Appearance, Background: ${hex}. Or "background": "${hex}" in the profile.`;
45
+ if (env.TERM_PROGRAM === "Apple_Terminal") return `Terminal.app: Settings, Profiles, Text, Background: ${hex}.`;
46
+ if (env.TERMINAL_EMULATOR === "JetBrains-JediTerm")
47
+ return `JetBrains: Settings, Editor, Color Scheme, Console Colors, Console, Background: ${hex}.`;
48
+ if (env.TMUX !== undefined) return `tmux: add set -g window-style 'bg=${hex}' to ~/.tmux.conf.`;
49
+ return `Set the background to ${hex} in the terminal's own settings.`;
50
+ };
51
+
52
+ /** What the last check found, for /umb-doctor in pi-umbra-help. */
53
+ export type BackgroundStatus = { wanted: string; ignored: boolean; howTo: string };
54
+
28
55
 
29
56
  const readSetting = (): string => {
30
57
  const path = `${process.env.PI_CODING_AGENT_DIR ?? `${process.env.USERPROFILE ?? process.env.HOME}/.pi/agent`}/settings.json`;
@@ -72,17 +99,37 @@ export default function umbraBackground(pi: ExtensionAPI) {
72
99
  // The theme name as of the last look, and the timer that does the looking.
73
100
  let watched: string | undefined;
74
101
  let watcher: ReturnType<typeof setInterval> | undefined;
102
+ // The query goes through pi's TUI, which routes the reply back. A widget factory is the only
103
+ // place an extension is handed the TUI; it runs at once and draws nothing.
104
+ let tui: TUI | undefined;
105
+ let warned = false;
106
+
107
+ const verify = async (ctx: ExtensionContext, hex: string) => {
108
+ // No reply means a terminal that answers no queries: unknown, not ignored.
109
+ const rgb = await tui?.queryTerminalBackgroundColor({ timeoutMs: 1000 });
110
+ if (rgb === undefined || current !== hex) return;
111
+ const status: BackgroundStatus = { wanted: hex, ignored: !sameColour(hex, rgb), howTo: backgroundHowTo(process.env, hex) };
112
+ (globalThis as { __umbraBackground?: BackgroundStatus }).__umbraBackground = status;
113
+ if (!status.ignored || warned) return;
114
+ warned = true;
115
+ ctx.ui.notify(`This terminal kept its own background, so the theme is not shown in full. ${status.howTo}`, "warning");
116
+ };
75
117
 
76
118
  const apply = (ctx: ExtensionContext): string | undefined => {
77
119
  const hex = resolve(ctx);
78
120
  if (hex === undefined) return undefined;
79
121
  setBackground(hex);
80
122
  current = hex;
123
+ void verify(ctx, hex);
81
124
  return hex;
82
125
  };
83
126
 
84
127
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
85
128
  if (ctx.mode !== "tui") return;
129
+ ctx.ui.setWidget("umbra-background", (given) => {
130
+ tui = given;
131
+ return { render: () => [], invalidate: () => {} };
132
+ });
86
133
  apply(ctx);
87
134
  // pi fires no theme-change event. Its internal onThemeChange slot holds a single callback
88
135
  // that the TUI already owns, and ui_prompt_end does not cover pi's own theme picker, so the
@@ -117,9 +164,12 @@ export default function umbraBackground(pi: ExtensionAPI) {
117
164
  if (ctx.mode !== "tui") return;
118
165
  const argument = args.trim();
119
166
 
167
+ // Asked for by hand, so a terminal that ignores it is reported again.
168
+ warned = false;
120
169
  if (argument === "off" || argument === "reset") {
121
170
  resetBackground();
122
171
  current = undefined;
172
+ (globalThis as { __umbraBackground?: BackgroundStatus }).__umbraBackground = undefined;
123
173
  ctx.ui.notify("Terminal background restored.", "info");
124
174
  return;
125
175
  }
@@ -127,6 +177,7 @@ export default function umbraBackground(pi: ExtensionAPI) {
127
177
  if (HEX.test(argument)) {
128
178
  setBackground(argument);
129
179
  current = argument;
180
+ void verify(ctx, argument);
130
181
  ctx.ui.notify(`Terminal background set to ${argument}.`, "info");
131
182
  return;
132
183
  }
@@ -144,7 +195,7 @@ export default function umbraBackground(pi: ExtensionAPI) {
144
195
  const hex = apply(ctx);
145
196
  ctx.ui.notify(
146
197
  hex !== undefined
147
- ? `Terminal background set to ${hex} from theme "${name}". If it did not change, this terminal ignores OSC 11.`
198
+ ? `Terminal background set to ${hex} from theme "${name}".`
148
199
  : file === undefined
149
200
  ? `pi lists no file for theme "${name}", so its background cannot be read. Use /umb-bg #rrggbb.`
150
201
  : `Theme "${name}" (${file}) defines no export.pageBg, so nothing was changed.`,