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.
- package/README.md +209 -161
- package/bin/osdy-pi.mjs +127 -0
- package/extensions/osdy-pi/account-profiles.test.ts +517 -0
- package/extensions/osdy-pi/account-profiles.ts +581 -0
- package/extensions/osdy-pi/animation.ts +47 -5
- package/extensions/osdy-pi/constants.ts +282 -103
- package/extensions/osdy-pi/diff-panel.ts +78 -24
- package/extensions/osdy-pi/editor-settings.test.ts +163 -0
- package/extensions/osdy-pi/editor-settings.ts +112 -0
- package/extensions/osdy-pi/metrics.ts +8 -135
- package/extensions/osdy-pi/plugin-events.ts +26 -0
- package/extensions/osdy-pi/profile-label.ts +29 -0
- package/extensions/osdy-pi/runtime-helpers.test.ts +26 -0
- package/extensions/osdy-pi/runtime-helpers.ts +87 -44
- package/extensions/osdy-pi/runtime.test.ts +62 -0
- package/extensions/osdy-pi/runtime.ts +256 -64
- package/extensions/osdy-pi/types.ts +33 -10
- package/extensions/osdy-pi/ui.test.ts +51 -0
- package/extensions/osdy-pi/ui.ts +298 -114
- package/extensions/osdy-pi/utils.ts +43 -1
- package/extensions/osdy-pi/working-tree.ts +25 -6
- package/package.json +22 -5
- package/scripts/osdy-pi-account-profiles.mjs +501 -0
- package/themes/osdy-pi-catppuccin-frappe.json +94 -0
- package/themes/osdy-pi-catppuccin-latte.json +94 -0
- package/themes/osdy-pi-catppuccin-macchiato.json +94 -0
- package/themes/osdy-pi-catppuccin-mocha.json +94 -0
- package/themes/osdy-pi-dark.json +0 -6
- package/themes/osdy-pi-dracula.json +83 -0
- package/themes/osdy-pi-kanagawa-dragon.json +91 -0
- package/themes/osdy-pi-kanagawa-lotus.json +92 -0
- package/themes/osdy-pi-kanagawa-wave.json +91 -0
- package/themes/osdy-pi-lucent-orange.json +80 -0
- package/themes/osdy-pi-matrix.json +83 -0
- package/themes/osdy-pi-new.json +91 -0
- package/themes/osdy-pi-sexy.json +84 -0
- package/themes/osdy-pi-tokyo-night.json +83 -0
- package/themes/osdy-pi-light.json +0 -84
- 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.
|
|
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
|
-
|
|
183
|
+
const files = this.filteredFiles;
|
|
184
|
+
if (files.length === 0) return;
|
|
170
185
|
this.selectedIndex = Math.max(
|
|
171
186
|
0,
|
|
172
|
-
Math.min(
|
|
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
|
-
|
|
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
|
|
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,
|
|
282
|
+
const innerWidth = Math.max(20, width - 4);
|
|
245
283
|
const listStart = Math.max(
|
|
246
284
|
0,
|
|
247
|
-
Math.min(this.selectedIndex - 4,
|
|
285
|
+
Math.min(this.selectedIndex - 4, Math.max(0, filteredFiles.length - 10)),
|
|
248
286
|
);
|
|
249
|
-
const listEntries =
|
|
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
|
-
|
|
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
|
-
"
|
|
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("
|
|
331
|
+
return this.theme.fg("toolDiffAdded", line);
|
|
288
332
|
if (line.startsWith("-") && !line.startsWith("---"))
|
|
289
|
-
return this.theme.fg("
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|