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 +15 -2
- package/extensions/osdy-pi/constants.ts +18 -12
- package/extensions/osdy-pi/runtime-helpers.test.ts +1 -0
- package/extensions/osdy-pi/runtime.test.ts +8 -2
- package/extensions/osdy-pi/runtime.ts +4 -3
- package/extensions/osdy-pi/types.ts +9 -0
- package/extensions/osdy-pi/working-activity.ts +57 -0
- package/extensions/osdy-pi/working-animation.test.ts +83 -29
- package/extensions/osdy-pi/working-animation.ts +6 -4
- package/extensions/osdy-pi/working-controller.test.ts +115 -0
- package/extensions/osdy-pi/working-controller.ts +26 -25
- package/package.json +2 -2
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 |
|
|
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
|
|
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
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const glyph
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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.
|
|
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
|
|
62
|
-
assert.ok(changed.includes(`${changedTheme
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
34
|
-
workingState.
|
|
35
|
-
workingState.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
workingState.active = true;
|
|
53
|
+
ensureWorkingTimer();
|
|
54
|
+
requestWorkingRender();
|
|
56
55
|
};
|
|
57
56
|
const resetActivity = () => {
|
|
58
57
|
activeAgent = false;
|
|
59
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
+
"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": "
|
|
86
|
+
"releaseSource": "545560153d13a25f1f0d840878a2a4813df669c0",
|
|
87
87
|
"repository": {
|
|
88
88
|
"type": "git",
|
|
89
89
|
"url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"
|