osdy-pi 0.1.7 → 0.1.9
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 +57 -2
- package/extensions/osdy-pi/constants.ts +1 -0
- package/extensions/osdy-pi/diff-panel.ts +381 -0
- package/extensions/osdy-pi/runtime-helpers.ts +21 -4
- package/extensions/osdy-pi/runtime.ts +287 -21
- package/extensions/osdy-pi/types.ts +31 -0
- package/extensions/osdy-pi/working-tree.ts +290 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -76,6 +76,13 @@ Osdy Pi includes a small command group:
|
|
|
76
76
|
/osdy-pi disable
|
|
77
77
|
/osdy-pi status
|
|
78
78
|
/osdy-pi sound setup
|
|
79
|
+
/osdy-pi working-tree on
|
|
80
|
+
/osdy-pi working-tree off
|
|
81
|
+
/osdy-pi working-tree toggle
|
|
82
|
+
/osdy-pi working-tree status
|
|
83
|
+
/osdy-pi working-tree position top
|
|
84
|
+
/osdy-pi working-tree position bottom
|
|
85
|
+
/osdy-pi diff
|
|
79
86
|
/osdy-pi osdy-theme
|
|
80
87
|
/osdy-pi classic
|
|
81
88
|
/osdy-pi-osdy-theme
|
|
@@ -86,12 +93,51 @@ Osdy Pi includes a small command group:
|
|
|
86
93
|
- `disable` restores Pi's built-in header, editor, footer, and working visibility, then switches back to the previous theme or `dark`.
|
|
87
94
|
- `status` shows whether the Osdy Pi UI is currently enabled, including the active style.
|
|
88
95
|
- `sound setup` opens the guided global sound-setup wizard for audio notifications.
|
|
96
|
+
- `working-tree on|off|toggle|status` controls the persistent git working-tree summary widget.
|
|
97
|
+
- `working-tree position top|bottom` moves the summary widget above or below the editor.
|
|
98
|
+
- `diff` opens a floating centered diff window to inspect the current per-file diff without leaving Pi.
|
|
89
99
|
- `osdy-theme` is the default OsdyTheme header with pink, cyan, and purple styling, plus the mascot glow on the right edge.
|
|
90
100
|
- `classic` keeps the previous classic header shape with the shared mascot.
|
|
91
101
|
- `/osdy-pi-osdy-theme` and `/osdy-pi-classic` are direct aliases.
|
|
92
102
|
|
|
93
103
|
After changing a local extension, run `/reload` or restart Pi so the updated commands are registered.
|
|
94
104
|
|
|
105
|
+
### Working tree widget
|
|
106
|
+
|
|
107
|
+
Osdy Pi can show a persistent git working-tree summary above or below the editor.
|
|
108
|
+
|
|
109
|
+
Current behavior:
|
|
110
|
+
|
|
111
|
+
- shows file count, `+/-` totals, and staged/unstaged/new counts;
|
|
112
|
+
- previews the top changed files;
|
|
113
|
+
- refreshes automatically when Pi finishes mutating tools such as `edit`, `write`, `ast_grep_replace`, or `bash`;
|
|
114
|
+
- is intentionally scoped to changes observed during Pi-driven work for now.
|
|
115
|
+
|
|
116
|
+
Use these commands to control it:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
/osdy-pi working-tree on
|
|
120
|
+
/osdy-pi working-tree off
|
|
121
|
+
/osdy-pi working-tree toggle
|
|
122
|
+
/osdy-pi working-tree status
|
|
123
|
+
/osdy-pi working-tree position top
|
|
124
|
+
/osdy-pi working-tree position bottom
|
|
125
|
+
/osdy-pi diff
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The `/osdy-pi diff` viewer now opens in the most viable floating-window form for Osdy Pi: a centered floating dialog with a selector step and a patch step.
|
|
129
|
+
|
|
130
|
+
1. select a changed file in the floating window
|
|
131
|
+
2. open its patch in the same centered floating window
|
|
132
|
+
3. return with `esc` / `backspace` or close with `q`
|
|
133
|
+
|
|
134
|
+
Controls:
|
|
135
|
+
|
|
136
|
+
- selector: type to filter, `↑` / `↓` or `j` / `k`, then `enter` or `→`
|
|
137
|
+
- patch view: `PgUp` / `PgDn`
|
|
138
|
+
- back: `esc` / `backspace` or `←`
|
|
139
|
+
- close: `q`
|
|
140
|
+
|
|
95
141
|
## Audio notifications
|
|
96
142
|
|
|
97
143
|
Osdy Pi can play your own sound files for these product-level events:
|
|
@@ -160,10 +206,18 @@ Notes:
|
|
|
160
206
|
|
|
161
207
|
## Local install
|
|
162
208
|
|
|
163
|
-
If you cloned this repository and want to test it locally:
|
|
209
|
+
If you cloned this repository and want to test it locally without colliding with an already installed global `osdy-pi`, use the isolated dev launcher:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
npm run pi:dev
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
This command runs `pi -e .` with `PI_CODING_AGENT_DIR=.pi-dev`, so it uses a separate local Pi config/package/extensions directory and does not load your global installed `osdy-pi` package.
|
|
216
|
+
|
|
217
|
+
You can still launch it manually if needed:
|
|
164
218
|
|
|
165
219
|
```bash
|
|
166
|
-
pi -e .
|
|
220
|
+
PI_CODING_AGENT_DIR="$PWD/.pi-dev" pi -e .
|
|
167
221
|
```
|
|
168
222
|
|
|
169
223
|
To install it from a local path:
|
|
@@ -195,6 +249,7 @@ If you are working on the package locally, you can run:
|
|
|
195
249
|
```bash
|
|
196
250
|
npm run typecheck
|
|
197
251
|
npm run lint
|
|
252
|
+
npm run pi:dev
|
|
198
253
|
```
|
|
199
254
|
|
|
200
255
|
## Uninstall or turn off
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
matchesKey,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
type Component,
|
|
12
|
+
type TUI,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import { truncateMiddle } from "./format.js";
|
|
15
|
+
import type {
|
|
16
|
+
SimpleTheme,
|
|
17
|
+
WorkingTreeFileSummary,
|
|
18
|
+
WorkingTreeState,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
|
|
21
|
+
type DiffFileView = {
|
|
22
|
+
file: WorkingTreeFileSummary;
|
|
23
|
+
patch: string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function runGit(
|
|
27
|
+
pi: ExtensionAPI,
|
|
28
|
+
ctx: ExtensionContext,
|
|
29
|
+
args: string[],
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const result = await pi.exec("git", args, { cwd: ctx.cwd });
|
|
32
|
+
if (result.code !== 0) {
|
|
33
|
+
throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
|
|
34
|
+
}
|
|
35
|
+
return result.stdout;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizePatchLines(output: string): string[] {
|
|
39
|
+
return output
|
|
40
|
+
.split("\n")
|
|
41
|
+
.filter(
|
|
42
|
+
(line, index, lines) => !(index === lines.length - 1 && line === ""),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadUntrackedPatch(
|
|
47
|
+
ctx: ExtensionContext,
|
|
48
|
+
path: string,
|
|
49
|
+
): Promise<string[]> {
|
|
50
|
+
const absolutePath = resolve(ctx.cwd, path);
|
|
51
|
+
const content = await readFile(absolutePath, "utf8");
|
|
52
|
+
const lines = content.split("\n");
|
|
53
|
+
return [
|
|
54
|
+
`diff --git a/${path} b/${path}`,
|
|
55
|
+
"new file mode 100644",
|
|
56
|
+
"--- /dev/null",
|
|
57
|
+
`+++ b/${path}`,
|
|
58
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
59
|
+
...lines.map((line) => `+${line}`),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadFilePatch(
|
|
64
|
+
pi: ExtensionAPI,
|
|
65
|
+
ctx: ExtensionContext,
|
|
66
|
+
file: WorkingTreeFileSummary,
|
|
67
|
+
): Promise<string[]> {
|
|
68
|
+
if (file.untracked) {
|
|
69
|
+
return loadUntrackedPatch(ctx, file.path);
|
|
70
|
+
}
|
|
71
|
+
const [stagedPatch, unstagedPatch] = await Promise.all([
|
|
72
|
+
runGit(pi, ctx, ["diff", "--cached", "--no-ext-diff", "--", file.path]),
|
|
73
|
+
runGit(pi, ctx, ["diff", "--no-ext-diff", "--", file.path]),
|
|
74
|
+
]);
|
|
75
|
+
const merged = [stagedPatch, unstagedPatch]
|
|
76
|
+
.filter((patch) => patch.trim().length > 0)
|
|
77
|
+
.join("\n");
|
|
78
|
+
return normalizePatchLines(merged);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function buildDiffFiles(
|
|
82
|
+
pi: ExtensionAPI,
|
|
83
|
+
ctx: ExtensionContext,
|
|
84
|
+
state: WorkingTreeState,
|
|
85
|
+
): Promise<DiffFileView[]> {
|
|
86
|
+
const files = state.snapshot?.files ?? [];
|
|
87
|
+
const entries = await Promise.all(
|
|
88
|
+
files.map(async (file) => {
|
|
89
|
+
try {
|
|
90
|
+
return {
|
|
91
|
+
file,
|
|
92
|
+
patch: await loadFilePatch(pi, ctx, file),
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const message =
|
|
96
|
+
error instanceof Error ? error.message : "Patch unavailable";
|
|
97
|
+
return {
|
|
98
|
+
file,
|
|
99
|
+
patch: [message],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
return entries;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fileFlags(theme: SimpleTheme, file: WorkingTreeFileSummary): string {
|
|
108
|
+
const flags: string[] = [];
|
|
109
|
+
if (file.untracked) flags.push(theme.fg("warning", "NEW"));
|
|
110
|
+
if (file.staged) flags.push(theme.fg("success", "STAGED"));
|
|
111
|
+
if (file.unstaged) flags.push(theme.fg("accent", "UNSTAGED"));
|
|
112
|
+
if (flags.length === 0) return theme.fg("muted", "[clean]");
|
|
113
|
+
return `${theme.fg("muted", "[")}${flags.join(theme.fg("muted", ", "))}${theme.fg("muted", "]")}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function padLine(line: string, width: number): string {
|
|
117
|
+
const currentWidth = visibleWidth(line);
|
|
118
|
+
return `${line}${" ".repeat(Math.max(0, width - currentWidth))}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function borderBox(
|
|
122
|
+
theme: SimpleTheme,
|
|
123
|
+
width: number,
|
|
124
|
+
title: string,
|
|
125
|
+
lines: string[],
|
|
126
|
+
): string[] {
|
|
127
|
+
const innerWidth = Math.max(1, width - 2);
|
|
128
|
+
const header = truncateToWidth(` ${title} `, innerWidth, "...", true);
|
|
129
|
+
const left = "─".repeat(
|
|
130
|
+
Math.max(0, Math.floor((innerWidth - visibleWidth(header)) / 2)),
|
|
131
|
+
);
|
|
132
|
+
const right = "─".repeat(
|
|
133
|
+
Math.max(0, innerWidth - visibleWidth(header) - left.length),
|
|
134
|
+
);
|
|
135
|
+
const result = [
|
|
136
|
+
theme.fg("border", `╭${left}`) +
|
|
137
|
+
theme.fg("accent", header) +
|
|
138
|
+
theme.fg("border", `${right}╮`),
|
|
139
|
+
];
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
result.push(
|
|
142
|
+
theme.fg("border", "│") +
|
|
143
|
+
padLine(truncateToWidth(line, innerWidth, "...", true), innerWidth) +
|
|
144
|
+
theme.fg("border", "│"),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
result.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class WorkingTreeDiffPanel implements Component {
|
|
152
|
+
private selectedIndex = 0;
|
|
153
|
+
private scrollOffset = 0;
|
|
154
|
+
private mode: "select" | "view" = "select";
|
|
155
|
+
private searchTerm = "";
|
|
156
|
+
|
|
157
|
+
constructor(
|
|
158
|
+
private readonly tui: TUI,
|
|
159
|
+
private readonly theme: SimpleTheme,
|
|
160
|
+
private readonly files: DiffFileView[],
|
|
161
|
+
private readonly close: () => void,
|
|
162
|
+
) {}
|
|
163
|
+
|
|
164
|
+
private get filteredFiles(): DiffFileView[] {
|
|
165
|
+
const query = this.searchTerm.trim().toLowerCase();
|
|
166
|
+
if (!query) return this.files;
|
|
167
|
+
return this.files.filter((entry) =>
|
|
168
|
+
entry.file.path.toLowerCase().includes(query),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private get selected(): DiffFileView | undefined {
|
|
173
|
+
return this.filteredFiles[this.selectedIndex];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private clampSelection(): void {
|
|
177
|
+
const files = this.filteredFiles;
|
|
178
|
+
this.selectedIndex = Math.max(
|
|
179
|
+
0,
|
|
180
|
+
Math.min(Math.max(0, files.length - 1), this.selectedIndex),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private moveSelection(delta: number): void {
|
|
185
|
+
const files = this.filteredFiles;
|
|
186
|
+
if (files.length === 0) return;
|
|
187
|
+
this.selectedIndex = Math.max(
|
|
188
|
+
0,
|
|
189
|
+
Math.min(files.length - 1, this.selectedIndex + delta),
|
|
190
|
+
);
|
|
191
|
+
this.scrollOffset = 0;
|
|
192
|
+
this.tui.requestRender();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private updateSearch(next: string): void {
|
|
196
|
+
this.searchTerm = next;
|
|
197
|
+
this.selectedIndex = 0;
|
|
198
|
+
this.scrollOffset = 0;
|
|
199
|
+
this.tui.requestRender();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private renderModal(width: number, title: string, lines: string[]): string[] {
|
|
203
|
+
return borderBox(this.theme, width, title, lines);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
handleInput(data: string): void {
|
|
207
|
+
if (matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
|
|
208
|
+
this.close();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (this.mode === "select") {
|
|
212
|
+
if (matchesKey(data, "escape")) {
|
|
213
|
+
if (this.searchTerm.length > 0) {
|
|
214
|
+
this.updateSearch("");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this.close();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (matchesKey(data, "backspace")) {
|
|
221
|
+
if (this.searchTerm.length > 0) {
|
|
222
|
+
this.updateSearch(this.searchTerm.slice(0, -1));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
227
|
+
this.moveSelection(-1);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
231
|
+
this.moveSelection(1);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (
|
|
235
|
+
matchesKey(data, "return") ||
|
|
236
|
+
matchesKey(data, "space") ||
|
|
237
|
+
matchesKey(data, "right") ||
|
|
238
|
+
matchesKey(data, "l")
|
|
239
|
+
) {
|
|
240
|
+
if (this.filteredFiles.length === 0) return;
|
|
241
|
+
this.mode = "view";
|
|
242
|
+
this.scrollOffset = 0;
|
|
243
|
+
this.tui.requestRender();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (data.length === 1 && data >= " " && data !== "\u007f") {
|
|
247
|
+
this.updateSearch(`${this.searchTerm}${data}`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (
|
|
253
|
+
matchesKey(data, "escape") ||
|
|
254
|
+
matchesKey(data, "backspace") ||
|
|
255
|
+
matchesKey(data, "left") ||
|
|
256
|
+
matchesKey(data, "h")
|
|
257
|
+
) {
|
|
258
|
+
this.mode = "select";
|
|
259
|
+
this.tui.requestRender();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (data === "\u001b[5~") {
|
|
263
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 12);
|
|
264
|
+
this.tui.requestRender();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (data === "\u001b[6~" || matchesKey(data, "space")) {
|
|
268
|
+
this.scrollOffset += 12;
|
|
269
|
+
this.tui.requestRender();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
render(width: number): string[] {
|
|
274
|
+
this.clampSelection();
|
|
275
|
+
const filteredFiles = this.filteredFiles;
|
|
276
|
+
const selected = this.selected;
|
|
277
|
+
if (this.files.length === 0) {
|
|
278
|
+
return this.renderModal(width, "Osdy Pi Diff", [
|
|
279
|
+
this.theme.fg("muted", "q or esc to close"),
|
|
280
|
+
this.theme.fg("warning", "No tracked changes available."),
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
if (this.mode === "select") {
|
|
284
|
+
const innerWidth = Math.max(20, width - 4);
|
|
285
|
+
const listStart = Math.max(
|
|
286
|
+
0,
|
|
287
|
+
Math.min(
|
|
288
|
+
this.selectedIndex - 4,
|
|
289
|
+
Math.max(0, filteredFiles.length - 10),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
const listEntries = filteredFiles.slice(listStart, listStart + 10);
|
|
293
|
+
const listLines = listEntries.map((entry, index) => {
|
|
294
|
+
const fileIndex = listStart + index;
|
|
295
|
+
const isSelected = fileIndex === this.selectedIndex;
|
|
296
|
+
const marker = isSelected
|
|
297
|
+
? this.theme.fg("accent", "❯")
|
|
298
|
+
: this.theme.fg("muted", "·");
|
|
299
|
+
const path = truncateMiddle(
|
|
300
|
+
entry.file.path,
|
|
301
|
+
Math.max(12, innerWidth - 34),
|
|
302
|
+
);
|
|
303
|
+
const stats = `${this.theme.fg("success", `+${entry.file.additions}`)}/${this.theme.fg("error", `-${entry.file.removals}`)}`;
|
|
304
|
+
const badges = fileFlags(this.theme, entry.file);
|
|
305
|
+
return `${marker} ${this.theme.fg(isSelected ? "accent" : "toolOutput", path)} ${badges} ${stats}`;
|
|
306
|
+
});
|
|
307
|
+
return this.renderModal(width, "Osdy Pi Diff · Select file", [
|
|
308
|
+
this.theme.fg(
|
|
309
|
+
"muted",
|
|
310
|
+
"Type to filter · ↑↓ move · enter/right open · esc clear/close",
|
|
311
|
+
),
|
|
312
|
+
`${this.theme.fg("accent", "Search:")} ${this.theme.fg("toolOutput", this.searchTerm || "")}${this.searchTerm.length === 0 ? this.theme.fg("muted", "(all files)") : ""}`,
|
|
313
|
+
this.theme.fg(
|
|
314
|
+
"mdLink",
|
|
315
|
+
`Files ${filteredFiles.length === 0 ? 0 : this.selectedIndex + 1}/${filteredFiles.length} · total ${this.files.length}`,
|
|
316
|
+
),
|
|
317
|
+
...(listLines.length > 0
|
|
318
|
+
? listLines
|
|
319
|
+
: [this.theme.fg("warning", "No files match the current search.")]),
|
|
320
|
+
]);
|
|
321
|
+
}
|
|
322
|
+
if (!selected) {
|
|
323
|
+
return this.renderModal(width, "Osdy Pi Diff", [
|
|
324
|
+
this.theme.fg("muted", "esc to go back"),
|
|
325
|
+
this.theme.fg("warning", "No file selected."),
|
|
326
|
+
]);
|
|
327
|
+
}
|
|
328
|
+
const patchLines =
|
|
329
|
+
selected.patch.length > 0
|
|
330
|
+
? selected.patch
|
|
331
|
+
: ["(No patch lines for this file)"];
|
|
332
|
+
const availablePatchHeight = Math.max(8, this.tui.terminal.rows - 10);
|
|
333
|
+
const maxOffset = Math.max(0, patchLines.length - availablePatchHeight);
|
|
334
|
+
this.scrollOffset = Math.min(this.scrollOffset, maxOffset);
|
|
335
|
+
const visiblePatch = patchLines
|
|
336
|
+
.slice(this.scrollOffset, this.scrollOffset + availablePatchHeight)
|
|
337
|
+
.map((line) => {
|
|
338
|
+
if (line.startsWith("+") && !line.startsWith("+++"))
|
|
339
|
+
return this.theme.fg("success", line);
|
|
340
|
+
if (line.startsWith("-") && !line.startsWith("---"))
|
|
341
|
+
return this.theme.fg("error", line);
|
|
342
|
+
if (line.startsWith("@@")) return this.theme.fg("accent", line);
|
|
343
|
+
return this.theme.fg("toolOutput", line);
|
|
344
|
+
});
|
|
345
|
+
return this.renderModal(width, `Diff · ${selected.file.path}`, [
|
|
346
|
+
this.theme.fg(
|
|
347
|
+
"muted",
|
|
348
|
+
"PgUp/PgDn scroll · esc/backspace/left back · q close",
|
|
349
|
+
),
|
|
350
|
+
this.theme.fg(
|
|
351
|
+
"mdLink",
|
|
352
|
+
`Patch ${Math.min(this.scrollOffset + 1, patchLines.length)}/${patchLines.length}`,
|
|
353
|
+
),
|
|
354
|
+
...visiblePatch,
|
|
355
|
+
]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
invalidate(): void {}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function showWorkingTreeDiffPanel(
|
|
362
|
+
pi: ExtensionAPI,
|
|
363
|
+
ctx: ExtensionContext,
|
|
364
|
+
state: WorkingTreeState,
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
const files = await buildDiffFiles(pi, ctx, state);
|
|
367
|
+
await ctx.ui.custom<void>(
|
|
368
|
+
(tui, theme, _keybindings, done) =>
|
|
369
|
+
new WorkingTreeDiffPanel(tui, theme, files, () => done()),
|
|
370
|
+
{
|
|
371
|
+
overlay: true,
|
|
372
|
+
overlayOptions: {
|
|
373
|
+
anchor: "center",
|
|
374
|
+
width: 112,
|
|
375
|
+
minWidth: 56,
|
|
376
|
+
maxHeight: "88%",
|
|
377
|
+
margin: 1,
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
}
|
|
@@ -3,15 +3,24 @@ import type {
|
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { asciiAnimationMode } from "./animation.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
THEME_NAME,
|
|
8
|
+
WORKING_TREE_WIDGET_KEY,
|
|
9
|
+
WORKING_WIDGET_KEY,
|
|
10
|
+
} from "./constants.js";
|
|
7
11
|
import { modelLabel, usageLabel } from "./metrics.js";
|
|
8
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
OsdyState,
|
|
14
|
+
WorkingTreeState,
|
|
15
|
+
WorkingWidgetState,
|
|
16
|
+
} from "./types.js";
|
|
9
17
|
import {
|
|
10
18
|
createEditorComponent,
|
|
11
19
|
createFooterComponent,
|
|
12
20
|
createHeaderComponent,
|
|
13
21
|
createWorkingWidgetFactory,
|
|
14
22
|
} from "./ui.js";
|
|
23
|
+
import { createWorkingTreeWidgetFactory } from "./working-tree.js";
|
|
15
24
|
|
|
16
25
|
function shouldRememberTheme(
|
|
17
26
|
previousThemeName: string | undefined,
|
|
@@ -52,6 +61,7 @@ export function mountOsdyUi(
|
|
|
52
61
|
ctx: ExtensionContext,
|
|
53
62
|
state: OsdyState,
|
|
54
63
|
workingState: WorkingWidgetState,
|
|
64
|
+
workingTreeState: WorkingTreeState,
|
|
55
65
|
): void {
|
|
56
66
|
ctx.ui.setHeader(createHeaderComponent(pi, ctx, state));
|
|
57
67
|
ctx.ui.setFooter((_tui, theme, footerData) =>
|
|
@@ -63,6 +73,11 @@ export function mountOsdyUi(
|
|
|
63
73
|
createWorkingWidgetFactory(workingState),
|
|
64
74
|
{ placement: "aboveEditor" },
|
|
65
75
|
);
|
|
76
|
+
ctx.ui.setWidget(
|
|
77
|
+
WORKING_TREE_WIDGET_KEY,
|
|
78
|
+
createWorkingTreeWidgetFactory(workingTreeState),
|
|
79
|
+
{ placement: state.workingTreePlacement },
|
|
80
|
+
);
|
|
66
81
|
ctx.ui.setEditorComponent(createEditorComponent(pi, ctx));
|
|
67
82
|
}
|
|
68
83
|
|
|
@@ -71,12 +86,13 @@ export function applyOsdyPi(
|
|
|
71
86
|
ctx: ExtensionContext,
|
|
72
87
|
state: OsdyState,
|
|
73
88
|
workingState: WorkingWidgetState,
|
|
89
|
+
workingTreeState: WorkingTreeState,
|
|
74
90
|
notify = false,
|
|
75
91
|
): void {
|
|
76
92
|
if (!ctx.hasUI) return;
|
|
77
93
|
rememberPreviousTheme(ctx, state);
|
|
78
94
|
applyOsdyTheme(ctx);
|
|
79
|
-
mountOsdyUi(pi, ctx, state, workingState);
|
|
95
|
+
mountOsdyUi(pi, ctx, state, workingState, workingTreeState);
|
|
80
96
|
if (notify) ctx.ui.notify("osdy-pi enabled", "info");
|
|
81
97
|
}
|
|
82
98
|
|
|
@@ -86,6 +102,7 @@ export function disableOsdyPi(ctx: ExtensionContext, state: OsdyState): void {
|
|
|
86
102
|
ctx.ui.setEditorComponent(undefined);
|
|
87
103
|
ctx.ui.setFooter(undefined);
|
|
88
104
|
ctx.ui.setWidget(WORKING_WIDGET_KEY, undefined);
|
|
105
|
+
ctx.ui.setWidget(WORKING_TREE_WIDGET_KEY, undefined);
|
|
89
106
|
ctx.ui.setWorkingVisible(true);
|
|
90
107
|
const targetTheme = state.previousThemeName ?? "dark";
|
|
91
108
|
const result = ctx.ui.setTheme(targetTheme);
|
|
@@ -95,7 +112,7 @@ export function disableOsdyPi(ctx: ExtensionContext, state: OsdyState): void {
|
|
|
95
112
|
|
|
96
113
|
export function notifyStatus(ctx: ExtensionContext, state: OsdyState): void {
|
|
97
114
|
ctx.ui.notify(
|
|
98
|
-
`osdy-pi ${state.enabled ? "enabled" : "disabled"} · theme ${ctx.ui.theme.name ?? "unknown"} · style ${state.headerVariant} · animation ${asciiAnimationMode()} · ${modelLabel(ctx)} · ${usageLabel(ctx).trim()}`,
|
|
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()}`,
|
|
99
116
|
"info",
|
|
100
117
|
);
|
|
101
118
|
}
|
|
@@ -9,7 +9,19 @@ import { createAudioPlaybackAdapter } from "./audio-playback.js";
|
|
|
9
9
|
import { createAudioSoundSettingsStore } from "./audio-sound-settings.js";
|
|
10
10
|
import { applyOsdyPi, disableOsdyPi, notifyStatus } from "./runtime-helpers.js";
|
|
11
11
|
import { runSoundSetupWizard } from "./sound-setup-wizard.js";
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
HeaderVariant,
|
|
14
|
+
OsdyState,
|
|
15
|
+
WorkingTreeState,
|
|
16
|
+
WorkingWidgetState,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { WORKING_TREE_WIDGET_KEY, WORKING_WIDGET_KEY } from "./constants.js";
|
|
19
|
+
import {
|
|
20
|
+
clearWorkingTree,
|
|
21
|
+
refreshWorkingTree,
|
|
22
|
+
shouldRefreshWorkingTree,
|
|
23
|
+
} from "./working-tree.js";
|
|
24
|
+
import { showWorkingTreeDiffPanel } from "./diff-panel.js";
|
|
13
25
|
import {
|
|
14
26
|
createWorkingController,
|
|
15
27
|
type WorkingController,
|
|
@@ -21,9 +33,11 @@ function scheduleOsdyRefresh(
|
|
|
21
33
|
ctx: ExtensionContext,
|
|
22
34
|
state: OsdyState,
|
|
23
35
|
workingState: WorkingWidgetState,
|
|
36
|
+
workingTreeState: WorkingTreeState,
|
|
24
37
|
): void {
|
|
25
38
|
setTimeout(() => {
|
|
26
|
-
if (state.enabled)
|
|
39
|
+
if (state.enabled)
|
|
40
|
+
applyOsdyPi(pi, ctx, state, workingState, workingTreeState);
|
|
27
41
|
}, delayMs);
|
|
28
42
|
}
|
|
29
43
|
|
|
@@ -32,11 +46,19 @@ function claimOsdyVisualLayer(
|
|
|
32
46
|
ctx: ExtensionContext,
|
|
33
47
|
state: OsdyState,
|
|
34
48
|
workingState: WorkingWidgetState,
|
|
49
|
+
workingTreeState: WorkingTreeState,
|
|
35
50
|
): void {
|
|
36
51
|
if (!state.enabled) return;
|
|
37
|
-
applyOsdyPi(pi, ctx, state, workingState);
|
|
52
|
+
applyOsdyPi(pi, ctx, state, workingState, workingTreeState);
|
|
38
53
|
for (const delayMs of [300, 1000]) {
|
|
39
|
-
scheduleOsdyRefresh(
|
|
54
|
+
scheduleOsdyRefresh(
|
|
55
|
+
delayMs,
|
|
56
|
+
pi,
|
|
57
|
+
ctx,
|
|
58
|
+
state,
|
|
59
|
+
workingState,
|
|
60
|
+
workingTreeState,
|
|
61
|
+
);
|
|
40
62
|
}
|
|
41
63
|
}
|
|
42
64
|
|
|
@@ -77,25 +99,30 @@ function isHeaderVariant(value: string): value is HeaderVariant {
|
|
|
77
99
|
return HEADER_VARIANTS.includes(value as HeaderVariant);
|
|
78
100
|
}
|
|
79
101
|
|
|
80
|
-
function enableOsdyPi(
|
|
102
|
+
async function enableOsdyPi(
|
|
81
103
|
pi: ExtensionAPI,
|
|
82
104
|
ctx: ExtensionContext,
|
|
83
105
|
state: OsdyState,
|
|
84
106
|
workingState: WorkingWidgetState,
|
|
107
|
+
workingTreeState: WorkingTreeState,
|
|
85
108
|
controller: WorkingController,
|
|
86
|
-
): void {
|
|
109
|
+
): Promise<void> {
|
|
87
110
|
state.enabled = true;
|
|
88
|
-
applyOsdyPi(pi, ctx, state, workingState, true);
|
|
111
|
+
applyOsdyPi(pi, ctx, state, workingState, workingTreeState, true);
|
|
89
112
|
controller.refreshWorking();
|
|
113
|
+
if (state.workingTreeEnabled)
|
|
114
|
+
await refreshWorkingTree(pi, ctx, workingTreeState);
|
|
90
115
|
}
|
|
91
116
|
|
|
92
117
|
function disableOsdyPiCommand(
|
|
93
118
|
ctx: ExtensionContext,
|
|
94
119
|
state: OsdyState,
|
|
120
|
+
workingTreeState: WorkingTreeState,
|
|
95
121
|
controller: WorkingController,
|
|
96
122
|
): void {
|
|
97
123
|
state.enabled = false;
|
|
98
124
|
controller.stopWorking();
|
|
125
|
+
clearWorkingTree(workingTreeState);
|
|
99
126
|
disableOsdyPi(ctx, state);
|
|
100
127
|
}
|
|
101
128
|
|
|
@@ -104,10 +131,12 @@ function setHeaderVariant(
|
|
|
104
131
|
ctx: ExtensionContext,
|
|
105
132
|
state: OsdyState,
|
|
106
133
|
workingState: WorkingWidgetState,
|
|
134
|
+
workingTreeState: WorkingTreeState,
|
|
107
135
|
variant: HeaderVariant,
|
|
108
136
|
): void {
|
|
109
137
|
state.headerVariant = variant;
|
|
110
|
-
if (state.enabled)
|
|
138
|
+
if (state.enabled)
|
|
139
|
+
applyOsdyPi(pi, ctx, state, workingState, workingTreeState);
|
|
111
140
|
ctx.ui.notify(`osdy-pi style: ${variant}`, "info");
|
|
112
141
|
}
|
|
113
142
|
|
|
@@ -115,16 +144,40 @@ function getOsdyCommandCompletions(prefix: string) {
|
|
|
115
144
|
const trimmed = prefix.trim();
|
|
116
145
|
const parts = parseCommandArgs(trimmed);
|
|
117
146
|
if (parts.length === 0) {
|
|
118
|
-
return [
|
|
119
|
-
|
|
120
|
-
|
|
147
|
+
return [
|
|
148
|
+
"enable",
|
|
149
|
+
"disable",
|
|
150
|
+
"status",
|
|
151
|
+
"sound",
|
|
152
|
+
"working-tree",
|
|
153
|
+
"diff",
|
|
154
|
+
...HEADER_VARIANTS,
|
|
155
|
+
].map((value) => ({ value, label: value }));
|
|
121
156
|
}
|
|
122
157
|
if (trimmed === "sound") {
|
|
123
158
|
return [{ value: "sound setup", label: "sound setup" }];
|
|
124
159
|
}
|
|
160
|
+
if (trimmed === "working-tree") {
|
|
161
|
+
return [
|
|
162
|
+
"working-tree on",
|
|
163
|
+
"working-tree off",
|
|
164
|
+
"working-tree toggle",
|
|
165
|
+
"working-tree status",
|
|
166
|
+
"working-tree position top",
|
|
167
|
+
"working-tree position bottom",
|
|
168
|
+
].map((value) => ({ value, label: value }));
|
|
169
|
+
}
|
|
125
170
|
if (parts.length === 1) {
|
|
126
171
|
const valuePrefix = parts[0] ?? "";
|
|
127
|
-
return [
|
|
172
|
+
return [
|
|
173
|
+
"enable",
|
|
174
|
+
"disable",
|
|
175
|
+
"status",
|
|
176
|
+
"sound",
|
|
177
|
+
"working-tree",
|
|
178
|
+
"diff",
|
|
179
|
+
...HEADER_VARIANTS,
|
|
180
|
+
]
|
|
128
181
|
.filter((value) => value.startsWith(valuePrefix))
|
|
129
182
|
.map((value) => ({ value, label: value }));
|
|
130
183
|
}
|
|
@@ -134,6 +187,28 @@ function getOsdyCommandCompletions(prefix: string) {
|
|
|
134
187
|
.filter((value) => value.startsWith(valuePrefix))
|
|
135
188
|
.map((value) => ({ value: `sound ${value}`, label: `sound ${value}` }));
|
|
136
189
|
}
|
|
190
|
+
if (parts[0] === "working-tree" && parts.length === 2) {
|
|
191
|
+
const valuePrefix = parts[1] ?? "";
|
|
192
|
+
return ["on", "off", "toggle", "status", "position"]
|
|
193
|
+
.filter((value) => value.startsWith(valuePrefix))
|
|
194
|
+
.map((value) => ({
|
|
195
|
+
value: `working-tree ${value}`,
|
|
196
|
+
label: `working-tree ${value}`,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
if (
|
|
200
|
+
parts[0] === "working-tree" &&
|
|
201
|
+
parts[1] === "position" &&
|
|
202
|
+
parts.length === 3
|
|
203
|
+
) {
|
|
204
|
+
const valuePrefix = parts[2] ?? "";
|
|
205
|
+
return ["top", "bottom", "status"]
|
|
206
|
+
.filter((value) => value.startsWith(valuePrefix))
|
|
207
|
+
.map((value) => ({
|
|
208
|
+
value: `working-tree position ${value}`,
|
|
209
|
+
label: `working-tree position ${value}`,
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
137
212
|
return null;
|
|
138
213
|
}
|
|
139
214
|
|
|
@@ -150,25 +225,161 @@ async function handleSoundCommand(
|
|
|
150
225
|
ctx.ui.notify("Usage: /osdy-pi sound setup", "warning");
|
|
151
226
|
}
|
|
152
227
|
|
|
228
|
+
function handleWorkingTreePositionCommand(
|
|
229
|
+
action: string | undefined,
|
|
230
|
+
pi: ExtensionAPI,
|
|
231
|
+
ctx: ExtensionContext,
|
|
232
|
+
state: OsdyState,
|
|
233
|
+
workingState: WorkingWidgetState,
|
|
234
|
+
workingTreeState: WorkingTreeState,
|
|
235
|
+
): void {
|
|
236
|
+
if (action === "top") {
|
|
237
|
+
state.workingTreePlacement = "aboveEditor";
|
|
238
|
+
if (state.enabled)
|
|
239
|
+
applyOsdyPi(pi, ctx, state, workingState, workingTreeState);
|
|
240
|
+
ctx.ui.notify("osdy-pi working tree position: top", "info");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (action === "bottom") {
|
|
244
|
+
state.workingTreePlacement = "belowEditor";
|
|
245
|
+
if (state.enabled)
|
|
246
|
+
applyOsdyPi(pi, ctx, state, workingState, workingTreeState);
|
|
247
|
+
ctx.ui.notify("osdy-pi working tree position: bottom", "info");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (action === "status" || action === undefined) {
|
|
251
|
+
ctx.ui.notify(
|
|
252
|
+
`osdy-pi working tree position ${state.workingTreePlacement === "aboveEditor" ? "top" : "bottom"}`,
|
|
253
|
+
"info",
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
ctx.ui.notify(
|
|
258
|
+
"Usage: /osdy-pi working-tree position top | bottom | status",
|
|
259
|
+
"warning",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function handleWorkingTreeCommand(
|
|
264
|
+
action: string | undefined,
|
|
265
|
+
rest: string[],
|
|
266
|
+
pi: ExtensionAPI,
|
|
267
|
+
ctx: ExtensionContext,
|
|
268
|
+
state: OsdyState,
|
|
269
|
+
workingState: WorkingWidgetState,
|
|
270
|
+
workingTreeState: WorkingTreeState,
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
if (action === "position") {
|
|
273
|
+
handleWorkingTreePositionCommand(
|
|
274
|
+
rest[0],
|
|
275
|
+
pi,
|
|
276
|
+
ctx,
|
|
277
|
+
state,
|
|
278
|
+
workingState,
|
|
279
|
+
workingTreeState,
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (action === "on") {
|
|
284
|
+
state.workingTreeEnabled = true;
|
|
285
|
+
workingTreeState.enabled = true;
|
|
286
|
+
await refreshWorkingTree(pi, ctx, workingTreeState);
|
|
287
|
+
ctx.ui.notify("osdy-pi working tree enabled", "info");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (action === "off") {
|
|
291
|
+
state.workingTreeEnabled = false;
|
|
292
|
+
workingTreeState.enabled = false;
|
|
293
|
+
clearWorkingTree(workingTreeState);
|
|
294
|
+
ctx.ui.notify("osdy-pi working tree disabled", "info");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (action === "toggle") {
|
|
298
|
+
const nextAction = state.workingTreeEnabled ? "off" : "on";
|
|
299
|
+
await handleWorkingTreeCommand(
|
|
300
|
+
nextAction,
|
|
301
|
+
[],
|
|
302
|
+
pi,
|
|
303
|
+
ctx,
|
|
304
|
+
state,
|
|
305
|
+
workingState,
|
|
306
|
+
workingTreeState,
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (action === "status" || action === undefined) {
|
|
311
|
+
const status = state.workingTreeEnabled ? "enabled" : "disabled";
|
|
312
|
+
ctx.ui.notify(`osdy-pi working tree ${status}`, "info");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
ctx.ui.notify(
|
|
316
|
+
"Usage: /osdy-pi working-tree on | off | toggle | status | position ...",
|
|
317
|
+
"warning",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function openDiffCommand(
|
|
322
|
+
pi: ExtensionAPI,
|
|
323
|
+
ctx: ExtensionContext,
|
|
324
|
+
state: OsdyState,
|
|
325
|
+
workingState: WorkingWidgetState,
|
|
326
|
+
workingTreeState: WorkingTreeState,
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
if (!state.workingTreeEnabled) {
|
|
329
|
+
ctx.ui.notify(
|
|
330
|
+
"Enable working-tree first with /osdy-pi working-tree on",
|
|
331
|
+
"warning",
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (workingTreeState.loading) {
|
|
336
|
+
ctx.ui.notify("working tree is still refreshing", "warning");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!workingTreeState.snapshot) {
|
|
340
|
+
await refreshWorkingTree(pi, ctx, workingTreeState);
|
|
341
|
+
}
|
|
342
|
+
ctx.ui.setHeader(undefined);
|
|
343
|
+
ctx.ui.setFooter(undefined);
|
|
344
|
+
ctx.ui.setWidget(WORKING_WIDGET_KEY, undefined);
|
|
345
|
+
ctx.ui.setWidget(WORKING_TREE_WIDGET_KEY, undefined);
|
|
346
|
+
ctx.ui.setWorkingVisible(false);
|
|
347
|
+
try {
|
|
348
|
+
await showWorkingTreeDiffPanel(pi, ctx, workingTreeState);
|
|
349
|
+
} finally {
|
|
350
|
+
if (state.enabled) {
|
|
351
|
+
applyOsdyPi(pi, ctx, state, workingState, workingTreeState);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
153
356
|
function registerCommand(
|
|
154
357
|
pi: ExtensionAPI,
|
|
155
358
|
state: OsdyState,
|
|
156
359
|
workingState: WorkingWidgetState,
|
|
360
|
+
workingTreeState: WorkingTreeState,
|
|
157
361
|
controller: WorkingController,
|
|
158
362
|
settingsStore: ReturnType<typeof createAudioSoundSettingsStore>,
|
|
159
363
|
): void {
|
|
160
364
|
pi.registerCommand("osdy-pi", {
|
|
161
365
|
description:
|
|
162
|
-
"Manage the Osdy Pi experience: enable, disable, status, style, or
|
|
366
|
+
"Manage the Osdy Pi experience: enable, disable, status, style, sound setup, working tree, or diff panel.",
|
|
163
367
|
getArgumentCompletions: getOsdyCommandCompletions,
|
|
164
368
|
handler: async (args, ctx) => {
|
|
165
369
|
const [action = "status", ...rest] = parseCommandArgs(args);
|
|
166
370
|
if (action === "enable") {
|
|
167
|
-
enableOsdyPi(
|
|
371
|
+
await enableOsdyPi(
|
|
372
|
+
pi,
|
|
373
|
+
ctx,
|
|
374
|
+
state,
|
|
375
|
+
workingState,
|
|
376
|
+
workingTreeState,
|
|
377
|
+
controller,
|
|
378
|
+
);
|
|
168
379
|
return;
|
|
169
380
|
}
|
|
170
381
|
if (action === "disable") {
|
|
171
|
-
disableOsdyPiCommand(ctx, state, controller);
|
|
382
|
+
disableOsdyPiCommand(ctx, state, workingTreeState, controller);
|
|
172
383
|
return;
|
|
173
384
|
}
|
|
174
385
|
if (action === "status") {
|
|
@@ -179,12 +390,35 @@ function registerCommand(
|
|
|
179
390
|
await handleSoundCommand(rest, ctx, settingsStore);
|
|
180
391
|
return;
|
|
181
392
|
}
|
|
393
|
+
if (action === "working-tree") {
|
|
394
|
+
await handleWorkingTreeCommand(
|
|
395
|
+
rest[0],
|
|
396
|
+
rest.slice(1),
|
|
397
|
+
pi,
|
|
398
|
+
ctx,
|
|
399
|
+
state,
|
|
400
|
+
workingState,
|
|
401
|
+
workingTreeState,
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (action === "diff") {
|
|
406
|
+
await openDiffCommand(pi, ctx, state, workingState, workingTreeState);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
182
409
|
if (isHeaderVariant(action)) {
|
|
183
|
-
setHeaderVariant(
|
|
410
|
+
setHeaderVariant(
|
|
411
|
+
pi,
|
|
412
|
+
ctx,
|
|
413
|
+
state,
|
|
414
|
+
workingState,
|
|
415
|
+
workingTreeState,
|
|
416
|
+
action,
|
|
417
|
+
);
|
|
184
418
|
return;
|
|
185
419
|
}
|
|
186
420
|
ctx.ui.notify(
|
|
187
|
-
"Usage: /osdy-pi enable | disable | status | sound setup | osdy-theme | classic",
|
|
421
|
+
"Usage: /osdy-pi enable | disable | status | sound setup | working-tree ... | diff | osdy-theme | classic",
|
|
188
422
|
"warning",
|
|
189
423
|
);
|
|
190
424
|
},
|
|
@@ -194,7 +428,14 @@ function registerCommand(
|
|
|
194
428
|
pi.registerCommand(`osdy-pi-${variant}`, {
|
|
195
429
|
description: `Switch Osdy Pi header to ${variant}.`,
|
|
196
430
|
handler: (_args, ctx) => {
|
|
197
|
-
setHeaderVariant(
|
|
431
|
+
setHeaderVariant(
|
|
432
|
+
pi,
|
|
433
|
+
ctx,
|
|
434
|
+
state,
|
|
435
|
+
workingState,
|
|
436
|
+
workingTreeState,
|
|
437
|
+
variant,
|
|
438
|
+
);
|
|
198
439
|
return Promise.resolve();
|
|
199
440
|
},
|
|
200
441
|
});
|
|
@@ -207,6 +448,8 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
|
207
448
|
headerVariant: "osdy-theme",
|
|
208
449
|
gitLabel: "-",
|
|
209
450
|
agentsLabel: "-",
|
|
451
|
+
workingTreeEnabled: true,
|
|
452
|
+
workingTreePlacement: "aboveEditor",
|
|
210
453
|
};
|
|
211
454
|
const workingState: WorkingWidgetState = {
|
|
212
455
|
active: false,
|
|
@@ -215,6 +458,13 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
|
215
458
|
timer: undefined,
|
|
216
459
|
tui: undefined,
|
|
217
460
|
};
|
|
461
|
+
const workingTreeState: WorkingTreeState = {
|
|
462
|
+
enabled: true,
|
|
463
|
+
loading: false,
|
|
464
|
+
snapshot: null,
|
|
465
|
+
error: undefined,
|
|
466
|
+
tui: undefined,
|
|
467
|
+
};
|
|
218
468
|
const controller = createWorkingController(state, workingState);
|
|
219
469
|
const settingsStore = createAudioSoundSettingsStore();
|
|
220
470
|
registerAudioNotificationFlags(pi);
|
|
@@ -240,12 +490,28 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
|
240
490
|
pi.on("tool_execution_end", (event, ctx) => {
|
|
241
491
|
controller.onToolEnd();
|
|
242
492
|
audioRouter.onToolExecutionEnd(event.isError === true, ctx);
|
|
493
|
+
if (event.isError !== true && shouldRefreshWorkingTree(event.toolName)) {
|
|
494
|
+
void refreshWorkingTree(pi, ctx, workingTreeState);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
pi.on("session_shutdown", () => {
|
|
498
|
+
controller.onShutdown();
|
|
499
|
+
workingTreeState.tui = undefined;
|
|
243
500
|
});
|
|
244
|
-
pi.on("session_shutdown", () => controller.onShutdown());
|
|
245
501
|
pi.on("session_start", (_event, ctx) => {
|
|
246
502
|
loadSessionMetadata(pi, ctx, state);
|
|
247
|
-
claimOsdyVisualLayer(pi, ctx, state, workingState);
|
|
503
|
+
claimOsdyVisualLayer(pi, ctx, state, workingState, workingTreeState);
|
|
504
|
+
if (state.workingTreeEnabled) {
|
|
505
|
+
void refreshWorkingTree(pi, ctx, workingTreeState);
|
|
506
|
+
}
|
|
248
507
|
});
|
|
249
508
|
|
|
250
|
-
registerCommand(
|
|
509
|
+
registerCommand(
|
|
510
|
+
pi,
|
|
511
|
+
state,
|
|
512
|
+
workingState,
|
|
513
|
+
workingTreeState,
|
|
514
|
+
controller,
|
|
515
|
+
settingsStore,
|
|
516
|
+
);
|
|
251
517
|
}
|
|
@@ -4,12 +4,16 @@ export type AnimationMode = "off" | "intro" | "continuous";
|
|
|
4
4
|
|
|
5
5
|
export type HeaderVariant = "osdy-theme" | "classic";
|
|
6
6
|
|
|
7
|
+
export type WorkingTreePlacement = "aboveEditor" | "belowEditor";
|
|
8
|
+
|
|
7
9
|
export type OsdyState = {
|
|
8
10
|
enabled: boolean;
|
|
9
11
|
headerVariant: HeaderVariant;
|
|
10
12
|
previousThemeName?: string;
|
|
11
13
|
gitLabel: string;
|
|
12
14
|
agentsLabel: string;
|
|
15
|
+
workingTreeEnabled: boolean;
|
|
16
|
+
workingTreePlacement: WorkingTreePlacement;
|
|
13
17
|
};
|
|
14
18
|
|
|
15
19
|
export type WorkingWidgetState = {
|
|
@@ -20,6 +24,33 @@ export type WorkingWidgetState = {
|
|
|
20
24
|
tui: TUI | undefined;
|
|
21
25
|
};
|
|
22
26
|
|
|
27
|
+
export type WorkingTreeFileSummary = {
|
|
28
|
+
path: string;
|
|
29
|
+
additions: number;
|
|
30
|
+
removals: number;
|
|
31
|
+
staged: boolean;
|
|
32
|
+
unstaged: boolean;
|
|
33
|
+
untracked: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type WorkingTreeSnapshot = {
|
|
37
|
+
files: WorkingTreeFileSummary[];
|
|
38
|
+
totalFiles: number;
|
|
39
|
+
stagedFiles: number;
|
|
40
|
+
unstagedFiles: number;
|
|
41
|
+
untrackedFiles: number;
|
|
42
|
+
additions: number;
|
|
43
|
+
removals: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type WorkingTreeState = {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
loading: boolean;
|
|
49
|
+
snapshot: WorkingTreeSnapshot | null;
|
|
50
|
+
error: string | undefined;
|
|
51
|
+
tui: TUI | undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
23
54
|
export type AssistantUsage = {
|
|
24
55
|
input?: number;
|
|
25
56
|
output?: number;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import type {
|
|
10
|
+
SimpleTheme,
|
|
11
|
+
WorkingTreeFileSummary,
|
|
12
|
+
WorkingTreeSnapshot,
|
|
13
|
+
WorkingTreeState,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import { fitCenterVisible } from "./utils.js";
|
|
16
|
+
|
|
17
|
+
const MUTATING_TOOL_NAMES = new Set(["edit", "write", "ast_grep_replace"]);
|
|
18
|
+
|
|
19
|
+
type FileFlags = {
|
|
20
|
+
path: string;
|
|
21
|
+
staged: boolean;
|
|
22
|
+
unstaged: boolean;
|
|
23
|
+
untracked: boolean;
|
|
24
|
+
additions: number;
|
|
25
|
+
removals: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function requestWorkingTreeRender(state: WorkingTreeState): void {
|
|
29
|
+
state.tui?.requestRender();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createEmptyFlags(path: string): FileFlags {
|
|
33
|
+
return {
|
|
34
|
+
path,
|
|
35
|
+
staged: false,
|
|
36
|
+
unstaged: false,
|
|
37
|
+
untracked: false,
|
|
38
|
+
additions: 0,
|
|
39
|
+
removals: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getOrCreateFile(map: Map<string, FileFlags>, path: string): FileFlags {
|
|
44
|
+
const current = map.get(path);
|
|
45
|
+
if (current) return current;
|
|
46
|
+
const next = createEmptyFlags(path);
|
|
47
|
+
map.set(path, next);
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseDiffNumber(value: string): number {
|
|
52
|
+
const parsed = Number.parseInt(value, 10);
|
|
53
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeGitPath(path: string): string {
|
|
57
|
+
const trimmed = path.trim();
|
|
58
|
+
if (!trimmed.includes(" -> ")) return trimmed;
|
|
59
|
+
const [, renamedPath = trimmed] = trimmed.split(" -> ");
|
|
60
|
+
return renamedPath.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mergeNumstat(
|
|
64
|
+
map: Map<string, FileFlags>,
|
|
65
|
+
output: string,
|
|
66
|
+
kind: "staged" | "unstaged",
|
|
67
|
+
): void {
|
|
68
|
+
for (const line of output.split("\n")) {
|
|
69
|
+
if (!line.trim()) continue;
|
|
70
|
+
const [additionsRaw = "0", removalsRaw = "0", ...pathParts] =
|
|
71
|
+
line.split("\t");
|
|
72
|
+
const path = normalizeGitPath(pathParts.join("\t"));
|
|
73
|
+
if (!path) continue;
|
|
74
|
+
const file = getOrCreateFile(map, path);
|
|
75
|
+
file.additions += parseDiffNumber(additionsRaw);
|
|
76
|
+
file.removals += parseDiffNumber(removalsRaw);
|
|
77
|
+
if (kind === "staged") file.staged = true;
|
|
78
|
+
if (kind === "unstaged") file.unstaged = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mergeStatus(map: Map<string, FileFlags>, output: string): void {
|
|
83
|
+
for (const line of output.split("\n")) {
|
|
84
|
+
if (!line.trim()) continue;
|
|
85
|
+
if (line.startsWith("?? ")) {
|
|
86
|
+
const path = normalizeGitPath(line.slice(3));
|
|
87
|
+
if (!path) continue;
|
|
88
|
+
const file = getOrCreateFile(map, path);
|
|
89
|
+
file.untracked = true;
|
|
90
|
+
file.unstaged = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (line.length < 4) continue;
|
|
94
|
+
const x = line[0] ?? " ";
|
|
95
|
+
const y = line[1] ?? " ";
|
|
96
|
+
const path = normalizeGitPath(line.slice(3));
|
|
97
|
+
if (!path) continue;
|
|
98
|
+
const file = getOrCreateFile(map, path);
|
|
99
|
+
if (x !== " ") file.staged = true;
|
|
100
|
+
if (y !== " ") file.unstaged = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toFileSummary(file: FileFlags): WorkingTreeFileSummary {
|
|
105
|
+
return {
|
|
106
|
+
path: file.path,
|
|
107
|
+
additions: file.additions,
|
|
108
|
+
removals: file.removals,
|
|
109
|
+
staged: file.staged,
|
|
110
|
+
unstaged: file.unstaged,
|
|
111
|
+
untracked: file.untracked,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function compareFiles(
|
|
116
|
+
left: WorkingTreeFileSummary,
|
|
117
|
+
right: WorkingTreeFileSummary,
|
|
118
|
+
): number {
|
|
119
|
+
const leftMagnitude = left.additions + left.removals;
|
|
120
|
+
const rightMagnitude = right.additions + right.removals;
|
|
121
|
+
if (leftMagnitude !== rightMagnitude) return rightMagnitude - leftMagnitude;
|
|
122
|
+
return left.path.localeCompare(right.path);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function hydrateUntrackedStats(
|
|
126
|
+
ctx: ExtensionContext,
|
|
127
|
+
fileMap: Map<string, FileFlags>,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
await Promise.all(
|
|
130
|
+
Array.from(fileMap.values()).map(async (file) => {
|
|
131
|
+
if (!file.untracked || file.additions > 0 || file.removals > 0) return;
|
|
132
|
+
try {
|
|
133
|
+
const content = await readFile(resolve(ctx.cwd, file.path), "utf8");
|
|
134
|
+
file.additions = content.length === 0 ? 0 : content.split("\n").length;
|
|
135
|
+
} catch {
|
|
136
|
+
file.additions = 1;
|
|
137
|
+
}
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createSnapshot(files: WorkingTreeFileSummary[]): WorkingTreeSnapshot {
|
|
143
|
+
const stagedFiles = files.filter((file) => file.staged).length;
|
|
144
|
+
const unstagedFiles = files.filter((file) => file.unstaged).length;
|
|
145
|
+
const untrackedFiles = files.filter((file) => file.untracked).length;
|
|
146
|
+
const additions = files.reduce((total, file) => total + file.additions, 0);
|
|
147
|
+
const removals = files.reduce((total, file) => total + file.removals, 0);
|
|
148
|
+
return {
|
|
149
|
+
files,
|
|
150
|
+
totalFiles: files.length,
|
|
151
|
+
stagedFiles,
|
|
152
|
+
unstagedFiles,
|
|
153
|
+
untrackedFiles,
|
|
154
|
+
additions,
|
|
155
|
+
removals,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runGit(
|
|
160
|
+
pi: ExtensionAPI,
|
|
161
|
+
ctx: ExtensionContext,
|
|
162
|
+
args: string[],
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
const result = await pi.exec("git", args, { cwd: ctx.cwd });
|
|
165
|
+
if (result.code !== 0) {
|
|
166
|
+
throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
|
|
167
|
+
}
|
|
168
|
+
return result.stdout;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let refreshGeneration = 0;
|
|
172
|
+
|
|
173
|
+
export async function refreshWorkingTree(
|
|
174
|
+
pi: ExtensionAPI,
|
|
175
|
+
ctx: ExtensionContext,
|
|
176
|
+
state: WorkingTreeState,
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
if (!state.enabled) return;
|
|
179
|
+
const generation = ++refreshGeneration;
|
|
180
|
+
state.loading = true;
|
|
181
|
+
state.error = undefined;
|
|
182
|
+
requestWorkingTreeRender(state);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await runGit(pi, ctx, ["rev-parse", "--is-inside-work-tree"]);
|
|
186
|
+
const [statusOutput, stagedOutput, unstagedOutput] = await Promise.all([
|
|
187
|
+
runGit(pi, ctx, ["status", "--short", "--untracked-files=all"]),
|
|
188
|
+
runGit(pi, ctx, ["diff", "--cached", "--numstat", "--no-ext-diff"]),
|
|
189
|
+
runGit(pi, ctx, ["diff", "--numstat", "--no-ext-diff"]),
|
|
190
|
+
]);
|
|
191
|
+
if (generation !== refreshGeneration) return;
|
|
192
|
+
const fileMap = new Map<string, FileFlags>();
|
|
193
|
+
mergeStatus(fileMap, statusOutput);
|
|
194
|
+
mergeNumstat(fileMap, stagedOutput, "staged");
|
|
195
|
+
mergeNumstat(fileMap, unstagedOutput, "unstaged");
|
|
196
|
+
await hydrateUntrackedStats(ctx, fileMap);
|
|
197
|
+
const files = Array.from(fileMap.values())
|
|
198
|
+
.map(toFileSummary)
|
|
199
|
+
.sort(compareFiles);
|
|
200
|
+
state.snapshot = createSnapshot(files);
|
|
201
|
+
} catch {
|
|
202
|
+
if (generation !== refreshGeneration) return;
|
|
203
|
+
state.snapshot = null;
|
|
204
|
+
state.error = "working tree unavailable";
|
|
205
|
+
} finally {
|
|
206
|
+
if (generation === refreshGeneration) {
|
|
207
|
+
state.loading = false;
|
|
208
|
+
requestWorkingTreeRender(state);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function clearWorkingTree(state: WorkingTreeState): void {
|
|
214
|
+
refreshGeneration += 1;
|
|
215
|
+
state.loading = false;
|
|
216
|
+
state.snapshot = null;
|
|
217
|
+
state.error = undefined;
|
|
218
|
+
requestWorkingTreeRender(state);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function shouldRefreshWorkingTree(toolName: string): boolean {
|
|
222
|
+
return MUTATING_TOOL_NAMES.has(toolName) || toolName === "bash";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderSummaryLine(
|
|
226
|
+
snapshot: WorkingTreeSnapshot,
|
|
227
|
+
theme: SimpleTheme,
|
|
228
|
+
): string {
|
|
229
|
+
if (snapshot.totalFiles === 0) {
|
|
230
|
+
return theme.fg("mdLink", "Git changes · working tree clean");
|
|
231
|
+
}
|
|
232
|
+
const parts = [
|
|
233
|
+
theme.fg("mdLink", `Git changes · ${snapshot.totalFiles} files`),
|
|
234
|
+
theme.fg("success", `+${snapshot.additions}`),
|
|
235
|
+
theme.fg("error", `-${snapshot.removals}`),
|
|
236
|
+
theme.fg("mdLink", `staged ${snapshot.stagedFiles}`),
|
|
237
|
+
theme.fg("mdLink", `unstaged ${snapshot.unstagedFiles}`),
|
|
238
|
+
];
|
|
239
|
+
if (snapshot.untrackedFiles > 0) {
|
|
240
|
+
parts.push(theme.fg("mdLink", `new ${snapshot.untrackedFiles}`));
|
|
241
|
+
}
|
|
242
|
+
return parts.join(theme.fg("muted", " · "));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
class WorkingTreeWidget implements Component {
|
|
246
|
+
constructor(
|
|
247
|
+
private readonly state: WorkingTreeState,
|
|
248
|
+
private readonly theme: SimpleTheme,
|
|
249
|
+
) {}
|
|
250
|
+
|
|
251
|
+
render(width: number): string[] {
|
|
252
|
+
if (!this.state.enabled) return [];
|
|
253
|
+
if (this.state.loading) {
|
|
254
|
+
return [
|
|
255
|
+
fitCenterVisible(
|
|
256
|
+
this.theme.fg("muted", "Git changes · refreshing..."),
|
|
257
|
+
width,
|
|
258
|
+
),
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
if (this.state.error) {
|
|
262
|
+
return [
|
|
263
|
+
fitCenterVisible(
|
|
264
|
+
this.theme.fg("warning", "Git changes unavailable"),
|
|
265
|
+
width,
|
|
266
|
+
),
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
const snapshot = this.state.snapshot;
|
|
270
|
+
if (!snapshot) return [];
|
|
271
|
+
return [
|
|
272
|
+
fitCenterVisible(
|
|
273
|
+
truncateToWidth(
|
|
274
|
+
renderSummaryLine(snapshot, this.theme),
|
|
275
|
+
Math.max(1, width),
|
|
276
|
+
),
|
|
277
|
+
width,
|
|
278
|
+
),
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
invalidate(): void {}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function createWorkingTreeWidgetFactory(state: WorkingTreeState) {
|
|
286
|
+
return (tui: TUI, theme: SimpleTheme): Component => {
|
|
287
|
+
state.tui = tui;
|
|
288
|
+
return new WorkingTreeWidget(state, theme);
|
|
289
|
+
};
|
|
290
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "osdy-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Osdy Pi package: themes, header, and custom editor for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"typecheck": "tsc --noEmit",
|
|
15
|
-
"lint": "eslint ."
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"pi:dev": "node ./scripts/pi-dev.mjs"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"README.md",
|