osdy-pi 1.11.3 → 1.11.4

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
@@ -303,7 +303,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
303
303
  | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
304
304
  | Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
305
305
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
306
- | Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
306
+ | Status | Six activity labels with compact per-state glyphs and a shared theme-color pulse, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
307
307
  | Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
308
308
  | Git | Working-tree summary and a centered, filterable diff panel |
309
309
  | Audio | Optional event sounds on macOS and Windows |
@@ -475,7 +475,20 @@ The default `auto` editor mode preserves the responsive behavior: it uses the fr
475
475
 
476
476
  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.
477
477
 
478
- While work is active, Osdy Pi shows the original Braille spinner above the editor in the selected theme's accent color. A two-color wave travels across the `Working...` and `Running ...` letters: the current letter uses the active theme's `accent`, the trailing letter uses `warning`, and the rest use `text`. Only the current `accent` letter tries the active theme's bold styling; this is a terminal-dependent visual trial, not a change to font size. The trailing `warning` letter stays plain, as does the accent spinner. 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.
478
+ While work is active, Osdy Pi centers an activity label and compact animated glyph above the editor:
479
+
480
+ | Activity | Glyph frames | Meaning |
481
+ | --- | --- | --- |
482
+ | `Thinking...` | ◌ ◎ ◉ ● | Waiting on the model without active tools—not guaranteed internal reasoning. |
483
+ | `Exploring...` | ✶ ✷ ✸ ✹ | Recognized reads, searches, and documentation tools. |
484
+ | `Verifying...` | ◇ ◈ ◆ ◈ | Recognized checks. |
485
+ | `Working...` | ✻ ✼ ✽ ❋ | Recognized mutations. |
486
+ | `Delegating...` | ✧ ✦ ✧ | Subagent handoffs. |
487
+ | `Executing...` | ◴ ◷ ◶ ◵ | Unknown or ambiguous tools. |
488
+
489
+ When tools overlap, the latest remaining tool owns the display. All glyphs share a brightness pulse through the active theme's `mdQuoteBorder`, `thinkingHigh`, `accent`, and `borderAccent` colors; the animation signals activity, not measured percentage progress. Glyphs and labels use a single 80 ms timer, with no new controls or settings.
490
+
491
+ A two-color wave travels across the label: the current letter uses the active theme's `accent`, the trailing letter uses `warning`, and the rest use `text`. Only the current `accent` letter tries the active theme's bold styling; this is a terminal-dependent visual trial, not a change to font size. The trailing `warning` letter and glyph stay plain. The glyph and label update when the theme changes. Osdy Pi hides Pi's built-in working row while enabled to avoid a duplicate indicator.
479
492
 
480
493
  ## Working tree and diff
481
494
 
@@ -1,20 +1,26 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
- import type { HeaderVariant, MascotChoice } from "./types.js";
2
+ import type { HeaderVariant, MascotChoice, WorkingActivity } from "./types.js";
3
3
 
4
4
  export const ANIMATION_ENABLED = true;
5
5
  export const ANIMATION_INTERVAL_MS = 30;
6
6
  export const INTRO_ANIMATION_FRAMES = 28;
7
- export const WORKING_SPINNER_FRAMES = [
8
- "⠋",
9
- "⠙",
10
- "⠹",
11
- "⠸",
12
- "⠼",
13
- "⠴",
14
- "⠦",
15
- "⠧",
16
- "⠇",
17
- "⠏",
7
+ export const WORKING_ACTIVITY_FRAMES: Record<WorkingActivity, readonly string[]> = {
8
+ thinking: ["◌", "◎", "◉", "●"],
9
+ exploring: ["✶", "✷", "✸", "✹"],
10
+ verifying: ["◇", "◈", "◆", "◈"],
11
+ working: ["✻", "✼", "✽", "❋"],
12
+ delegating: ["✧", "✦", "✧"],
13
+ executing: ["◴", "◷", "◶", "◵"],
14
+ };
15
+ export const WORKING_SYMBOL_PULSE: readonly string[] = [
16
+ "mdQuoteBorder",
17
+ "thinkingHigh",
18
+ "accent",
19
+ "borderAccent",
20
+ "accent",
21
+ "thinkingHigh",
22
+ "mdQuoteBorder",
23
+ "mdQuoteBorder",
18
24
  ];
19
25
  export const WORKING_WIDGET_KEY = "osdy-pi-working";
20
26
  export const WORKING_TREE_WIDGET_KEY = "osdy-pi-working-tree";
@@ -101,6 +101,7 @@ function createWorkingTreeState(): WorkingTreeState {
101
101
  function createWorkingState(): WorkingWidgetState {
102
102
  return {
103
103
  active: false,
104
+ activity: "thinking",
104
105
  label: "Working...",
105
106
  frame: 0,
106
107
  timer: undefined,
@@ -111,6 +111,12 @@ void test("Control Center refuses RPC, print, JSON and missing UI without custom
111
111
  }
112
112
  });
113
113
 
114
+ void test("runtime forwards tool identity and unknown start args to the working controller", () => {
115
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
116
+ assert.match(source, /controller\.onToolStart\(event\.toolName, event\.toolCallId, event\.args\)/);
117
+ assert.match(source, /controller\.onToolEnd\(event\.toolCallId\)/);
118
+ });
119
+
114
120
  void test("Control Center reports custom UI startup errors", async () => {
115
121
  const notices: string[] = [];
116
122
  await showControlCenter({
@@ -149,7 +155,7 @@ void test("shared visual actions apply immediately, save the complete settings,
149
155
  editorEffective: true, smallMode: false, workingTreeEnabled: false, workingTreePlacement: "aboveEditor",
150
156
  codexUsage: { kind: "idle" }, fallbackEditorFactory: undefined, tui: undefined };
151
157
  const tree: WorkingTreeState = { enabled: false, visible: false, loading: false, snapshot: null, error: undefined, tui: undefined };
152
- const working: WorkingWidgetState = { active: false, label: "Working", frame: 0, timer: undefined, tui: undefined };
158
+ const working: WorkingWidgetState = { active: false, activity: "thinking", label: "Working", frame: 0, timer: undefined, tui: undefined };
153
159
  let mounts = 0;
154
160
  let editor: unknown = "unchanged";
155
161
  const ctx = { hasUI: true, ui: { setHeader: () => { mounts++; }, setFooter: () => {}, setWidget: () => {},
@@ -216,7 +222,7 @@ async function focusFixture(options: { boundary?: boolean; throwApply?: boolean;
216
222
  editorEffective: !smallMode, smallMode, workingTreeEnabled: false, workingTreePlacement: "aboveEditor",
217
223
  codexUsage: { kind: "idle" }, fallbackEditorFactory: fallback, tui };
218
224
  const tree: WorkingTreeState = { enabled: false, visible: false, loading: false, snapshot: null, error: undefined, tui: undefined };
219
- const working: WorkingWidgetState = { active: false, label: "Working", frame: 0, timer: undefined, tui: undefined };
225
+ const working: WorkingWidgetState = { active: false, activity: "thinking", label: "Working", frame: 0, timer: undefined, tui: undefined };
220
226
  let resolveSave: () => void = () => {};
221
227
  let rejectSave: () => void = () => {};
222
228
  const store = { path: "/unused", load: () => Promise.reject(new Error("must not load")), save: (settings: GlobalEditorSettings) => new Promise<void>((resolve, reject) => {
@@ -1093,7 +1093,8 @@ export async function registerOsdyPi(
1093
1093
  };
1094
1094
  const workingState: WorkingWidgetState = {
1095
1095
  active: false,
1096
- label: "Working...",
1096
+ activity: "thinking",
1097
+ label: "Thinking...",
1097
1098
  frame: 0,
1098
1099
  timer: undefined,
1099
1100
  tui: undefined,
@@ -1195,10 +1196,10 @@ export async function registerOsdyPi(
1195
1196
  }
1196
1197
  });
1197
1198
  pi.on("tool_execution_start", (event) =>
1198
- controller.onToolStart(event.toolName),
1199
+ controller.onToolStart(event.toolName, event.toolCallId, event.args),
1199
1200
  );
1200
1201
  pi.on("tool_execution_end", (event, ctx) => {
1201
- controller.onToolEnd();
1202
+ controller.onToolEnd(event.toolCallId);
1202
1203
  audioRouter.onToolExecutionEnd(event.isError === true, ctx);
1203
1204
  if (state.enabled) clearGentleShellChangesWidget(ctx);
1204
1205
  if (
@@ -76,8 +76,17 @@ export type OsdyState = {
76
76
  workingTreePlacement: WorkingTreePlacement;
77
77
  };
78
78
 
79
+ export type WorkingActivity =
80
+ | "thinking"
81
+ | "exploring"
82
+ | "verifying"
83
+ | "working"
84
+ | "delegating"
85
+ | "executing";
86
+
79
87
  export type WorkingWidgetState = {
80
88
  active: boolean;
89
+ activity: WorkingActivity;
81
90
  label: string;
82
91
  frame: number;
83
92
  timer: ReturnType<typeof setInterval> | undefined;
@@ -0,0 +1,57 @@
1
+ import type { WorkingActivity } from "./types.js";
2
+
3
+ export const WORKING_ACTIVITY_LABELS: Record<WorkingActivity, string> = {
4
+ thinking: "Thinking...",
5
+ exploring: "Exploring...",
6
+ verifying: "Verifying...",
7
+ working: "Working...",
8
+ delegating: "Delegating...",
9
+ executing: "Executing...",
10
+ };
11
+
12
+ function stringArgument(args: unknown, key: string): string | undefined {
13
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return undefined;
14
+ const value = (args as Record<string, unknown>)[key];
15
+ return typeof value === "string" ? value : undefined;
16
+ }
17
+
18
+ function isSimpleCheck(command: string): boolean {
19
+ // Deliberately not a shell parser: reject quoting, expansion and shell syntax.
20
+ // Only plain command/argument tokens can enter the anchored allowlist.
21
+ if (!/^[a-zA-Z0-9_./:=@%+*,? -]+$/.test(command)) return false;
22
+ const text = command.trim();
23
+ return /^(?:npm test|npm run (?:test|lint|typecheck|build)|node (?:--experimental-strip-types )?--test|bun test|npx tsc --noEmit|pytest|go test)(?: +[a-zA-Z0-9_./:=@%+*,?-]+)*$/.test(text);
24
+ }
25
+
26
+ export function classifyWorkingActivity(toolName: string, args: unknown): WorkingActivity {
27
+ switch (toolName) {
28
+ case "read": case "grep": case "find": case "ls":
29
+ case "codegraph": case "symbol_search": case "project_report": case "module_report":
30
+ case "read_symbol": case "read_enclosing": case "ast_grep_search":
31
+ case "resolve-library-id": case "query-docs": case "get-library-docs":
32
+ case "subagent_status": case "subagent_result":
33
+ return "exploring";
34
+ case "mcp": case "mcp__context7": {
35
+ // Only exact documentation selectors; never recurse into wrapped arguments.
36
+ const tool = stringArgument(args, "tool");
37
+ return tool === "resolve-library-id" || tool === "query-docs" || tool === "get-library-docs"
38
+ || tool === "context7_resolve-library-id" || tool === "context7_query-docs" ? "exploring" : "executing";
39
+ }
40
+ case "edit": case "write": case "ast_grep_replace":
41
+ return "working";
42
+ case "subagent_run":
43
+ return "delegating";
44
+ case "lens_diagnostics":
45
+ return "verifying";
46
+ case "intercom": {
47
+ const action = stringArgument(args, "action");
48
+ return action === "send" || action === "ask" || action === "handover" ? "delegating" : "executing";
49
+ }
50
+ case "bash": {
51
+ const command = stringArgument(args, "command");
52
+ return command !== undefined && isSimpleCheck(command) ? "verifying" : "executing";
53
+ }
54
+ default:
55
+ return "executing";
56
+ }
57
+ }
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { registerHooks } from "node:module";
3
3
  import test from "node:test";
4
4
  import { visibleWidth } from "@earendil-works/pi-tui";
5
- import type { SimpleTheme, WorkingWidgetState } from "./types.js";
5
+ import type { SimpleTheme, WorkingActivity, WorkingWidgetState } from "./types.js";
6
6
 
7
7
  registerHooks({
8
8
  resolve(specifier, context, nextResolve) {
@@ -19,38 +19,87 @@ registerHooks({
19
19
 
20
20
  // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
21
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");
22
+ const expectedFrames: Record<WorkingActivity, readonly string[]> = {
23
+ thinking: ["◌", "◎", "◉", "●"],
24
+ exploring: ["✶", "✷", "✸", "✹"],
25
+ verifying: ["◇", "◈", "◆", "◈"],
26
+ working: ["✻", "✼", "✽", "❋"],
27
+ delegating: ["✧", "✦", "✧"],
28
+ executing: ["◴", "◷", "◶", "◵"],
29
+ };
30
+ const activities: readonly WorkingActivity[] = [
31
+ "thinking", "exploring", "verifying", "working", "delegating", "executing",
32
+ ];
33
+ const expectedPulse = [
34
+ "mdQuoteBorder", "thinkingHigh", "accent", "borderAccent",
35
+ "accent", "thinkingHigh", "mdQuoteBorder", "mdQuoteBorder",
36
+ ];
37
+
38
+ function symbol(
39
+ theme: SimpleTheme,
40
+ frame: number,
41
+ activity: WorkingActivity = "thinking",
42
+ ): string {
43
+ const frames = expectedFrames[activity];
44
+ const color = expectedPulse[frame % expectedPulse.length] ?? "";
45
+ const glyph = frames[frame % frames.length] ?? "";
46
+ return theme.fg(color, glyph);
47
+ }
24
48
 
25
49
  function state(label: string, frame = 0, active = true): WorkingWidgetState {
26
- return { active, label, frame, timer: undefined, tui: undefined };
50
+ return { active, activity: "thinking", label, frame, timer: undefined, tui: undefined };
27
51
  }
28
52
 
29
53
  function theme(base: string, accent: string, warning: string): SimpleTheme {
30
54
  return {
31
55
  fg(name, text) {
32
- const color = name === "accent" ? accent : name === "warning" ? warning : base;
33
- return `\u001B[${color}m${text}\u001B[0m`;
56
+ const colors: Record<string, string> = {
57
+ accent,
58
+ warning,
59
+ mdQuoteBorder: `2;${base}`,
60
+ thinkingHigh: `2;${accent}`,
61
+ borderAccent: `1;${accent}`,
62
+ };
63
+ return `\u001B[${colors[name] ?? base}m${text}\u001B[0m`;
34
64
  },
35
65
  };
36
66
  }
37
67
 
38
68
  const palette = theme("37", "33", "34");
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
- }
69
+ for (const activity of activities) {
70
+ void test(`${activity} uses its approved one-cell frames and full shared pulse cycle`, () => {
71
+ const frames = expectedFrames[activity];
72
+ for (const glyph of frames) assert.equal(visibleWidth(glyph), 1, glyph);
73
+ for (let frame = 0; frame < 24; frame += 1) {
74
+ const current = { ...state("Working...", frame), activity };
75
+ const line = renderWorkingWidget(current, palette, 40)[0] ?? "";
76
+ assert.ok(line.includes(`${symbol(palette, frame, activity)} `), `${activity} frame ${frame}`);
77
+ assert.equal(visibleWidth(line.trimStart()), 12, "one symbol cell plus separator and label");
78
+ }
79
+ });
80
+ }
81
+
82
+ void test("letter wave advances independently when the symbol wraps", () => {
48
83
  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")}`));
84
+ assert.ok(at(0).includes(`${symbol(palette, 0)} ${palette.fg("accent", "W")}${palette.fg("text", "o")}`));
50
85
  assert.ok(at(0).includes(`${palette.fg("text", ".")}${palette.fg("warning", ".")}`), "frame zero wraps the trailing position to the last grapheme");
51
86
  assert.ok(at(1).includes(`${palette.fg("warning", "W")}${palette.fg("accent", "o")}`), "frame one trails the current accent");
52
87
  const runningAt = (frame: number) => renderWorkingWidget(state("Running build...", frame), palette, 40)[0] ?? "";
53
- assert.notEqual(runningAt(0), runningAt(10), "letter wave advances when the spinner wraps");
88
+ assert.ok(runningAt(4).includes(`${palette.fg("warning", "n")}${palette.fg("accent", "i")}`), "letter wave advances when the symbol wraps");
89
+ });
90
+
91
+ void test("every activity resolves the live theme for each pulse phase", () => {
92
+ const changedTheme = theme("36", "35", "32");
93
+ for (const activity of activities) {
94
+ for (let frame = 0; frame < expectedPulse.length; frame += 1) {
95
+ const current = { ...state("AB", frame), activity };
96
+ const first = renderWorkingWidget(current, palette, 20)[0] ?? "";
97
+ const changed = renderWorkingWidget(current, changedTheme, 20)[0] ?? "";
98
+ assert.ok(first.includes(symbol(palette, frame, activity)));
99
+ assert.ok(changed.includes(symbol(changedTheme, frame, activity)));
100
+ assert.ok(!changed.includes(symbol(palette, frame, activity)), "old symbol color is not cached");
101
+ }
102
+ }
54
103
  });
55
104
 
56
105
  void test("Running label skips spaces, wraps the trailing grapheme, and follows theme on next render", () => {
@@ -58,8 +107,8 @@ void test("Running label skips spaces, wraps the trailing grapheme, and follows
58
107
  const changedTheme = theme("36", "35", "32");
59
108
  const first = renderWorkingWidget(state(label, 7), palette, 40)[0] ?? "";
60
109
  const changed = renderWorkingWidget(state(label, 7), changedTheme, 40)[0] ?? "";
61
- assert.ok(first.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[7] ?? "")} `));
62
- assert.ok(changed.includes(`${changedTheme.fg("accent", WORKING_SPINNER_FRAMES[7] ?? "")} `));
110
+ assert.ok(first.includes(`${symbol(palette, 7)} `));
111
+ assert.ok(changed.includes(`${symbol(changedTheme, 7)} `));
63
112
  assert.ok(first.includes(`${palette.fg("warning", "g")} ${palette.fg("accent", "b")}`));
64
113
  assert.ok(changed.includes(`${changedTheme.fg("warning", "g")} ${changedTheme.fg("accent", "b")}`));
65
114
  assert.ok(!changed.includes(palette.fg("warning", "g")), "old trailing color does not persist after theme change");
@@ -81,7 +130,7 @@ void test("only the moving accent grapheme is bold without changing width", () =
81
130
  ...palette,
82
131
  bold(text: string) { return `\u001B[1m${text}\u001B[22m`; },
83
132
  };
84
- const spinner = (frame: number) => boldTheme.fg("accent", WORKING_SPINNER_FRAMES[frame] ?? "");
133
+ const spinner = (frame: number) => symbol(boldTheme, frame);
85
134
  const accent = (segment: string) => boldTheme.bold(boldTheme.fg("accent", segment));
86
135
  const label = "A\u0301👩‍💻 B";
87
136
  for (const [frame, expected] of [
@@ -114,7 +163,7 @@ void test("trailing warning stays plain even when theme exposes inverse and bold
114
163
  [1, `${pulseTheme.fg("warning", "A\u0301")}${accent("👩‍💻")} ${pulseTheme.fg("text", "B")}`],
115
164
  [2, `${pulseTheme.fg("text", "A\u0301")}${pulseTheme.fg("warning", "👩‍💻")} ${accent("B")}`],
116
165
  ] as const) {
117
- const spinner = pulseTheme.fg("accent", WORKING_SPINNER_FRAMES[frame] ?? "");
166
+ const spinner = symbol(pulseTheme, frame);
118
167
  const line = renderWorkingWidget(state(label, frame), pulseTheme, 40)[0] ?? "";
119
168
  assert.ok(line.includes(`${spinner} ${expected}`), `frame ${frame} keeps warning plain`);
120
169
  assert.equal(line.split("\u001B[1m").length - 1, 1, "spinner and warning stay unbolded");
@@ -122,7 +171,7 @@ void test("trailing warning stays plain even when theme exposes inverse and bold
122
171
  assert.equal(visibleWidth(line.trimStart()), 2 + visibleWidth(label));
123
172
  }
124
173
  const single = renderWorkingWidget(state("A", 3), pulseTheme, 20)[0] ?? "";
125
- assert.ok(single.includes(`${pulseTheme.fg("accent", WORKING_SPINNER_FRAMES[3] ?? "")} ${accent("A")}`));
174
+ assert.ok(single.includes(`${symbol(pulseTheme, 3)} ${accent("A")}`));
126
175
  assert.equal(single.split("\u001B[1m").length - 1, 1);
127
176
  assert.ok(!single.includes("\u001B[7m"));
128
177
  const changedTheme = {
@@ -138,7 +187,7 @@ void test("trailing warning stays plain even when theme exposes inverse and bold
138
187
  void test("tab-separated label skips whitespace when advancing the highlight", () => {
139
188
  const label = "A\tB";
140
189
  const line = renderWorkingWidget(state(label, 1), palette, 20)[0] ?? "";
141
- assert.ok(line.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[1] ?? "")} ${palette.fg("warning", "A")}\t${palette.fg("accent", "B")}`));
190
+ assert.ok(line.includes(`${symbol(palette, 1)} ${palette.fg("warning", "A")}\t${palette.fg("accent", "B")}`));
142
191
  assert.equal(visibleWidth(line.trimStart()), 2 + visibleWidth(label));
143
192
  });
144
193
 
@@ -150,17 +199,22 @@ void test("combining marks and ZWJ emoji remain whole graphemes in the traveling
150
199
  [2, `${palette.fg("text", "A\u0301")}${palette.fg("warning", "👩‍💻")} ${palette.fg("accent", "B")}`],
151
200
  ] as const) {
152
201
  const line = renderWorkingWidget(state(label, frame), palette, 40)[0] ?? "";
153
- assert.ok(line.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[frame] ?? "")} ${expected}`), `frame ${frame} preserves graphemes`);
202
+ assert.ok(line.includes(`${symbol(palette, frame)} ${expected}`), `frame ${frame} preserves graphemes`);
154
203
  assert.equal(visibleWidth(line.trimStart()), 2 + visibleWidth(label));
155
204
  }
156
205
  });
157
206
 
158
207
  void test("inactive widget renders nothing; narrow output fits and full output stays centered", () => {
159
208
  assert.deepEqual(renderWorkingWidget(state("Working...", 18, false), palette, 20), []);
160
- for (const frame of [0, 2, 4, 6]) {
161
- const line = renderWorkingWidget(state("Working...", frame), palette, 5)[0] ?? "";
162
- assert.equal(visibleWidth(line), 5);
209
+ for (const activity of activities) {
210
+ assert.deepEqual(renderWorkingWidget({ ...state("Working...", 18, false), activity }, palette, 20), []);
211
+ for (const frame of [0, 1, 2, 3, 4, 6, 7, 8]) {
212
+ for (const width of [0, 1, 2, 5]) {
213
+ const line = renderWorkingWidget({ ...state("Working...", frame), activity }, palette, width)[0] ?? "";
214
+ assert.equal(visibleWidth(line), Math.max(1, width), `${activity} frame ${frame} width ${width}; existing clipping has a one-cell minimum`);
215
+ }
216
+ }
217
+ const centered = renderWorkingWidget({ ...state("Working..."), activity }, palette, 20)[0] ?? "";
218
+ assert.equal(centered.match(/^ */)?.[0].length, 4);
163
219
  }
164
- const centered = renderWorkingWidget(state("Working..."), palette, 20)[0] ?? "";
165
- assert.equal(centered.match(/^ */)?.[0].length, 4);
166
220
  });
@@ -1,17 +1,19 @@
1
- import { WORKING_SPINNER_FRAMES } from "./constants.js";
1
+ import { WORKING_ACTIVITY_FRAMES, WORKING_SYMBOL_PULSE } from "./constants.js";
2
2
  import type { SimpleTheme, WorkingWidgetState } from "./types.js";
3
3
  import { fitCenterVisible } from "./utils.js";
4
4
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
5
  const isWhitespace = (segment: string) => /\s/u.test(segment);
6
6
 
7
- /** Render the theme-accent Braille spinner beside a theme-aware traveling letter highlight. */
7
+ /** Render a compact activity symbol with a shared theme pulse beside the traveling letter highlight. */
8
8
  export function renderWorkingWidget(
9
9
  state: WorkingWidgetState,
10
10
  theme: SimpleTheme,
11
11
  width: number,
12
12
  ): string[] {
13
13
  if (!state.active) return [];
14
- const spinner = WORKING_SPINNER_FRAMES[state.frame % WORKING_SPINNER_FRAMES.length] ?? "";
14
+ const frames = WORKING_ACTIVITY_FRAMES[state.activity];
15
+ const symbol = frames[state.frame % frames.length] ?? "";
16
+ const symbolColor = WORKING_SYMBOL_PULSE[state.frame % WORKING_SYMBOL_PULSE.length] ?? "mdQuoteBorder";
15
17
  const segments = [...graphemes.segment(state.label)].map(({ segment }) => segment);
16
18
  const letterCount = segments.filter((segment) => !isWhitespace(segment)).length;
17
19
  const highlighted = letterCount > 0 ? state.frame % letterCount : -1;
@@ -29,5 +31,5 @@ export function renderWorkingWidget(
29
31
  return theme.fg("text", segment);
30
32
  })
31
33
  .join("");
32
- return [fitCenterVisible(`${theme.fg("accent", spinner)} ${label}`, width)];
34
+ return [fitCenterVisible(`${theme.fg(symbolColor, symbol)} ${label}`, width)];
33
35
  }
@@ -0,0 +1,115 @@
1
+ import assert from "node:assert/strict";
2
+ import { registerHooks } from "node:module";
3
+ import test from "node:test";
4
+ import type { WorkingWidgetState } from "./types.js";
5
+
6
+ registerHooks({ resolve(specifier, context, nextResolve) {
7
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts"))
8
+ return nextResolve(`${specifier.slice(0, -3)}.ts`, context);
9
+ return nextResolve(specifier, context);
10
+ } });
11
+ const { createWorkingController } = await import("./working-controller.js");
12
+
13
+ function fixture() {
14
+ const state = { enabled: true };
15
+ const working: WorkingWidgetState = { active: false, activity: "thinking", label: "Thinking...", frame: 0, timer: undefined, tui: undefined };
16
+ return { state, working, controller: createWorkingController(state, working) };
17
+ }
18
+
19
+ void test("agent without tools thinks and matching ends restore the newest remaining tool", (t) => {
20
+ t.mock.timers.enable({ apis: ["setInterval"] });
21
+ const { working, controller: c } = fixture();
22
+ c.onAgentStart(); assert.equal(working.label, "Thinking...");
23
+ c.onToolStart("read", "a", {}); assert.equal(working.activity, "exploring");
24
+ c.onToolStart("write", "b", {}); assert.equal(working.label, "Working...");
25
+ c.onToolStart("bash", "c", { command: "npm test" }); assert.equal(working.label, "Verifying...");
26
+ c.onToolEnd("b"); assert.equal(working.activity, "verifying");
27
+ c.onToolEnd("c"); assert.equal(working.label, "Exploring...");
28
+ c.onToolEnd("a"); assert.equal(working.label, "Thinking...");
29
+ c.onAgentEnd(); assert.equal(working.active, false); assert.equal(working.timer, undefined);
30
+ });
31
+
32
+ void test("duplicate starts and unmatched ends neither replace nor reorder activity", (t) => {
33
+ t.mock.timers.enable({ apis: ["setInterval"] });
34
+ const { working, controller: c } = fixture();
35
+ c.onToolStart("read", "a"); c.onToolStart("subagent_run", "b");
36
+ c.onToolStart("write", "a"); c.onToolEnd("missing");
37
+ assert.equal(working.label, "Delegating...");
38
+ c.onToolEnd("b"); assert.equal(working.label, "Exploring...");
39
+ c.onToolEnd("a"); assert.equal(working.active, false);
40
+ c.onToolEnd("a"); assert.equal(working.timer, undefined);
41
+ });
42
+
43
+ void test("single 80ms clock stops while disabled and resumes the current tool", (t) => {
44
+ t.mock.timers.enable({ apis: ["setInterval"] });
45
+ const { state, working, controller: c } = fixture();
46
+ c.onAgentStart(); const timer = working.timer;
47
+ c.onToolStart("read", "a"); c.refreshWorking(); assert.equal(working.timer, timer);
48
+ t.mock.timers.tick(79); assert.equal(working.frame, 0);
49
+ t.mock.timers.tick(1); assert.equal(working.frame, 1);
50
+ state.enabled = false; c.stopWorking();
51
+ t.mock.timers.tick(800); assert.equal(working.frame, 1); assert.equal(working.timer, undefined);
52
+ c.onToolStart("write", "b"); assert.equal(working.active, false);
53
+ state.enabled = true; c.refreshWorking(); assert.equal(working.label, "Working...");
54
+ t.mock.timers.tick(80); assert.equal(working.frame, 2);
55
+ c.onToolEnd("b"); assert.equal(working.label, "Exploring...");
56
+ c.onShutdown(); assert.equal(working.timer, undefined); assert.equal(working.active, false);
57
+ t.mock.timers.tick(800); assert.equal(working.frame, 2);
58
+ c.refreshWorking(); assert.equal(working.active, false);
59
+ });
60
+
61
+ void test("agent end clears tool identities before the next agent starts", (t) => {
62
+ t.mock.timers.enable({ apis: ["setInterval"] });
63
+ const { working, controller: c } = fixture();
64
+ c.onToolStart("unknown", "a"); assert.equal(working.label, "Executing...");
65
+ c.onAgentEnd(); c.onAgentStart(); c.onToolEnd("a");
66
+ assert.equal(working.label, "Thinking...");
67
+ c.onToolStart("edit", "a"); assert.equal(working.label, "Working...");
68
+ c.onShutdown(); assert.equal(working.activity, "thinking");
69
+ });
70
+
71
+ void test("bounded exact tool classification and unknown argument narrowing", (t) => {
72
+ t.mock.timers.enable({ apis: ["setInterval"] });
73
+ const { working, controller: c } = fixture();
74
+ const cases: Array<[string, unknown, string]> = [];
75
+ for (const name of ["read", "grep", "find", "ls", "codegraph", "symbol_search", "project_report", "module_report", "read_symbol", "read_enclosing", "ast_grep_search", "resolve-library-id", "query-docs", "get-library-docs", "subagent_status", "subagent_result"])
76
+ cases.push([name, undefined, "Exploring..."]);
77
+ for (const name of ["edit", "write", "ast_grep_replace"]) cases.push([name, undefined, "Working..."]);
78
+ cases.push(["subagent_run", undefined, "Delegating..."], ["lens_diagnostics", null, "Verifying..."], ["unknown", {}, "Executing..."], ["read_more", {}, "Executing..."]);
79
+ for (const action of ["send", "ask", "handover"]) cases.push(["intercom", { action }, "Delegating..."]);
80
+ for (const args of [undefined, null, [], "send", { action: 1 }, { action: "status" }, { action: "receive" }]) cases.push(["intercom", args, "Executing..."]);
81
+ for (const [name, args, label] of cases) {
82
+ c.onToolStart(name, "case", args); assert.equal(working.label, label, name); c.onToolEnd("case");
83
+ }
84
+ });
85
+
86
+ void test("MCP wrappers explore only exact Context7 documentation selectors", (t) => {
87
+ t.mock.timers.enable({ apis: ["setInterval"] });
88
+ const { working, controller: c } = fixture();
89
+ for (const wrapper of ["mcp", "mcp__context7"]) {
90
+ for (const tool of ["resolve-library-id", "query-docs", "get-library-docs", "context7_resolve-library-id", "context7_query-docs"]) {
91
+ c.onToolStart(wrapper, "case", { tool, args: {} });
92
+ assert.equal(working.label, "Exploring...", `${wrapper}: ${tool}`);
93
+ c.onToolEnd("case");
94
+ }
95
+ for (const args of [undefined, null, [], "query-docs", { tool: null }, { tool: 1 }, { tool: {} }, { tool: ["query-docs"] }, { tool: "" }, { tool: "unknown" }, { tool: "write" }, { tool: "query-docs-more" }, { tool: " QUERY-DOCS" }, { args: { tool: "query-docs" } }, { tool: "mcp", args: { tool: "query-docs" } }, { search: "query-docs" }]) {
96
+ c.onToolStart(wrapper, "case", args);
97
+ assert.equal(working.label, "Executing...", `${wrapper}: ${JSON.stringify(args)}`);
98
+ c.onToolEnd("case");
99
+ }
100
+ }
101
+ c.onToolStart("mcp__other", "case", { tool: "query-docs" });
102
+ assert.equal(working.label, "Executing...");
103
+ c.onToolEnd("case");
104
+ });
105
+
106
+ void test("bash accepts only simple anchored explicit check commands", (t) => {
107
+ t.mock.timers.enable({ apis: ["setInterval"] });
108
+ const { working, controller: c } = fixture();
109
+ for (const command of ["npm test", "npm test -- --runInBand", "npm run test", "npm run lint", "npm run typecheck", "npm run build", "node --test test.ts", "node --test --experimental-strip-types test.ts", "node --experimental-strip-types --test test.ts", "bun test", "npx tsc --noEmit", "npx tsc --noEmit --pretty false", "pytest -q tests", "go test ./...", " npm test "]) {
110
+ c.onToolStart("bash", "case", { command }); assert.equal(working.label, "Verifying...", command); c.onToolEnd("case");
111
+ }
112
+ for (const args of [undefined, null, [], "npm test", { command: 1 }, ...["echo npm test", "npm install", "npm run testing", "npm run test:unit", "npx tsc", "node script.js --test", "npm test && echo done", "npm test | cat", "npm test; npm install", "npm test > log", "npm test\necho done", "pytest $(echo tests)", "go test `echo ./...`", "npm test &", "npm test # comment"].map(command => ({ command }))]) {
113
+ c.onToolStart("bash", "case", args); assert.equal(working.label, "Executing...", JSON.stringify(args)); c.onToolEnd("case");
114
+ }
115
+ });
@@ -1,21 +1,23 @@
1
- import type { OsdyState, WorkingWidgetState } from "./types.js";
1
+ import type { OsdyState, WorkingActivity, WorkingWidgetState } from "./types.js";
2
+ import { classifyWorkingActivity, WORKING_ACTIVITY_LABELS } from "./working-activity.js";
2
3
 
3
4
  export type WorkingController = {
4
5
  onAgentStart(): void;
5
6
  onAgentEnd(): void;
6
- onToolStart(toolName: string): void;
7
- onToolEnd(): void;
7
+ onToolStart(toolName: string, toolCallId: string, args?: unknown): void;
8
+ onToolEnd(toolCallId: string): void;
8
9
  onShutdown(): void;
9
10
  refreshWorking(): void;
10
11
  stopWorking(): void;
11
12
  };
12
13
 
13
14
  export function createWorkingController(
14
- state: OsdyState,
15
+ state: Pick<OsdyState, "enabled">,
15
16
  workingState: WorkingWidgetState,
16
17
  ): WorkingController {
17
18
  let activeAgent = false;
18
- let activeToolCount = 0;
19
+ // Map insertion order is start order; duplicate starts must not reorder it.
20
+ const activeTools = new Map<string, WorkingActivity>();
19
21
 
20
22
  const requestWorkingRender = () => workingState.tui?.requestRender();
21
23
  const clearWorkingTimer = () => {
@@ -30,33 +32,31 @@ export function createWorkingController(
30
32
  requestWorkingRender();
31
33
  }, 80);
32
34
  };
33
- const setWorkingState = (active: boolean, label: string) => {
34
- workingState.active = active;
35
- workingState.label = label;
36
- };
37
- const startWorking = (label: string) => {
38
- if (!state.enabled) return;
39
- setWorkingState(true, label);
40
- ensureWorkingTimer();
41
- requestWorkingRender();
35
+ const setActivity = (activity: WorkingActivity) => {
36
+ workingState.activity = activity;
37
+ workingState.label = WORKING_ACTIVITY_LABELS[activity];
42
38
  };
43
39
  const stopWorking = () => {
44
- setWorkingState(false, workingState.label);
40
+ workingState.active = false;
45
41
  clearWorkingTimer();
46
42
  requestWorkingRender();
47
43
  };
48
- const getIdleLabel = () =>
49
- activeToolCount > 0 ? "Running tool..." : "Working...";
50
44
  const refreshWorking = () => {
51
- if (!state.enabled || (!activeAgent && activeToolCount === 0)) {
45
+ let activity: WorkingActivity = "thinking";
46
+ for (const remaining of activeTools.values()) activity = remaining;
47
+ setActivity(activity);
48
+ if (!state.enabled || (!activeAgent && activeTools.size === 0)) {
52
49
  stopWorking();
53
50
  return;
54
51
  }
55
- startWorking(getIdleLabel());
52
+ workingState.active = true;
53
+ ensureWorkingTimer();
54
+ requestWorkingRender();
56
55
  };
57
56
  const resetActivity = () => {
58
57
  activeAgent = false;
59
- activeToolCount = 0;
58
+ activeTools.clear();
59
+ setActivity("thinking");
60
60
  };
61
61
 
62
62
  return {
@@ -68,12 +68,13 @@ export function createWorkingController(
68
68
  resetActivity();
69
69
  refreshWorking();
70
70
  },
71
- onToolStart(toolName: string): void {
72
- activeToolCount += 1;
73
- startWorking(`Running ${toolName}...`);
71
+ onToolStart(toolName: string, toolCallId: string, args?: unknown): void {
72
+ if (activeTools.has(toolCallId)) return;
73
+ activeTools.set(toolCallId, classifyWorkingActivity(toolName, args));
74
+ refreshWorking();
74
75
  },
75
- onToolEnd(): void {
76
- activeToolCount = Math.max(0, activeToolCount - 1);
76
+ onToolEnd(toolCallId: string): void {
77
+ if (!activeTools.delete(toolCallId)) return;
77
78
  refreshWorking();
78
79
  },
79
80
  onShutdown(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.11.3",
3
+ "version": "1.11.4",
4
4
  "description": "Osdy Pi package: themes, header, and custom editor for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,7 +83,7 @@
83
83
  "typescript": "^5.9.3",
84
84
  "typescript-eslint": "^8.46.3"
85
85
  },
86
- "releaseSource": "d415942b7668e23c987441a5133f392762b4fe95",
86
+ "releaseSource": "545560153d13a25f1f0d840878a2a4813df669c0",
87
87
  "repository": {
88
88
  "type": "git",
89
89
  "url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"