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
@@ -12,7 +12,6 @@ import {
12
12
  type TUI,
13
13
  } from "@earendil-works/pi-tui";
14
14
  import { truncateMiddle } from "./format.js";
15
- import { centerVisible } from "./utils.js";
16
15
  import type {
17
16
  SimpleTheme,
18
17
  WorkingTreeFileSummary,
@@ -39,9 +38,7 @@ async function runGit(
39
38
  function normalizePatchLines(output: string): string[] {
40
39
  return output
41
40
  .split("\n")
42
- .filter(
43
- (line, index, lines) => !(index === lines.length - 1 && line === ""),
44
- );
41
+ .filter((line, index, lines) => !(index === lines.length - 1 && line === ""));
45
42
  }
46
43
 
47
44
  async function loadUntrackedPatch(
@@ -153,6 +150,7 @@ class WorkingTreeDiffPanel implements Component {
153
150
  private selectedIndex = 0;
154
151
  private scrollOffset = 0;
155
152
  private mode: "select" | "view" = "select";
153
+ private searchTerm = "";
156
154
 
157
155
  constructor(
158
156
  private readonly tui: TUI,
@@ -161,24 +159,46 @@ class WorkingTreeDiffPanel implements Component {
161
159
  private readonly close: () => void,
162
160
  ) {}
163
161
 
162
+ private get filteredFiles(): DiffFileView[] {
163
+ const query = this.searchTerm.trim().toLowerCase();
164
+ if (!query) return this.files;
165
+ return this.files.filter((entry) =>
166
+ entry.file.path.toLowerCase().includes(query),
167
+ );
168
+ }
169
+
164
170
  private get selected(): DiffFileView | undefined {
165
- return this.files[this.selectedIndex];
171
+ return this.filteredFiles[this.selectedIndex];
172
+ }
173
+
174
+ private clampSelection(): void {
175
+ const files = this.filteredFiles;
176
+ this.selectedIndex = Math.max(
177
+ 0,
178
+ Math.min(Math.max(0, files.length - 1), this.selectedIndex),
179
+ );
166
180
  }
167
181
 
168
182
  private moveSelection(delta: number): void {
169
- if (this.files.length === 0) return;
183
+ const files = this.filteredFiles;
184
+ if (files.length === 0) return;
170
185
  this.selectedIndex = Math.max(
171
186
  0,
172
- Math.min(this.files.length - 1, this.selectedIndex + delta),
187
+ Math.min(files.length - 1, this.selectedIndex + delta),
173
188
  );
174
189
  this.scrollOffset = 0;
175
190
  this.tui.requestRender();
176
191
  }
177
192
 
193
+ private updateSearch(next: string): void {
194
+ this.searchTerm = next;
195
+ this.selectedIndex = 0;
196
+ this.scrollOffset = 0;
197
+ this.tui.requestRender();
198
+ }
199
+
178
200
  private renderModal(width: number, title: string, lines: string[]): string[] {
179
- const modalWidth = Math.max(56, Math.min(width - 6, 112));
180
- const modalLines = borderBox(this.theme, modalWidth, title, lines);
181
- return ["", ...modalLines.map((line) => centerVisible(line, width)), ""];
201
+ return borderBox(this.theme, width, title, lines);
182
202
  }
183
203
 
184
204
  handleInput(data: string): void {
@@ -188,9 +208,19 @@ class WorkingTreeDiffPanel implements Component {
188
208
  }
189
209
  if (this.mode === "select") {
190
210
  if (matchesKey(data, "escape")) {
211
+ if (this.searchTerm.length > 0) {
212
+ this.updateSearch("");
213
+ return;
214
+ }
191
215
  this.close();
192
216
  return;
193
217
  }
218
+ if (matchesKey(data, "backspace")) {
219
+ if (this.searchTerm.length > 0) {
220
+ this.updateSearch(this.searchTerm.slice(0, -1));
221
+ return;
222
+ }
223
+ }
194
224
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
195
225
  this.moveSelection(-1);
196
226
  return;
@@ -205,9 +235,15 @@ class WorkingTreeDiffPanel implements Component {
205
235
  matchesKey(data, "right") ||
206
236
  matchesKey(data, "l")
207
237
  ) {
238
+ if (this.filteredFiles.length === 0) return;
208
239
  this.mode = "view";
209
240
  this.scrollOffset = 0;
210
241
  this.tui.requestRender();
242
+ return;
243
+ }
244
+ if (data.length === 1 && data >= " " && data !== "\u007f") {
245
+ this.updateSearch(`${this.searchTerm}${data}`);
246
+ return;
211
247
  }
212
248
  return;
213
249
  }
@@ -233,44 +269,52 @@ class WorkingTreeDiffPanel implements Component {
233
269
  }
234
270
 
235
271
  render(width: number): string[] {
272
+ this.clampSelection();
273
+ const filteredFiles = this.filteredFiles;
236
274
  const selected = this.selected;
237
- if (this.files.length === 0 || !selected) {
275
+ if (this.files.length === 0) {
238
276
  return this.renderModal(width, "Osdy Pi Diff", [
239
277
  this.theme.fg("muted", "q or esc to close"),
240
278
  this.theme.fg("warning", "No tracked changes available."),
241
279
  ]);
242
280
  }
243
281
  if (this.mode === "select") {
244
- const innerWidth = Math.max(20, Math.min(width - 10, 80));
282
+ const innerWidth = Math.max(20, width - 4);
245
283
  const listStart = Math.max(
246
284
  0,
247
- Math.min(this.selectedIndex - 4, this.files.length - 10),
285
+ Math.min(this.selectedIndex - 4, Math.max(0, filteredFiles.length - 10)),
248
286
  );
249
- const listEntries = this.files.slice(listStart, listStart + 10);
287
+ const listEntries = filteredFiles.slice(listStart, listStart + 10);
250
288
  const listLines = listEntries.map((entry, index) => {
251
289
  const fileIndex = listStart + index;
252
290
  const isSelected = fileIndex === this.selectedIndex;
253
291
  const marker = isSelected
254
292
  ? this.theme.fg("accent", "❯")
255
293
  : this.theme.fg("muted", "·");
256
- const path = truncateMiddle(
257
- entry.file.path,
258
- Math.max(12, innerWidth - 34),
259
- );
260
- const stats = `${this.theme.fg("success", `+${entry.file.additions}`)}/${this.theme.fg("error", `-${entry.file.removals}`)}`;
294
+ const path = truncateMiddle(entry.file.path, Math.max(12, innerWidth - 34));
295
+ const stats = `${this.theme.fg("toolDiffAdded", `+${entry.file.additions}`)}/${this.theme.fg("toolDiffRemoved", `-${entry.file.removals}`)}`;
261
296
  const badges = fileFlags(this.theme, entry.file);
262
297
  return `${marker} ${this.theme.fg(isSelected ? "accent" : "toolOutput", path)} ${badges} ${stats}`;
263
298
  });
264
299
  return this.renderModal(width, "Osdy Pi Diff · Select file", [
265
300
  this.theme.fg(
266
301
  "muted",
267
- "↑↓ or j/k move · enter/right open · esc/q close",
302
+ "Type to filter · ↑↓ move · enter/right open · esc clear/close",
268
303
  ),
304
+ `${this.theme.fg("accent", "Search:")} ${this.theme.fg("toolOutput", this.searchTerm || "")}${this.searchTerm.length === 0 ? this.theme.fg("muted", "(all files)") : ""}`,
269
305
  this.theme.fg(
270
306
  "mdLink",
271
- `Files ${this.selectedIndex + 1}/${this.files.length}`,
307
+ `Files ${filteredFiles.length === 0 ? 0 : this.selectedIndex + 1}/${filteredFiles.length} · total ${this.files.length}`,
272
308
  ),
273
- ...listLines,
309
+ ...(listLines.length > 0
310
+ ? listLines
311
+ : [this.theme.fg("warning", "No files match the current search.")]),
312
+ ]);
313
+ }
314
+ if (!selected) {
315
+ return this.renderModal(width, "Osdy Pi Diff", [
316
+ this.theme.fg("muted", "esc to go back"),
317
+ this.theme.fg("warning", "No file selected."),
274
318
  ]);
275
319
  }
276
320
  const patchLines =
@@ -284,9 +328,9 @@ class WorkingTreeDiffPanel implements Component {
284
328
  .slice(this.scrollOffset, this.scrollOffset + availablePatchHeight)
285
329
  .map((line) => {
286
330
  if (line.startsWith("+") && !line.startsWith("+++"))
287
- return this.theme.fg("success", line);
331
+ return this.theme.fg("toolDiffAdded", line);
288
332
  if (line.startsWith("-") && !line.startsWith("---"))
289
- return this.theme.fg("error", line);
333
+ return this.theme.fg("toolDiffRemoved", line);
290
334
  if (line.startsWith("@@")) return this.theme.fg("accent", line);
291
335
  return this.theme.fg("toolOutput", line);
292
336
  });
@@ -315,5 +359,15 @@ export async function showWorkingTreeDiffPanel(
315
359
  await ctx.ui.custom<void>(
316
360
  (tui, theme, _keybindings, done) =>
317
361
  new WorkingTreeDiffPanel(tui, theme, files, () => done()),
362
+ {
363
+ overlay: true,
364
+ overlayOptions: {
365
+ anchor: "center",
366
+ width: 112,
367
+ minWidth: 56,
368
+ maxHeight: "88%",
369
+ margin: 1,
370
+ },
371
+ },
318
372
  );
319
373
  }
@@ -0,0 +1,163 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ createEditorSettingsStore,
5
+ normalizeEditorSettings,
6
+ type EditorSettingsFileSystem,
7
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
8
+ } from "./editor-settings.ts";
9
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
10
+ import { EDITOR_MODES } from "./types.ts";
11
+
12
+ class MemoryEditorSettingsFileSystem implements EditorSettingsFileSystem {
13
+ content: string | undefined;
14
+ mkdirPaths: string[] = [];
15
+ writePaths: string[] = [];
16
+ renameCalls: Array<[string, string]> = [];
17
+
18
+ mkdir(directory: string): Promise<void> {
19
+ this.mkdirPaths.push(directory);
20
+ return Promise.resolve();
21
+ }
22
+
23
+ readFile(): Promise<string> {
24
+ return this.content === undefined
25
+ ? Promise.reject(new Error("missing settings"))
26
+ : Promise.resolve(this.content);
27
+ }
28
+
29
+ rename(source: string, destination: string): Promise<void> {
30
+ this.renameCalls.push([source, destination]);
31
+ this.content = this.pendingContent;
32
+ return Promise.resolve();
33
+ }
34
+
35
+ writeFile(filePath: string, content: string): Promise<void> {
36
+ this.writePaths.push(filePath);
37
+ this.pendingContent = content;
38
+ return Promise.resolve();
39
+ }
40
+
41
+ private pendingContent = "";
42
+ }
43
+
44
+ void test("editor settings normalize only supported versioned modes", () => {
45
+ assert.deepEqual(
46
+ normalizeEditorSettings({ version: 1, editorMode: "simple" }),
47
+ {
48
+ version: 1,
49
+ editorMode: EDITOR_MODES.SIMPLE,
50
+ workingTreeEnabled: true,
51
+ },
52
+ );
53
+ assert.deepEqual(
54
+ normalizeEditorSettings({ version: 1, editorMode: "invalid" }),
55
+ {
56
+ version: 1,
57
+ editorMode: EDITOR_MODES.AUTO,
58
+ workingTreeEnabled: true,
59
+ },
60
+ );
61
+ assert.deepEqual(
62
+ normalizeEditorSettings({ version: 2, editorMode: "extended" }),
63
+ {
64
+ version: 1,
65
+ editorMode: EDITOR_MODES.AUTO,
66
+ workingTreeEnabled: true,
67
+ },
68
+ );
69
+ assert.deepEqual(normalizeEditorSettings(null), {
70
+ version: 1,
71
+ editorMode: EDITOR_MODES.AUTO,
72
+ workingTreeEnabled: true,
73
+ });
74
+ });
75
+
76
+ void test("working-tree visibility defaults safely and persists globally", async () => {
77
+ assert.equal(
78
+ normalizeEditorSettings({
79
+ version: 1,
80
+ editorMode: "auto",
81
+ workingTreeEnabled: false,
82
+ }).workingTreeEnabled,
83
+ false,
84
+ );
85
+ assert.equal(
86
+ normalizeEditorSettings({
87
+ version: 1,
88
+ editorMode: "auto",
89
+ workingTreeEnabled: "disabled",
90
+ }).workingTreeEnabled,
91
+ true,
92
+ );
93
+
94
+ const fileSystem = new MemoryEditorSettingsFileSystem();
95
+ const firstStore = createEditorSettingsStore(
96
+ "/agent/extensions/osdy-pi/settings.json",
97
+ fileSystem,
98
+ );
99
+ await firstStore.save({
100
+ version: 1,
101
+ editorMode: EDITOR_MODES.AUTO,
102
+ workingTreeEnabled: false,
103
+ });
104
+ const reloadedStore = createEditorSettingsStore(
105
+ "/agent/extensions/osdy-pi/settings.json",
106
+ fileSystem,
107
+ );
108
+ assert.equal((await reloadedStore.load()).workingTreeEnabled, false);
109
+ });
110
+
111
+ void test("editor settings load safely and save atomically", async () => {
112
+ const fileSystem = new MemoryEditorSettingsFileSystem();
113
+ const store = createEditorSettingsStore(
114
+ "/agent/extensions/osdy-pi/settings.json",
115
+ fileSystem,
116
+ );
117
+
118
+ assert.equal((await store.load()).editorMode, EDITOR_MODES.AUTO);
119
+ assert.equal((await store.load()).workingTreeEnabled, true);
120
+ fileSystem.content = JSON.stringify({ version: 1, editorMode: "extended" });
121
+ assert.equal((await store.load()).editorMode, EDITOR_MODES.EXTENDED);
122
+ assert.equal((await store.load()).workingTreeEnabled, true);
123
+ fileSystem.content = "{ invalid JSON";
124
+ assert.equal((await store.load()).workingTreeEnabled, true);
125
+
126
+ await store.save({
127
+ version: 1,
128
+ editorMode: EDITOR_MODES.SIMPLE,
129
+ workingTreeEnabled: true,
130
+ });
131
+ assert.deepEqual(fileSystem.mkdirPaths, ["/agent/extensions/osdy-pi"]);
132
+ assert.deepEqual(fileSystem.writePaths, [
133
+ "/agent/extensions/osdy-pi/settings.json.tmp",
134
+ ]);
135
+ assert.deepEqual(fileSystem.renameCalls, [
136
+ [
137
+ "/agent/extensions/osdy-pi/settings.json.tmp",
138
+ "/agent/extensions/osdy-pi/settings.json",
139
+ ],
140
+ ]);
141
+ assert.equal((await store.load()).editorMode, EDITOR_MODES.SIMPLE);
142
+ });
143
+
144
+ void test("new editor settings stores load their own persisted mode", async () => {
145
+ const fileSystem = new MemoryEditorSettingsFileSystem();
146
+ fileSystem.content = JSON.stringify({ version: 1, editorMode: "auto" });
147
+ const firstStore = createEditorSettingsStore(
148
+ "/agent/extensions/osdy-pi/settings.json",
149
+ fileSystem,
150
+ );
151
+ assert.equal((await firstStore.load()).editorMode, EDITOR_MODES.AUTO);
152
+
153
+ await firstStore.save({
154
+ version: 1,
155
+ editorMode: EDITOR_MODES.EXTENDED,
156
+ workingTreeEnabled: true,
157
+ });
158
+ const reloadedStore = createEditorSettingsStore(
159
+ "/agent/extensions/osdy-pi/settings.json",
160
+ fileSystem,
161
+ );
162
+ assert.equal((await reloadedStore.load()).editorMode, EDITOR_MODES.EXTENDED);
163
+ });
@@ -0,0 +1,112 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { EditorMode, GlobalEditorSettings } from "./types.js";
5
+
6
+ const SUPPORTED_EDITOR_MODES = ["auto", "extended", "simple"] as const satisfies readonly EditorMode[];
7
+ const DEFAULT_EDITOR_MODE: EditorMode = "auto";
8
+ const EDITOR_SETTINGS_VERSION = 1;
9
+ const EDITOR_SETTINGS_FILE_NAME = "settings.json";
10
+ const EDITOR_SETTINGS_RELATIVE_DIR = path.join("extensions", "osdy-pi");
11
+
12
+ export interface EditorSettingsFileSystem {
13
+ mkdir(directory: string): Promise<void>;
14
+ readFile(filePath: string): Promise<string>;
15
+ rename(source: string, destination: string): Promise<void>;
16
+ writeFile(filePath: string, content: string): Promise<void>;
17
+ }
18
+
19
+ function getAgentDir(): string {
20
+ const configuredDir = process.env.PI_CODING_AGENT_DIR?.trim();
21
+ if (configuredDir) return configuredDir;
22
+ return path.join(os.homedir(), ".pi", "agent");
23
+ }
24
+
25
+ function createDefaultEditorSettings(): GlobalEditorSettings {
26
+ return {
27
+ version: EDITOR_SETTINGS_VERSION,
28
+ editorMode: DEFAULT_EDITOR_MODE,
29
+ workingTreeEnabled: true,
30
+ };
31
+ }
32
+
33
+ function isEditorMode(value: unknown): value is EditorMode {
34
+ return typeof value === "string" && SUPPORTED_EDITOR_MODES.some((mode) => mode === value);
35
+ }
36
+
37
+ export function normalizeEditorSettings(value: unknown): GlobalEditorSettings {
38
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
39
+ return createDefaultEditorSettings();
40
+ }
41
+ const recordValue = value as Record<string, unknown>;
42
+ if (recordValue.version !== EDITOR_SETTINGS_VERSION) {
43
+ return createDefaultEditorSettings();
44
+ }
45
+ return {
46
+ version: EDITOR_SETTINGS_VERSION,
47
+ editorMode: isEditorMode(recordValue.editorMode)
48
+ ? recordValue.editorMode
49
+ : DEFAULT_EDITOR_MODE,
50
+ workingTreeEnabled:
51
+ typeof recordValue.workingTreeEnabled === "boolean"
52
+ ? recordValue.workingTreeEnabled
53
+ : true,
54
+ };
55
+ }
56
+
57
+ const nodeEditorSettingsFileSystem: EditorSettingsFileSystem = {
58
+ async mkdir(directory): Promise<void> {
59
+ await mkdir(directory, { recursive: true });
60
+ },
61
+ readFile: (filePath) => readFile(filePath, "utf8"),
62
+ rename,
63
+ writeFile: (filePath, content) => writeFile(filePath, content, "utf8"),
64
+ };
65
+
66
+ export interface EditorSettingsStore {
67
+ readonly path: string;
68
+ load(): Promise<GlobalEditorSettings>;
69
+ save(settings: GlobalEditorSettings): Promise<void>;
70
+ }
71
+
72
+ class FileEditorSettingsStore implements EditorSettingsStore {
73
+ readonly path: string;
74
+ private readonly fileSystem: EditorSettingsFileSystem;
75
+
76
+ constructor(filePath: string, fileSystem: EditorSettingsFileSystem) {
77
+ this.path = filePath;
78
+ this.fileSystem = fileSystem;
79
+ }
80
+
81
+ async load(): Promise<GlobalEditorSettings> {
82
+ try {
83
+ const content = await this.fileSystem.readFile(this.path);
84
+ return normalizeEditorSettings(JSON.parse(content) as unknown);
85
+ } catch {
86
+ return createDefaultEditorSettings();
87
+ }
88
+ }
89
+
90
+ async save(settings: GlobalEditorSettings): Promise<void> {
91
+ const parentDir = path.dirname(this.path);
92
+ const tempPath = `${this.path}.tmp`;
93
+ const normalizedSettings = normalizeEditorSettings(settings);
94
+ await this.fileSystem.mkdir(parentDir);
95
+ await this.fileSystem.writeFile(
96
+ tempPath,
97
+ `${JSON.stringify(normalizedSettings, null, 2)}\n`,
98
+ );
99
+ await this.fileSystem.rename(tempPath, this.path);
100
+ }
101
+ }
102
+
103
+ export function createEditorSettingsStore(
104
+ filePath = path.join(
105
+ getAgentDir(),
106
+ EDITOR_SETTINGS_RELATIVE_DIR,
107
+ EDITOR_SETTINGS_FILE_NAME,
108
+ ),
109
+ fileSystem = nodeEditorSettingsFileSystem,
110
+ ): EditorSettingsStore {
111
+ return new FileEditorSettingsStore(filePath, fileSystem);
112
+ }
@@ -1,17 +1,6 @@
1
- import {
2
- type ExtensionAPI,
3
- type ExtensionContext,
4
- VERSION,
5
- } from "@earendil-works/pi-coding-agent";
6
- import { truncateToWidth } from "@earendil-works/pi-tui";
7
- import { formatPath, shortNumber, truncateMiddle } from "./format.js";
8
- import type {
9
- AssistantSessionEntry,
10
- HeaderMetaRow,
11
- OsdyState,
12
- SimpleTheme,
13
- } from "./types.js";
14
- import { centerVisible, fitCenterVisible } from "./utils.js";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { shortNumber } from "./format.js";
3
+ import type { AssistantSessionEntry } from "./types.js";
15
4
 
16
5
  function isAssistantSessionEntry(
17
6
  entry: unknown,
@@ -86,125 +75,9 @@ function mergeUsageTotals(
86
75
  }
87
76
 
88
77
  function collectUsageTotals(ctx: ExtensionContext): UsageTotals {
89
- return ctx.sessionManager
90
- .getEntries()
91
- .reduce<UsageTotals>((totals, entry) => {
92
- return isAssistantSessionEntry(entry)
93
- ? mergeUsageTotals(totals, entry)
94
- : totals;
95
- }, createUsageTotals());
96
- }
97
-
98
- function getToolSources(pi: ExtensionAPI) {
99
- return pi.getAllTools().map((tool) => ({
100
- name: tool.name.toLowerCase(),
101
- origin: tool.sourceInfo?.origin?.toLowerCase() ?? "",
102
- source: tool.sourceInfo?.source?.toLowerCase() ?? "",
103
- path: tool.sourceInfo?.path?.toLowerCase() ?? "",
104
- }));
105
- }
106
-
107
- function isMcpTool(tool: ReturnType<typeof getToolSources>[number]): boolean {
108
- return tool.name.includes("mcp") || tool.source.includes("mcp");
109
- }
110
-
111
- function getToolServerKey(
112
- tool: ReturnType<typeof getToolSources>[number],
113
- ): string {
114
- return tool.source || tool.path || tool.name;
115
- }
116
-
117
- function getMcpServerCount(pi: ExtensionAPI): number {
118
- const serverKeys = new Set(
119
- getToolSources(pi).filter(isMcpTool).map(getToolServerKey),
120
- );
121
- return serverKeys.size;
122
- }
123
-
124
- function countToolsByOrigin(pi: ExtensionAPI, origin: string): number {
125
- return getToolSources(pi).filter((tool) => tool.origin === origin).length;
126
- }
127
-
128
- function countToolsBySource(pi: ExtensionAPI, source: string): number {
129
- return getToolSources(pi).filter((tool) => tool.source.includes(source))
130
- .length;
131
- }
132
-
133
- function getPathWidth(width: number): number {
134
- return width < 110 ? 34 : 52;
135
- }
136
-
137
- export function renderHeaderMetadata(
138
- pi: ExtensionAPI,
139
- ctx: ExtensionContext,
140
- state: OsdyState,
141
- width: number,
142
- ): HeaderMetaRow[] {
143
- const allTools = pi.getAllTools().length;
144
- const git = state.gitLabel === "no-git" ? "Not a git repo" : state.gitLabel;
145
- const path = truncateMiddle(formatPath(ctx.cwd), getPathWidth(width));
146
- const mcpServerCount = getMcpServerCount(pi);
147
- const pluginCount = countToolsByOrigin(pi, "package");
148
- const extensionCount = countToolsBySource(pi, "extension");
149
- return [
150
- {
151
- leftLabel: "GIT:",
152
- leftValue: git,
153
- rightLabel: "PATH:",
154
- rightValue: path,
155
- },
156
- {
157
- leftLabel: "MCP:",
158
- leftValue: `${mcpServerCount} servers`,
159
- rightLabel: "PLUGINS:",
160
- rightValue: `${pluginCount} package`,
161
- },
162
- {
163
- leftLabel: "AGENTS:",
164
- leftValue: `${state.agentsLabel} loaded`,
165
- rightLabel: "EXTENSIONS:",
166
- rightValue: `${extensionCount} active`,
167
- },
168
- {
169
- leftLabel: "VER:",
170
- leftValue: VERSION,
171
- rightLabel: "TOOLS:",
172
- rightValue: `${allTools} customs`,
173
- },
174
- ];
175
- }
176
-
177
- export function renderMetaRows(
178
- rows: HeaderMetaRow[],
179
- width: number,
180
- theme: SimpleTheme,
181
- ): string[] {
182
- const labelWidth = 11;
183
- const leftValueWidth = 32;
184
- const rightValueWidth = 52;
185
- const gapWidth = 10;
186
- const blockWidth =
187
- labelWidth +
188
- 1 +
189
- leftValueWidth +
190
- gapWidth +
191
- labelWidth +
192
- 1 +
193
- rightValueWidth;
194
- return rows.map((row) => {
195
- const leftLabel = theme.fg("mdLink", row.leftLabel.padEnd(labelWidth));
196
- const leftValue = theme.fg(
197
- "accent",
198
- truncateToWidth(row.leftValue, leftValueWidth).padEnd(leftValueWidth),
199
- );
200
- const rightLabel = theme.fg("mdLink", row.rightLabel.padEnd(labelWidth));
201
- const rightValue = theme.fg(
202
- "accent",
203
- truncateToWidth(row.rightValue, rightValueWidth).padEnd(rightValueWidth),
204
- );
205
- const rowText = `${leftLabel} ${leftValue}${" ".repeat(gapWidth)}${rightLabel} ${rightValue}`;
206
- return width >= blockWidth
207
- ? centerVisible(rowText, width)
208
- : fitCenterVisible(rowText, width);
209
- });
78
+ return ctx.sessionManager.getEntries().reduce<UsageTotals>((totals, entry) => {
79
+ return isAssistantSessionEntry(entry)
80
+ ? mergeUsageTotals(totals, entry)
81
+ : totals;
82
+ }, createUsageTotals());
210
83
  }
@@ -0,0 +1,26 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ export const PLUGIN_EVENTS = {
6
+ QUESTION_PROMPT: "rpiv:ask-user:prompt",
7
+ } as const;
8
+
9
+ export interface SessionContextProvider {
10
+ getCurrentSessionContext(): ExtensionContext | undefined;
11
+ }
12
+
13
+ export interface QuestionAudioNotificationRouter {
14
+ onQuestionRequested(ctx: ExtensionContext): void;
15
+ }
16
+
17
+ export function subscribeQuestionPromptAudioNotification(
18
+ api: Pick<ExtensionAPI, "events">,
19
+ sessionContextProvider: SessionContextProvider,
20
+ audioRouter: QuestionAudioNotificationRouter,
21
+ ): void {
22
+ api.events.on(PLUGIN_EVENTS.QUESTION_PROMPT, () => {
23
+ const ctx = sessionContextProvider.getCurrentSessionContext();
24
+ if (ctx) audioRouter.onQuestionRequested(ctx);
25
+ });
26
+ }
@@ -0,0 +1,29 @@
1
+ const PROFILE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
2
+ const RESERVED_PROFILE_NAMES = new Set(["default", "profiles", "auth.json"]);
3
+
4
+ export function resolveActiveProfileLabel(
5
+ env: { OSDY_PI_PROFILE_NAME?: string | undefined } = process.env,
6
+ ): string | undefined {
7
+ const profileName = env.OSDY_PI_PROFILE_NAME;
8
+ return typeof profileName === "string" &&
9
+ PROFILE_NAME.test(profileName) &&
10
+ !RESERVED_PROFILE_NAMES.has(profileName)
11
+ ? profileName
12
+ : undefined;
13
+ }
14
+
15
+ export function formatModelMetadata(
16
+ model: string,
17
+ thinkingLevel: string,
18
+ profileName: string | undefined,
19
+ ): string {
20
+ return profileName === undefined
21
+ ? `${model} · think ${thinkingLevel}`
22
+ : `${model} · ${profileName} · think ${thinkingLevel}`;
23
+ }
24
+
25
+ export function resolveEditorTitleLabel(
26
+ profileName: string | undefined,
27
+ ): string {
28
+ return profileName ?? "Osdy-Pi";
29
+ }