osdy-pi 1.4.0 → 1.4.1

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
@@ -33,6 +33,15 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
33
33
  pi install git:github.com/OsdyOrtiz/Osdy-Pi
34
34
  ```
35
35
 
36
+ Already installed a pinned `npm:osdy-pi@1.3.0` or `npm:osdy-pi@1.4.0` in **normal Pi**? Once 1.4.1 is published, replace the older pin (no uninstall or `--local` needed):
37
+
38
+ ```bash
39
+ pi install npm:osdy-pi@1.4.1
40
+ pi list
41
+ ```
42
+
43
+ Check that `pi list` shows `npm:osdy-pi@1.4.1`, then restart Pi or run `/reload`. `pi update` does not move a pinned npm version. Installing Osdy does not automatically install Joker; use the [explicit agents setup](#explicit-joker-agents-setup-in-normal-pi) after updating if you want Joker in normal Pi.
44
+
36
45
  2. Start Pi:
37
46
 
38
47
  ```bash
@@ -208,7 +217,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
208
217
  | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
209
218
  | Messages | Pi-native, theme-aware user and assistant message cards |
210
219
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
211
- | Status | Custom working spinner, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
220
+ | Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
212
221
  | Git | Working-tree summary and a centered, filterable diff panel |
213
222
  | Audio | Optional event sounds on macOS and Windows |
214
223
 
@@ -326,7 +335,7 @@ The default `auto` editor mode preserves the responsive behavior: it uses the fr
326
335
 
327
336
  In auto or extended mode at a non-small width, the framed editor shows the model and thinking level in its title and session usage in its footer. For an account-profile launch, the left title shows the active profile name instead of `Osdy-Pi`. When the native editor is effective (simple mode or any small terminal), the Osdy footer instead shows model, active profile when present, thinking, and usage rows before its path/branch and status rows. It uses the currently active Pi/Osdy theme palette; no separate editor theme selector exists. Usage covers input, output, cache read, cache write when present, cost, and context. If Pi supports autocomplete, the editor uses Pi's native autocomplete rendering while the completion UI is visible.
328
337
 
329
- A custom spinner appears above the editor while work is active. Osdy Pi hides Pi's built-in working row while enabled to avoid a duplicate indicator.
338
+ While work is active, Osdy Pi shows the original Braille spinner above the editor in the selected theme's accent color. A highlight travels across the `Working...` and `Running ...` letters using the active theme's accent and text colors; the spinner and label update when the theme changes. Osdy Pi hides Pi's built-in working row while enabled to avoid a duplicate indicator.
330
339
 
331
340
  ## Working tree and diff
332
341
 
@@ -39,9 +39,9 @@ import {
39
39
  STACKED_HEADER_MIN_ROWS,
40
40
  type MascotConfig,
41
41
  type ScaledHeaderArt,
42
- WORKING_SPINNER_FRAMES,
43
42
  } from "./constants.js";
44
43
  import { formatPath } from "./format.js";
44
+ import { renderWorkingWidget } from "./working-animation.js";
45
45
  import { contextUsageData, modelLabel } from "./metrics.js";
46
46
  import {
47
47
  formatModelMetadata,
@@ -363,13 +363,7 @@ class OsdyWorkingWidget implements Component {
363
363
  }
364
364
 
365
365
  render(width: number): string[] {
366
- if (!this.state.active) return [];
367
- const frame =
368
- WORKING_SPINNER_FRAMES[this.state.frame % WORKING_SPINNER_FRAMES.length] ??
369
- WORKING_SPINNER_FRAMES[0] ??
370
- "⠋";
371
- const line = `${this.theme.fg("accent", frame)} ${this.theme.fg("muted", this.state.label)}`;
372
- return [fitCenterVisible(line, width)];
366
+ return renderWorkingWidget(this.state, this.theme, width);
373
367
  }
374
368
 
375
369
  invalidate(): void {}
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { registerHooks } from "node:module";
3
+ import test from "node:test";
4
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
+ import type { SimpleTheme, WorkingWidgetState } from "./types.js";
6
+
7
+ registerHooks({
8
+ resolve(specifier, context, nextResolve) {
9
+ if (
10
+ context.parentURL?.includes("/extensions/osdy-pi/") &&
11
+ specifier.startsWith("./") &&
12
+ specifier.endsWith(".js")
13
+ ) {
14
+ return nextResolve(`${specifier.slice(0, -3)}.ts`, context);
15
+ }
16
+ return nextResolve(specifier, context);
17
+ },
18
+ });
19
+
20
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
21
+ const { renderWorkingWidget } = await import("./working-animation.ts");
22
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
23
+ const { WORKING_SPINNER_FRAMES } = await import("./constants.ts");
24
+
25
+ function state(label: string, frame = 0, active = true): WorkingWidgetState {
26
+ return { active, label, frame, timer: undefined, tui: undefined };
27
+ }
28
+
29
+ function theme(base: string, accent: string, muted: string): SimpleTheme {
30
+ return {
31
+ fg(name, text) {
32
+ const color = name === "accent" ? accent : name === "muted" ? muted : base;
33
+ return `\u001B[${color}m${text}\u001B[0m`;
34
+ },
35
+ };
36
+ }
37
+
38
+ const palette = theme("37", "33", "90");
39
+ void test("original Braille frames advance every 80ms tick, wrap at ten, and use theme accent", () => {
40
+ assert.equal(WORKING_SPINNER_FRAMES.length, 10);
41
+ for (let frame = 0; frame <= WORKING_SPINNER_FRAMES.length; frame += 1) {
42
+ const glyph = WORKING_SPINNER_FRAMES[frame % WORKING_SPINNER_FRAMES.length] ?? "";
43
+ const spinner = palette.fg("accent", glyph);
44
+ const line = renderWorkingWidget(state("Working...", frame), palette, 40)[0] ?? "";
45
+ assert.ok(line.includes(`${spinner} `), `frame ${frame} uses accent Braille ${glyph}`);
46
+ assert.equal(visibleWidth(spinner), 1);
47
+ }
48
+ const at = (frame: number) => renderWorkingWidget(state("Working...", frame), palette, 40)[0] ?? "";
49
+ assert.ok(at(0).includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[0] ?? "")} ${palette.fg("accent", "W")}${palette.fg("text", "o")}`));
50
+ assert.ok(at(1).includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[1] ?? "")} ${palette.fg("text", "W")}${palette.fg("accent", "o")}`));
51
+ const runningAt = (frame: number) => renderWorkingWidget(state("Running build...", frame), palette, 40)[0] ?? "";
52
+ assert.notEqual(runningAt(0), runningAt(10), "letter wave advances when the spinner wraps");
53
+ });
54
+
55
+ void test("Running label skips spaces, wraps its one highlighted grapheme, and follows theme on next render", () => {
56
+ const label = "Running build...";
57
+ const changedTheme = theme("36", "35", "34");
58
+ const first = renderWorkingWidget(state(label, 7), palette, 40)[0] ?? "";
59
+ const changed = renderWorkingWidget(state(label, 7), changedTheme, 40)[0] ?? "";
60
+ assert.ok(first.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[7] ?? "")} `));
61
+ assert.ok(changed.includes(`${changedTheme.fg("accent", WORKING_SPINNER_FRAMES[7] ?? "")} `));
62
+ assert.ok(first.includes(`${palette.fg("text", "g")} ${palette.fg("accent", "b")}`));
63
+ assert.ok(changed.includes(`${changedTheme.fg("text", "g")} ${changedTheme.fg("accent", "b")}`));
64
+ assert.ok(changed.includes(changedTheme.fg("text", "R")));
65
+ assert.ok(renderWorkingWidget(state("A B", 1), palette, 20)[0]?.includes(
66
+ `${palette.fg("text", "A")} ${palette.fg("accent", "B")}`,
67
+ ));
68
+ assert.ok(renderWorkingWidget(state("AB", 2), palette, 20)[0]?.includes(
69
+ `${palette.fg("accent", "A")}${palette.fg("text", "B")}`,
70
+ ));
71
+ });
72
+
73
+ void test("tab-separated label skips whitespace when advancing the highlight", () => {
74
+ const label = "A\tB";
75
+ const line = renderWorkingWidget(state(label, 1), palette, 20)[0] ?? "";
76
+ assert.ok(line.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[1] ?? "")} ${palette.fg("text", "A")}\t${palette.fg("accent", "B")}`));
77
+ assert.equal(visibleWidth(line.trimStart()), 2 + visibleWidth(label));
78
+ });
79
+
80
+ void test("combining marks and ZWJ emoji remain whole graphemes in the traveling wave", () => {
81
+ const label = "A\u0301👩‍💻 B";
82
+ for (const [frame, expected] of [
83
+ [0, `${palette.fg("accent", "A\u0301")}${palette.fg("text", "👩‍💻")} ${palette.fg("text", "B")}`],
84
+ [1, `${palette.fg("text", "A\u0301")}${palette.fg("accent", "👩‍💻")} ${palette.fg("text", "B")}`],
85
+ [2, `${palette.fg("text", "A\u0301")}${palette.fg("text", "👩‍💻")} ${palette.fg("accent", "B")}`],
86
+ ] as const) {
87
+ const line = renderWorkingWidget(state(label, frame), palette, 40)[0] ?? "";
88
+ assert.ok(line.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[frame] ?? "")} ${expected}`), `frame ${frame} preserves graphemes`);
89
+ assert.equal(visibleWidth(line.trimStart()), 2 + visibleWidth(label));
90
+ }
91
+ });
92
+
93
+ void test("inactive widget renders nothing; narrow output fits and full output stays centered", () => {
94
+ assert.deepEqual(renderWorkingWidget(state("Working...", 18, false), palette, 20), []);
95
+ for (const frame of [0, 2, 4, 6]) {
96
+ const line = renderWorkingWidget(state("Working...", frame), palette, 5)[0] ?? "";
97
+ assert.equal(visibleWidth(line), 5);
98
+ }
99
+ const centered = renderWorkingWidget(state("Working..."), palette, 20)[0] ?? "";
100
+ assert.equal(centered.match(/^ */)?.[0].length, 4);
101
+ });
@@ -0,0 +1,27 @@
1
+ import { WORKING_SPINNER_FRAMES } from "./constants.js";
2
+ import type { SimpleTheme, WorkingWidgetState } from "./types.js";
3
+ import { fitCenterVisible } from "./utils.js";
4
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
+ const isWhitespace = (segment: string) => /\s/u.test(segment);
6
+
7
+ /** Render the theme-accent Braille spinner beside a theme-aware traveling letter highlight. */
8
+ export function renderWorkingWidget(
9
+ state: WorkingWidgetState,
10
+ theme: SimpleTheme,
11
+ width: number,
12
+ ): string[] {
13
+ if (!state.active) return [];
14
+ const spinner = WORKING_SPINNER_FRAMES[state.frame % WORKING_SPINNER_FRAMES.length] ?? "";
15
+ const segments = [...graphemes.segment(state.label)].map(({ segment }) => segment);
16
+ const letterCount = segments.filter((segment) => !isWhitespace(segment)).length;
17
+ const highlighted = letterCount > 0 ? state.frame % letterCount : -1;
18
+ let letterIndex = 0;
19
+ const label = segments
20
+ .map((segment) => {
21
+ if (isWhitespace(segment)) return segment;
22
+ const color = letterIndex++ === highlighted ? "accent" : "text";
23
+ return theme.fg(color, segment);
24
+ })
25
+ .join("");
26
+ return [fitCenterVisible(`${theme.fg("accent", spinner)} ${label}`, width)];
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Osdy Pi package: themes, header, and custom editor for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",