specpi 0.10.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 +150 -0
- package/LICENSE +21 -0
- package/NPM_RELEASE.md +110 -0
- package/README.md +155 -0
- package/SECURITY.md +85 -0
- package/SECURITY_MODEL.md +107 -0
- package/THIRD_PARTY.md +61 -0
- package/browser-runtime/package-lock.json +86 -0
- package/browser-runtime/package.json +15 -0
- package/extensions/browser/core.mjs +306 -0
- package/extensions/browser/index.ts +723 -0
- package/extensions/browser/smoke.mjs +47 -0
- package/extensions/command-guard/bash.mjs +1426 -0
- package/extensions/command-guard/cmd.mjs +369 -0
- package/extensions/command-guard/core.mjs +506 -0
- package/extensions/command-guard/index.ts +634 -0
- package/extensions/command-guard/managed-files.mjs +22 -0
- package/extensions/command-guard/paths.mjs +398 -0
- package/extensions/command-guard/powershell-parser.ps1 +47 -0
- package/extensions/command-guard/powershell.mjs +655 -0
- package/extensions/command-guard/redact.mjs +65 -0
- package/extensions/command-guard/rules.mjs +2557 -0
- package/extensions/command-guard/smoke.mjs +422 -0
- package/extensions/files/core.mjs +422 -0
- package/extensions/files/index.ts +678 -0
- package/extensions/spec/core.mjs +47 -0
- package/extensions/spec.ts +457 -0
- package/extensions/tool-wishlist/capabilities.json +114 -0
- package/extensions/tool-wishlist/core.mjs +1525 -0
- package/extensions/tool-wishlist/index.ts +804 -0
- package/extensions/tool-wishlist/registry.mjs +99 -0
- package/extensions/tool-wishlist/validators.mjs +345 -0
- package/extensions/ui-refresh/index.ts +54 -0
- package/extensions/workflow-controls/challenge.mjs +196 -0
- package/extensions/workflow-controls/experiments.mjs +628 -0
- package/extensions/workflow-controls/index.ts +1144 -0
- package/extensions/workflow-controls/scope.mjs +272 -0
- package/extensions/workflow-controls/smoke.mjs +201 -0
- package/package.json +98 -0
- package/scripts/check-package.mjs +483 -0
- package/scripts/check-pi-package.mjs +223 -0
- package/scripts/check-release-order.mjs +97 -0
- package/scripts/lib.mjs +182 -0
- package/scripts/lock.mjs +122 -0
- package/scripts/specpi.mjs +2037 -0
- package/scripts/verify-artifact.mjs +21 -0
- package/shell/pi-profiles.sh +14 -0
- package/site/logo.svg +9 -0
- package/site/self-improvement-loop-v2.svg +108 -0
- package/skills/donsetch/SKILL.md +76 -0
- package/skills/specpi-improve/SKILL.md +54 -0
- package/specpi +4 -0
- package/specpi.cmd +4 -0
- package/templates/AGENTS.md +23 -0
- package/templates/settings.json +10 -0
- package/themes/specpi-spec.json +96 -0
- package/themes/tea-house.json +89 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecPi Files — a dependency-free, theme-native project browser.
|
|
3
|
+
*
|
|
4
|
+
* /files [path] opens a compact tree, themed source/Markdown viewer,
|
|
5
|
+
* git diff view, and line-comment workflow without external renderers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { getLanguageFromPath, getMarkdownTheme, highlightCode } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Key, Markdown, matchesKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
12
|
+
import {
|
|
13
|
+
buildFileTree,
|
|
14
|
+
discoverProject,
|
|
15
|
+
flattenFileTree,
|
|
16
|
+
formatReviewMessage,
|
|
17
|
+
readGitDiff,
|
|
18
|
+
readTextFile,
|
|
19
|
+
resolveBrowserRoot,
|
|
20
|
+
sanitizeTerminalText,
|
|
21
|
+
} from "./core.mjs";
|
|
22
|
+
|
|
23
|
+
type Screen = "tree" | "file";
|
|
24
|
+
type InputMode = "normal" | "search" | "select" | "comment";
|
|
25
|
+
|
|
26
|
+
type TreeNode = {
|
|
27
|
+
name: string;
|
|
28
|
+
relativePath: string;
|
|
29
|
+
absolutePath: string;
|
|
30
|
+
parent?: TreeNode;
|
|
31
|
+
directory: boolean;
|
|
32
|
+
children?: TreeNode[];
|
|
33
|
+
status?: string;
|
|
34
|
+
changed: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type Snapshot = ReturnType<typeof discoverProject>;
|
|
38
|
+
type TreeRow = { node: TreeNode; depth: number };
|
|
39
|
+
|
|
40
|
+
const DEFAULT_HEIGHT = 20;
|
|
41
|
+
const MIN_HEIGHT = 8;
|
|
42
|
+
const MAX_HEIGHT = 40;
|
|
43
|
+
|
|
44
|
+
function compactHome(filePath: string): string {
|
|
45
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
46
|
+
if (!home) {
|
|
47
|
+
return filePath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (filePath === home) {
|
|
51
|
+
return "~";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return filePath.startsWith(`${home}${path.sep}`) ? `~${filePath.slice(home.length)}` : filePath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printableInput(data: string): string {
|
|
58
|
+
if (!data || data.includes("\x1b")) {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [...data]
|
|
63
|
+
.filter((character) => {
|
|
64
|
+
const code = character.codePointAt(0) ?? 0;
|
|
65
|
+
|
|
66
|
+
return code >= 32 && (code < 127 || code > 159);
|
|
67
|
+
})
|
|
68
|
+
.join("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function borderLine(
|
|
72
|
+
left: string,
|
|
73
|
+
right: string,
|
|
74
|
+
label: string,
|
|
75
|
+
width: number,
|
|
76
|
+
theme: Theme,
|
|
77
|
+
color: "borderAccent" | "borderMuted" = "borderMuted",
|
|
78
|
+
): string {
|
|
79
|
+
if (width <= 1) {
|
|
80
|
+
return theme.fg(color, "─".repeat(Math.max(0, width)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const middleWidth = width - 2;
|
|
84
|
+
const title = truncateToWidth(label, middleWidth, "");
|
|
85
|
+
const fill = "─".repeat(Math.max(0, middleWidth - visibleWidth(title)));
|
|
86
|
+
|
|
87
|
+
return theme.fg(color, `${left}${title}${fill}${right}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function framedRow(content: string, width: number, theme: Theme, selected = false): string {
|
|
91
|
+
if (width <= 2) {
|
|
92
|
+
return truncateToWidth(content, width, "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const innerWidth = width - 4;
|
|
96
|
+
const clipped = truncateToWidth(content, innerWidth, "");
|
|
97
|
+
const padded = `${clipped}${" ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)))}`;
|
|
98
|
+
const body = selected ? theme.bg("selectedBg", padded) : padded;
|
|
99
|
+
|
|
100
|
+
return `${theme.fg("borderMuted", "│")} ${body} ${theme.fg("borderMuted", "│")}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function statusLabel(status: string | undefined, theme: Theme): string {
|
|
104
|
+
if (!status) {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (status === "??") {
|
|
109
|
+
return theme.fg("dim", " ?");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (status.includes("D")) {
|
|
113
|
+
return theme.fg("error", " D");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (status.includes("A")) {
|
|
117
|
+
return theme.fg("success", " A");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return theme.fg("warning", " M");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function diffLine(line: string, theme: Theme): string {
|
|
124
|
+
const safe = sanitizeTerminalText(line);
|
|
125
|
+
if (safe.startsWith("+++ ") || safe.startsWith("--- ")) {
|
|
126
|
+
return theme.fg("muted", safe);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (safe.startsWith("+")) {
|
|
130
|
+
return theme.fg("toolDiffAdded", safe);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (safe.startsWith("-")) {
|
|
134
|
+
return theme.fg("toolDiffRemoved", safe);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (safe.startsWith("@@")) {
|
|
138
|
+
return theme.fg("accent", safe);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return theme.fg("toolDiffContext", safe);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class SpecFilesComponent implements Component {
|
|
145
|
+
private snapshot: Snapshot;
|
|
146
|
+
private root: TreeNode;
|
|
147
|
+
private expanded = new Set<string>();
|
|
148
|
+
private rows: TreeRow[] = [];
|
|
149
|
+
private selected = 0;
|
|
150
|
+
private scroll = 0;
|
|
151
|
+
private screen: Screen = "tree";
|
|
152
|
+
private mode: InputMode = "normal";
|
|
153
|
+
private query = "";
|
|
154
|
+
private changedOnly = false;
|
|
155
|
+
private height = DEFAULT_HEIGHT;
|
|
156
|
+
private file?: TreeNode;
|
|
157
|
+
private raw = "";
|
|
158
|
+
private rawLines: string[] = [];
|
|
159
|
+
private fileError = "";
|
|
160
|
+
private diffMode = false;
|
|
161
|
+
private markdownMode = false;
|
|
162
|
+
private selectionAnchor = 0;
|
|
163
|
+
private selectionCursor = 0;
|
|
164
|
+
private comment = "";
|
|
165
|
+
private renderWidth = 80;
|
|
166
|
+
private renderedCache?: { width: number; kind: string; lines: string[] };
|
|
167
|
+
|
|
168
|
+
constructor(
|
|
169
|
+
private readonly initialRoot: string,
|
|
170
|
+
private theme: Theme,
|
|
171
|
+
private readonly requestRender: () => void,
|
|
172
|
+
private readonly close: () => void,
|
|
173
|
+
private readonly notify: (message: string, level: "info" | "warning" | "error" | "success") => void,
|
|
174
|
+
private readonly sendComment: (message: string) => void,
|
|
175
|
+
) {
|
|
176
|
+
this.snapshot = discoverProject(initialRoot);
|
|
177
|
+
this.root = buildFileTree(this.snapshot) as TreeNode;
|
|
178
|
+
this.refreshRows();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private refreshRows(): void {
|
|
182
|
+
this.rows = flattenFileTree(this.root, this.expanded, this.query, this.changedOnly) as TreeRow[];
|
|
183
|
+
this.selected = Math.max(0, Math.min(this.selected, Math.max(0, this.rows.length - 1)));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private reloadTree(): void {
|
|
187
|
+
const selectedPath = this.rows[this.selected]?.node.relativePath;
|
|
188
|
+
this.snapshot = discoverProject(this.initialRoot);
|
|
189
|
+
this.root = buildFileTree(this.snapshot) as TreeNode;
|
|
190
|
+
this.refreshRows();
|
|
191
|
+
if (selectedPath) {
|
|
192
|
+
const next = this.rows.findIndex((row) => row.node.relativePath === selectedPath);
|
|
193
|
+
if (next >= 0) {
|
|
194
|
+
this.selected = next;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.file) {
|
|
199
|
+
const filePath = this.file.relativePath;
|
|
200
|
+
const row = flattenFileTree(this.root, new Set<string>([...this.expanded]), filePath, false).find(
|
|
201
|
+
(candidate: TreeRow) => candidate.node.relativePath === filePath,
|
|
202
|
+
) as TreeRow | undefined;
|
|
203
|
+
if (row) {
|
|
204
|
+
this.file = row.node;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.renderedCache = undefined;
|
|
209
|
+
this.notify("Spec Files refreshed.", "info");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private openFile(node: TreeNode): void {
|
|
213
|
+
this.file = node;
|
|
214
|
+
this.screen = "file";
|
|
215
|
+
this.mode = "normal";
|
|
216
|
+
this.scroll = 0;
|
|
217
|
+
this.query = "";
|
|
218
|
+
this.fileError = "";
|
|
219
|
+
this.diffMode = Boolean(node.status && node.status !== "??" && this.snapshot.repoRoot);
|
|
220
|
+
this.markdownMode = /\.md(?:own)?$/i.test(node.name) && !this.diffMode;
|
|
221
|
+
try {
|
|
222
|
+
this.raw = readTextFile(node.absolutePath, undefined, this.initialRoot);
|
|
223
|
+
this.rawLines = this.raw.split("\n");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.raw = "";
|
|
226
|
+
this.rawLines = [];
|
|
227
|
+
this.fileError = error instanceof Error ? error.message : String(error);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.renderedCache = undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private backToTree(): void {
|
|
234
|
+
this.screen = "tree";
|
|
235
|
+
this.mode = "normal";
|
|
236
|
+
this.query = "";
|
|
237
|
+
this.scroll = 0;
|
|
238
|
+
this.file = undefined;
|
|
239
|
+
this.comment = "";
|
|
240
|
+
this.renderedCache = undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private fileContent(width: number): string[] {
|
|
244
|
+
const contentWidth = Math.max(1, width - 8);
|
|
245
|
+
const kind = this.diffMode ? "diff" : this.markdownMode ? "markdown" : "source";
|
|
246
|
+
if (this.renderedCache?.width === contentWidth && this.renderedCache.kind === kind) {
|
|
247
|
+
return this.renderedCache.lines;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let lines: string[];
|
|
251
|
+
if (this.diffMode && this.file) {
|
|
252
|
+
const diff = readGitDiff(this.file.absolutePath, this.snapshot.repoRoot);
|
|
253
|
+
lines =
|
|
254
|
+
diff.length > 0
|
|
255
|
+
? diff.map((line) => diffLine(line, this.theme))
|
|
256
|
+
: [this.theme.fg("dim", "No diff against HEAD.")];
|
|
257
|
+
} else if (this.fileError) {
|
|
258
|
+
lines = [this.theme.fg("error", sanitizeTerminalText(this.fileError))];
|
|
259
|
+
} else if (this.markdownMode) {
|
|
260
|
+
const markdown = new Markdown(
|
|
261
|
+
sanitizeTerminalText(this.raw, { preserveNewlines: true }),
|
|
262
|
+
0,
|
|
263
|
+
0,
|
|
264
|
+
getMarkdownTheme(),
|
|
265
|
+
);
|
|
266
|
+
lines = markdown.render(contentWidth);
|
|
267
|
+
} else {
|
|
268
|
+
const language = this.file ? getLanguageFromPath(this.file.absolutePath) : undefined;
|
|
269
|
+
const safeSource = sanitizeTerminalText(this.raw, { preserveNewlines: true });
|
|
270
|
+
const highlighted = highlightCode(safeSource, language, this.theme).split("\n");
|
|
271
|
+
const digits = Math.max(3, String(Math.max(1, highlighted.length)).length);
|
|
272
|
+
lines = highlighted.map((line, index) => {
|
|
273
|
+
const number = this.theme.fg("dim", String(index + 1).padStart(digits));
|
|
274
|
+
|
|
275
|
+
return `${number} ${this.theme.fg("borderMuted", "│")} ${line}`;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.renderedCache = { width: contentWidth, kind, lines };
|
|
280
|
+
|
|
281
|
+
return lines;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private maxScroll(total: number): number {
|
|
285
|
+
return Math.max(0, total - this.height);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private selectedRange(): [number, number] {
|
|
289
|
+
return [
|
|
290
|
+
Math.min(this.selectionAnchor, this.selectionCursor),
|
|
291
|
+
Math.max(this.selectionAnchor, this.selectionCursor),
|
|
292
|
+
];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private submitComment(): void {
|
|
296
|
+
if (!this.file || !this.comment.trim()) {
|
|
297
|
+
this.mode = "normal";
|
|
298
|
+
this.comment = "";
|
|
299
|
+
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const [start, end] = this.selectedRange();
|
|
304
|
+
const selectedText = this.rawLines.slice(start, end + 1).join("\n");
|
|
305
|
+
const message = formatReviewMessage(this.file.relativePath, start + 1, end + 1, selectedText, this.comment);
|
|
306
|
+
this.sendComment(message);
|
|
307
|
+
this.mode = "normal";
|
|
308
|
+
this.comment = "";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private renderTree(width: number): string[] {
|
|
312
|
+
const lines = [borderLine("╭", "╮", "─ Z E N F I L E S ◆ ", width, this.theme, "borderAccent")];
|
|
313
|
+
const changed = [...this.snapshot.statuses.values()].length;
|
|
314
|
+
let subtitle = `${sanitizeTerminalText(compactHome(this.initialRoot))} · ${this.snapshot.files.length} files`;
|
|
315
|
+
if (changed > 0) {
|
|
316
|
+
subtitle += ` · ${changed} changed`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (this.snapshot.truncated) {
|
|
320
|
+
subtitle += " · partial";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (this.mode === "search") {
|
|
324
|
+
subtitle += ` · /${this.query}█`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (this.changedOnly) {
|
|
328
|
+
subtitle += " · changed only";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
lines.push(framedRow(this.theme.fg("muted", subtitle), width, this.theme));
|
|
332
|
+
lines.push(borderLine("├", "┤", "", width, this.theme));
|
|
333
|
+
|
|
334
|
+
if (this.rows.length === 0) {
|
|
335
|
+
lines.push(
|
|
336
|
+
framedRow(
|
|
337
|
+
this.theme.fg("dim", this.query ? "No matching files." : "No files found."),
|
|
338
|
+
width,
|
|
339
|
+
this.theme,
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
for (let index = 1; index < this.height; index += 1) {
|
|
343
|
+
lines.push(framedRow("", width, this.theme));
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
const start = Math.max(
|
|
347
|
+
0,
|
|
348
|
+
Math.min(this.selected - Math.floor(this.height / 2), this.rows.length - this.height),
|
|
349
|
+
);
|
|
350
|
+
const end = Math.min(this.rows.length, start + this.height);
|
|
351
|
+
for (let index = start; index < end; index += 1) {
|
|
352
|
+
const { node, depth } = this.rows[index];
|
|
353
|
+
const open = node.directory && (this.expanded.has(node.relativePath) || Boolean(this.query));
|
|
354
|
+
const marker = node.directory ? (open ? "◇" : "◆") : "·";
|
|
355
|
+
const nameColor = node.directory
|
|
356
|
+
? node.changed
|
|
357
|
+
? "warning"
|
|
358
|
+
: "accent"
|
|
359
|
+
: node.status
|
|
360
|
+
? "warning"
|
|
361
|
+
: "text";
|
|
362
|
+
const safeName = sanitizeTerminalText(node.name);
|
|
363
|
+
const content = `${" ".repeat(depth)}${this.theme.fg(node.directory ? "accent" : "dim", marker)} ${this.theme.fg(nameColor, safeName)}${statusLabel(node.status, this.theme)}`;
|
|
364
|
+
lines.push(framedRow(content, width, this.theme, index === this.selected));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (let index = end - start; index < this.height; index += 1) {
|
|
368
|
+
lines.push(framedRow("", width, this.theme));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
lines.push(borderLine("├", "┤", "", width, this.theme));
|
|
373
|
+
const help =
|
|
374
|
+
this.mode === "search"
|
|
375
|
+
? "type to filter · ↑↓ move · enter accept · esc clear"
|
|
376
|
+
: "j/k move · enter open · h/l fold · / search · c changed · r refresh · q close";
|
|
377
|
+
lines.push(framedRow(this.theme.fg("dim", help), width, this.theme));
|
|
378
|
+
lines.push(borderLine("╰", "╯", "─ quiet review, in place ", width, this.theme));
|
|
379
|
+
|
|
380
|
+
return lines;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private renderFile(width: number): string[] {
|
|
384
|
+
const content = this.fileContent(width);
|
|
385
|
+
const lines = [borderLine("╭", "╮", "─ Z E N V I E W ◆ ", width, this.theme, "borderAccent")];
|
|
386
|
+
let title = sanitizeTerminalText(this.file?.relativePath || "");
|
|
387
|
+
if (this.diffMode) {
|
|
388
|
+
title += " · DIFF";
|
|
389
|
+
} else if (this.markdownMode) {
|
|
390
|
+
title += " · RENDERED";
|
|
391
|
+
} else {
|
|
392
|
+
title += " · SOURCE";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (this.mode === "select" || this.mode === "comment") {
|
|
396
|
+
const [start, end] = this.selectedRange();
|
|
397
|
+
title += ` · L${start + 1}${start === end ? "" : `–${end + 1}`}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
lines.push(framedRow(this.theme.fg("muted", title), width, this.theme));
|
|
401
|
+
lines.push(borderLine("├", "┤", "", width, this.theme));
|
|
402
|
+
|
|
403
|
+
const start = Math.min(this.scroll, this.maxScroll(content.length));
|
|
404
|
+
const [selectionStart, selectionEnd] = this.selectedRange();
|
|
405
|
+
for (let offset = 0; offset < this.height; offset += 1) {
|
|
406
|
+
const index = start + offset;
|
|
407
|
+
const selected =
|
|
408
|
+
!this.diffMode &&
|
|
409
|
+
!this.markdownMode &&
|
|
410
|
+
(this.mode === "select" || this.mode === "comment") &&
|
|
411
|
+
index >= selectionStart &&
|
|
412
|
+
index <= selectionEnd;
|
|
413
|
+
lines.push(framedRow(content[index] || this.theme.fg("dim", "~"), width, this.theme, selected));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
lines.push(borderLine("├", "┤", "", width, this.theme));
|
|
417
|
+
if (this.mode === "comment") {
|
|
418
|
+
const safeComment = sanitizeTerminalText(this.comment);
|
|
419
|
+
lines.push(
|
|
420
|
+
framedRow(
|
|
421
|
+
`${this.theme.fg("accent", "Comment")} ${safeComment || this.theme.fg("dim", "type feedback")}█`,
|
|
422
|
+
width,
|
|
423
|
+
this.theme,
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
lines.push(framedRow(this.theme.fg("dim", "ctrl+enter send · esc cancel"), width, this.theme));
|
|
427
|
+
} else if (this.mode === "select") {
|
|
428
|
+
lines.push(framedRow(this.theme.fg("dim", "j/k select lines · c comment · esc cancel"), width, this.theme));
|
|
429
|
+
} else {
|
|
430
|
+
const markdownHint =
|
|
431
|
+
this.file && /\.md(?:own)?$/i.test(this.file.name) && !this.diffMode ? " · m raw/render" : "";
|
|
432
|
+
const diffHint = this.file?.status && this.file.status !== "??" ? " · d diff/source" : "";
|
|
433
|
+
lines.push(
|
|
434
|
+
framedRow(
|
|
435
|
+
this.theme.fg("dim", `j/k scroll · g/G ends · v select${markdownHint}${diffHint} · q back`),
|
|
436
|
+
width,
|
|
437
|
+
this.theme,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
lines.push(borderLine("╰", "╯", "", width, this.theme));
|
|
443
|
+
|
|
444
|
+
return lines;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
render(width: number): string[] {
|
|
448
|
+
this.renderWidth = width;
|
|
449
|
+
|
|
450
|
+
return this.screen === "tree" ? this.renderTree(width) : this.renderFile(width);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
invalidate(): void {
|
|
454
|
+
this.renderedCache = undefined;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
handleInput(data: string): void {
|
|
458
|
+
if (this.screen === "tree") {
|
|
459
|
+
this.handleTreeInput(data);
|
|
460
|
+
} else {
|
|
461
|
+
this.handleFileInput(data);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
this.requestRender();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private handleTreeInput(data: string): void {
|
|
468
|
+
if (this.mode === "search") {
|
|
469
|
+
if (matchesKey(data, Key.escape)) {
|
|
470
|
+
this.mode = "normal";
|
|
471
|
+
this.query = "";
|
|
472
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
473
|
+
this.mode = "normal";
|
|
474
|
+
} else if (matchesKey(data, Key.backspace)) {
|
|
475
|
+
this.query = this.query.slice(0, -1);
|
|
476
|
+
} else if (matchesKey(data, Key.down) || matchesKey(data, "ctrl+n")) {
|
|
477
|
+
this.selected = Math.min(this.rows.length - 1, this.selected + 1);
|
|
478
|
+
} else if (matchesKey(data, Key.up) || matchesKey(data, "ctrl+p")) {
|
|
479
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
480
|
+
} else {
|
|
481
|
+
this.query += printableInput(data);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
this.refreshRows();
|
|
485
|
+
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (matchesKey(data, "q") || matchesKey(data, Key.escape)) {
|
|
490
|
+
return this.close();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (matchesKey(data, "/")) {
|
|
494
|
+
this.mode = "search";
|
|
495
|
+
this.query = "";
|
|
496
|
+
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
|
|
501
|
+
this.selected = Math.min(this.rows.length - 1, this.selected + 1);
|
|
502
|
+
} else if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
|
|
503
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
504
|
+
} else if (matchesKey(data, Key.pageDown)) {
|
|
505
|
+
this.selected = Math.min(this.rows.length - 1, this.selected + this.height);
|
|
506
|
+
} else if (matchesKey(data, Key.pageUp)) {
|
|
507
|
+
this.selected = Math.max(0, this.selected - this.height);
|
|
508
|
+
} else if (matchesKey(data, "c")) {
|
|
509
|
+
this.changedOnly = !this.changedOnly;
|
|
510
|
+
this.selected = 0;
|
|
511
|
+
this.refreshRows();
|
|
512
|
+
} else if (matchesKey(data, "r")) {
|
|
513
|
+
this.reloadTree();
|
|
514
|
+
} else if (matchesKey(data, "+") || matchesKey(data, "=")) {
|
|
515
|
+
this.height = Math.min(MAX_HEIGHT, this.height + 2);
|
|
516
|
+
} else if (matchesKey(data, "-") || matchesKey(data, "_")) {
|
|
517
|
+
this.height = Math.max(MIN_HEIGHT, this.height - 2);
|
|
518
|
+
} else if (matchesKey(data, Key.enter) || matchesKey(data, "l") || matchesKey(data, Key.right)) {
|
|
519
|
+
const node = this.rows[this.selected]?.node;
|
|
520
|
+
if (!node) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (node.directory) {
|
|
525
|
+
if (this.expanded.has(node.relativePath)) {
|
|
526
|
+
this.expanded.delete(node.relativePath);
|
|
527
|
+
} else {
|
|
528
|
+
this.expanded.add(node.relativePath);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
this.refreshRows();
|
|
532
|
+
} else {
|
|
533
|
+
this.openFile(node);
|
|
534
|
+
}
|
|
535
|
+
} else if (matchesKey(data, "h") || matchesKey(data, Key.left)) {
|
|
536
|
+
const node = this.rows[this.selected]?.node;
|
|
537
|
+
if (!node) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (node.directory && this.expanded.has(node.relativePath)) {
|
|
542
|
+
this.expanded.delete(node.relativePath);
|
|
543
|
+
this.refreshRows();
|
|
544
|
+
} else if (node.parent?.relativePath) {
|
|
545
|
+
const parentIndex = this.rows.findIndex((row) => row.node.relativePath === node.parent?.relativePath);
|
|
546
|
+
if (parentIndex >= 0) {
|
|
547
|
+
this.selected = parentIndex;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private handleFileInput(data: string): void {
|
|
554
|
+
if (this.mode === "comment") {
|
|
555
|
+
if (matchesKey(data, "ctrl+enter") || matchesKey(data, "ctrl+d")) {
|
|
556
|
+
this.submitComment();
|
|
557
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
558
|
+
this.mode = "select";
|
|
559
|
+
this.comment = "";
|
|
560
|
+
} else if (matchesKey(data, Key.backspace)) {
|
|
561
|
+
this.comment = this.comment.slice(0, -1);
|
|
562
|
+
} else {
|
|
563
|
+
this.comment += printableInput(data);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (this.mode === "select") {
|
|
570
|
+
if (matchesKey(data, Key.escape)) {
|
|
571
|
+
this.mode = "normal";
|
|
572
|
+
} else if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
|
|
573
|
+
this.selectionCursor = Math.min(this.rawLines.length - 1, this.selectionCursor + 1);
|
|
574
|
+
this.scroll = Math.min(this.selectionCursor, this.maxScroll(this.rawLines.length));
|
|
575
|
+
} else if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
|
|
576
|
+
this.selectionCursor = Math.max(0, this.selectionCursor - 1);
|
|
577
|
+
this.scroll = Math.min(this.scroll, this.selectionCursor);
|
|
578
|
+
} else if (matchesKey(data, "c")) {
|
|
579
|
+
this.mode = "comment";
|
|
580
|
+
this.comment = "";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const content = this.fileContent(this.renderWidth);
|
|
587
|
+
if (matchesKey(data, "q") || matchesKey(data, Key.escape)) {
|
|
588
|
+
return this.backToTree();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
|
|
592
|
+
this.scroll = Math.min(this.maxScroll(content.length), this.scroll + 1);
|
|
593
|
+
} else if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
|
|
594
|
+
this.scroll = Math.max(0, this.scroll - 1);
|
|
595
|
+
} else if (matchesKey(data, Key.pageDown)) {
|
|
596
|
+
this.scroll = Math.min(this.maxScroll(content.length), this.scroll + this.height);
|
|
597
|
+
} else if (matchesKey(data, Key.pageUp)) {
|
|
598
|
+
this.scroll = Math.max(0, this.scroll - this.height);
|
|
599
|
+
} else if (matchesKey(data, "g")) {
|
|
600
|
+
this.scroll = 0;
|
|
601
|
+
} else if (matchesKey(data, "shift+g") || data === "G") {
|
|
602
|
+
this.scroll = this.maxScroll(content.length);
|
|
603
|
+
} else if (matchesKey(data, "+") || matchesKey(data, "=")) {
|
|
604
|
+
this.height = Math.min(MAX_HEIGHT, this.height + 2);
|
|
605
|
+
} else if (matchesKey(data, "-") || matchesKey(data, "_")) {
|
|
606
|
+
this.height = Math.max(MIN_HEIGHT, this.height - 2);
|
|
607
|
+
} else if (matchesKey(data, "d") && this.file?.status && this.file.status !== "??") {
|
|
608
|
+
this.diffMode = !this.diffMode;
|
|
609
|
+
if (this.diffMode) {
|
|
610
|
+
this.markdownMode = false;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
this.scroll = 0;
|
|
614
|
+
this.renderedCache = undefined;
|
|
615
|
+
} else if (matchesKey(data, "m") && this.file && /\.md(?:own)?$/i.test(this.file.name) && !this.diffMode) {
|
|
616
|
+
this.markdownMode = !this.markdownMode;
|
|
617
|
+
this.scroll = 0;
|
|
618
|
+
this.renderedCache = undefined;
|
|
619
|
+
} else if (matchesKey(data, "v") && this.rawLines.length > 0) {
|
|
620
|
+
if (this.diffMode || this.markdownMode) {
|
|
621
|
+
this.diffMode = false;
|
|
622
|
+
this.markdownMode = false;
|
|
623
|
+
this.scroll = 0;
|
|
624
|
+
this.renderedCache = undefined;
|
|
625
|
+
this.notify("Source view enabled; press v again to select lines.", "info");
|
|
626
|
+
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
this.selectionAnchor = Math.min(this.scroll, this.rawLines.length - 1);
|
|
631
|
+
this.selectionCursor = this.selectionAnchor;
|
|
632
|
+
this.mode = "select";
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export default function filesExtension(pi: ExtensionAPI): void {
|
|
638
|
+
pi.registerCommand("files", {
|
|
639
|
+
description: "Browse, read, diff, and comment on project files in the active SpecPi theme",
|
|
640
|
+
handler: async (args, ctx) => {
|
|
641
|
+
if (ctx.mode !== "tui") {
|
|
642
|
+
ctx.ui.notify("/files is available in interactive TUI mode.", "warning");
|
|
643
|
+
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let root: string;
|
|
648
|
+
try {
|
|
649
|
+
root = resolveBrowserRoot(args, ctx.cwd);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
652
|
+
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
657
|
+
const component = new SpecFilesComponent(
|
|
658
|
+
root,
|
|
659
|
+
theme,
|
|
660
|
+
() => tui.requestRender(),
|
|
661
|
+
() => done(),
|
|
662
|
+
(message, level) => ctx.ui.notify(message, level),
|
|
663
|
+
(message) => {
|
|
664
|
+
if (ctx.isIdle()) {
|
|
665
|
+
pi.sendUserMessage(message);
|
|
666
|
+
} else {
|
|
667
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
ctx.ui.notify("Review comment sent to the agent.", "success");
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
return component;
|
|
675
|
+
});
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
}
|