osdy-pi 0.1.8 → 1.2.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 (39) hide show
  1. package/README.md +209 -161
  2. package/bin/osdy-pi.mjs +127 -0
  3. package/extensions/osdy-pi/account-profiles.test.ts +517 -0
  4. package/extensions/osdy-pi/account-profiles.ts +581 -0
  5. package/extensions/osdy-pi/animation.ts +47 -5
  6. package/extensions/osdy-pi/constants.ts +282 -103
  7. package/extensions/osdy-pi/diff-panel.ts +78 -24
  8. package/extensions/osdy-pi/editor-settings.test.ts +163 -0
  9. package/extensions/osdy-pi/editor-settings.ts +112 -0
  10. package/extensions/osdy-pi/metrics.ts +8 -135
  11. package/extensions/osdy-pi/plugin-events.ts +26 -0
  12. package/extensions/osdy-pi/profile-label.ts +29 -0
  13. package/extensions/osdy-pi/runtime-helpers.test.ts +26 -0
  14. package/extensions/osdy-pi/runtime-helpers.ts +87 -44
  15. package/extensions/osdy-pi/runtime.test.ts +62 -0
  16. package/extensions/osdy-pi/runtime.ts +256 -64
  17. package/extensions/osdy-pi/types.ts +33 -10
  18. package/extensions/osdy-pi/ui.test.ts +51 -0
  19. package/extensions/osdy-pi/ui.ts +298 -114
  20. package/extensions/osdy-pi/utils.ts +43 -1
  21. package/extensions/osdy-pi/working-tree.ts +25 -6
  22. package/package.json +22 -5
  23. package/scripts/osdy-pi-account-profiles.mjs +501 -0
  24. package/themes/osdy-pi-catppuccin-frappe.json +94 -0
  25. package/themes/osdy-pi-catppuccin-latte.json +94 -0
  26. package/themes/osdy-pi-catppuccin-macchiato.json +94 -0
  27. package/themes/osdy-pi-catppuccin-mocha.json +94 -0
  28. package/themes/osdy-pi-dark.json +0 -6
  29. package/themes/osdy-pi-dracula.json +83 -0
  30. package/themes/osdy-pi-kanagawa-dragon.json +91 -0
  31. package/themes/osdy-pi-kanagawa-lotus.json +92 -0
  32. package/themes/osdy-pi-kanagawa-wave.json +91 -0
  33. package/themes/osdy-pi-lucent-orange.json +80 -0
  34. package/themes/osdy-pi-matrix.json +83 -0
  35. package/themes/osdy-pi-new.json +91 -0
  36. package/themes/osdy-pi-sexy.json +84 -0
  37. package/themes/osdy-pi-tokyo-night.json +83 -0
  38. package/themes/osdy-pi-light.json +0 -84
  39. package/themes/osdy-pi-simple.json +0 -83
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ DEFAULT_EDITOR_MODE,
5
+ EDITOR_MODES,
6
+ resolveEffectiveEditorMode,
7
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
8
+ } from "./types.ts";
9
+
10
+ void test("auto editor mode is the default and small terminals use the native editor", () => {
11
+ assert.equal(DEFAULT_EDITOR_MODE, EDITOR_MODES.AUTO);
12
+ assert.equal(
13
+ resolveEffectiveEditorMode(DEFAULT_EDITOR_MODE, false),
14
+ "extended",
15
+ );
16
+ assert.equal(resolveEffectiveEditorMode(DEFAULT_EDITOR_MODE, true), "simple");
17
+ assert.equal(resolveEffectiveEditorMode(EDITOR_MODES.SIMPLE, false), "simple");
18
+ assert.equal(
19
+ resolveEffectiveEditorMode(EDITOR_MODES.EXTENDED, false),
20
+ "extended",
21
+ );
22
+ assert.equal(
23
+ resolveEffectiveEditorMode(EDITOR_MODES.EXTENDED, true),
24
+ "simple",
25
+ );
26
+ });
@@ -3,16 +3,13 @@ import type {
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { asciiAnimationMode } from "./animation.js";
6
- import {
7
- THEME_NAME,
8
- WORKING_TREE_WIDGET_KEY,
9
- WORKING_WIDGET_KEY,
10
- } from "./constants.js";
6
+ import { WORKING_TREE_WIDGET_KEY, WORKING_WIDGET_KEY } from "./constants.js";
11
7
  import { modelLabel, usageLabel } from "./metrics.js";
12
- import type {
13
- OsdyState,
14
- WorkingTreeState,
15
- WorkingWidgetState,
8
+ import {
9
+ resolveEffectiveEditorMode,
10
+ type OsdyState,
11
+ type WorkingTreeState,
12
+ type WorkingWidgetState,
16
13
  } from "./types.js";
17
14
  import {
18
15
  createEditorComponent,
@@ -21,39 +18,82 @@ import {
21
18
  createWorkingWidgetFactory,
22
19
  } from "./ui.js";
23
20
  import { createWorkingTreeWidgetFactory } from "./working-tree.js";
21
+ import { isSmallResponsiveMode } from "./utils.js";
24
22
 
25
- function shouldRememberTheme(
26
- previousThemeName: string | undefined,
27
- currentTheme: string | undefined,
28
- ): currentTheme is string {
29
- return (
30
- previousThemeName === undefined &&
31
- currentTheme !== undefined &&
32
- currentTheme !== THEME_NAME
33
- );
23
+ const RESPONSIVE_WATCH_INTERVAL_MS = 150;
24
+
25
+ function workingTreeEffective(state: OsdyState): boolean {
26
+ return state.workingTreeEnabled && !state.smallMode && state.enabled;
27
+ }
28
+
29
+ function desiredSmallMode(state: OsdyState): boolean {
30
+ const tui = state.tui;
31
+ return tui
32
+ ? isSmallResponsiveMode(
33
+ state.headerVariant,
34
+ tui.terminal.columns,
35
+ tui.terminal.rows,
36
+ )
37
+ : false;
34
38
  }
35
39
 
36
- export function rememberPreviousTheme(
40
+ export function reconcileResponsiveUi(
41
+ pi: ExtensionAPI,
37
42
  ctx: ExtensionContext,
38
43
  state: OsdyState,
44
+ workingTreeState: WorkingTreeState,
39
45
  ): void {
40
- const currentTheme = ctx.ui.theme.name;
41
- if (shouldRememberTheme(state.previousThemeName, currentTheme)) {
42
- state.previousThemeName = currentTheme;
46
+ if (!ctx.hasUI || !state.enabled) return;
47
+ const nextSmallMode = desiredSmallMode(state);
48
+ const smallModeChanged = nextSmallMode !== state.smallMode;
49
+ state.smallMode = nextSmallMode;
50
+ const editorEffective =
51
+ resolveEffectiveEditorMode(state.editorMode, state.smallMode) === "extended";
52
+ if (state.editorEffective !== editorEffective) {
53
+ state.editorEffective = editorEffective;
54
+ if (editorEffective) mountOsdyEditor(pi, ctx);
55
+ else unmountOsdyEditor(ctx);
43
56
  }
57
+ const treeVisible = state.workingTreeEnabled && !state.smallMode;
58
+ if (workingTreeState.visible !== treeVisible) {
59
+ workingTreeState.visible = treeVisible;
60
+ if (!smallModeChanged) workingTreeState.tui?.requestRender();
61
+ }
62
+ if (smallModeChanged) (state.tui ?? workingTreeState.tui)?.requestRender();
44
63
  }
45
64
 
46
- export function applyOsdyTheme(ctx: ExtensionContext): void {
47
- const osdyTheme = ctx.ui.getTheme(THEME_NAME);
48
- const themeResult = osdyTheme
49
- ? ctx.ui.setTheme(osdyTheme)
50
- : ctx.ui.setTheme(THEME_NAME);
51
- if (!themeResult.success) {
52
- ctx.ui.notify(
53
- `osdy-pi theme failed: ${themeResult.error ?? "unknown error"}`,
54
- "warning",
55
- );
56
- }
65
+ export function createResponsiveCoordinator(
66
+ pi: ExtensionAPI,
67
+ ctx: ExtensionContext,
68
+ state: OsdyState,
69
+ workingTreeState: WorkingTreeState,
70
+ ) {
71
+ let timer: ReturnType<typeof setInterval> | undefined;
72
+ return {
73
+ start(): void {
74
+ if (timer) return;
75
+ reconcileResponsiveUi(pi, ctx, state, workingTreeState);
76
+ timer = setInterval(() => {
77
+ if (!state.enabled) return;
78
+ const nextSmallMode = desiredSmallMode(state);
79
+ if (nextSmallMode !== state.smallMode) {
80
+ reconcileResponsiveUi(pi, ctx, state, workingTreeState);
81
+ }
82
+ }, RESPONSIVE_WATCH_INTERVAL_MS);
83
+ },
84
+ stop(): void {
85
+ if (timer) clearInterval(timer);
86
+ timer = undefined;
87
+ },
88
+ };
89
+ }
90
+
91
+ export function mountOsdyEditor(pi: ExtensionAPI, ctx: ExtensionContext): void {
92
+ ctx.ui.setEditorComponent(createEditorComponent(pi, ctx));
93
+ }
94
+
95
+ export function unmountOsdyEditor(ctx: ExtensionContext): void {
96
+ ctx.ui.setEditorComponent(undefined);
57
97
  }
58
98
 
59
99
  export function mountOsdyUi(
@@ -64,9 +104,10 @@ export function mountOsdyUi(
64
104
  workingTreeState: WorkingTreeState,
65
105
  ): void {
66
106
  ctx.ui.setHeader(createHeaderComponent(pi, ctx, state));
67
- ctx.ui.setFooter((_tui, theme, footerData) =>
68
- createFooterComponent(ctx, footerData, theme),
69
- );
107
+ ctx.ui.setFooter((tui, theme, footerData) => {
108
+ state.tui = tui;
109
+ return createFooterComponent(pi, ctx, state, footerData, theme);
110
+ });
70
111
  ctx.ui.setWorkingVisible(false);
71
112
  ctx.ui.setWidget(
72
113
  WORKING_WIDGET_KEY,
@@ -75,10 +116,14 @@ export function mountOsdyUi(
75
116
  );
76
117
  ctx.ui.setWidget(
77
118
  WORKING_TREE_WIDGET_KEY,
78
- createWorkingTreeWidgetFactory(workingTreeState),
119
+ createWorkingTreeWidgetFactory(
120
+ workingTreeState,
121
+ workingState,
122
+ state.workingTreePlacement,
123
+ ),
79
124
  { placement: state.workingTreePlacement },
80
125
  );
81
- ctx.ui.setEditorComponent(createEditorComponent(pi, ctx));
126
+ reconcileResponsiveUi(pi, ctx, state, workingTreeState);
82
127
  }
83
128
 
84
129
  export function applyOsdyPi(
@@ -90,8 +135,6 @@ export function applyOsdyPi(
90
135
  notify = false,
91
136
  ): void {
92
137
  if (!ctx.hasUI) return;
93
- rememberPreviousTheme(ctx, state);
94
- applyOsdyTheme(ctx);
95
138
  mountOsdyUi(pi, ctx, state, workingState, workingTreeState);
96
139
  if (notify) ctx.ui.notify("osdy-pi enabled", "info");
97
140
  }
@@ -104,15 +147,15 @@ export function disableOsdyPi(ctx: ExtensionContext, state: OsdyState): void {
104
147
  ctx.ui.setWidget(WORKING_WIDGET_KEY, undefined);
105
148
  ctx.ui.setWidget(WORKING_TREE_WIDGET_KEY, undefined);
106
149
  ctx.ui.setWorkingVisible(true);
107
- const targetTheme = state.previousThemeName ?? "dark";
108
- const result = ctx.ui.setTheme(targetTheme);
109
- if (!result.success && targetTheme !== "dark") ctx.ui.setTheme("dark");
150
+ state.editorEffective = false;
151
+ state.smallMode = false;
152
+ state.tui = undefined;
110
153
  ctx.ui.notify("osdy-pi disabled", "info");
111
154
  }
112
155
 
113
156
  export function notifyStatus(ctx: ExtensionContext, state: OsdyState): void {
114
157
  ctx.ui.notify(
115
- `osdy-pi ${state.enabled ? "enabled" : "disabled"} · working-tree ${state.workingTreeEnabled ? "on" : "off"} · widget ${state.workingTreePlacement === "aboveEditor" ? "top" : "bottom"} · theme ${ctx.ui.theme.name ?? "unknown"} · style ${state.headerVariant} · animation ${asciiAnimationMode()} · ${modelLabel(ctx)} · ${usageLabel(ctx).trim()}`,
158
+ `osdy-pi ${state.enabled ? "enabled" : "disabled"} · editor ${state.editorMode}, effective ${state.editorEffective ? "extended" : "simple/native"} · working-tree desired ${state.workingTreeEnabled ? "on" : "off"}, effective ${workingTreeEffective(state) ? "visible" : "hidden"} · widget ${state.workingTreePlacement === "aboveEditor" ? "top" : "bottom"} · theme ${ctx.ui.theme.name ?? "unknown"} · style ${state.headerVariant} · animation ${asciiAnimationMode()} · ${modelLabel(ctx)} · ${usageLabel(ctx).trim()}`,
116
159
  "info",
117
160
  );
118
161
  }
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
5
+ import { PLUGIN_EVENTS, subscribeQuestionPromptAudioNotification } from "./plugin-events.ts";
6
+
7
+ class TestEventBus {
8
+ event: string | undefined;
9
+ handler: ((data: unknown) => void) | undefined;
10
+
11
+ on(event: string, handler: (data: unknown) => void): () => void {
12
+ this.event = event;
13
+ this.handler = handler;
14
+ return () => {
15
+ this.handler = undefined;
16
+ };
17
+ }
18
+
19
+ emit(event: string, data: unknown): void {
20
+ if (event === this.event) this.handler?.(data);
21
+ }
22
+ }
23
+
24
+ class TestExtensionApi {
25
+ readonly events = new TestEventBus();
26
+ lifecycleOnCallCount = 0;
27
+
28
+ on(): void {
29
+ this.lifecycleOnCallCount += 1;
30
+ }
31
+ }
32
+
33
+ class TestSessionContextProvider {
34
+ context: ExtensionContext | undefined;
35
+
36
+ getCurrentSessionContext(): ExtensionContext | undefined {
37
+ return this.context;
38
+ }
39
+ }
40
+
41
+ void test("question prompts subscribe through the plugin event bus", () => {
42
+ const api = new TestExtensionApi();
43
+ const sessionContextProvider = new TestSessionContextProvider();
44
+ const notifiedContexts: ExtensionContext[] = [];
45
+
46
+ subscribeQuestionPromptAudioNotification(
47
+ api,
48
+ sessionContextProvider,
49
+ { onQuestionRequested: (ctx) => notifiedContexts.push(ctx) },
50
+ );
51
+
52
+ assert.equal(api.events.event, PLUGIN_EVENTS.QUESTION_PROMPT);
53
+ assert.equal(api.lifecycleOnCallCount, 0);
54
+ api.events.emit(PLUGIN_EVENTS.QUESTION_PROMPT, undefined);
55
+ assert.deepEqual(notifiedContexts, []);
56
+
57
+ // SAFETY: The notification contract only passes this context through unchanged.
58
+ const sessionContext = {} as ExtensionContext;
59
+ sessionContextProvider.context = sessionContext;
60
+ api.events.emit(PLUGIN_EVENTS.QUESTION_PROMPT, { question: "Continue?" });
61
+ assert.deepEqual(notifiedContexts, [sessionContext]);
62
+ });