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 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
@@ -18,6 +18,7 @@ export const WORKING_SPINNER_FRAMES = [
18
18
  "⠏",
19
19
  ];
20
20
  export const WORKING_WIDGET_KEY = "osdy-pi-working";
21
+ export const WORKING_TREE_WIDGET_KEY = "osdy-pi-working-tree";
21
22
  export const MASCOT_MIN_ROWS = 34;
22
23
  export const MASCOT_GAP = 0;
23
24
 
@@ -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 { THEME_NAME, WORKING_WIDGET_KEY } from "./constants.js";
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 { OsdyState, WorkingWidgetState } from "./types.js";
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 { HeaderVariant, OsdyState, WorkingWidgetState } from "./types.js";
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) applyOsdyPi(pi, ctx, state, workingState);
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(delayMs, pi, ctx, state, workingState);
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) applyOsdyPi(pi, ctx, state, workingState);
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 ["enable", "disable", "status", "sound", ...HEADER_VARIANTS].map(
119
- (value) => ({ value, label: value }),
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 ["enable", "disable", "status", "sound", ...HEADER_VARIANTS]
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 sound setup.",
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(pi, ctx, state, workingState, controller);
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(pi, ctx, state, workingState, action);
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(pi, ctx, state, workingState, variant);
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(pi, state, workingState, controller, settingsStore);
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.7",
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",