@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -2
  4. package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
  5. package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
  6. package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
  7. package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
  8. package/dist/chunks/chunk-F5QFC255.ts +101 -0
  9. package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
  10. package/dist/chunks/chunk-NFNGJETD.ts +77 -0
  11. package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
  12. package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
  13. package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
  14. package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
  15. package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
  16. package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
  17. package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
  18. package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
  19. package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
  20. package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
  21. package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
  22. package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
  23. package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
  24. package/dist/chunks/menu-P6IBMROH.ts +876 -0
  25. package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
  26. package/dist/index.ts +1789 -0
  27. package/dist/index.ts.map +7 -0
  28. package/docs/edit-scope-reference.md +142 -0
  29. package/docs/file-context-reference.md +91 -0
  30. package/docs/migration.md +105 -0
  31. package/docs/verification.md +99 -0
  32. package/package.json +58 -4
  33. package/src/content-search-session.ts +204 -0
  34. package/src/content-search-ui.ts +159 -0
  35. package/src/content-search.ts +170 -0
  36. package/src/context-service.ts +515 -0
  37. package/src/external-editor.ts +143 -0
  38. package/src/file-browser-ui.ts +128 -0
  39. package/src/file-browser.ts +76 -0
  40. package/src/file-context-explorer.ts +992 -0
  41. package/src/file-context-menu.ts +360 -0
  42. package/src/file-context-preview-ui.ts +354 -0
  43. package/src/file-context-settings.ts +301 -0
  44. package/src/file-context.ts +321 -0
  45. package/src/file-search.ts +246 -0
  46. package/src/files-menu.ts +125 -0
  47. package/src/files-settings.ts +314 -0
  48. package/src/files-widget.ts +65 -0
  49. package/src/files.ts +148 -0
  50. package/src/git-context.ts +485 -0
  51. package/src/index.ts +1 -0
  52. package/src/scope/contracts.ts +243 -0
  53. package/src/scope/display.ts +27 -0
  54. package/src/scope/edit-scope.ts +435 -0
  55. package/src/scope/files.ts +118 -0
  56. package/src/scope/input.ts +207 -0
  57. package/src/scope/menu.ts +277 -0
  58. package/src/scope/paths.ts +142 -0
  59. package/src/scope/picker-keys.ts +154 -0
  60. package/src/scope/picker.ts +484 -0
  61. package/src/scope/policy.ts +55 -0
  62. package/src/scope/settings.ts +138 -0
  63. package/src/scope/state.ts +91 -0
  64. package/src/scope/ui.ts +106 -0
@@ -0,0 +1,55 @@
1
+ import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import type { ScopeState } from "./state.js";
3
+
4
+ const READERS = new Set(["read", "ls", "find", "grep"]);
5
+ const WRITERS = new Set(["write", "edit"]);
6
+
7
+ /** Provenance is a stock-CLI compatibility check, not an implementation attestation. */
8
+ export function isStockBuiltin(tool: ToolInfo | undefined, name: string): boolean {
9
+ return tool?.sourceInfo.source === "builtin" && tool.sourceInfo.path === `builtin:${name}`;
10
+ }
11
+
12
+ export function assertUsableLoadout(pi: Pick<ExtensionAPI, "getAllTools" | "getActiveTools" | "getSettings">): void {
13
+ const tools = pi.getAllTools();
14
+ const active = new Set(pi.getActiveTools());
15
+ const codemode = tools.find((tool) => tool.name === "codemode");
16
+ if (active.has("codemode") && isStockBuiltin(codemode, "codemode") && pi.getSettings().codemode?.mode === "only") {
17
+ throw new Error(
18
+ "Scope cannot start with active codemode.mode=only: codemode can bypass tool guards and hides the direct editing tools. Set Pi's codemode.mode to on and reload first; Scope does not change that setting.",
19
+ );
20
+ }
21
+ if (
22
+ ![...WRITERS].some(
23
+ (name) =>
24
+ active.has(name) &&
25
+ isStockBuiltin(
26
+ tools.find((tool) => tool.name === name),
27
+ name,
28
+ ),
29
+ )
30
+ ) {
31
+ throw new Error(
32
+ "Scope requires an active, unmodified built-in write or edit tool; custom replacements are unsupported",
33
+ );
34
+ }
35
+ }
36
+
37
+ export type ToolDecision = { kind: "pass" } | { kind: "target" } | { kind: "block"; reason: string };
38
+
39
+ export function decideTool(name: string, tools: readonly ToolInfo[], scope: ScopeState): ToolDecision {
40
+ if (scope.state === "inactive") return { kind: "pass" };
41
+ const tool = tools.find((candidate) => candidate.name === name);
42
+ if (READERS.has(name) && isStockBuiltin(tool, name)) return { kind: "pass" };
43
+ if (scope.state === "blocked") {
44
+ return {
45
+ kind: "block",
46
+ reason:
47
+ scope.error ?? "Scope is not initialized; edits remain blocked. Use /scope to repair or /scope off to exit.",
48
+ };
49
+ }
50
+ if (WRITERS.has(name) && isStockBuiltin(tool, name)) return { kind: "target" };
51
+ return {
52
+ kind: "block",
53
+ reason: `${JSON.stringify(name)} is blocked while Scope is active. Only stock readers and target-checked write/edit calls are supported. Shell, codemode, delegation, and opaque tools require /scope off.`,
54
+ };
55
+ }
@@ -0,0 +1,138 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+
5
+ export interface ScopeSettings {
6
+ showHiddenFiles: boolean;
7
+ }
8
+
9
+ // Keep one lexical key across first creation and atomic replacement. A file-identity
10
+ // queue can change keys when a missing file appears through a parent symlink.
11
+ // In-process ordering only, shared by stores with the same absolute lexical path.
12
+ // This is not a cross-process lock and does not merge aliases of parent symlinks.
13
+ const queues = new Map<string, Promise<void>>();
14
+
15
+ function enqueue<T>(path: string, operation: () => Promise<T>): Promise<T> {
16
+ const result = (queues.get(path) ?? Promise.resolve()).then(operation);
17
+ const settled = result.then(
18
+ () => {},
19
+ () => {},
20
+ );
21
+ queues.set(path, settled);
22
+ void settled.then(() => {
23
+ if (queues.get(path) === settled) queues.delete(path);
24
+ });
25
+ return result;
26
+ }
27
+
28
+ function isMissing(error: unknown): boolean {
29
+ return !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
30
+ }
31
+
32
+ function validateDocument(value: unknown, path: string): Record<string, unknown> {
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
34
+ throw new Error(`Invalid Scope settings at ${JSON.stringify(path)}: expected a JSON object.`);
35
+ }
36
+ const document = value as Record<string, unknown>;
37
+ if (Object.hasOwn(document, "showHiddenFiles") && typeof document.showHiddenFiles !== "boolean") {
38
+ throw new Error(`Invalid Scope settings at ${JSON.stringify(path)}: showHiddenFiles must be a boolean.`);
39
+ }
40
+ return document;
41
+ }
42
+
43
+ async function readDocument(path: string): Promise<{ document: Record<string, unknown>; mode: number }> {
44
+ let stats: Awaited<ReturnType<typeof fs.lstat>>;
45
+ try {
46
+ stats = await fs.lstat(path);
47
+ } catch (error) {
48
+ if (isMissing(error)) return { document: {}, mode: 0o600 };
49
+ throw error;
50
+ }
51
+ // Atomic rename would replace a settings symlink instead of its referent.
52
+ // Reject it explicitly, including dangling links, rather than surprise the user.
53
+ if (stats.isSymbolicLink() || !stats.isFile()) {
54
+ throw new Error(`Scope settings must be a regular file, not a symlink: ${JSON.stringify(path)}.`);
55
+ }
56
+ const text = await fs.readFile(path, "utf8");
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(text);
60
+ } catch (error) {
61
+ throw new Error(`Invalid Scope settings JSON at ${JSON.stringify(path)}. Repair the file before saving.`, {
62
+ cause: error,
63
+ });
64
+ }
65
+ return { document: validateDocument(parsed, path), mode: Number(stats.mode) & 0o777 };
66
+ }
67
+
68
+ export class ScopeSettingsStore {
69
+ readonly #filePath: string;
70
+ #current: ScopeSettings = { showHiddenFiles: false };
71
+
72
+ constructor(filePath: string) {
73
+ this.#filePath = resolve(filePath);
74
+ }
75
+
76
+ get current(): ScopeSettings {
77
+ return { ...this.#current };
78
+ }
79
+
80
+ /** Side-effect-free load; a failed read leaves the last effective value intact. */
81
+ load(): Promise<ScopeSettings> {
82
+ return enqueue(this.#filePath, async () => {
83
+ const { document } = await readDocument(this.#filePath);
84
+ this.#current = { showHiddenFiles: (document.showHiddenFiles as boolean | undefined) ?? false };
85
+ return this.current;
86
+ });
87
+ }
88
+
89
+ /** Read the latest valid document and change only the owned field. */
90
+ saveShowHiddenFiles(value: boolean): Promise<ScopeSettings> {
91
+ return enqueue(this.#filePath, async () => {
92
+ if (typeof value !== "boolean") throw new Error("showHiddenFiles must be a boolean.");
93
+ const { document, mode } = await readDocument(this.#filePath);
94
+ const directory = dirname(this.#filePath);
95
+ await fs.mkdir(directory, { recursive: true });
96
+ const temporary = join(directory, `.${basename(this.#filePath)}.${process.pid}.${randomUUID()}.tmp`);
97
+ let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
98
+ let ownedTemporary = false;
99
+ try {
100
+ handle = await fs.open(temporary, "wx", mode);
101
+ ownedTemporary = true;
102
+ await handle.writeFile(`${JSON.stringify({ ...document, showHiddenFiles: value }, null, 2)}\n`, "utf8");
103
+ await handle.close();
104
+ handle = undefined;
105
+ await fs.rename(temporary, this.#filePath);
106
+ ownedTemporary = false;
107
+ } catch (error) {
108
+ const failures: unknown[] = [error];
109
+ if (handle) {
110
+ try {
111
+ await handle.close();
112
+ } catch (closeError) {
113
+ failures.push(closeError);
114
+ }
115
+ }
116
+ if (ownedTemporary) {
117
+ try {
118
+ await fs.unlink(temporary);
119
+ } catch (cleanupError) {
120
+ if (!isMissing(cleanupError)) failures.push(cleanupError);
121
+ }
122
+ }
123
+ if (failures.length > 1) {
124
+ throw new AggregateError(failures, `Scope settings save and cleanup failed at ${JSON.stringify(temporary)}.`);
125
+ }
126
+ throw error;
127
+ }
128
+ // Publish runtime state only after the complete file was renamed successfully.
129
+ this.#current = { showHiddenFiles: value };
130
+ return this.current;
131
+ });
132
+ }
133
+
134
+ /** Drain prior operations; load/save promises carry their own errors to the caller. */
135
+ flush(): Promise<void> {
136
+ return enqueue(this.#filePath, async () => {});
137
+ }
138
+ }
@@ -0,0 +1,91 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import type { ScopeTarget } from "./paths.js";
4
+
5
+ // Persisted session identity must survive package renames or active guards would be lost.
6
+ export const STATE_TYPE = "scope-state";
7
+ export const MAX_TARGETS = 100;
8
+ export const MAX_STATE_BYTES = 24_000;
9
+
10
+ export interface ScopeSnapshot {
11
+ version: 1;
12
+ cwd: string;
13
+ targets: ScopeTarget[] | null;
14
+ }
15
+
16
+ export interface ScopeState {
17
+ state: "inactive" | "active" | "blocked";
18
+ entryId?: string;
19
+ snapshot?: ScopeSnapshot;
20
+ error?: string;
21
+ }
22
+
23
+ function absolutePath(value: unknown): value is string {
24
+ return typeof value === "string" && !value.includes("\0") && isAbsolute(value) && resolve(value) === value;
25
+ }
26
+
27
+ export function createSnapshot(cwd: string, targets: readonly ScopeTarget[] | null): ScopeSnapshot {
28
+ if (!absolutePath(cwd)) throw new Error("Scope workspace must be an absolute normalized path");
29
+ if (targets !== null && (targets.length === 0 || targets.length > MAX_TARGETS)) {
30
+ throw new Error(`Select between 1 and ${MAX_TARGETS} files; use /scope off to end the restriction`);
31
+ }
32
+ const unique = new Map<string, ScopeTarget>();
33
+ for (const target of targets ?? []) {
34
+ if (!absolutePath(target?.path) || !absolutePath(target?.canonical)) {
35
+ throw new Error("Scope targets must contain absolute normalized paths");
36
+ }
37
+ if (!unique.has(target.canonical)) unique.set(target.canonical, { path: target.path, canonical: target.canonical });
38
+ }
39
+ const snapshot: ScopeSnapshot = {
40
+ version: 1,
41
+ cwd,
42
+ targets:
43
+ targets === null
44
+ ? null
45
+ : [...unique.values()].sort((a, b) => (a.canonical < b.canonical ? -1 : a.canonical > b.canonical ? 1 : 0)),
46
+ };
47
+ if (Buffer.byteLength(JSON.stringify(snapshot)) > MAX_STATE_BYTES) {
48
+ throw new Error(`Scope selection exceeds the ${MAX_STATE_BYTES}-byte notice limit; select fewer files`);
49
+ }
50
+ return snapshot;
51
+ }
52
+
53
+ export function parseSnapshot(value: unknown): ScopeSnapshot {
54
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid scope snapshot");
55
+ const data = value as Record<string, unknown>;
56
+ if (data.version !== 1 || typeof data.cwd !== "string" || (data.targets !== null && !Array.isArray(data.targets))) {
57
+ throw new Error("Invalid or unsupported scope snapshot");
58
+ }
59
+ return createSnapshot(data.cwd, data.targets as ScopeTarget[] | null);
60
+ }
61
+
62
+ export function restoreScope(branch: readonly SessionEntry[], cwd: string): ScopeState {
63
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
64
+ const entry = branch[index];
65
+ if (entry.type !== "custom" || entry.customType !== STATE_TYPE) continue;
66
+ try {
67
+ const snapshot = parseSnapshot(entry.data);
68
+ if (snapshot.cwd !== resolve(cwd)) throw new Error("Saved scope belongs to another workspace");
69
+ return { state: snapshot.targets === null ? "inactive" : "active", snapshot, entryId: entry.id };
70
+ } catch (error) {
71
+ return {
72
+ state: "blocked",
73
+ entryId: entry.id,
74
+ error: `${error instanceof Error ? error.message : "Scope restoration failed"}. Edits remain blocked; repair with /scope or exit with /scope off.`,
75
+ };
76
+ }
77
+ }
78
+ return { state: "inactive" };
79
+ }
80
+
81
+ export function sameScope(left: ScopeSnapshot | undefined, right: ScopeSnapshot): boolean {
82
+ if (!left || left.cwd !== right.cwd || (left.targets === null) !== (right.targets === null)) return false;
83
+ if (left.targets === null || right.targets === null) return left.targets === right.targets;
84
+ return (
85
+ left.targets.length === right.targets.length &&
86
+ left.targets.every((target, index) => {
87
+ const other = right.targets?.[index];
88
+ return target.canonical === other?.canonical && target.path === other.path;
89
+ })
90
+ );
91
+ }
@@ -0,0 +1,106 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { ExtensionCommandContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import type { Component, TUI } from "@earendil-works/pi-tui";
4
+ import { HorizontalRule, hardWrapTerminalDocument, renderBoundedFrameLayout } from "@narumitw/pi-tui-kit";
5
+
6
+ export function scopeFrame(
7
+ tui: TUI,
8
+ theme: Theme,
9
+ width: number,
10
+ title: string,
11
+ context: readonly string[],
12
+ content: readonly string[],
13
+ priorityRows: readonly number[],
14
+ focusedRow?: number,
15
+ hints: readonly string[] = [],
16
+ ) {
17
+ // The owned overlay uses terminal bounds; three spare rows are presentation
18
+ // space, not a prediction of the native editor dock or another widget's height.
19
+ const rows = Number.isFinite(tui.terminal.rows) ? Math.floor(tui.terminal.rows) : 24;
20
+ const rule = new HorizontalRule({ ruleStyle: (line) => theme.fg("border", line) });
21
+ return renderBoundedFrameLayout({
22
+ width,
23
+ maxRows: Math.max(1, rows - 3),
24
+ rule: rule.render(width)[0] ?? "",
25
+ title: [theme.fg("accent", theme.bold(title))],
26
+ context: context.flatMap((line) => hardWrapTerminalDocument(line, width).map((part) => theme.fg("muted", part))),
27
+ content,
28
+ hints: hints.flatMap((line) => exactRows(line, width).map((part) => theme.fg("dim", part))),
29
+ compactHint: hints[0] ? theme.fg("dim", hints[0]) : "",
30
+ priorityRows,
31
+ focusedRow,
32
+ });
33
+ }
34
+
35
+ export function exactRows(value: string, width: number): string[] {
36
+ return hardWrapTerminalDocument(value, width);
37
+ }
38
+
39
+ /**
40
+ * Pi does not expose the editor dock's allocated height to custom components.
41
+ * A full-width bottom overlay gives every Scope screen the real terminal bounds
42
+ * without inspecting, hiding, or changing another component's widgets or footer.
43
+ */
44
+ export function scopeOverlayContext(ctx: ExtensionCommandContext): ExtensionCommandContext {
45
+ const ui = ctx.ui;
46
+ const custom: ExtensionUIContext["custom"] = (factory, options) =>
47
+ ui.custom(
48
+ (tui, theme, keys, done) => {
49
+ const created = factory(tui, theme, keys, done);
50
+ return created instanceof Promise
51
+ ? created.then((component) => boundedOverlay(component, tui, theme))
52
+ : boundedOverlay(created, tui, theme);
53
+ },
54
+ {
55
+ ...options,
56
+ overlay: true,
57
+ overlayOptions: {
58
+ anchor: "bottom-left",
59
+ width: "100%",
60
+ maxHeight: "100%",
61
+ margin: 0,
62
+ },
63
+ },
64
+ );
65
+ const scopedUI = new Proxy(ui, {
66
+ get(target, key) {
67
+ if (key === "custom") return custom;
68
+ const value = Reflect.get(target, key, target);
69
+ return typeof value === "function" ? value.bind(target) : value;
70
+ },
71
+ });
72
+ return new Proxy(ctx, {
73
+ get(target, key) {
74
+ if (key === "ui") return scopedUI;
75
+ const value = Reflect.get(target, key, target);
76
+ return typeof value === "function" ? value.bind(target) : value;
77
+ },
78
+ });
79
+ }
80
+
81
+ function boundedOverlay<C extends Component>(component: C, tui: TUI, theme: Theme): C {
82
+ const render = (width: number) => {
83
+ width = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
84
+ if (width === 0) return [];
85
+ const lines = component.render(width);
86
+ const height = Math.max(1, Number.isFinite(tui.terminal.rows) ? Math.floor(tui.terminal.rows) : 24);
87
+ if (lines.length <= height) return lines;
88
+ // Scope and Kit menu frames already fit. Kit's fixed-height task loader can
89
+ // overflow tiny terminals: remove only its frame/spacing, then retain the
90
+ // task label through the public bounded frame without changing cancellation.
91
+ const rule = new HorizontalRule({ ruleStyle: (line) => theme.fg("border", line) }).render(width)[0] ?? "";
92
+ const framed = lines[0] === rule && lines.at(-1) === rule;
93
+ const content = (framed ? lines.slice(1, -1) : lines).filter((line) => stripVTControlCharacters(line).trim());
94
+ return renderBoundedFrameLayout({ width, maxRows: height, rule, title: [], content, priorityRows: [0] }).lines;
95
+ };
96
+ return new Proxy(component, {
97
+ get(target, key) {
98
+ if (key === "render") return render;
99
+ const value = Reflect.get(target, key, target);
100
+ return typeof value === "function" ? value.bind(target) : value;
101
+ },
102
+ set(target, key, value) {
103
+ return Reflect.set(target, key, value, target);
104
+ },
105
+ });
106
+ }