@narumitw/pi-files 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
- package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
- package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
- package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
- package/dist/chunks/chunk-F5QFC255.ts +101 -0
- package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
- package/dist/chunks/chunk-NFNGJETD.ts +77 -0
- package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
- package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
- package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
- package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
- package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
- package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
- package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
- package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
- package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
- package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
- package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
- package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
- package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
- package/dist/chunks/menu-P6IBMROH.ts +876 -0
- package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
- package/dist/index.ts +1789 -0
- package/dist/index.ts.map +7 -0
- package/docs/edit-scope-reference.md +142 -0
- package/docs/file-context-reference.md +91 -0
- package/docs/migration.md +105 -0
- package/docs/verification.md +99 -0
- package/package.json +58 -4
- package/src/content-search-session.ts +204 -0
- package/src/content-search-ui.ts +159 -0
- package/src/content-search.ts +170 -0
- package/src/context-service.ts +515 -0
- package/src/external-editor.ts +143 -0
- package/src/file-browser-ui.ts +128 -0
- package/src/file-browser.ts +76 -0
- package/src/file-context-explorer.ts +992 -0
- package/src/file-context-menu.ts +360 -0
- package/src/file-context-preview-ui.ts +354 -0
- package/src/file-context-settings.ts +301 -0
- package/src/file-context.ts +321 -0
- package/src/file-search.ts +246 -0
- package/src/files-menu.ts +125 -0
- package/src/files-settings.ts +314 -0
- package/src/files-widget.ts +65 -0
- package/src/files.ts +148 -0
- package/src/git-context.ts +485 -0
- package/src/index.ts +1 -0
- package/src/scope/contracts.ts +243 -0
- package/src/scope/display.ts +27 -0
- package/src/scope/edit-scope.ts +435 -0
- package/src/scope/files.ts +118 -0
- package/src/scope/input.ts +207 -0
- package/src/scope/menu.ts +277 -0
- package/src/scope/paths.ts +142 -0
- package/src/scope/picker-keys.ts +154 -0
- package/src/scope/picker.ts +484 -0
- package/src/scope/policy.ts +55 -0
- package/src/scope/settings.ts +138 -0
- package/src/scope/state.ts +91 -0
- package/src/scope/ui.ts +106 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { type ExtensionCommandContext, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type Focusable, matchesKey, type SettingItem, SettingsList, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
|
+
import { HorizontalRule, renderBoundedFrameLayout, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
|
|
5
|
+
import type { ContextService } from "./context-service.js";
|
|
6
|
+
import { normalizeKeyId } from "./file-context-settings.js";
|
|
7
|
+
import type { ScopeService } from "./scope/edit-scope.js";
|
|
8
|
+
import { ScopeInput } from "./scope/input.js";
|
|
9
|
+
|
|
10
|
+
/** Edit the two retained settings stores without changing context or edit permission. */
|
|
11
|
+
export async function showFilesSettings(
|
|
12
|
+
ctx: ExtensionCommandContext,
|
|
13
|
+
context: ContextService,
|
|
14
|
+
scope: ScopeService,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error("Files settings require Pi TUI mode");
|
|
17
|
+
|
|
18
|
+
const ownerSignal = AbortSignal.any([context.signal(ctx), scope.signal(ctx), ...(ctx.signal ? [ctx.signal] : [])]);
|
|
19
|
+
if (ownerSignal.aborted) return;
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const signal = AbortSignal.any([ownerSignal, controller.signal]);
|
|
22
|
+
let pending = Promise.resolve();
|
|
23
|
+
let shortcut = context.getState(ctx).shortcut;
|
|
24
|
+
let hidden = scope.getSettings(ctx).showHiddenFiles;
|
|
25
|
+
let requestedHidden = hidden;
|
|
26
|
+
let hiddenSaves = 0;
|
|
27
|
+
let shortcutSaving = false;
|
|
28
|
+
let accepting = true;
|
|
29
|
+
let dispose: (() => void) | undefined;
|
|
30
|
+
let resolveDisposal!: (reload: boolean) => void;
|
|
31
|
+
const disposal = new Promise<boolean>((resolve) => {
|
|
32
|
+
resolveDisposal = resolve;
|
|
33
|
+
});
|
|
34
|
+
let redraw = () => {};
|
|
35
|
+
const live = () => !signal.aborted;
|
|
36
|
+
const notify = (message: string, type: "warning" | "error") => {
|
|
37
|
+
if (live()) ctx.ui.notify(sanitizeTerminalText(message), type);
|
|
38
|
+
};
|
|
39
|
+
const shortcutBlock = (): string | undefined => {
|
|
40
|
+
const state = context.getState(ctx);
|
|
41
|
+
if (state.settingsInvalidReason) {
|
|
42
|
+
return `Repair ${sanitizeTerminalText(state.settingsPath)} first: ${sanitizeTerminalText(state.settingsInvalidReason)}`;
|
|
43
|
+
}
|
|
44
|
+
if (state.quotes.length) return "Submit or remove queued context before changing the shortcut.";
|
|
45
|
+
if (shortcutSaving) return "Saving the shortcut…";
|
|
46
|
+
return undefined;
|
|
47
|
+
};
|
|
48
|
+
// Every action joins one queue. A rejected store write cannot poison later actions.
|
|
49
|
+
const enqueue = (save: () => Promise<void>) => {
|
|
50
|
+
pending = pending.then(async () => {
|
|
51
|
+
if (!live()) return;
|
|
52
|
+
try {
|
|
53
|
+
await save();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
notify(`Could not save Files settings; the previous value remains: ${errorMessage(error)}`, "error");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let reload = false;
|
|
61
|
+
try {
|
|
62
|
+
const interaction = ctx.ui.custom<boolean>((tui, theme, keybindings, done) => {
|
|
63
|
+
let ended = false;
|
|
64
|
+
let focused = false;
|
|
65
|
+
let editor: ScopeInput | undefined;
|
|
66
|
+
let submenuDone: (() => void) | undefined;
|
|
67
|
+
let mouseRows = new Map<number, number>();
|
|
68
|
+
const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
|
|
69
|
+
// Keep native SettingsList styling and use the callback theme, including after invalidation.
|
|
70
|
+
const listTheme = getSettingsListTheme();
|
|
71
|
+
listTheme.label = (text, selected) => (selected ? theme.fg("accent", text) : text);
|
|
72
|
+
listTheme.value = (text, selected) => theme.fg(selected ? "accent" : "muted", text);
|
|
73
|
+
listTheme.description = (text) => theme.fg("dim", text);
|
|
74
|
+
listTheme.hint = (text) => theme.fg("dim", text);
|
|
75
|
+
const release = () => {
|
|
76
|
+
if (ended) return false;
|
|
77
|
+
ended = true;
|
|
78
|
+
accepting = false;
|
|
79
|
+
focused = false;
|
|
80
|
+
if (editor) editor.input.focused = false;
|
|
81
|
+
signal.removeEventListener("abort", onAbort);
|
|
82
|
+
controller.abort();
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
const finish = (shouldReload: boolean) => {
|
|
86
|
+
if (release()) done(shouldReload);
|
|
87
|
+
};
|
|
88
|
+
const onAbort = () => finish(false);
|
|
89
|
+
dispose = () => {
|
|
90
|
+
// Pi resets its live dialog array through dispose(). Do not call done():
|
|
91
|
+
// it would splice that array and restore a draft from the previous session.
|
|
92
|
+
if (release()) resolveDisposal(false);
|
|
93
|
+
};
|
|
94
|
+
redraw = () => {
|
|
95
|
+
if (live() && !ended) tui.requestRender();
|
|
96
|
+
};
|
|
97
|
+
const leaveSubmenu = () => {
|
|
98
|
+
if (editor) editor.input.focused = false;
|
|
99
|
+
editor = undefined;
|
|
100
|
+
const complete = submenuDone;
|
|
101
|
+
submenuDone = undefined;
|
|
102
|
+
complete?.();
|
|
103
|
+
};
|
|
104
|
+
const openShortcut: NonNullable<SettingItem["submenu"]> = (_value, complete) => {
|
|
105
|
+
submenuDone = () => complete();
|
|
106
|
+
const input = new ScopeInput(shortcut ?? "", (text) => theme.fg("muted", text));
|
|
107
|
+
editor = input;
|
|
108
|
+
input.input.focused = focused;
|
|
109
|
+
input.input.onEscape = leaveSubmenu;
|
|
110
|
+
input.input.onSubmit = (value) => {
|
|
111
|
+
if (!accepting || !live()) return;
|
|
112
|
+
const blocked = shortcutBlock();
|
|
113
|
+
if (blocked) {
|
|
114
|
+
notify(blocked, "warning");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const raw = value.trim();
|
|
118
|
+
const normalized = raw ? normalizeKeyId(raw) : null;
|
|
119
|
+
if (normalized === undefined) {
|
|
120
|
+
notify("Invalid Pi key identifier. Use a value such as ctrl+shift+x or f8.", "warning");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
leaveSubmenu();
|
|
124
|
+
if (normalized === shortcut) return;
|
|
125
|
+
shortcutSaving = true;
|
|
126
|
+
enqueue(async () => {
|
|
127
|
+
try {
|
|
128
|
+
// Context can change while an earlier hidden-file save is pending.
|
|
129
|
+
const state = context.getState(ctx);
|
|
130
|
+
if (state.quotes.length || state.settingsInvalidReason) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"Submit or remove queued context and repair invalid context settings before changing the shortcut",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
await context.saveShortcut(ctx, normalized, signal);
|
|
136
|
+
if (!live()) return;
|
|
137
|
+
shortcut = normalized;
|
|
138
|
+
accepting = false;
|
|
139
|
+
// Drain all already accepted saves before closing and reloading Pi.
|
|
140
|
+
void pending.then(() => {
|
|
141
|
+
if (live()) finish(true);
|
|
142
|
+
});
|
|
143
|
+
} finally {
|
|
144
|
+
shortcutSaving = false;
|
|
145
|
+
redraw();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
render(width) {
|
|
151
|
+
return [
|
|
152
|
+
theme.fg("accent", "Open shortcut"),
|
|
153
|
+
...input.render(width),
|
|
154
|
+
theme.fg("dim", "Use a Pi key identifier. An empty value disables the shortcut."),
|
|
155
|
+
];
|
|
156
|
+
},
|
|
157
|
+
invalidate: () => input.input.invalidate(),
|
|
158
|
+
handleInput(data) {
|
|
159
|
+
input.handleInput(data, (key) => {
|
|
160
|
+
if (matchesKey(key, "ctrl+c")) {
|
|
161
|
+
finish(false);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
// Input owns configured cancellation, editing, and submission priority.
|
|
165
|
+
return false;
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
handleMouse(event) {
|
|
169
|
+
return event.y === 1 ? input.handleMouse({ ...event, y: 0 }) : undefined;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
const items: SettingItem[] = [
|
|
174
|
+
{ id: "shortcut", label: "Open shortcut", currentValue: shortcut ?? "disabled" },
|
|
175
|
+
{
|
|
176
|
+
id: "hidden",
|
|
177
|
+
label: "Show hidden files",
|
|
178
|
+
description: "Include hidden files in the edit-scope picker. Applies immediately.",
|
|
179
|
+
currentValue: String(hidden),
|
|
180
|
+
values: ["false", "true"],
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
const list = new SettingsList(
|
|
184
|
+
items,
|
|
185
|
+
items.length,
|
|
186
|
+
listTheme,
|
|
187
|
+
(id) => {
|
|
188
|
+
if (id !== "hidden") return;
|
|
189
|
+
// Native toggles are synchronous. Display only durable values while retaining
|
|
190
|
+
// each requested toggle separately, including rapid toggles during a write.
|
|
191
|
+
list.updateValue("hidden", String(hidden));
|
|
192
|
+
if (!accepting || !live()) return;
|
|
193
|
+
requestedHidden = !requestedHidden;
|
|
194
|
+
const value = requestedHidden;
|
|
195
|
+
hiddenSaves += 1;
|
|
196
|
+
enqueue(async () => {
|
|
197
|
+
try {
|
|
198
|
+
await scope.saveShowHiddenFiles(ctx, value);
|
|
199
|
+
if (!live()) return;
|
|
200
|
+
hidden = value;
|
|
201
|
+
} finally {
|
|
202
|
+
hiddenSaves -= 1;
|
|
203
|
+
if (!hiddenSaves) requestedHidden = hidden;
|
|
204
|
+
redraw();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
() => finish(false),
|
|
209
|
+
);
|
|
210
|
+
const component: Focusable & {
|
|
211
|
+
capturesViewportInput: boolean;
|
|
212
|
+
render(width: number): string[];
|
|
213
|
+
invalidate(): void;
|
|
214
|
+
handleInput(data: string): void;
|
|
215
|
+
handleMouse(event: TuiMouseEvent): ReturnType<SettingsList["handleMouse"]>;
|
|
216
|
+
dispose(): void;
|
|
217
|
+
} = {
|
|
218
|
+
capturesViewportInput: true,
|
|
219
|
+
get focused() {
|
|
220
|
+
return focused;
|
|
221
|
+
},
|
|
222
|
+
set focused(value) {
|
|
223
|
+
focused = value;
|
|
224
|
+
if (editor) editor.input.focused = value;
|
|
225
|
+
},
|
|
226
|
+
render(width) {
|
|
227
|
+
if (!live()) return [];
|
|
228
|
+
const renderWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
|
|
229
|
+
const blocked = shortcutBlock();
|
|
230
|
+
const shortcutItem = items[0];
|
|
231
|
+
if (shortcutItem) {
|
|
232
|
+
shortcutItem.description = blocked ?? "Open the context browser. Saving reloads Pi.";
|
|
233
|
+
shortcutItem.submenu = blocked ? undefined : openShortcut;
|
|
234
|
+
}
|
|
235
|
+
list.updateValue("shortcut", shortcut ?? "disabled");
|
|
236
|
+
list.updateValue("hidden", String(hidden));
|
|
237
|
+
listTheme.cursor = theme.fg("accent", "→ ");
|
|
238
|
+
// Native hints contain fixed Enter/Escape labels. Omit them rather than
|
|
239
|
+
// advertise keys that can be remapped. Native input dispatch is unchanged.
|
|
240
|
+
// Keep the native cursor marker measurable in narrow lists. Inputs must
|
|
241
|
+
// receive their actual width so horizontal scrolling retains the IME cursor.
|
|
242
|
+
const native = list.render(editor ? renderWidth : Math.max(8, renderWidth));
|
|
243
|
+
const content = editor ? native : native.slice(0, -2);
|
|
244
|
+
const focusedRow = editor ? 1 : content.findIndex((line) => stripVTControlCharacters(line).startsWith("→"));
|
|
245
|
+
const terminalRows = Number.isFinite(tui.terminal.rows) ? Math.floor(tui.terminal.rows) : 24;
|
|
246
|
+
const layout = renderBoundedFrameLayout({
|
|
247
|
+
width: renderWidth,
|
|
248
|
+
maxRows: Math.max(1, terminalRows - 3),
|
|
249
|
+
rule: rule.render(renderWidth)[0] ?? "",
|
|
250
|
+
title: [theme.fg("accent", theme.bold("Files Settings"))],
|
|
251
|
+
content,
|
|
252
|
+
priorityRows: focusedRow < 0 ? [] : [focusedRow],
|
|
253
|
+
focusedRow,
|
|
254
|
+
});
|
|
255
|
+
mouseRows = new Map(layout.contentRows.map(({ frameIndex, contentIndex }) => [frameIndex, contentIndex]));
|
|
256
|
+
return layout.lines;
|
|
257
|
+
},
|
|
258
|
+
invalidate() {
|
|
259
|
+
rule.invalidate();
|
|
260
|
+
list.invalidate();
|
|
261
|
+
},
|
|
262
|
+
handleInput(data) {
|
|
263
|
+
if (!live()) return;
|
|
264
|
+
if (editor) list.handleInput(data);
|
|
265
|
+
else if (matchesKey(data, "ctrl+c")) finish(false);
|
|
266
|
+
else if (accepting) list.handleInput(data);
|
|
267
|
+
else if (keybindings.matches(data, "tui.select.cancel")) finish(false);
|
|
268
|
+
redraw();
|
|
269
|
+
},
|
|
270
|
+
handleMouse(event) {
|
|
271
|
+
if (!live() || !accepting) return undefined;
|
|
272
|
+
const row = mouseRows.get(event.y);
|
|
273
|
+
if (row === undefined) return undefined;
|
|
274
|
+
const result = list.handleMouse({ ...event, y: row });
|
|
275
|
+
redraw();
|
|
276
|
+
return result;
|
|
277
|
+
},
|
|
278
|
+
dispose,
|
|
279
|
+
};
|
|
280
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
281
|
+
if (signal.aborted) finish(false);
|
|
282
|
+
return component;
|
|
283
|
+
});
|
|
284
|
+
// Host reset can leave custom() pending. Release this flow independently.
|
|
285
|
+
reload = await Promise.race([interaction, disposal]);
|
|
286
|
+
} finally {
|
|
287
|
+
dispose?.();
|
|
288
|
+
controller.abort();
|
|
289
|
+
// A hidden-file write cannot be interrupted through the service API. Do not
|
|
290
|
+
// let command completion, session replacement, or shutdown race its durability.
|
|
291
|
+
await pending;
|
|
292
|
+
}
|
|
293
|
+
if (!reload || ownerSignal.aborted) return;
|
|
294
|
+
if (context.getState(ctx).quotes.length) {
|
|
295
|
+
ctx.ui.notify("The shortcut was saved. Submit or remove queued context, then run /reload to apply it.", "warning");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await ctx.reload();
|
|
300
|
+
return;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (ownerSignal.aborted) return;
|
|
303
|
+
ctx.ui.notify(
|
|
304
|
+
sanitizeTerminalText(
|
|
305
|
+
`The shortcut was saved, but Pi could not reload. Run /reload to apply it: ${errorMessage(error)}`,
|
|
306
|
+
),
|
|
307
|
+
"error",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function errorMessage(error: unknown): string {
|
|
313
|
+
return error instanceof Error ? error.message : String(error);
|
|
314
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { EditorStatusWidget } from "@narumitw/pi-tui-kit/editor-status-widget";
|
|
4
|
+
import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text";
|
|
5
|
+
import type { FileQuote } from "./file-context.js";
|
|
6
|
+
import { displayPath } from "./scope/display.js";
|
|
7
|
+
import type { ScopeView } from "./scope/menu.js";
|
|
8
|
+
|
|
9
|
+
/** One owned widget; context summaries never replace an active restriction. */
|
|
10
|
+
export function publishFilesWidget(ctx: ExtensionContext, quotes: readonly FileQuote[], scope?: ScopeView): void {
|
|
11
|
+
if (!ctx.hasUI) return;
|
|
12
|
+
if (!quotes.length && (!scope || scope.state === "inactive")) {
|
|
13
|
+
ctx.ui.setWidget("files", undefined);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const totalBytes = quotes.reduce((total, quote) => total + Buffer.byteLength(quote.text, "utf8"), 0);
|
|
17
|
+
const lines: Array<{ role: "accent" | "muted" | "error"; text: string }> = [];
|
|
18
|
+
if (scope && scope.state !== "inactive") {
|
|
19
|
+
lines.push({
|
|
20
|
+
role: scope.state === "blocked" ? "error" : "accent",
|
|
21
|
+
text: `${scope.state === "blocked" ? "Edit Scope unavailable · edits blocked" : `Editing limited to ${scope.targets.length} file${scope.targets.length === 1 ? "" : "s"}`} · /files off`,
|
|
22
|
+
});
|
|
23
|
+
lines.push({
|
|
24
|
+
role: "muted",
|
|
25
|
+
text: `${scope.targets
|
|
26
|
+
.slice(0, 2)
|
|
27
|
+
.map((target) => displayPath(target.path))
|
|
28
|
+
.join(" · ")}${scope.targets.length > 2 ? " · …" : ""}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (quotes.length) {
|
|
32
|
+
lines.push({
|
|
33
|
+
role: "accent",
|
|
34
|
+
text: `Next prompt context · ${quotes.length} ${quotes.length === 1 ? "snippet" : "snippets"} · ~${Math.max(1, Math.ceil(totalBytes / 4))} tokens · /file-context to review`,
|
|
35
|
+
});
|
|
36
|
+
lines.push(
|
|
37
|
+
...quotes.map((quote, index) => ({
|
|
38
|
+
role: "muted" as const,
|
|
39
|
+
text: `${index + 1}. ${sanitizeTerminalText(escapeControls(quote.path))} · lines ${quote.startLine}-${quote.endLine} · ~${Math.max(1, Math.ceil(Buffer.byteLength(quote.text, "utf8") / 4))} tokens`,
|
|
40
|
+
})),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const render = (theme: Pick<Theme, "fg">) => lines.map((line) => theme.fg(line.role, line.text));
|
|
44
|
+
if (ctx.mode !== "tui") {
|
|
45
|
+
ctx.ui.setWidget("files", render(ctx.ui.theme));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
ctx.ui.setWidget(
|
|
49
|
+
"files",
|
|
50
|
+
(_tui, theme) =>
|
|
51
|
+
new EditorStatusWidget({
|
|
52
|
+
theme,
|
|
53
|
+
renderBody: (width) =>
|
|
54
|
+
width === 0 ? lines.map(() => "") : render(theme).flatMap((line) => wrapTextWithAnsi(line, width)),
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
function escapeControls(text: string): string {
|
|
59
|
+
return [...text]
|
|
60
|
+
.map((character) => {
|
|
61
|
+
const code = character.charCodeAt(0);
|
|
62
|
+
return code <= 31 || (code >= 127 && code <= 159) ? `\\x${code.toString(16).padStart(2, "0")}` : character;
|
|
63
|
+
})
|
|
64
|
+
.join("");
|
|
65
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type ContextOptions, type ContextService, registerFileContext } from "./context-service.js";
|
|
3
|
+
import type { FileQuote } from "./file-context.js";
|
|
4
|
+
import { publishFilesWidget } from "./files-widget.js";
|
|
5
|
+
import { registerScope, type ScopeOptions, type ScopeView } from "./scope/edit-scope.js";
|
|
6
|
+
|
|
7
|
+
interface Flow {
|
|
8
|
+
kind: "context" | "scope" | "manager";
|
|
9
|
+
controller: AbortController;
|
|
10
|
+
promise: Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
interface Display {
|
|
13
|
+
quotes: readonly FileQuote[];
|
|
14
|
+
scope?: ScopeView;
|
|
15
|
+
}
|
|
16
|
+
export interface FilesOptions {
|
|
17
|
+
context?: ContextOptions;
|
|
18
|
+
scope?: Pick<ScopeOptions, "showMenu">;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The single extension factory; services share UI ownership, not permission state. */
|
|
22
|
+
export async function registerFiles(pi: ExtensionAPI, options: FilesOptions = {}): Promise<void> {
|
|
23
|
+
const flows = new Map<object, Flow>();
|
|
24
|
+
const displays = new Map<object, Display>();
|
|
25
|
+
const display = (ctx: ExtensionContext): Display => {
|
|
26
|
+
let state = displays.get(ctx.sessionManager);
|
|
27
|
+
if (!state) {
|
|
28
|
+
state = { quotes: [] };
|
|
29
|
+
displays.set(ctx.sessionManager, state);
|
|
30
|
+
}
|
|
31
|
+
return state;
|
|
32
|
+
};
|
|
33
|
+
const redraw = (ctx: ExtensionContext) => {
|
|
34
|
+
const state = display(ctx);
|
|
35
|
+
publishFilesWidget(ctx, state.quotes, state.scope);
|
|
36
|
+
};
|
|
37
|
+
const runFlow = (
|
|
38
|
+
ctx: ExtensionContext,
|
|
39
|
+
kind: Flow["kind"],
|
|
40
|
+
run: (ctx: ExtensionContext) => Promise<void> | void,
|
|
41
|
+
): Promise<void> => {
|
|
42
|
+
const key = ctx.sessionManager;
|
|
43
|
+
const previous = flows.get(key);
|
|
44
|
+
if (previous) {
|
|
45
|
+
if (kind === "context" && previous.kind === kind) return previous.promise;
|
|
46
|
+
throw new Error(
|
|
47
|
+
kind === "scope" && previous.kind === "scope"
|
|
48
|
+
? "A Scope menu is already open; close it before issuing another Scope command"
|
|
49
|
+
: "A Files menu is already open; close it before opening another file flow",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const flow: Flow = { kind, controller: new AbortController(), promise: Promise.resolve() };
|
|
53
|
+
const signal = AbortSignal.any([flow.controller.signal, ...(ctx.signal ? [ctx.signal] : [])]);
|
|
54
|
+
const scoped = Object.create(ctx) as ExtensionContext;
|
|
55
|
+
Object.defineProperty(scoped, "signal", { value: signal });
|
|
56
|
+
flows.set(key, flow);
|
|
57
|
+
flow.promise = Promise.resolve()
|
|
58
|
+
.then(() => {
|
|
59
|
+
signal.throwIfAborted();
|
|
60
|
+
return run(scoped);
|
|
61
|
+
})
|
|
62
|
+
.finally(() => {
|
|
63
|
+
if (flows.get(key) === flow) flows.delete(key);
|
|
64
|
+
});
|
|
65
|
+
return flow.promise;
|
|
66
|
+
};
|
|
67
|
+
const cancelFlow = (ctx: ExtensionContext) => {
|
|
68
|
+
const flow = flows.get(ctx.sessionManager);
|
|
69
|
+
if (!flow) return;
|
|
70
|
+
flow.controller.abort(new DOMException("Files session or branch changed", "AbortError"));
|
|
71
|
+
// Reload waits for lifecycle hooks: do not wait here for the command that requested it.
|
|
72
|
+
// Services drain native work and writes; aborted UI continuations cannot publish new state.
|
|
73
|
+
if (flows.get(ctx.sessionManager) === flow) flows.delete(ctx.sessionManager);
|
|
74
|
+
};
|
|
75
|
+
// Run before the service handlers so replaced UI cannot outlive restoration.
|
|
76
|
+
pi.on("session_start", (_event, ctx) => cancelFlow(ctx));
|
|
77
|
+
pi.on("session_tree", (_event, ctx) => cancelFlow(ctx));
|
|
78
|
+
pi.on("session_shutdown", (_event, ctx) => cancelFlow(ctx));
|
|
79
|
+
|
|
80
|
+
const serviceAPI: ExtensionAPI = {
|
|
81
|
+
...pi,
|
|
82
|
+
registerCommand(name, command) {
|
|
83
|
+
pi.registerCommand(name, {
|
|
84
|
+
...command,
|
|
85
|
+
handler: (args, ctx) => {
|
|
86
|
+
const route = name === "file-context" ? args.trim().toLowerCase() : args.trim();
|
|
87
|
+
const valid =
|
|
88
|
+
name === "file-context" ? ["", "browse", "remove"].includes(route) : ["", "off"].includes(route);
|
|
89
|
+
if (!valid || ctx.mode !== "tui") return command.handler(args, ctx);
|
|
90
|
+
return runFlow(ctx, name === "file-context" ? "context" : "scope", (scoped) =>
|
|
91
|
+
command.handler(args, scoped as ExtensionCommandContext),
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
registerShortcut(key, shortcut) {
|
|
97
|
+
pi.registerShortcut(key, {
|
|
98
|
+
...shortcut,
|
|
99
|
+
handler: (ctx) => runFlow(ctx, "context", (scoped) => shortcut.handler(scoped)),
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
// Enforcement is registered before the first settings await and stays eager.
|
|
104
|
+
const scope = registerScope(serviceAPI, {
|
|
105
|
+
...options.scope,
|
|
106
|
+
onChange: (ctx, state) => {
|
|
107
|
+
display(ctx).scope = state;
|
|
108
|
+
redraw(ctx);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const context: ContextService = await registerFileContext(serviceAPI, {
|
|
112
|
+
...options.context,
|
|
113
|
+
onChange: (ctx, quotes) => {
|
|
114
|
+
display(ctx).quotes = quotes;
|
|
115
|
+
redraw(ctx);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
pi.registerCommand("files", {
|
|
120
|
+
description: "Browse file context and manage allowed edit targets",
|
|
121
|
+
getArgumentCompletions: (prefix) =>
|
|
122
|
+
[
|
|
123
|
+
{ value: "browse", label: "browse", description: "Open the context browser" },
|
|
124
|
+
{ value: "off", label: "off", description: "End the editing restriction" },
|
|
125
|
+
].filter((item) => item.value.startsWith(prefix)),
|
|
126
|
+
handler: async (args, ctx) => {
|
|
127
|
+
const route = args.trim();
|
|
128
|
+
if (route !== "" && route !== "browse" && route !== "off") throw new Error("Usage: /files [browse|off]");
|
|
129
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error("/files requires Pi TUI mode");
|
|
130
|
+
await runFlow(ctx, route === "browse" ? "context" : "manager", async (scoped) => {
|
|
131
|
+
if (route === "browse") return context.browse(scoped);
|
|
132
|
+
const commandCtx = scoped as ExtensionCommandContext;
|
|
133
|
+
if (route === "off") {
|
|
134
|
+
scope.off(commandCtx);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const { showFilesMenu } = await import("./files-menu.js");
|
|
138
|
+
scoped.signal?.throwIfAborted();
|
|
139
|
+
await showFilesMenu(commandCtx, context, scope);
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
144
|
+
displays.delete(ctx.sessionManager);
|
|
145
|
+
if (ctx.hasUI) ctx.ui.setWidget("files", undefined);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export default registerFiles;
|