osdy-pi 1.2.0 → 1.3.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 (44) hide show
  1. package/README.md +160 -30
  2. package/bin/osdy-pi.mjs +29 -6
  3. package/bin/osdy.mjs +43 -0
  4. package/extensions/osdy-pi/account-profiles.test.ts +111 -97
  5. package/extensions/osdy-pi/account-profiles.ts +91 -162
  6. package/extensions/osdy-pi/animation.test.ts +84 -0
  7. package/extensions/osdy-pi/animation.ts +17 -8
  8. package/extensions/osdy-pi/codex-usage-ui.test.ts +1145 -0
  9. package/extensions/osdy-pi/codex-usage-ui.ts +728 -0
  10. package/extensions/osdy-pi/codex-usage.test.ts +276 -0
  11. package/extensions/osdy-pi/codex-usage.ts +276 -0
  12. package/extensions/osdy-pi/constants.ts +134 -34
  13. package/extensions/osdy-pi/context-usage-ui.test.ts +168 -0
  14. package/extensions/osdy-pi/context-usage-ui.ts +60 -0
  15. package/extensions/osdy-pi/editor-settings.test.ts +121 -23
  16. package/extensions/osdy-pi/editor-settings.ts +39 -3
  17. package/extensions/osdy-pi/metrics.test.ts +95 -0
  18. package/extensions/osdy-pi/metrics.ts +39 -1
  19. package/extensions/osdy-pi/profile-label.ts +2 -2
  20. package/extensions/osdy-pi/runtime-helpers.test.ts +220 -1
  21. package/extensions/osdy-pi/runtime-helpers.ts +42 -19
  22. package/extensions/osdy-pi/runtime.test.ts +355 -7
  23. package/extensions/osdy-pi/runtime.ts +344 -66
  24. package/extensions/osdy-pi/types.ts +26 -1
  25. package/extensions/osdy-pi/ui.test.ts +128 -0
  26. package/extensions/osdy-pi/ui.ts +57 -40
  27. package/package.json +5 -2
  28. package/scripts/osdy-pi-account-profiles.mjs +521 -76
  29. package/scripts/osdy-pi-gentle-coexistence.mjs +200 -0
  30. package/scripts/osdy-pi-profile-setup.mjs +175 -0
  31. package/themes/osdy-pi-catppuccin-frappe.json +6 -2
  32. package/themes/osdy-pi-catppuccin-latte.json +6 -2
  33. package/themes/osdy-pi-catppuccin-macchiato.json +6 -2
  34. package/themes/osdy-pi-catppuccin-mocha.json +6 -2
  35. package/themes/osdy-pi-dark.json +6 -2
  36. package/themes/osdy-pi-dracula.json +6 -2
  37. package/themes/osdy-pi-kanagawa-dragon.json +6 -2
  38. package/themes/osdy-pi-kanagawa-lotus.json +6 -2
  39. package/themes/osdy-pi-kanagawa-wave.json +6 -2
  40. package/themes/osdy-pi-lucent-orange.json +6 -2
  41. package/themes/osdy-pi-matrix.json +6 -2
  42. package/themes/osdy-pi-new.json +6 -2
  43. package/themes/osdy-pi-sexy.json +6 -2
  44. package/themes/osdy-pi-tokyo-night.json +6 -2
@@ -1,9 +1,20 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import type { EditorMode, GlobalEditorSettings } from "./types.js";
4
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
5
+ import { HEADER_VARIANT_CHOICES, MASCOT_CHOICES } from "./types.ts";
6
+ import type {
7
+ EditorMode,
8
+ GlobalEditorSettings,
9
+ HeaderVariant,
10
+ MascotChoice,
11
+ } from "./types.js";
5
12
 
6
- const SUPPORTED_EDITOR_MODES = ["auto", "extended", "simple"] as const satisfies readonly EditorMode[];
13
+ const SUPPORTED_EDITOR_MODES = [
14
+ "auto",
15
+ "extended",
16
+ "simple",
17
+ ] as const satisfies readonly EditorMode[];
7
18
  const DEFAULT_EDITOR_MODE: EditorMode = "auto";
8
19
  const EDITOR_SETTINGS_VERSION = 1;
9
20
  const EDITOR_SETTINGS_FILE_NAME = "settings.json";
@@ -25,13 +36,34 @@ function getAgentDir(): string {
25
36
  function createDefaultEditorSettings(): GlobalEditorSettings {
26
37
  return {
27
38
  version: EDITOR_SETTINGS_VERSION,
39
+ enabled: true,
28
40
  editorMode: DEFAULT_EDITOR_MODE,
29
41
  workingTreeEnabled: true,
42
+ headerVariant: "osdy-theme",
43
+ mascot: "current",
30
44
  };
31
45
  }
32
46
 
33
47
  function isEditorMode(value: unknown): value is EditorMode {
34
- return typeof value === "string" && SUPPORTED_EDITOR_MODES.some((mode) => mode === value);
48
+ return (
49
+ typeof value === "string" &&
50
+ SUPPORTED_EDITOR_MODES.some((mode) => mode === value)
51
+ );
52
+ }
53
+
54
+ function normalizeHeaderVariant(value: unknown): HeaderVariant {
55
+ return typeof value === "string" &&
56
+ HEADER_VARIANT_CHOICES.includes(value as HeaderVariant)
57
+ ? (value as HeaderVariant)
58
+ : "osdy-theme";
59
+ }
60
+
61
+ function normalizeMascotChoice(value: unknown): MascotChoice {
62
+ if (value === "raccoon") return "bts";
63
+ return typeof value === "string" &&
64
+ MASCOT_CHOICES.includes(value as MascotChoice)
65
+ ? (value as MascotChoice)
66
+ : "current";
35
67
  }
36
68
 
37
69
  export function normalizeEditorSettings(value: unknown): GlobalEditorSettings {
@@ -44,6 +76,8 @@ export function normalizeEditorSettings(value: unknown): GlobalEditorSettings {
44
76
  }
45
77
  return {
46
78
  version: EDITOR_SETTINGS_VERSION,
79
+ enabled:
80
+ typeof recordValue.enabled === "boolean" ? recordValue.enabled : true,
47
81
  editorMode: isEditorMode(recordValue.editorMode)
48
82
  ? recordValue.editorMode
49
83
  : DEFAULT_EDITOR_MODE,
@@ -51,6 +85,8 @@ export function normalizeEditorSettings(value: unknown): GlobalEditorSettings {
51
85
  typeof recordValue.workingTreeEnabled === "boolean"
52
86
  ? recordValue.workingTreeEnabled
53
87
  : true,
88
+ headerVariant: normalizeHeaderVariant(recordValue.headerVariant),
89
+ mascot: normalizeMascotChoice(recordValue.mascot),
54
90
  };
55
91
  }
56
92
 
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import ts from "typescript";
7
+ import type { ContextUsageSource } from "./metrics.ts";
8
+
9
+ registerHooks({
10
+ resolve(specifier, context, nextResolve) {
11
+ if (
12
+ specifier.startsWith(".") &&
13
+ specifier.endsWith(".js") &&
14
+ context.parentURL?.endsWith(".ts")
15
+ ) {
16
+ const sourceUrl = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
17
+ if (existsSync(fileURLToPath(sourceUrl))) {
18
+ return { shortCircuit: true, url: sourceUrl.href };
19
+ }
20
+ }
21
+ return nextResolve(specifier, context);
22
+ },
23
+ load(url, context, nextLoad) {
24
+ if (url.endsWith(".ts")) {
25
+ return {
26
+ format: "module",
27
+ shortCircuit: true,
28
+ source: ts.transpileModule(readFileSync(fileURLToPath(url), "utf8"), {
29
+ compilerOptions: {
30
+ module: ts.ModuleKind.ESNext,
31
+ target: ts.ScriptTarget.ES2022,
32
+ },
33
+ }).outputText,
34
+ };
35
+ }
36
+ return nextLoad(url, context);
37
+ },
38
+ });
39
+
40
+ const { contextUsageData } = await import("./metrics.js");
41
+
42
+ function createContext(percent: number | undefined): ContextUsageSource {
43
+ return {
44
+ getContextUsage: () =>
45
+ percent === undefined ? undefined : { percent, contextWindow: 200_000 },
46
+ sessionManager: {
47
+ getEntries: () => [
48
+ {
49
+ type: "message",
50
+ message: {
51
+ role: "assistant",
52
+ usage: {
53
+ input: 1_500,
54
+ output: 2_500_000,
55
+ cacheRead: 12,
56
+ cacheWrite: 34,
57
+ cost: { total: 1.23456 },
58
+ },
59
+ },
60
+ },
61
+ ],
62
+ },
63
+ };
64
+ }
65
+
66
+ void test("returns presentation-neutral usage totals and context data", () => {
67
+ assert.deepEqual(contextUsageData(createContext(30)), {
68
+ input: 1_500,
69
+ output: 2_500_000,
70
+ cacheRead: 12,
71
+ cacheWrite: 34,
72
+ cost: 1.23456,
73
+ percent: 30,
74
+ contextWindow: 200_000,
75
+ });
76
+ });
77
+
78
+ void test("uses the model window when context usage omits it", () => {
79
+ const context = createContext(30);
80
+ context.getContextUsage = () => ({ percent: 30 });
81
+ context.model = { contextWindow: 272_000 };
82
+ assert.equal(contextUsageData(context).contextWindow, 272_000);
83
+ });
84
+
85
+ void test("represents unavailable context usage and window without formatting", () => {
86
+ assert.deepEqual(contextUsageData(createContext(undefined)), {
87
+ input: 1_500,
88
+ output: 2_500_000,
89
+ cacheRead: 12,
90
+ cacheWrite: 34,
91
+ cost: 1.23456,
92
+ percent: undefined,
93
+ contextWindow: undefined,
94
+ });
95
+ });
@@ -34,6 +34,44 @@ function getCacheLabel(totals: UsageTotals): string {
34
34
  : "";
35
35
  }
36
36
 
37
+ export type ContextUsageData = {
38
+ input: number;
39
+ output: number;
40
+ cacheRead: number;
41
+ cacheWrite: number;
42
+ cost: number;
43
+ percent: number | undefined;
44
+ contextWindow: number | undefined;
45
+ };
46
+
47
+ export type ContextUsageSource = {
48
+ getContextUsage():
49
+ | {
50
+ percent: number | null;
51
+ contextWindow?: number | null;
52
+ }
53
+ | undefined;
54
+ model?:
55
+ | {
56
+ contextWindow?: number | null;
57
+ }
58
+ | undefined;
59
+ sessionManager: {
60
+ getEntries(): readonly unknown[];
61
+ };
62
+ };
63
+
64
+ export function contextUsageData(ctx: ContextUsageSource): ContextUsageData {
65
+ const totals = collectUsageTotals(ctx);
66
+ const context = ctx.getContextUsage();
67
+ return {
68
+ ...totals,
69
+ percent: context?.percent ?? undefined,
70
+ contextWindow:
71
+ context?.contextWindow ?? ctx.model?.contextWindow ?? undefined,
72
+ };
73
+ }
74
+
37
75
  export function usageLabel(ctx: ExtensionContext): string {
38
76
  const totals = collectUsageTotals(ctx);
39
77
  const cacheText = getCacheLabel(totals);
@@ -74,7 +112,7 @@ function mergeUsageTotals(
74
112
  };
75
113
  }
76
114
 
77
- function collectUsageTotals(ctx: ExtensionContext): UsageTotals {
115
+ function collectUsageTotals(ctx: ContextUsageSource): UsageTotals {
78
116
  return ctx.sessionManager.getEntries().reduce<UsageTotals>((totals, entry) => {
79
117
  return isAssistantSessionEntry(entry)
80
118
  ? mergeUsageTotals(totals, entry)
@@ -1,4 +1,4 @@
1
- const PROFILE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
1
+ const PROFILE_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})$/;
2
2
  const RESERVED_PROFILE_NAMES = new Set(["default", "profiles", "auth.json"]);
3
3
 
4
4
  export function resolveActiveProfileLabel(
@@ -7,7 +7,7 @@ export function resolveActiveProfileLabel(
7
7
  const profileName = env.OSDY_PI_PROFILE_NAME;
8
8
  return typeof profileName === "string" &&
9
9
  PROFILE_NAME.test(profileName) &&
10
- !RESERVED_PROFILE_NAMES.has(profileName)
10
+ !RESERVED_PROFILE_NAMES.has(profileName.toLowerCase())
11
11
  ? profileName
12
12
  : undefined;
13
13
  }
@@ -1,12 +1,131 @@
1
1
  import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
2
4
  import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import type {
7
+ ExtensionAPI,
8
+ ExtensionContext,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import ts from "typescript";
3
11
  import {
4
12
  DEFAULT_EDITOR_MODE,
5
13
  EDITOR_MODES,
6
14
  resolveEffectiveEditorMode,
7
- // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
15
+ type OsdyState,
16
+ type WorkingTreeState,
17
+ type WorkingWidgetState,
18
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
8
19
  } from "./types.ts";
9
20
 
21
+ registerHooks({
22
+ resolve(specifier, context, nextResolve) {
23
+ if (
24
+ specifier.startsWith(".") &&
25
+ specifier.endsWith(".js") &&
26
+ context.parentURL?.endsWith(".ts")
27
+ ) {
28
+ const sourceUrl = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
29
+ if (existsSync(fileURLToPath(sourceUrl))) {
30
+ return { shortCircuit: true, url: sourceUrl.href };
31
+ }
32
+ }
33
+ return nextResolve(specifier, context);
34
+ },
35
+ load(url, context, nextLoad) {
36
+ if (url.endsWith(".ts")) {
37
+ const source = readFileSync(fileURLToPath(url), "utf8");
38
+ return {
39
+ format: "module",
40
+ shortCircuit: true,
41
+ source: ts.transpileModule(source, {
42
+ compilerOptions: {
43
+ module: ts.ModuleKind.ESNext,
44
+ target: ts.ScriptTarget.ES2022,
45
+ },
46
+ }).outputText,
47
+ };
48
+ }
49
+ return nextLoad(url, context);
50
+ },
51
+ });
52
+
53
+ const {
54
+ disableOsdyPi,
55
+ mountOsdyEditor,
56
+ mountOsdyUi,
57
+ syncWorkingTreeWidget,
58
+ unmountOsdyEditor,
59
+ } = await import(
60
+ // @ts-expect-error The test hook maps production .js specifiers to TypeScript sources.
61
+ "./runtime-helpers.ts"
62
+ );
63
+
64
+ const GENTLE_SHELL_CHANGES_WIDGET_KEY = "gentle-shell-changes";
65
+ const WORKING_TREE_WIDGET_KEY = "osdy-pi-working-tree";
66
+
67
+ type WidgetCall = {
68
+ key: string;
69
+ factory: unknown;
70
+ options: unknown;
71
+ };
72
+
73
+ function createState(overrides: Partial<OsdyState> = {}): OsdyState {
74
+ return {
75
+ codexUsage: { kind: "idle" },
76
+ enabled: true,
77
+ editorEffective: false,
78
+ editorMode: EDITOR_MODES.SIMPLE,
79
+ fallbackEditorFactory: undefined,
80
+ headerVariant: "osdy-theme",
81
+ mascot: "current",
82
+ smallMode: false,
83
+ tui: undefined,
84
+ workingTreeEnabled: true,
85
+ workingTreePlacement: "aboveEditor",
86
+ ...overrides,
87
+ };
88
+ }
89
+
90
+ function createWorkingTreeState(): WorkingTreeState {
91
+ return {
92
+ enabled: true,
93
+ loading: false,
94
+ visible: true,
95
+ snapshot: null,
96
+ error: undefined,
97
+ tui: undefined,
98
+ };
99
+ }
100
+
101
+ function createWorkingState(): WorkingWidgetState {
102
+ return {
103
+ active: false,
104
+ label: "Working...",
105
+ frame: 0,
106
+ timer: undefined,
107
+ tui: undefined,
108
+ };
109
+ }
110
+
111
+ function createContext(widgetCalls: WidgetCall[], editorCalls: unknown[]) {
112
+ return {
113
+ hasUI: true,
114
+ ui: {
115
+ setEditorComponent(component: unknown): void {
116
+ editorCalls.push(component);
117
+ },
118
+ notify(): void {},
119
+ setFooter(): void {},
120
+ setHeader(): void {},
121
+ setWorkingVisible(): void {},
122
+ setWidget(key: string, factory: unknown, options?: unknown): void {
123
+ widgetCalls.push({ key, factory, options });
124
+ },
125
+ },
126
+ } as unknown as ExtensionContext;
127
+ }
128
+
10
129
  void test("auto editor mode is the default and small terminals use the native editor", () => {
11
130
  assert.equal(DEFAULT_EDITOR_MODE, EDITOR_MODES.AUTO);
12
131
  assert.equal(
@@ -24,3 +143,103 @@ void test("auto editor mode is the default and small terminals use the native ed
24
143
  "simple",
25
144
  );
26
145
  });
146
+
147
+ void test("unmountOsdyEditor removes the custom editor without restoring the fallback", () => {
148
+ const editorCalls: unknown[] = [];
149
+ const ctx = createContext([], editorCalls);
150
+
151
+ unmountOsdyEditor(ctx);
152
+
153
+ assert.deepEqual(editorCalls, [undefined]);
154
+ });
155
+
156
+ void test("disableOsdyPi restores the captured editor and Osdy can reclaim it", () => {
157
+ const editorCalls: unknown[] = [];
158
+ const ctx = createContext([], editorCalls);
159
+ const fallbackEditorFactory =
160
+ (() => ({})) as unknown as OsdyState["fallbackEditorFactory"];
161
+ const state = createState({ fallbackEditorFactory });
162
+
163
+ disableOsdyPi(ctx, state);
164
+ mountOsdyEditor({} as ExtensionAPI, ctx, state);
165
+
166
+ assert.equal(editorCalls[0], fallbackEditorFactory);
167
+ assert.equal(typeof editorCalls[1], "function");
168
+ assert.notEqual(editorCalls[1], fallbackEditorFactory);
169
+ });
170
+
171
+ void test("syncWorkingTreeWidget unmounts a disabled widget", () => {
172
+ const widgetCalls: WidgetCall[] = [];
173
+ const ctx = createContext(widgetCalls, []);
174
+
175
+ syncWorkingTreeWidget(
176
+ ctx,
177
+ createState({ workingTreeEnabled: false }),
178
+ createWorkingState(),
179
+ createWorkingTreeState(),
180
+ );
181
+
182
+ assert.deepEqual(widgetCalls, [
183
+ { key: WORKING_TREE_WIDGET_KEY, factory: undefined, options: undefined },
184
+ ]);
185
+ });
186
+
187
+ void test("syncWorkingTreeWidget remounts an enabled widget", () => {
188
+ const widgetCalls: WidgetCall[] = [];
189
+ const ctx = createContext(widgetCalls, []);
190
+
191
+ syncWorkingTreeWidget(
192
+ ctx,
193
+ createState({ workingTreePlacement: "belowEditor" }),
194
+ createWorkingState(),
195
+ createWorkingTreeState(),
196
+ );
197
+
198
+ assert.equal(widgetCalls.length, 1);
199
+ assert.equal(widgetCalls[0]?.key, WORKING_TREE_WIDGET_KEY);
200
+ assert.equal(typeof widgetCalls[0]?.factory, "function");
201
+ assert.deepEqual(widgetCalls[0]?.options, { placement: "belowEditor" });
202
+ });
203
+
204
+ void test("initial UI mount clears Gentle's changed-files widget without affecting Osdy's working tree", () => {
205
+ const widgetCalls: WidgetCall[] = [];
206
+ const ctx = createContext(widgetCalls, []);
207
+
208
+ mountOsdyUi(
209
+ {} as ExtensionAPI,
210
+ ctx,
211
+ createState(),
212
+ createWorkingState(),
213
+ createWorkingTreeState(),
214
+ );
215
+
216
+ assert.deepEqual(widgetCalls[0], {
217
+ key: GENTLE_SHELL_CHANGES_WIDGET_KEY,
218
+ factory: undefined,
219
+ options: undefined,
220
+ });
221
+ assert.equal(widgetCalls[1]?.key, "osdy-pi-working");
222
+ assert.equal(widgetCalls[2]?.key, WORKING_TREE_WIDGET_KEY);
223
+ });
224
+
225
+ void test("initial UI mount leaves a persisted disabled working tree unmounted", () => {
226
+ const widgetCalls: WidgetCall[] = [];
227
+ const editorCalls: unknown[] = [];
228
+ const ctx = createContext(widgetCalls, editorCalls);
229
+
230
+ mountOsdyUi(
231
+ {} as ExtensionAPI,
232
+ ctx,
233
+ createState({ workingTreeEnabled: false }),
234
+ createWorkingState(),
235
+ createWorkingTreeState(),
236
+ );
237
+
238
+ const workingTreeCalls = widgetCalls.filter(
239
+ (call) => call.key === WORKING_TREE_WIDGET_KEY,
240
+ );
241
+ assert.deepEqual(workingTreeCalls, [
242
+ { key: WORKING_TREE_WIDGET_KEY, factory: undefined, options: undefined },
243
+ ]);
244
+ assert.deepEqual(editorCalls, []);
245
+ });
@@ -20,10 +20,18 @@ import {
20
20
  import { createWorkingTreeWidgetFactory } from "./working-tree.js";
21
21
  import { isSmallResponsiveMode } from "./utils.js";
22
22
 
23
+ const GENTLE_SHELL_CHANGES_WIDGET_KEY = "gentle-shell-changes";
23
24
  const RESPONSIVE_WATCH_INTERVAL_MS = 150;
24
25
 
25
- function workingTreeEffective(state: OsdyState): boolean {
26
- return state.workingTreeEnabled && !state.smallMode && state.enabled;
26
+ export function clearGentleShellChangesWidget(ctx: ExtensionContext): void {
27
+ ctx.ui.setWidget(GENTLE_SHELL_CHANGES_WIDGET_KEY, undefined);
28
+ }
29
+
30
+ function workingTreeEffective(
31
+ state: OsdyState,
32
+ ): "visible" | "hidden" | "unmounted" {
33
+ if (!state.enabled || !state.workingTreeEnabled) return "unmounted";
34
+ return state.smallMode ? "hidden" : "visible";
27
35
  }
28
36
 
29
37
  function desiredSmallMode(state: OsdyState): boolean {
@@ -51,7 +59,7 @@ export function reconcileResponsiveUi(
51
59
  resolveEffectiveEditorMode(state.editorMode, state.smallMode) === "extended";
52
60
  if (state.editorEffective !== editorEffective) {
53
61
  state.editorEffective = editorEffective;
54
- if (editorEffective) mountOsdyEditor(pi, ctx);
62
+ if (editorEffective) mountOsdyEditor(pi, ctx, state);
55
63
  else unmountOsdyEditor(ctx);
56
64
  }
57
65
  const treeVisible = state.workingTreeEnabled && !state.smallMode;
@@ -88,14 +96,39 @@ export function createResponsiveCoordinator(
88
96
  };
89
97
  }
90
98
 
91
- export function mountOsdyEditor(pi: ExtensionAPI, ctx: ExtensionContext): void {
92
- ctx.ui.setEditorComponent(createEditorComponent(pi, ctx));
99
+ export function mountOsdyEditor(
100
+ pi: ExtensionAPI,
101
+ ctx: ExtensionContext,
102
+ state: OsdyState,
103
+ ): void {
104
+ ctx.ui.setEditorComponent(createEditorComponent(pi, ctx, state));
93
105
  }
94
106
 
95
107
  export function unmountOsdyEditor(ctx: ExtensionContext): void {
96
108
  ctx.ui.setEditorComponent(undefined);
97
109
  }
98
110
 
111
+ export function syncWorkingTreeWidget(
112
+ ctx: ExtensionContext,
113
+ state: OsdyState,
114
+ workingState: WorkingWidgetState,
115
+ workingTreeState: WorkingTreeState,
116
+ ): void {
117
+ if (!state.enabled || !state.workingTreeEnabled) {
118
+ ctx.ui.setWidget(WORKING_TREE_WIDGET_KEY, undefined);
119
+ return;
120
+ }
121
+ ctx.ui.setWidget(
122
+ WORKING_TREE_WIDGET_KEY,
123
+ createWorkingTreeWidgetFactory(
124
+ workingTreeState,
125
+ workingState,
126
+ state.workingTreePlacement,
127
+ ),
128
+ { placement: state.workingTreePlacement },
129
+ );
130
+ }
131
+
99
132
  export function mountOsdyUi(
100
133
  pi: ExtensionAPI,
101
134
  ctx: ExtensionContext,
@@ -109,20 +142,13 @@ export function mountOsdyUi(
109
142
  return createFooterComponent(pi, ctx, state, footerData, theme);
110
143
  });
111
144
  ctx.ui.setWorkingVisible(false);
145
+ clearGentleShellChangesWidget(ctx);
112
146
  ctx.ui.setWidget(
113
147
  WORKING_WIDGET_KEY,
114
148
  createWorkingWidgetFactory(workingState),
115
149
  { placement: "aboveEditor" },
116
150
  );
117
- ctx.ui.setWidget(
118
- WORKING_TREE_WIDGET_KEY,
119
- createWorkingTreeWidgetFactory(
120
- workingTreeState,
121
- workingState,
122
- state.workingTreePlacement,
123
- ),
124
- { placement: state.workingTreePlacement },
125
- );
151
+ syncWorkingTreeWidget(ctx, state, workingState, workingTreeState);
126
152
  reconcileResponsiveUi(pi, ctx, state, workingTreeState);
127
153
  }
128
154
 
@@ -132,17 +158,15 @@ export function applyOsdyPi(
132
158
  state: OsdyState,
133
159
  workingState: WorkingWidgetState,
134
160
  workingTreeState: WorkingTreeState,
135
- notify = false,
136
161
  ): void {
137
162
  if (!ctx.hasUI) return;
138
163
  mountOsdyUi(pi, ctx, state, workingState, workingTreeState);
139
- if (notify) ctx.ui.notify("osdy-pi enabled", "info");
140
164
  }
141
165
 
142
166
  export function disableOsdyPi(ctx: ExtensionContext, state: OsdyState): void {
143
167
  if (!ctx.hasUI) return;
144
168
  ctx.ui.setHeader(undefined);
145
- ctx.ui.setEditorComponent(undefined);
169
+ ctx.ui.setEditorComponent(state.fallbackEditorFactory);
146
170
  ctx.ui.setFooter(undefined);
147
171
  ctx.ui.setWidget(WORKING_WIDGET_KEY, undefined);
148
172
  ctx.ui.setWidget(WORKING_TREE_WIDGET_KEY, undefined);
@@ -150,12 +174,11 @@ export function disableOsdyPi(ctx: ExtensionContext, state: OsdyState): void {
150
174
  state.editorEffective = false;
151
175
  state.smallMode = false;
152
176
  state.tui = undefined;
153
- ctx.ui.notify("osdy-pi disabled", "info");
154
177
  }
155
178
 
156
179
  export function notifyStatus(ctx: ExtensionContext, state: OsdyState): void {
157
180
  ctx.ui.notify(
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()}`,
181
+ `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)} · widget ${state.workingTreePlacement === "aboveEditor" ? "top" : "bottom"} · theme ${ctx.ui.theme.name ?? "unknown"} · style ${state.headerVariant} · animation ${asciiAnimationMode()} · ${modelLabel(ctx)} · ${usageLabel(ctx).trim()}`,
159
182
  "info",
160
183
  );
161
184
  }