@gavin-hu/my-pi-agent 0.8.1 → 0.9.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 (98) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +7 -7
  3. package/assets/file-browse.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +49 -36
  6. package/extensions/bytefly/doctor/format.ts +36 -10
  7. package/extensions/bytefly/doctor/tools.ts +6 -5
  8. package/extensions/bytefly/footer/config.ts +1 -1
  9. package/extensions/bytefly/footer/render/format.ts +5 -18
  10. package/extensions/bytefly/footer/render/layout.ts +2 -1
  11. package/extensions/bytefly/footer/render/segments.ts +51 -78
  12. package/extensions/bytefly/header/component.ts +33 -16
  13. package/extensions/bytefly/header/config.ts +28 -18
  14. package/extensions/bytefly/index.ts +1 -1
  15. package/extensions/bytefly/panel/commands.ts +69 -35
  16. package/extensions/bytefly/panel/format.ts +8 -8
  17. package/extensions/bytefly/panel/tui.ts +45 -70
  18. package/extensions/bytefly/separator/config.ts +16 -8
  19. package/extensions/bytefly/separator/format.ts +13 -40
  20. package/extensions/bytefly/separator/types.ts +0 -10
  21. package/extensions/document/README.md +1 -1
  22. package/extensions/document/render.ts +11 -10
  23. package/extensions/document/tool.ts +1 -1
  24. package/extensions/file-browse/README.md +51 -9
  25. package/extensions/file-browse/client.ts +103 -2
  26. package/extensions/file-browse/config.ts +12 -0
  27. package/extensions/file-browse/diff.ts +187 -0
  28. package/extensions/file-browse/git.ts +176 -1
  29. package/extensions/file-browse/html.ts +385 -19
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +33 -0
  32. package/extensions/file-browse/router.ts +94 -2
  33. package/extensions/file-browse/server.ts +4 -1
  34. package/extensions/goal/format.ts +11 -10
  35. package/extensions/goal/tools.ts +6 -5
  36. package/extensions/goal/tui.ts +1 -1
  37. package/extensions/job/README.md +2 -2
  38. package/extensions/job/format.ts +36 -13
  39. package/extensions/job/index.ts +1 -1
  40. package/extensions/job/tools.ts +11 -10
  41. package/extensions/job/tui.ts +141 -220
  42. package/extensions/job/ui.ts +24 -49
  43. package/extensions/memory/README.md +1 -1
  44. package/extensions/memory/tools.ts +4 -3
  45. package/extensions/memory/tui.ts +32 -36
  46. package/extensions/plan/README.md +64 -34
  47. package/extensions/plan/blocks.ts +44 -2
  48. package/extensions/plan/feedback.ts +20 -5
  49. package/extensions/plan/index.ts +1 -1
  50. package/extensions/plan/runtime.ts +3 -2
  51. package/extensions/plan/tools.ts +23 -22
  52. package/extensions/plan/tui.ts +21 -27
  53. package/extensions/plan/web-client.ts +335 -30
  54. package/extensions/plan/web-page.ts +110 -30
  55. package/extensions/plan/web-router.ts +6 -4
  56. package/extensions/plan/web.ts +41 -10
  57. package/extensions/question/README.md +1 -1
  58. package/extensions/question/tools.ts +12 -11
  59. package/extensions/question/tui.ts +132 -76
  60. package/extensions/rewind/README.md +1 -1
  61. package/extensions/rewind/format.ts +29 -1
  62. package/extensions/rewind/rewind-tui.ts +66 -62
  63. package/extensions/rewind/status.ts +3 -2
  64. package/extensions/subagent/render.ts +62 -47
  65. package/extensions/todo/README.md +2 -2
  66. package/extensions/todo/format.ts +31 -10
  67. package/extensions/todo/tools.ts +5 -4
  68. package/extensions/todo/tui.ts +45 -26
  69. package/extensions/watch/README.md +2 -2
  70. package/extensions/watch/format.ts +33 -13
  71. package/extensions/watch/index.ts +1 -1
  72. package/extensions/watch/tools.ts +12 -7
  73. package/extensions/watch/tui.ts +135 -194
  74. package/extensions/watch/ui.ts +24 -46
  75. package/extensions/web-access/fetch/tool.ts +12 -11
  76. package/extensions/web-access/search/tool.ts +16 -13
  77. package/extensions/wechat/README.md +2 -2
  78. package/extensions/wechat/index.ts +4 -3
  79. package/extensions/wechat/login-screen.ts +8 -5
  80. package/extensions/wechat/status-screen.ts +41 -22
  81. package/extensions/wechat/tools.ts +6 -5
  82. package/extensions/worktree/enter.ts +1 -1
  83. package/extensions/worktree/index.ts +1 -1
  84. package/extensions/worktree/runtime.ts +1 -1
  85. package/lib/README.md +10 -7
  86. package/lib/requirements.ts +2 -2
  87. package/lib/{tui.ts → tui/chrome.ts} +9 -30
  88. package/lib/tui/columns.ts +96 -0
  89. package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
  90. package/lib/tui/gauge.ts +78 -0
  91. package/lib/tui/index.ts +25 -0
  92. package/lib/tui/line-pane.ts +173 -0
  93. package/lib/tui/rows.ts +109 -0
  94. package/lib/tui/rules.ts +88 -0
  95. package/lib/tui/status-controller.ts +107 -0
  96. package/lib/tui/tokens.ts +58 -0
  97. package/package.json +1 -1
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -28,6 +28,12 @@ export interface ServeConfig {
28
28
  markdown: boolean;
29
29
  /** Files collected for the quick-open index before it is truncated. */
30
30
  maxIndexEntries: number;
31
+ /** Offer the read-only git diff view. */
32
+ gitDiff: boolean;
33
+ /** Unified diff context lines. */
34
+ diffContext: number;
35
+ /** Diff lines rendered before the view is truncated. */
36
+ maxDiffLines: number;
31
37
  }
32
38
 
33
39
  export const DEFAULT_CONFIG: ServeConfig = {
@@ -40,6 +46,9 @@ export const DEFAULT_CONFIG: ServeConfig = {
40
46
  thumbnails: true,
41
47
  markdown: true,
42
48
  maxIndexEntries: 20000,
49
+ gitDiff: true,
50
+ diffContext: 3,
51
+ maxDiffLines: 4000,
43
52
  };
44
53
 
45
54
  /** Base of the per-session port range. */
@@ -73,6 +82,9 @@ export function normalizeConfig(
73
82
  thumbnails: bool(raw.thumbnails, base.thumbnails),
74
83
  markdown: bool(raw.markdown, base.markdown),
75
84
  maxIndexEntries: clampInteger(raw.maxIndexEntries, base.maxIndexEntries, 1, 1_000_000),
85
+ gitDiff: bool(raw.gitDiff, base.gitDiff),
86
+ diffContext: clampInteger(raw.diffContext, base.diffContext, 0, 100),
87
+ maxDiffLines: clampInteger(raw.maxDiffLines, base.maxDiffLines, 1, 1_000_000),
76
88
  };
77
89
  }
78
90
 
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Pure parsing for the git diff view.
3
+ *
4
+ * Turns unified-diff text and `git status --porcelain=v1 -z` output into plain
5
+ * view models. No git, no filesystem, no HTML: `git.ts` runs the commands and
6
+ * `html.ts` renders the result, so the parser can be tested in isolation.
7
+ */
8
+
9
+ /** The three comparison scopes the diff view offers. */
10
+ export type DiffMode = "head" | "staged" | "worktree";
11
+
12
+ /** Every valid mode, in segmented-control order. */
13
+ export const DIFF_MODES: readonly DiffMode[] = ["head", "staged", "worktree"];
14
+
15
+ /** Whether an untrusted query value names a mode. */
16
+ export function isDiffMode(value: string | null | undefined): value is DiffMode {
17
+ return value === "head" || value === "staged" || value === "worktree";
18
+ }
19
+
20
+ /**
21
+ * Revision arguments for `git diff` for a mode, placed before `--`.
22
+ * `head` compares the working tree and index against the commit; `staged`
23
+ * compares the index against the commit; `worktree` compares the working tree
24
+ * against the index.
25
+ */
26
+ export function diffArgs(mode: DiffMode): string[] {
27
+ if (mode === "staged") return ["--cached"];
28
+ if (mode === "worktree") return [];
29
+ return ["HEAD"];
30
+ }
31
+
32
+ /** A line's role in a unified diff. */
33
+ export type DiffLineKind = "meta" | "hunk" | "add" | "del" | "ctx" | "note";
34
+
35
+ export interface DiffLine {
36
+ kind: DiffLineKind;
37
+ /** Content without the leading marker for add/del/ctx; raw text otherwise. */
38
+ text: string;
39
+ /** Old-file line number, when the line exists on that side. */
40
+ oldNo?: number;
41
+ /** New-file line number, when the line exists on that side. */
42
+ newNo?: number;
43
+ }
44
+
45
+ export interface ParsedDiff {
46
+ lines: DiffLine[];
47
+ /** True when `maxLines` cut the diff short. */
48
+ truncated: boolean;
49
+ /** True for a binary diff (`Binary files … differ` / `GIT binary patch`). */
50
+ binary: boolean;
51
+ }
52
+
53
+ const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
54
+
55
+ /** A line git emits for a binary change; there is no text diff to show. */
56
+ const BINARY_LINE = /^(?:Binary files .* differ|GIT binary patch)$/;
57
+
58
+ /**
59
+ * Parse unified-diff text into lines tagged by role and line number.
60
+ *
61
+ * `---` / `+++` are treated as file metadata only before the first hunk, so a
62
+ * removed line whose content itself begins with `--` still reads as a deletion.
63
+ * A trailing newline is not a line; `maxLines` bounds the result and sets
64
+ * `truncated`.
65
+ */
66
+ export function parseUnifiedDiff(text: string, maxLines: number): ParsedDiff {
67
+ const raw = text.split("\n");
68
+ if (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
69
+ const truncated = raw.length > maxLines;
70
+ const lines: DiffLine[] = [];
71
+ let binary = false;
72
+ let inHunk = false;
73
+ let oldNo = 0;
74
+ let newNo = 0;
75
+
76
+ for (let line of truncated ? raw.slice(0, maxLines) : raw) {
77
+ if (line.endsWith("\r")) line = line.slice(0, -1);
78
+
79
+ const hunk = HUNK_HEADER.exec(line);
80
+ if (hunk) {
81
+ oldNo = Number(hunk[1]);
82
+ newNo = Number(hunk[2]);
83
+ inHunk = true;
84
+ lines.push({ kind: "hunk", text: line });
85
+ continue;
86
+ }
87
+
88
+ if (inHunk) {
89
+ if (line.startsWith("\\ ")) {
90
+ lines.push({ kind: "note", text: line });
91
+ } else if (line.startsWith("+")) {
92
+ lines.push({ kind: "add", text: line.slice(1), newNo: newNo++ });
93
+ } else if (line.startsWith("-")) {
94
+ lines.push({ kind: "del", text: line.slice(1), oldNo: oldNo++ });
95
+ } else if (line.startsWith(" ") || line === "") {
96
+ lines.push({
97
+ kind: "ctx",
98
+ text: line.startsWith(" ") ? line.slice(1) : "",
99
+ oldNo: oldNo++,
100
+ newNo: newNo++,
101
+ });
102
+ } else {
103
+ // A new file block (`diff --git …`) closes the hunk.
104
+ inHunk = false;
105
+ if (BINARY_LINE.test(line)) binary = true;
106
+ lines.push({ kind: line.startsWith("\\ ") ? "note" : "meta", text: line });
107
+ }
108
+ continue;
109
+ }
110
+
111
+ if (BINARY_LINE.test(line)) binary = true;
112
+ lines.push({ kind: line.startsWith("\\ ") ? "note" : "meta", text: line });
113
+ }
114
+
115
+ return { lines, truncated, binary };
116
+ }
117
+
118
+ /** One changed path from `git status --porcelain=v1 -z`. */
119
+ export interface ChangedFile {
120
+ /** Root-relative, posix-style path. */
121
+ rel: string;
122
+ /** Original path for a rename or copy. */
123
+ origRel?: string;
124
+ /** Index (staged) status character; `" "` when unchanged there. */
125
+ index: string;
126
+ /** Worktree (unstaged) status character; `" "` when unchanged there. */
127
+ worktree: string;
128
+ /** Both status characters are `?`, so the path is untracked. */
129
+ untracked: boolean;
130
+ }
131
+
132
+ /**
133
+ * Parse `git status --porcelain=v1 -z`.
134
+ *
135
+ * NUL-delimited records survive spaces and newlines in a path. A rename or copy
136
+ * (`R`/`C` in either column) carries the new path first and the original path as
137
+ * the next field; the destination is the entry's `rel`.
138
+ */
139
+ export function parseStatusZ(stdout: string): ChangedFile[] {
140
+ const tokens = stdout.split("\0");
141
+ const files: ChangedFile[] = [];
142
+ for (let index = 0; index < tokens.length; index++) {
143
+ const entry = tokens[index];
144
+ if (entry.length < 3 || entry[2] !== " ") continue;
145
+ const x = entry[0];
146
+ const y = entry[1];
147
+ const untracked = x === "?" && y === "?";
148
+ const file: ChangedFile = { rel: entry.slice(3), index: x, worktree: y, untracked };
149
+ if (x === "R" || x === "C" || y === "R" || y === "C") {
150
+ const original = tokens[++index] ?? "";
151
+ if (original) file.origRel = original;
152
+ }
153
+ files.push(file);
154
+ }
155
+ return files;
156
+ }
157
+
158
+ /** The single status letter shown in the change list. */
159
+ export function statusLetter(file: ChangedFile): string {
160
+ if (file.untracked) return "?";
161
+ return file.index !== " " ? file.index : file.worktree;
162
+ }
163
+
164
+ /** Whether a changed path belongs in a mode's list. */
165
+ export function inMode(file: ChangedFile, mode: DiffMode): boolean {
166
+ if (mode === "staged") return !file.untracked && file.index !== " " && file.index !== "?";
167
+ if (mode === "worktree") return file.untracked || (file.worktree !== " " && file.worktree !== "?");
168
+ return true;
169
+ }
170
+
171
+ /**
172
+ * The ancestor directories of every changed path, root-relative. A directory in
173
+ * this set contains a change, which lets a listing or tree mark it without
174
+ * walking its subtree.
175
+ */
176
+ export function changeDirs(files: readonly ChangedFile[]): Set<string> {
177
+ const dirs = new Set<string>();
178
+ for (const file of files) {
179
+ const parts = file.rel.split("/");
180
+ let prefix = "";
181
+ for (let index = 0; index < parts.length - 1; index++) {
182
+ prefix = prefix ? `${prefix}/${parts[index]}` : parts[index];
183
+ dirs.add(prefix);
184
+ }
185
+ }
186
+ return dirs;
187
+ }
@@ -8,7 +8,17 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { createExecRunner, currentBranch, repoRoot, revParse, type RunGit } from "../../lib/git/index.ts";
11
+ import { createExecRunner, currentBranch, hasCommits, repoRoot, revParse, type RunGit } from "../../lib/git/index.ts";
12
+ import {
13
+ diffArgs,
14
+ inMode,
15
+ parseStatusZ,
16
+ parseUnifiedDiff,
17
+ type ChangedFile,
18
+ type DiffLine,
19
+ type DiffMode,
20
+ } from "./diff.ts";
21
+ import { readFileView } from "./files.ts";
12
22
 
13
23
  export interface GitStatus {
14
24
  /** Checked-out branch, when HEAD is attached. */
@@ -72,3 +82,168 @@ export function createGitStatus(
72
82
  return pending;
73
83
  };
74
84
  }
85
+
86
+ /** One comparison scope's change list. */
87
+ export interface DiffList {
88
+ mode: DiffMode;
89
+ files: ChangedFile[];
90
+ /** `git diff --stat` output for the scope, or an empty string. */
91
+ stat: string;
92
+ /** Added lines in the scope. */
93
+ insertions: number;
94
+ /** Removed lines in the scope. */
95
+ deletions: number;
96
+ }
97
+
98
+ /** A single file's diff for a scope. */
99
+ export interface DiffFileView {
100
+ mode: DiffMode;
101
+ rel: string;
102
+ lines: DiffLine[];
103
+ /** True for a binary or unreadable change; `lines` is then empty. */
104
+ binary: boolean;
105
+ truncated: boolean;
106
+ }
107
+
108
+ /**
109
+ * Change list and per-file diffs for the browse pages. Like
110
+ * {@link createGitStatus}, it never throws: a non-repository, a failed git
111
+ * call, or a timeout yields `undefined`, and the page omits the diff UI.
112
+ */
113
+ export interface GitDiffProvider {
114
+ list(mode: DiffMode): Promise<DiffList | undefined>;
115
+ file(rel: string, mode: DiffMode): Promise<DiffFileView | undefined>;
116
+ }
117
+
118
+ export interface GitDiffOptions {
119
+ /** How long the status list is reused, in ms (default 2000). */
120
+ ttlMs?: number;
121
+ /** Unified context lines (default 3). */
122
+ context?: number;
123
+ /** Rendered diff lines before truncation (default 4000). */
124
+ maxLines?: number;
125
+ /** Per-call git timeout in ms (default 4000). */
126
+ timeoutMs?: number;
127
+ /** Max bytes read for an untracked file's all-add diff (default 1 MiB). */
128
+ maxFileBytes?: number;
129
+ /** Max lines read for an untracked file's all-add diff (default 5000). */
130
+ maxTextLines?: number;
131
+ /** Wall-clock seam; defaults to the real clock. */
132
+ now?: () => number;
133
+ }
134
+
135
+ interface RepoState {
136
+ files: ChangedFile[];
137
+ hasCommits: boolean;
138
+ }
139
+
140
+ const INSERTIONS = /(\d+) insertions?\(\+\)/;
141
+ const DELETIONS = /(\d+) deletions?\(-\)/;
142
+
143
+ /** A count from a `--shortstat` line, or `0` when the line does not carry it. */
144
+ function shortstatCount(text: string, pattern: RegExp): number {
145
+ const match = pattern.exec(text);
146
+ return match ? Number(match[1]) : 0;
147
+ }
148
+
149
+ /** Build a read-only diff provider for `root`, with a TTL-cached status list. */
150
+ export function createGitDiff(
151
+ pi: Pick<ExtensionAPI, "exec">,
152
+ root: string,
153
+ options: GitDiffOptions = {},
154
+ ): GitDiffProvider {
155
+ const exec = createExecRunner(pi);
156
+ const run: RunGit = (args, opts) => exec(args, { timeoutMs: options.timeoutMs ?? 4000, ...opts });
157
+ const ttlMs = options.ttlMs ?? 2000;
158
+ const context = Math.max(0, options.context ?? 3);
159
+ const maxLines = Math.max(1, options.maxLines ?? 4000);
160
+ const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
161
+ const maxTextLines = options.maxTextLines ?? 5000;
162
+ const now = options.now ?? (() => Date.now());
163
+
164
+ let cached: RepoState | undefined;
165
+ let checkedAt = 0;
166
+ let pending: Promise<RepoState | undefined> | undefined;
167
+
168
+ async function read(): Promise<RepoState | undefined> {
169
+ if (!(await repoRoot(run, root))) return undefined;
170
+ const status = await run(["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root });
171
+ if (status.code !== 0) return undefined;
172
+ return { files: parseStatusZ(status.stdout), hasCommits: await hasCommits(run, root) };
173
+ }
174
+
175
+ /** The cached repository state, or `undefined` outside a repository. */
176
+ function state(): Promise<RepoState | undefined> {
177
+ if (pending) return pending;
178
+ const t = now();
179
+ if (checkedAt !== 0 && t - checkedAt < ttlMs) return Promise.resolve(cached);
180
+ checkedAt = t;
181
+ pending = read()
182
+ .catch(() => undefined)
183
+ .then((next) => {
184
+ cached = next;
185
+ return next;
186
+ })
187
+ .finally(() => {
188
+ pending = undefined;
189
+ });
190
+ return pending;
191
+ }
192
+
193
+ /** `head` needs a commit; before the first one, compare the index instead. */
194
+ const effectiveMode = (mode: DiffMode, has: boolean): DiffMode => (mode === "head" && !has ? "staged" : mode);
195
+
196
+ /** An untracked file as an all-add diff, read through the shared file view. */
197
+ async function untracked(rel: string, mode: DiffMode): Promise<DiffFileView> {
198
+ try {
199
+ const view = await readFileView(root, rel, maxFileBytes, maxTextLines);
200
+ if (view.kind === "text" || view.kind === "markdown") {
201
+ // Drop the empty element a trailing newline produces, so the added
202
+ // lines match the count git shows for the same file.
203
+ const last = view.lines.length - 1;
204
+ const source = last >= 0 && view.lines[last] === "" ? view.lines.slice(0, last) : view.lines;
205
+ return {
206
+ mode,
207
+ rel,
208
+ lines: source.map((text, index) => ({ kind: "add" as const, text, newNo: index + 1 })),
209
+ binary: false,
210
+ truncated: view.truncated,
211
+ };
212
+ }
213
+ return { mode, rel, lines: [], binary: true, truncated: false };
214
+ } catch {
215
+ return { mode, rel, lines: [], binary: false, truncated: false };
216
+ }
217
+ }
218
+
219
+ return {
220
+ async list(mode) {
221
+ const current = await state();
222
+ if (!current) return undefined;
223
+ const args = diffArgs(effectiveMode(mode, current.hasCommits));
224
+ const [stat, shortstat] = await Promise.all([
225
+ run(["diff", "--no-color", "--stat", ...args, "--"], { cwd: root }),
226
+ run(["diff", "--no-color", "--shortstat", ...args, "--"], { cwd: root }),
227
+ ]);
228
+ const summary = shortstat.code === 0 ? shortstat.stdout : "";
229
+ return {
230
+ mode,
231
+ files: current.files.filter((file) => inMode(file, mode)),
232
+ stat: stat.code === 0 ? stat.stdout.trim() : "",
233
+ insertions: shortstatCount(summary, INSERTIONS),
234
+ deletions: shortstatCount(summary, DELETIONS),
235
+ };
236
+ },
237
+ async file(rel, mode) {
238
+ const current = await state();
239
+ if (!current) return undefined;
240
+ if (current.files.some((file) => file.rel === rel && file.untracked)) return untracked(rel, mode);
241
+ const result = await run(
242
+ ["diff", "--no-color", `-U${context}`, "-M", ...diffArgs(effectiveMode(mode, current.hasCommits)), "--", rel],
243
+ { cwd: root, env: { GIT_LITERAL_PATHSPECS: "1" } },
244
+ );
245
+ if (result.code !== 0) return undefined;
246
+ return { mode, rel, ...parseUnifiedDiff(result.stdout, maxLines) };
247
+ },
248
+ };
249
+ }