pi-umbra 0.1.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -0
  3. package/node_modules/pi-umbra-ask/LICENSE +21 -0
  4. package/node_modules/pi-umbra-ask/README.md +21 -0
  5. package/node_modules/pi-umbra-ask/checks/umbra-ask.check.ts +132 -0
  6. package/node_modules/pi-umbra-ask/extensions/umbra-ask.ts +484 -0
  7. package/node_modules/pi-umbra-ask/lib/clean.ts +25 -0
  8. package/node_modules/pi-umbra-ask/package.json +37 -0
  9. package/node_modules/pi-umbra-copy-chat/LICENSE +21 -0
  10. package/node_modules/pi-umbra-copy-chat/README.md +19 -0
  11. package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +154 -0
  12. package/node_modules/pi-umbra-copy-chat/package.json +32 -0
  13. package/node_modules/pi-umbra-help/LICENSE +21 -0
  14. package/node_modules/pi-umbra-help/README.md +21 -0
  15. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +108 -0
  16. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +223 -0
  17. package/node_modules/pi-umbra-help/lib/umbra-settings.ts +52 -0
  18. package/node_modules/pi-umbra-help/package.json +34 -0
  19. package/node_modules/pi-umbra-inputbar/LICENSE +21 -0
  20. package/node_modules/pi-umbra-inputbar/README.md +19 -0
  21. package/node_modules/pi-umbra-inputbar/checks/umbra-inputbar.check.ts +53 -0
  22. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +130 -0
  23. package/node_modules/pi-umbra-inputbar/package.json +33 -0
  24. package/node_modules/pi-umbra-preview/LICENSE +21 -0
  25. package/node_modules/pi-umbra-preview/README.md +32 -0
  26. package/node_modules/pi-umbra-preview/checks/umbra-preview.check.ts +38 -0
  27. package/node_modules/pi-umbra-preview/extensions/umbra-preview.ts +147 -0
  28. package/node_modules/pi-umbra-preview/package.json +33 -0
  29. package/node_modules/pi-umbra-rename/LICENSE +21 -0
  30. package/node_modules/pi-umbra-rename/README.md +19 -0
  31. package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +55 -0
  32. package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +22 -0
  33. package/node_modules/pi-umbra-rename/package.json +33 -0
  34. package/node_modules/pi-umbra-shimmer/LICENSE +21 -0
  35. package/node_modules/pi-umbra-shimmer/README.md +19 -0
  36. package/node_modules/pi-umbra-shimmer/checks/umbra-shimmer.check.ts +89 -0
  37. package/node_modules/pi-umbra-shimmer/extensions/umbra-shimmer.ts +183 -0
  38. package/node_modules/pi-umbra-shimmer/package.json +33 -0
  39. package/node_modules/pi-umbra-skill-matcher/LICENSE +21 -0
  40. package/node_modules/pi-umbra-skill-matcher/README.md +19 -0
  41. package/node_modules/pi-umbra-skill-matcher/checks/umbra-skill-matcher.check.ts +94 -0
  42. package/node_modules/pi-umbra-skill-matcher/extensions/umbra-skill-matcher.ts +124 -0
  43. package/node_modules/pi-umbra-skill-matcher/package.json +35 -0
  44. package/node_modules/pi-umbra-theme/LICENSE +21 -0
  45. package/node_modules/pi-umbra-theme/README.md +38 -0
  46. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +158 -0
  47. package/node_modules/pi-umbra-theme/checks/umbra-footer.check.ts +72 -0
  48. package/node_modules/pi-umbra-theme/checks/umbra-gutter.check.ts +59 -0
  49. package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +71 -0
  50. package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +120 -0
  51. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +155 -0
  52. package/node_modules/pi-umbra-theme/extensions/umbra-footer.ts +150 -0
  53. package/node_modules/pi-umbra-theme/extensions/umbra-gutter.ts +35 -0
  54. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox.ts +129 -0
  55. package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +209 -0
  56. package/node_modules/pi-umbra-theme/lib/umbra-format.ts +16 -0
  57. package/node_modules/pi-umbra-theme/lib/umbra-settings.ts +52 -0
  58. package/node_modules/pi-umbra-theme/package.json +45 -0
  59. package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +88 -0
  60. package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +88 -0
  61. package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +88 -0
  62. package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +88 -0
  63. package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +88 -0
  64. package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +88 -0
  65. package/node_modules/pi-umbra-theme/themes/umbra-violet-forge.json +88 -0
  66. package/package.json +68 -0
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pi-umbra-rename",
3
+ "version": "0.1.0",
4
+ "description": "One command that renames a pi session and the terminal window title together, which pi keeps behind two separate APIs.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-extension",
9
+ "session",
10
+ "rename",
11
+ "title"
12
+ ],
13
+ "author": "grkn",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/grknbyk/pi-umbra.git",
18
+ "directory": "pi-umbra-rename"
19
+ },
20
+ "homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-rename#readme",
21
+ "bugs": "https://github.com/grknbyk/pi-umbra/issues",
22
+ "files": [
23
+ "extensions",
24
+ "checks",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./extensions"
31
+ ]
32
+ }
33
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 grkn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ # pi-umbra-shimmer
2
+
3
+ A wave of colour running through pi's working indicator, over both the spinner and the text. Needs the umbra bundle patches; without them it does nothing.
4
+
5
+ ```
6
+ pi-umbra-shimmer/
7
+ ├── checks
8
+ │ └── umbra-shimmer.check.ts
9
+ └── extensions
10
+ └── umbra-shimmer.ts
11
+ ```
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pi install npm:pi-umbra-shimmer
17
+ ```
18
+
19
+ MIT.
@@ -0,0 +1,89 @@
1
+ // What goes wrong here without anything failing: the band never reaches an end of the line, the
2
+ // two halves of the indicator drift into different coordinate spaces, and `off` quietly keeps
3
+ // animating. All three look like a working shimmer until you watch it.
4
+ //
5
+ // bun run umbra-shimmer.check.ts
6
+ import assert from "node:assert";
7
+ import shimmer, { blend, highlight, middleAt, painters, parseSpec, strength, toneOf } from "../extensions/umbra-shimmer.ts";
8
+
9
+ const RGB = { muted: [128, 128, 128], text: [255, 255, 255], accent: [0, 200, 255] } as const;
10
+ const CLASSIC = { mode: "classic", speed: 1 } as const;
11
+ const theme = {
12
+ fg: (color: string, text: string) => {
13
+ const rgb = RGB[color as keyof typeof RGB] ?? [10, 20, 30];
14
+ return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${text}\x1b[39m`;
15
+ },
16
+ };
17
+
18
+ // pi exposes no raw theme values, so the colour is read back out of a painted character.
19
+ assert.deepEqual(toneOf(theme, "muted"), [128, 128, 128], "the tone comes back out of the escape");
20
+ assert.equal(toneOf({ fg: (_c, t) => t }, "muted"), undefined, "a theme that paints nothing has no tone");
21
+
22
+ assert.deepEqual(blend([0, 0, 0], [10, 20, 30], 0), [0, 0, 0], "no highlight is the base colour");
23
+ assert.deepEqual(blend([0, 0, 0], [10, 20, 30], 1), [10, 20, 30], "full highlight is the tone itself");
24
+ assert.deepEqual(blend([0, 0, 0], [10, 20, 40], 0.5), [5, 10, 20], "and halfway is halfway");
25
+
26
+ assert.equal(strength(10, 10, 4), 1, "the middle of the band gets all of it");
27
+ assert.equal(strength(14, 10, 4), 0, "its edge gets none");
28
+ assert.equal(strength(40, 10, 4), 0, "and nothing outside goes negative");
29
+
30
+ // classic has to enter from off the left edge and leave past the right one, or the wave appears
31
+ // and vanishes mid-line instead of running through it.
32
+ const classic = [0, 500, 1000, 1500, 1999].map((ms) => middleAt("classic", ms, 30, 1));
33
+ assert.ok(classic[0]! < 0, "the pass starts off the left edge");
34
+ assert.ok(classic[4]! > 30, "and ends past the right one");
35
+ assert.deepEqual(classic, [...classic].sort((a, b) => a - b), "it only ever moves one way");
36
+
37
+ // kitt has to turn around, and reach both ends while doing it.
38
+ const kitt = Array.from({ length: 45 }, (_, i) => middleAt("kitt", i * 50, 30, 1));
39
+ assert.ok(Math.min(...kitt) < 1, "the scanner reaches the left end");
40
+ assert.ok(Math.max(...kitt) > 29, "and the right one");
41
+ assert.ok(kitt.some((v, i) => i > 0 && v < kitt[i - 1]!), "and comes back");
42
+
43
+ // One tone per pass, cycling: a highlight that never changes is the thing this replaces.
44
+ // The ramp runs white -> the accent lifted toward white -> lifted a little -> the accent, and
45
+ // it is walked rather than stepped: two frames a moment apart must not jump colour.
46
+ const ACCENT: [number, number, number] = [0, 200, 255];
47
+ assert.deepEqual(highlight(ACCENT, 0, 1), [255, 255, 255], "the ramp starts at white");
48
+ assert.deepEqual(highlight(ACCENT, 3300, 1), ACCENT, "and reaches the theme's own colour");
49
+ assert.deepEqual(highlight(ACCENT, 6600, 1), [255, 255, 255], "then comes back rather than cutting");
50
+ const near = highlight(ACCENT, 1000, 1);
51
+ const later = highlight(ACCENT, 1080, 1);
52
+ assert.notDeepEqual(near, later, "the tone moves between frames");
53
+ assert.ok(Math.max(...[0, 1, 2].map((i) => Math.abs(near[i]! - later[i]!))) < 20, "but never jumps");
54
+
55
+ // The speed multiplier has to reach both halves: the sweep and the colour.
56
+ assert.equal(parseSpec("classic 3")?.speed, 3, "a second argument is the speed");
57
+ assert.equal(parseSpec("kitt")?.speed, 1, "no argument is speed 1");
58
+ assert.equal(parseSpec("classic x")?.speed, 1, "and so is a speed that is not a number");
59
+ assert.equal(parseSpec("classic 99")?.speed, 8, "an absurd speed is clamped, not refused");
60
+ assert.equal(parseSpec("nope"), undefined, "an unknown mode is refused");
61
+ assert.deepEqual(highlight(ACCENT, 1650, 2), highlight(ACCENT, 3300, 1), "speed 2 gets there twice as fast");
62
+ assert.equal(middleAt("classic", 500, 30, 2), middleAt("classic", 1000, 30, 1), "and sweeps twice as fast");
63
+
64
+ // off is off. It also takes the footer's colour rather than pi's own accent for the spinner.
65
+ const dark = painters(theme, { mode: "off", speed: 1 });
66
+ assert.equal(dark.spinner("x"), theme.fg("muted", "x"), "off paints the spinner in the footer colour");
67
+ assert.equal(dark.message("y"), theme.fg("muted", "y"), "and the message in the same one");
68
+
69
+ // The message is what tells the spinner how long the line is; without that they shimmer as two
70
+ // separate waves that meet in the middle.
71
+ const live = painters(theme, CLASSIC);
72
+ const message = live.message("Working... (esc to interrupt)");
73
+ assert.equal([...message.matchAll(/38;2;/g)].length, 29, "every character is painted on its own");
74
+ assert.ok(live.spinner("⠾").includes("38;2;"), "and so is the glyph");
75
+ assert.ok(!message.includes("\x1b[39m\x1b[39m"), "no character is painted twice");
76
+
77
+ // pi reads `label` when it applies a completion. An item with only `value` is an
78
+ // uncaughtException on the first Tab, which takes pi down rather than logging anything.
79
+ const commands: Record<string, { getArgumentCompletions(text: string): { value: string; label: string }[] | null }> = {};
80
+ shimmer({ on() {}, registerCommand: (name: string, def: never) => (commands[name] = def) } as never);
81
+ const complete = commands["umb-shimmer"]!.getArgumentCompletions.bind(commands["umb-shimmer"]);
82
+ for (const item of complete("") ?? []) {
83
+ assert.equal(typeof item.value, "string", "every completion carries a value");
84
+ assert.equal(typeof item.label, "string", "and a label, which is the one pi reads");
85
+ }
86
+ assert.equal((complete("k") ?? []).length, 1, "a prefix narrows the list");
87
+ assert.equal(complete("zzz"), null, "no match is null, not an empty list");
88
+
89
+ console.log("umbra-shimmer.check.ts ok - tone 2 cases, blend 3, band 3, classic 3, kitt 3, ramp 5, speed 7, off 2, span 3, completions 4");
@@ -0,0 +1,183 @@
1
+ // A wave of colour running through the working indicator, over both the spinner and the text.
2
+ //
3
+ // pi builds that indicator with a colour function already in hand - the editor embeds the working
4
+ // status, so pi hands it the editor's border colour - and paints custom spinner frames verbatim,
5
+ // skipping the colour step for them entirely. repatch stands both of those down when a shimmer is
6
+ // installed and routes the two colour functions here. With nothing installed pi keeps its own
7
+ // behaviour, so the patches are inert on their own.
8
+ //
9
+ // The highlight is not one colour. It travels from white down to the theme's own main colour and
10
+ // back, blending the whole way rather than stepping, so the band changes tone while it moves.
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+
16
+ export const MODES = ["classic", "kitt", "off"] as const;
17
+ export type Mode = (typeof MODES)[number];
18
+ export type Spec = { mode: Mode; speed: number };
19
+
20
+ const FILE = join(homedir(), ".pi", "agent", "shimmer");
21
+
22
+ // One pass of the wave at speed 1. kitt is quicker because it covers the same ground twice.
23
+ const CLASSIC_MS = 2000;
24
+ const KITT_MS = 1100;
25
+
26
+ // How long the highlight takes to travel the whole ramp and come back. Deliberately not a
27
+ // multiple of either sweep, so the tone lands somewhere new on every pass instead of arriving
28
+ // in the same colour each time.
29
+ const TONE_MS = 3300;
30
+
31
+ // Half the band's width, as a share of the line. kitt's is tighter - a scanner, not a sweep.
32
+ const CLASSIC_BAND = 0.18;
33
+ const KITT_BAND = 0.12;
34
+
35
+ // Below a quarter speed the wave stops reading as motion; above eight it is a flicker.
36
+ const SLOWEST = 0.25;
37
+ const FASTEST = 8;
38
+
39
+ type Rgb = [number, number, number];
40
+ type Paint = (text: string) => string;
41
+ type Palette = { fg: (color: string, text: string) => string };
42
+
43
+ // The ramp the highlight travels: white, the theme's main colour lifted most of the way to
44
+ // white, the same colour lifted a little, then the colour itself. Written as how far each stop
45
+ // sits from the accent toward white, so every theme gets the same shape in its own hue.
46
+ const TOWARD_WHITE = [1, 0.75, 0.35, 0];
47
+ const WHITE: Rgb = [255, 255, 255];
48
+
49
+ /** The width the wave travels over when the message has not been seen yet. */
50
+ const NOMINAL = 34;
51
+
52
+ /** The colour pi would paint with, read back out of the escape it emits. */
53
+ // There is no API for a theme's raw values, and parsing the theme file again would miss the
54
+ // live one during a /settings preview. Painting one character and reading the escape asks the
55
+ // same object pi is about to paint with.
56
+ export const toneOf = (theme: Palette, key: string): Rgb | undefined => {
57
+ const found = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(theme.fg(key, "x"));
58
+ return found === null ? undefined : [Number(found[1]), Number(found[2]), Number(found[3])];
59
+ };
60
+
61
+ export const blend = (from: Rgb, to: Rgb, amount: number): Rgb => [
62
+ Math.round(from[0] + (to[0] - from[0]) * amount),
63
+ Math.round(from[1] + (to[1] - from[1]) * amount),
64
+ Math.round(from[2] + (to[2] - from[2]) * amount),
65
+ ];
66
+
67
+ const ink = ([r, g, b]: Rgb, text: string) => `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
68
+
69
+ /** Where on the ramp the highlight is at time `ms`, blended rather than stepped. */
70
+ // Stepping from one stop to the next makes the whole line jump colour between two frames. The
71
+ // ramp is walked there and back rather than wrapping, so white never cuts straight back to the
72
+ // accent at the seam.
73
+ export const highlight = (accent: Rgb, ms: number, speed: number): Rgb => {
74
+ const stops = TOWARD_WHITE.map((toward) => blend(accent, WHITE, toward));
75
+ const period = (TONE_MS * 2) / speed;
76
+ const phase = (ms % period) / period;
77
+ const along = (phase < 0.5 ? phase * 2 : 2 - phase * 2) * (stops.length - 1);
78
+ const at = Math.min(stops.length - 2, Math.floor(along));
79
+ return blend(stops[at] as Rgb, stops[at + 1] as Rgb, along - at);
80
+ };
81
+
82
+ /** How much of the highlight column `index` gets: 1 at the middle of the band, 0 outside it. */
83
+ export const strength = (index: number, middle: number, half: number): number =>
84
+ half <= 0 ? 0 : Math.max(0, 1 - Math.abs(index - middle) / half);
85
+
86
+ /** Where the band sits at time `ms`, in columns. */
87
+ // classic starts a band-width off the left edge and ends one off the right, so the wave enters
88
+ // and leaves rather than appearing mid-line. kitt turns around at both ends, and eases into the
89
+ // turn - a linear bounce reads as a hard bounce off a wall.
90
+ export const middleAt = (mode: Mode, ms: number, width: number, speed: number): number => {
91
+ if (mode === "kitt") {
92
+ const period = (KITT_MS * 2) / speed;
93
+ const phase = (ms % period) / period;
94
+ const there = phase < 0.5 ? phase * 2 : 2 - phase * 2;
95
+ return there * there * (3 - 2 * there) * width;
96
+ }
97
+ const period = CLASSIC_MS / speed;
98
+ const travel = width * (1 + CLASSIC_BAND * 2);
99
+ return ((ms % period) / period) * travel - width * CLASSIC_BAND;
100
+ };
101
+
102
+ /** `classic`, `kitt 3`, `off`. A speed that makes no sense is not a reason to refuse the mode. */
103
+ export const parseSpec = (text: string): Spec | undefined => {
104
+ const [name, rate] = text.trim().split(/\s+/);
105
+ if (!(MODES as readonly string[]).includes(name ?? "")) return undefined;
106
+ const asked = Number(rate);
107
+ const speed = Number.isFinite(asked) && asked > 0 ? Math.min(FASTEST, Math.max(SLOWEST, asked)) : 1;
108
+ return { mode: name as Mode, speed };
109
+ };
110
+
111
+ // The spinner and the message are painted by two separate calls, and only the second one knows
112
+ // how long the line is. Remembering it keeps both in the same coordinate space, so the band
113
+ // crosses the glyph and the text as one wave instead of two.
114
+ let span = NOMINAL;
115
+
116
+ const wave = (theme: Palette, spec: Spec, text: string, offset: number): string => {
117
+ const base = toneOf(theme, "muted");
118
+ const accent = toneOf(theme, "accent");
119
+ if (base === undefined || accent === undefined) return theme.fg("muted", text);
120
+
121
+ const now = Date.now();
122
+ const high = highlight(accent, now, spec.speed);
123
+ const middle = middleAt(spec.mode, now, span, spec.speed);
124
+ const half = span * (spec.mode === "kitt" ? KITT_BAND : CLASSIC_BAND);
125
+ return [...text]
126
+ .map((glyph, at) => ink(blend(base, high, strength(offset + at, middle, half)), glyph))
127
+ .join("");
128
+ };
129
+
130
+ export const painters = (theme: Palette, spec: Spec): { spinner: Paint; message: Paint } =>
131
+ spec.mode === "off"
132
+ ? { spinner: (text) => theme.fg("muted", text), message: (text) => theme.fg("muted", text) }
133
+ : {
134
+ spinner: (text) => wave(theme, spec, text, 0),
135
+ message: (text) => {
136
+ span = text.length + 2; // the glyph and the space pi puts after it
137
+ return wave(theme, spec, text, 2);
138
+ },
139
+ };
140
+
141
+ const DEFAULT: Spec = { mode: "classic", speed: 1 };
142
+
143
+ const read = (): Spec => (existsSync(FILE) ? (parseSpec(readFileSync(FILE, "utf8")) ?? DEFAULT) : DEFAULT);
144
+
145
+ const written = (spec: Spec) => (spec.speed === 1 ? spec.mode : `${spec.mode} ${spec.speed}`);
146
+
147
+ const install = (ctx: ExtensionContext | ExtensionCommandContext, spec: Spec): void => {
148
+ // Read the theme on every call rather than closing over it: pi swaps the theme object during
149
+ // a /settings preview, and a captured one would shimmer in the palette of the theme that was
150
+ // active when the command ran.
151
+ const live = painters({ fg: (color, text) => ctx.ui.theme.fg(color as never, text) }, spec);
152
+ (globalThis as { __umbraShimmer?: unknown }).__umbraShimmer = live;
153
+ };
154
+
155
+ export default function (pi: ExtensionAPI) {
156
+ pi.on("session_start", (_event, ctx) => {
157
+ if (ctx.mode !== "tui") return;
158
+ install(ctx, read());
159
+ });
160
+
161
+ pi.registerCommand("umb-shimmer", {
162
+ description: "Working indicator animation: /umb-shimmer classic | kitt | off [speed]",
163
+ // Both fields, and null rather than an empty list: pi reads `label` when it applies a
164
+ // completion, so an item carrying only `value` takes the whole process down on the first
165
+ // Tab. That is an uncaughtException, not a caught extension error.
166
+ getArgumentCompletions(text: string) {
167
+ const items = MODES.filter((mode) => mode.startsWith(text.trim())).map((mode) => ({ value: mode, label: mode }));
168
+ return items.length > 0 ? items : null;
169
+ },
170
+ handler: (args: string, ctx: ExtensionCommandContext) => {
171
+ if (args.trim() === "") {
172
+ return ctx.ui.notify(`Now: ${written(read())}. /umb-shimmer ${MODES.join(" | ")} [speed]`, "info");
173
+ }
174
+ const spec = parseSpec(args);
175
+ if (spec === undefined) {
176
+ return ctx.ui.notify(`No such shimmer: ${args.trim()}. One of: ${MODES.join(", ")}`, "warning");
177
+ }
178
+ writeFileSync(FILE, written(spec));
179
+ install(ctx, spec);
180
+ ctx.ui.notify(`Shimmer: ${written(spec)}.`, "info");
181
+ },
182
+ });
183
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pi-umbra-shimmer",
3
+ "version": "0.1.0",
4
+ "description": "A wave of colour running through pi's working indicator, over both the spinner and the text. Needs the umbra bundle patches; without them it does nothing.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-extension",
9
+ "tui",
10
+ "spinner",
11
+ "animation"
12
+ ],
13
+ "author": "grkn",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/grknbyk/pi-umbra.git",
18
+ "directory": "pi-umbra-shimmer"
19
+ },
20
+ "homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-shimmer#readme",
21
+ "bugs": "https://github.com/grknbyk/pi-umbra/issues",
22
+ "files": [
23
+ "extensions",
24
+ "checks",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./extensions"
31
+ ]
32
+ }
33
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 grkn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ # pi-umbra-skill-matcher
2
+
3
+ Type /huh and reach skill:huh. pi lists every skill under its bare name, which is not the name that dispatches.
4
+
5
+ ```
6
+ pi-umbra-skill-matcher/
7
+ ├── checks
8
+ │ └── umbra-skill-matcher.check.ts
9
+ └── extensions
10
+ └── umbra-skill-matcher.ts
11
+ ```
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pi install npm:pi-umbra-skill-matcher
17
+ ```
18
+
19
+ MIT.
@@ -0,0 +1,94 @@
1
+ // Runs skill-fuzzy against pi's REAL built-in provider, so a change in pi's prefix/value
2
+ // contract fails here instead of silently in the TUI.
3
+ // bun run umbra-skill-matcher.check.ts
4
+ import assert from "node:assert";
5
+ import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
6
+ import skillFuzzy, { midPromptWord } from "../extensions/umbra-skill-matcher.ts";
7
+
8
+ const COMMANDS = [
9
+ { name: "huh", source: "skill", description: "Restate the last message plainly." },
10
+ { name: "hula-hola", source: "skill", description: "dance" },
11
+ { name: "what-the-hex", source: "skill", description: "hex" },
12
+ { name: "picasso", source: "skill", description: "frontend" },
13
+ { name: "call-council", source: "skill", description: "council skill" },
14
+ { name: "call-council", source: "extension", description: "council extension" },
15
+ { name: "help", source: "builtin", description: "help" },
16
+ ];
17
+
18
+ let wrap: (current: unknown) => any;
19
+ const pi = {
20
+ getCommands: () => COMMANDS,
21
+ on: (_event: string, handler: (e: unknown, ctx: unknown) => void) =>
22
+ handler({}, { ui: { addAutocompleteProvider: (factory: typeof wrap) => (wrap = factory) } }),
23
+ };
24
+ skillFuzzy(pi as never);
25
+
26
+ const builtin = new CombinedAutocompleteProvider(COMMANDS as never, process.cwd());
27
+ const provider = wrap!(builtin);
28
+ const labels = async (typed: string) => {
29
+ const got = await provider.getSuggestions([typed], 0, typed.length, { signal: AbortSignal.timeout(5000) });
30
+ return (got?.items ?? []).map((item: { label: string }) => item.label);
31
+ };
32
+
33
+ // The user's three examples: a shorter query lists more skills, all in the runnable form.
34
+ // pi's matcher is subsequence-based, not contains, so "/huh" also reaches "hula-hola" (h-u-h).
35
+ // That is pi's own ranking to own; all this checks is that the exact match leads.
36
+ assert.equal((await labels("/huh"))[0], "skill:huh");
37
+ assert.ok((await labels("/hu")).includes("skill:huh"), "/hu must offer skill:huh");
38
+ assert.ok((await labels("/hu")).includes("skill:hula-hola"), "/hu must offer skill:hula-hola");
39
+ assert.ok((await labels("/h")).includes("skill:what-the-hex"), "/h must reach a mid-word match");
40
+
41
+ // A skill is never offered under the bare name that does not run.
42
+ assert.ok(!(await labels("/pic")).includes("picasso"), "bare picasso must be gone");
43
+ assert.deepEqual(await labels("/pic"), ["skill:picasso"]);
44
+
45
+ // "skill:" typed by hand still finds it, which plain fuzzy on the bare name cannot.
46
+ assert.ok((await labels("/skill:pic")).includes("skill:picasso"), "explicit prefix must still match");
47
+
48
+ // call-council belongs to an extension too, so its bare form stays runnable and untouched.
49
+ assert.ok((await labels("/call")).includes("call-council"), "extension command must survive");
50
+
51
+ // Browsing the whole list with a bare "/" must show the runnable form too, not just a search.
52
+ const all = await labels("/");
53
+ assert.ok(all.includes("skill:huh"), "bare / must list skills in runnable form");
54
+ assert.ok(!all.includes("huh"), "bare / must not leak the name that does not run");
55
+ assert.ok(all.includes("help"), "bare / must keep non-skill commands");
56
+
57
+ // Selecting the row writes exactly "/skill:huh " — one slash, one trailing space.
58
+ const item = { value: "skill:huh", label: "skill:huh" };
59
+ const applied = provider.applyCompletion(["/huh"], 0, 4, item, "/huh");
60
+ assert.equal(applied.lines[0], "/skill:huh ");
61
+ assert.equal(applied.cursorCol, 11);
62
+
63
+ console.log("umbra-skill-matcher: all checks passed");
64
+
65
+ // --- mid-sentence ---------------------------------------------------------------------------
66
+ // The token test, first on its own. These are the shapes the repatch entries also decide with,
67
+ // so a change here without a change there means the menu opens on something it cannot fill.
68
+ assert.equal(midPromptWord("run this /pic"), "pic", "a token after real text is ours");
69
+ assert.equal(midPromptWord("run this /"), "", "an empty token is ours - that is the keystroke");
70
+ assert.equal(midPromptWord("/pic"), null, "the line-start form belongs to the built-in provider");
71
+ assert.equal(midPromptWord(" /pic"), null, "an indented line start is still a line start");
72
+ assert.equal(midPromptWord("open src/pic"), null, "a path is not a command");
73
+ assert.equal(midPromptWord("open ~/pic"), null, "nor is a home path");
74
+ assert.equal(midPromptWord("run /pic then"), null, "the token must end at the cursor");
75
+
76
+ // Mid-sentence rows carry the bare skill name, because nothing dispatches them: pi runs a command
77
+ // only when the message starts with a slash. The name is what pi's own <available_skills> block
78
+ // lists, so it is the form the model can act on.
79
+ const mid = async (typed: string) => {
80
+ const got = await provider.getSuggestions([typed], 0, typed.length, { signal: AbortSignal.timeout(5000) });
81
+ return (got?.items ?? []).map((item: { value: string }) => item.value);
82
+ };
83
+ assert.deepEqual(await mid("use the /pic"), ["/picasso"]);
84
+ assert.ok(!(await mid("use the /pic")).includes("/skill:picasso"), "no dispatcher prefix mid-sentence");
85
+ assert.ok((await mid("use the /call")).includes("/call-council"), "skills only, once");
86
+ assert.equal((await mid("use the /call")).length, 1, "the extension twin must not double the row");
87
+ assert.equal((await mid("use the /zzz")).length, 0, "no match means no menu");
88
+
89
+ // Selecting a mid-sentence row keeps the sentence and writes one slash.
90
+ const midItem = { value: "/picasso", label: "picasso" };
91
+ const midApplied = provider.applyCompletion(["use the /pic"], 0, 12, midItem, "/pic");
92
+ assert.equal(midApplied.lines[0], "use the /picasso");
93
+
94
+ console.log("umbra-skill-matcher: mid-sentence checks passed");
@@ -0,0 +1,124 @@
1
+ // pi registers a skill in the completion list under its bare name ("huh"), but only
2
+ // "/skill:huh" actually runs one. So the built-in dropdown offers "/huh", the user presses
3
+ // enter, and pi answers "Unknown command". The matching itself is already fuzzy; the name it
4
+ // matches against is what is wrong.
5
+ //
6
+ // This wraps the built-in provider and rewrites every skill row to the form that runs. It also
7
+ // re-runs the match against the prefixed name, so typing "/skill:hu" still finds "skill:huh"
8
+ // after the built-in fuzzy pass has given up on it.
9
+ //
10
+ // It also fills the mid-sentence slash menu that two repatch entries open. pi's editor gates the
11
+ // menu on the line starting with "/", so a "/name" written inside a sentence never reached a
12
+ // provider at all; the patches lift that gate and the branch below decides what appears.
13
+ //
14
+ // Contract taken from pi-tui's CombinedAutocompleteProvider: `prefix` is the text before the
15
+ // cursor INCLUDING the leading slash, and `item.value` is the bare command name — applyCompletion
16
+ // writes `${before}/${value} ` itself, so the value carries neither slash nor trailing space.
17
+ // That holds only while the token starts the line. Mid-sentence the same method falls through to
18
+ // `${before}${value}`, which is why those rows carry their own slash.
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+
21
+ const SKILL_PREFIX = "skill:";
22
+ const MAX_ROWS = 12;
23
+
24
+ type Item = { value: string; label: string; description?: string };
25
+ type Suggestions = { items: Item[]; prefix: string } | null;
26
+
27
+ // Only complete while the command word is the whole line: "/pic" completes, "/skill:picasso go"
28
+ // does not, because there the user is writing the argument.
29
+ const commandWord = (before: string): string | null => {
30
+ const match = /^\/(\S*)$/.exec(before);
31
+ return match ? match[1] : null;
32
+ };
33
+
34
+ // The same token test the two repatch entries use to open the slash menu mid-sentence, so the
35
+ // menu and its contents agree on what counts: last token on the line, no space in it, and real
36
+ // text before it that ends in whitespace. "src/foo" and an indented " /foo" both fail, which
37
+ // leaves paths and line-start commands to the built-in provider.
38
+ export const midPromptWord = (before: string): string | null => {
39
+ const slash = before.lastIndexOf("/");
40
+ if (slash < 1 || before.slice(slash + 1).includes(" ")) return null;
41
+ const head = before.slice(0, slash);
42
+ return head.trim() === "" || head === head.trimEnd() ? null : before.slice(slash + 1);
43
+ };
44
+
45
+ export default function skillFuzzy(pi: ExtensionAPI) {
46
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
47
+ ctx.ui.addAutocompleteProvider((current) => ({
48
+ triggerCharacters: ["/"],
49
+
50
+ async getSuggestions(lines: string[], line: number, col: number, options: never) {
51
+ const before = (lines[line] ?? "").slice(0, col);
52
+
53
+ // pi dispatches a command only when the whole message starts with a slash, so a
54
+ // "/name" written mid-sentence is never run - it is text the model reads. pi's
55
+ // system prompt already lists every skill and tells the model to open the
56
+ // matching SKILL.md, so the bare name is what belongs here. The "skill:" form
57
+ // below exists for the dispatcher, and the dispatcher never sees this one.
58
+ const midWord = midPromptWord(before);
59
+ if (midWord !== null) {
60
+ const query = midWord.toLowerCase();
61
+ const items = pi
62
+ .getCommands()
63
+ .filter((command) => command.source === "skill" && command.name.toLowerCase().includes(query))
64
+ .slice(0, MAX_ROWS)
65
+ .map((command) => ({
66
+ // applyCompletion writes `${before}${value}` for a token that does not
67
+ // start the line, so the value carries its own slash.
68
+ value: `/${command.name}`,
69
+ label: command.name,
70
+ description: command.description ?? "skill",
71
+ }));
72
+ return items.length === 0 ? null : { items, prefix: `/${midWord}` };
73
+ }
74
+
75
+ const base = (await current.getSuggestions(lines, line, col, options)) as Suggestions;
76
+ const typed = commandWord(before);
77
+ if (typed === null) return base;
78
+
79
+ const commands = pi.getCommands();
80
+ // "call-council" is both a skill and an extension command, and there the bare
81
+ // "/call-council" is the real one. Only touch a name nothing else claims.
82
+ const taken = new Set(
83
+ commands.filter((command) => command.source !== "skill").map((command) => command.name),
84
+ );
85
+ const skills = commands.filter(
86
+ (command) => command.source === "skill" && !taken.has(command.name),
87
+ );
88
+ const byName = new Map(skills.map((command) => [command.name, command]));
89
+
90
+ const asSkill = (name: string, description?: string): Item => ({
91
+ value: `${SKILL_PREFIX}${name}`,
92
+ label: `${SKILL_PREFIX}${name}`,
93
+ description: description ?? byName.get(name)?.description ?? "skill",
94
+ });
95
+
96
+ const fixed = (base?.items ?? []).map((item) =>
97
+ byName.has(item.value) ? asSkill(item.value, item.description) : item,
98
+ );
99
+
100
+ const query = typed.toLowerCase().replace(/^skill:/, "");
101
+ const shown = new Set(fixed.map((item) => item.value));
102
+ const extra = query
103
+ ? skills
104
+ .filter((command) => command.name.toLowerCase().includes(query))
105
+ .map((command) => asSkill(command.name))
106
+ .filter((item) => !shown.has(item.value))
107
+ .slice(0, MAX_ROWS)
108
+ : [];
109
+
110
+ const items = [...fixed, ...extra];
111
+ if (items.length === 0) return null;
112
+ return { items, prefix: base?.prefix ?? `/${typed}` };
113
+ },
114
+
115
+ applyCompletion(lines: string[], line: number, col: number, item: Item, prefix: string) {
116
+ return current.applyCompletion(lines, line, col, item, prefix);
117
+ },
118
+
119
+ shouldTriggerFileCompletion(lines: string[], line: number, col: number) {
120
+ return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true;
121
+ },
122
+ }));
123
+ });
124
+ }