@gavin-hu/my-pi-agent 0.8.1 → 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.
Files changed (174) hide show
  1. package/README.md +35 -8
  2. package/README.zh-CN.md +33 -8
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/file-browse.png +0 -0
  6. package/assets/job.png +0 -0
  7. package/assets/plan-review.png +0 -0
  8. package/assets/question.png +0 -0
  9. package/assets/rewind.png +0 -0
  10. package/assets/todo.png +0 -0
  11. package/assets/tool-renderings.png +0 -0
  12. package/assets/tools/ask_user_question.png +0 -0
  13. package/assets/tools/bash.png +0 -0
  14. package/assets/tools/edit.png +0 -0
  15. package/assets/tools/enter_plan_mode.png +0 -0
  16. package/assets/tools/enter_worktree.png +0 -0
  17. package/assets/tools/exit_plan_mode.png +0 -0
  18. package/assets/tools/exit_worktree.png +0 -0
  19. package/assets/tools/find.png +0 -0
  20. package/assets/tools/goal.png +0 -0
  21. package/assets/tools/grep.png +0 -0
  22. package/assets/tools/job.png +0 -0
  23. package/assets/tools/list_worktrees.png +0 -0
  24. package/assets/tools/ls.png +0 -0
  25. package/assets/tools/memory.png +0 -0
  26. package/assets/tools/notice.png +0 -0
  27. package/assets/tools/open_path.png +0 -0
  28. package/assets/tools/prune_worktrees.png +0 -0
  29. package/assets/tools/read.png +0 -0
  30. package/assets/tools/read_doc.png +0 -0
  31. package/assets/tools/run_doctor.png +0 -0
  32. package/assets/tools/send_wechat.png +0 -0
  33. package/assets/tools/set_plan_steps.png +0 -0
  34. package/assets/tools/subagent.png +0 -0
  35. package/assets/tools/todo.png +0 -0
  36. package/assets/tools/turn-rule.png +0 -0
  37. package/assets/tools/user-message.png +0 -0
  38. package/assets/tools/watch.png +0 -0
  39. package/assets/tools/web_fetch.png +0 -0
  40. package/assets/tools/web_search.png +0 -0
  41. package/assets/tools/write.png +0 -0
  42. package/assets/tools/write_plan.png +0 -0
  43. package/extensions/bytefly/README.md +306 -68
  44. package/extensions/bytefly/block/component.ts +282 -0
  45. package/extensions/bytefly/block/config.ts +24 -0
  46. package/extensions/bytefly/block/fallback.ts +120 -0
  47. package/extensions/bytefly/block/register.ts +164 -0
  48. package/extensions/bytefly/chrome.ts +97 -0
  49. package/extensions/bytefly/doctor/format.ts +36 -10
  50. package/extensions/bytefly/doctor/tools.ts +10 -6
  51. package/extensions/bytefly/footer/config.ts +1 -1
  52. package/extensions/bytefly/footer/render/format.ts +5 -18
  53. package/extensions/bytefly/footer/render/layout.ts +2 -1
  54. package/extensions/bytefly/footer/render/segments.ts +51 -78
  55. package/extensions/bytefly/header/component.ts +49 -21
  56. package/extensions/bytefly/header/config.ts +28 -21
  57. package/extensions/bytefly/header/register.ts +6 -1
  58. package/extensions/bytefly/index.ts +32 -12
  59. package/extensions/bytefly/message/config.ts +15 -0
  60. package/extensions/bytefly/message/format.ts +42 -0
  61. package/extensions/bytefly/message/register.ts +15 -0
  62. package/extensions/bytefly/notice/component.ts +107 -0
  63. package/extensions/bytefly/notice/config.ts +41 -0
  64. package/extensions/bytefly/notice/register.ts +27 -0
  65. package/extensions/bytefly/pad.ts +42 -0
  66. package/extensions/bytefly/panel/commands.ts +69 -35
  67. package/extensions/bytefly/panel/format.ts +8 -8
  68. package/extensions/bytefly/panel/tui.ts +45 -70
  69. package/extensions/bytefly/separator/component.ts +2 -1
  70. package/extensions/bytefly/separator/config.ts +16 -8
  71. package/extensions/bytefly/separator/format.ts +18 -38
  72. package/extensions/bytefly/separator/register.ts +3 -2
  73. package/extensions/bytefly/separator/types.ts +0 -10
  74. package/extensions/bytefly/working/config.ts +20 -0
  75. package/extensions/bytefly/working/format.ts +25 -0
  76. package/extensions/bytefly/working/register.ts +88 -0
  77. package/extensions/document/README.md +12 -5
  78. package/extensions/document/paging.ts +10 -10
  79. package/extensions/document/render.ts +17 -16
  80. package/extensions/document/tool.ts +1 -1
  81. package/extensions/file-browse/README.md +54 -10
  82. package/extensions/file-browse/client.ts +110 -2
  83. package/extensions/file-browse/config.ts +12 -0
  84. package/extensions/file-browse/diff.ts +187 -0
  85. package/extensions/file-browse/git.ts +180 -8
  86. package/extensions/file-browse/html.ts +401 -25
  87. package/extensions/file-browse/index.ts +15 -4
  88. package/extensions/file-browse/markdown.ts +33 -0
  89. package/extensions/file-browse/router.ts +94 -2
  90. package/extensions/file-browse/server.ts +4 -1
  91. package/extensions/file-open/README.md +6 -0
  92. package/extensions/goal/README.md +16 -10
  93. package/extensions/goal/format.ts +13 -25
  94. package/extensions/goal/tools.ts +11 -22
  95. package/extensions/goal/tui.ts +12 -40
  96. package/extensions/job/README.md +22 -13
  97. package/extensions/job/format.ts +36 -13
  98. package/extensions/job/index.ts +26 -21
  99. package/extensions/job/tools.ts +15 -12
  100. package/extensions/job/tui.ts +148 -222
  101. package/extensions/job/ui.ts +24 -49
  102. package/extensions/memory/README.md +7 -1
  103. package/extensions/memory/format.ts +5 -3
  104. package/extensions/memory/tools.ts +4 -3
  105. package/extensions/memory/tui.ts +32 -36
  106. package/extensions/plan/README.md +86 -36
  107. package/extensions/plan/blocks.ts +44 -2
  108. package/extensions/plan/feedback.ts +20 -5
  109. package/extensions/plan/index.ts +1 -1
  110. package/extensions/plan/runtime.ts +3 -2
  111. package/extensions/plan/tools.ts +43 -27
  112. package/extensions/plan/tui.ts +21 -27
  113. package/extensions/plan/web-client.ts +386 -40
  114. package/extensions/plan/web-page.ts +138 -33
  115. package/extensions/plan/web-router.ts +46 -13
  116. package/extensions/plan/web.ts +63 -12
  117. package/extensions/question/README.md +14 -2
  118. package/extensions/question/answers.ts +25 -18
  119. package/extensions/question/tools.ts +22 -16
  120. package/extensions/question/tui.ts +132 -76
  121. package/extensions/rewind/README.md +3 -1
  122. package/extensions/rewind/format.ts +35 -2
  123. package/extensions/rewind/index.ts +3 -1
  124. package/extensions/rewind/rewind-tui.ts +66 -62
  125. package/extensions/rewind/snapshot-run.ts +1 -1
  126. package/extensions/rewind/status.ts +3 -2
  127. package/extensions/subagent/README.md +6 -0
  128. package/extensions/subagent/index.ts +1 -1
  129. package/extensions/subagent/render.ts +108 -66
  130. package/extensions/todo/README.md +20 -13
  131. package/extensions/todo/format.ts +47 -34
  132. package/extensions/todo/tools.ts +6 -4
  133. package/extensions/todo/tui.ts +46 -26
  134. package/extensions/watch/README.md +16 -8
  135. package/extensions/watch/format.ts +33 -13
  136. package/extensions/watch/index.ts +29 -24
  137. package/extensions/watch/tools.ts +16 -9
  138. package/extensions/watch/tui.ts +142 -196
  139. package/extensions/watch/ui.ts +24 -46
  140. package/extensions/web-access/README.md +16 -7
  141. package/extensions/web-access/fetch/tool.ts +12 -11
  142. package/extensions/web-access/search/tool.ts +16 -13
  143. package/extensions/wechat/README.md +8 -2
  144. package/extensions/wechat/index.ts +4 -3
  145. package/extensions/wechat/login-screen.ts +8 -5
  146. package/extensions/wechat/status-screen.ts +41 -22
  147. package/extensions/wechat/tools.ts +6 -5
  148. package/extensions/worktree/README.md +6 -0
  149. package/extensions/worktree/enter.ts +1 -1
  150. package/extensions/worktree/index.ts +1 -1
  151. package/extensions/worktree/runtime.ts +1 -1
  152. package/lib/README.md +11 -7
  153. package/lib/format.ts +17 -0
  154. package/lib/notice.ts +87 -0
  155. package/lib/requirements.ts +2 -2
  156. package/lib/{tui.ts → tui/chrome.ts} +17 -34
  157. package/lib/tui/columns.ts +96 -0
  158. package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
  159. package/lib/tui/gauge.ts +78 -0
  160. package/lib/{ui.ts → tui/glyphs.ts} +10 -11
  161. package/lib/tui/indent.ts +166 -0
  162. package/lib/tui/index.ts +27 -0
  163. package/lib/tui/line-pane.ts +173 -0
  164. package/lib/tui/rows.ts +143 -0
  165. package/lib/tui/rules.ts +94 -0
  166. package/lib/tui/status-controller.ts +107 -0
  167. package/lib/tui/tokens.ts +72 -0
  168. package/lib/web/base.ts +2 -1
  169. package/lib/web/components.ts +3 -1
  170. package/lib/web/layout.ts +5 -1
  171. package/package.json +7 -3
  172. package/themes/bytefly-dark.json +7 -8
  173. package/themes/bytefly-light.json +10 -11
  174. package/assets/bytefly-chrome.png +0 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * bytefly's live working timer.
3
+ *
4
+ * Pi's working row shows a static `Working`, so a long run reads the same as a
5
+ * short one. This surface advances the message once a second while the row is
6
+ * up, and hands it back to Pi when the row comes down.
7
+ *
8
+ * The timer is tied to the row's visibility. The host shows the working
9
+ * indicator in its own `turn_start` handler and clears it at `agent_end`
10
+ * (`interactive-mode.js`), so the tick starts at `turn_start` and stops at
11
+ * `agent_end` — the two moments the row appears and disappears. Turns inside
12
+ * one run do not restart it: the host leaves the same indicator up across
13
+ * turns, and `start` is a no-op while it is counting.
14
+ *
15
+ * Compaction and auto-retry also replace the row, but both run outside this
16
+ * window (`auto_retry_start` follows `agent_end`; the before-settle compaction
17
+ * runs after it), and neither is an extension event, so there is nothing to
18
+ * hook and nothing to undo.
19
+ *
20
+ * The handle is global (`setWorkingMessage` drives the single working row), so
21
+ * the tick is cancelled and the message reset on stop and, belt and braces, on
22
+ * `session_shutdown` — the factory itself only registers.
23
+ */
24
+
25
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
+ import { WORKING_CONFIG } from "./config.ts";
27
+ import { workingCounter } from "./format.ts";
28
+
29
+ /** Host seams, injected by tests so the tick is deterministic. */
30
+ export interface WorkingDeps {
31
+ /** The clock the count is read from. */
32
+ now?: () => number;
33
+ /** Start the repeating tick; returns a cancel handle. */
34
+ schedule?: (tick: () => void, intervalMs: number) => () => void;
35
+ }
36
+
37
+ /** A repeating timer that never holds the process open on its own. */
38
+ function defaultSchedule(tick: () => void, intervalMs: number): () => void {
39
+ const timer = setInterval(tick, intervalMs);
40
+ (timer as { unref?: () => void }).unref?.();
41
+ return () => clearInterval(timer);
42
+ }
43
+
44
+ /** Register the timer; it runs only in interactive (`tui`) sessions. */
45
+ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}): void {
46
+ const now = deps.now ?? (() => Date.now());
47
+ const schedule = deps.schedule ?? defaultSchedule;
48
+ let cancel: (() => void) | undefined;
49
+ let startedAt = 0;
50
+ // A boolean, not `startedAt === 0`: an injected clock is allowed to start at 0.
51
+ let active = false;
52
+
53
+ /** Stop the tick and hand the working message back to Pi. Idempotent. */
54
+ const stop = (ctx: ExtensionContext): void => {
55
+ cancel?.();
56
+ cancel = undefined;
57
+ if (!active) return;
58
+ active = false;
59
+ ctx.ui.setWorkingMessage(undefined);
60
+ };
61
+
62
+ /** Start the count for the current turn; a no-op while already counting. */
63
+ const start = (ctx: ExtensionContext): void => {
64
+ if (active) return;
65
+ startedAt = now();
66
+ active = true;
67
+ const tick = (): void => {
68
+ ctx.ui.setWorkingMessage(workingCounter(now() - startedAt));
69
+ };
70
+ tick();
71
+ cancel = schedule(tick, WORKING_CONFIG.intervalMs);
72
+ };
73
+
74
+ // The host shows the working indicator on its own `turn_start` handler, so
75
+ // this is the first frame the row is up.
76
+ pi.on("turn_start", (_event, ctx) => {
77
+ if (ctx.mode !== "tui") return;
78
+ start(ctx);
79
+ });
80
+
81
+ // The host clears the working indicator at `agent_end`.
82
+ pi.on("agent_end", (_event, ctx) => {
83
+ if (ctx.mode !== "tui") return;
84
+ stop(ctx);
85
+ });
86
+
87
+ pi.on("session_shutdown", (_event, ctx) => stop(ctx));
88
+ }
@@ -8,6 +8,12 @@
8
8
  the effective working directory with the same symlink-aware guard the built-in
9
9
  path tools use.
10
10
 
11
+ ## Tool rendering
12
+
13
+ How the `read_doc` call and its result look in the transcript — the call line as a headline and the body indented under it:
14
+
15
+ <img src="../../assets/tools/read_doc.png" alt="The read_doc tool's result: the call line and an indented body with the file path, format, size, and the extracted text preview" width="100%">
16
+
11
17
  ## Quickstart
12
18
 
13
19
  ```bash
@@ -76,19 +82,20 @@ interactive transcript (and HTML exports).
76
82
 
77
83
  The call line carries the path (`~`-shortened, OSC-8 linked when the terminal
78
84
  supports it) and any paging options. The result line drops the path — it is
79
- already on the call line — and leads with the format and a humanized range:
85
+ already on the call line — and leads with the format and the character range,
86
+ grouped so a `k` suffix never sits on one bound and not the other:
80
87
 
81
88
  ```
82
89
  read_doc ~/docs/spec.pdf (from 40k, max 80k)
83
- PDF · 1–40k of 42k chars · more at 40k · ctrl+o
90
+ PDF · chars 40,001–120,000 of 250,000 · next at 120,000 · ctrl+o
84
91
  ```
85
92
 
86
93
  Expanded adds a blank line before the preview body, matching the built-in
87
94
  header/output gap:
88
95
 
89
96
  ```
90
- read_doc ~/docs/spec.pdf
91
- PDF · 1–40k of 42k chars · more at 40k
97
+ read_doc ~/docs/spec.pdf (from 40k, max 80k)
98
+ PDF · chars 40,001–120,000 of 250,000 · next at 120,000
92
99
 
93
100
  Chapter 1
94
101
  This specification defines ...
@@ -218,7 +225,7 @@ config defaults, and error listing all derive from it.
218
225
  | Tool | `read_doc`, `exposure: "direct"`, `defaultActive: true`, default (`parallel`) execution, `annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }`, TypeBox `parameters` and `outputSchema`, and `renderCall`/`renderResult` (reuse `context.lastComponent`; read `context.cwd` and `context.isError`). |
219
226
  | State | None persisted. Per-result state lives in the tool-result `details` / `structuredContent`; the renderer reads only that. |
220
227
  | Lifecycle | No `session_start` / `session_shutdown` hooks: each read imports a parser, runs it, and holds no long-lived resources. |
221
- | Host seams | `resolveEffectiveCwd` (`lib/env.ts`), `isInsideReal` / `realPathOfNearest` (`lib/path.ts`), `loadConfigSection` (`lib/config.ts`), `stripControlChars` / `sanitize` / `formatTokens` (`lib/format.ts`), `SEPARATORS` (`lib/ui.ts`). |
228
+ | Host seams | `resolveEffectiveCwd` (`lib/env.ts`), `isInsideReal` / `realPathOfNearest` (`lib/path.ts`), `loadConfigSection` (`lib/config.ts`), `stripControlChars` / `sanitize` / `formatTokens` (`lib/format.ts`), `SEPARATORS` (`lib/tui/glyphs.ts`). |
222
229
  | Config | The `document` section of the shared `bytefly.json` via `loadConfigSection` (`~/.pi/agent`, `<cwd>/.pi`). |
223
230
 
224
231
  ## Design notes
@@ -6,7 +6,7 @@
6
6
  * the clean slice; the text adds a header and any truncation note.
7
7
  */
8
8
 
9
- import { formatTokens } from "../../lib/format.ts";
9
+ import { formatCount } from "../../lib/format.ts";
10
10
  import type { FormatId } from "./formats.ts";
11
11
 
12
12
  export interface DocPageInput {
@@ -72,9 +72,9 @@ export interface DocSummaryInput {
72
72
  export interface DocSummary {
73
73
  /** Uppercase format id, e.g. `PDF`. */
74
74
  format: string;
75
- /** Range detail, e.g. `1–40k of 42k chars`. */
75
+ /** Range detail, e.g. `chars 1–40,000 of 42,478`. */
76
76
  detail: string;
77
- /** `more at N` when truncated, `complete` for a non-first final page. */
77
+ /** `next at N` when truncated, `complete` for a non-first final page. */
78
78
  note?: string;
79
79
  /** Whether more text remains; the renderer colours the note with this. */
80
80
  truncated: boolean;
@@ -83,9 +83,9 @@ export interface DocSummary {
83
83
  /**
84
84
  * Describe a `read_doc` result for the transcript, without theme colours.
85
85
  *
86
- * Counts are humanized. A complete first read shows the total; a paged read
87
- * shows the returned range so a truncated result is never mistaken for the
88
- * whole document.
86
+ * A complete first read shows the total; a paged read shows the returned range
87
+ * and where to continue, so a truncated result is never mistaken for the whole
88
+ * document.
89
89
  */
90
90
  export function summarizeDoc(input: DocSummaryInput): DocSummary {
91
91
  const format = input.format.toUpperCase();
@@ -93,18 +93,18 @@ export function summarizeDoc(input: DocSummaryInput): DocSummary {
93
93
  if (input.startIndex >= input.chars) {
94
94
  return {
95
95
  format,
96
- detail: `no text at ${formatTokens(input.startIndex)} of ${formatTokens(input.chars)} chars`,
96
+ detail: `no text at ${formatCount(input.startIndex)} of ${formatCount(input.chars)} chars`,
97
97
  truncated: false,
98
98
  };
99
99
  }
100
100
  if (input.startIndex <= 0 && !input.truncated) {
101
- return { format, detail: `${formatTokens(input.chars)} chars`, truncated: false };
101
+ return { format, detail: `${formatCount(input.chars)} chars`, truncated: false };
102
102
  }
103
- const range = `${formatTokens(input.startIndex + 1)}–${formatTokens(input.nextIndex)} of ${formatTokens(input.chars)} chars`;
103
+ const range = `chars ${formatCount(input.startIndex + 1)}–${formatCount(input.nextIndex)} of ${formatCount(input.chars)}`;
104
104
  return {
105
105
  format,
106
106
  detail: range,
107
- note: input.truncated ? `more at ${formatTokens(input.nextIndex)}` : "complete",
107
+ note: input.truncated ? `next at ${formatCount(input.nextIndex)}` : "complete",
108
108
  truncated: input.truncated,
109
109
  };
110
110
  }
@@ -2,11 +2,11 @@
2
2
  * Transcript rendering for `read_doc`.
3
3
  *
4
4
  * Theme-aware formatting only: the tool definition in `tool.ts` wires these to
5
- * `renderCall` / `renderResult`. The call line carries the path (accent,
5
+ * `renderCall` / `renderResult`. The call line carries the path (body colour,
6
6
  * `~`-shortened, OSC-8 linked when the terminal supports it); the result line
7
- * leads with the format and a humanized range, so the path is never repeated.
8
- * The expanded view appends a sanitized, line-capped preview of the extracted
9
- * text. Every field is sanitized before a theme colour is applied.
7
+ * leads with the format and the exact character range, so the path is never
8
+ * repeated. The expanded view appends a sanitized, line-capped preview of the
9
+ * extracted text. Every field is sanitized before a theme colour is applied.
10
10
  */
11
11
 
12
12
  import { isAbsolute, resolve } from "node:path";
@@ -14,19 +14,20 @@ import { pathToFileURL } from "node:url";
14
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
15
  import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
16
16
  import { formatTokens, sanitizeLine, shortenPath, stripControlChars } from "../../lib/format.ts";
17
- import { expandKey, SEPARATORS } from "../../lib/ui.ts";
17
+ import { expandKey, SEPARATORS } from "../../lib/tui/glyphs.ts";
18
+ import { paint } from "../../lib/tui/tokens.ts";
18
19
  import { summarizeDoc } from "./paging.ts";
19
20
  import { TOOL_NAME, type DocArgs, type DocResult } from "./schema.ts";
20
21
 
21
22
  /** Lines of extracted text shown in the expanded preview. */
22
23
  export const PREVIEW_LINES = 20;
23
24
 
24
- /** Render the argument path: accent, `~`-shortened, OSC-8 linked when supported. */
25
+ /** Render the argument path: body colour, `~`-shortened, OSC-8 linked when supported. */
25
26
  export function renderDocPath(rawPath: string | undefined, theme: Theme, cwd: string): string {
26
- if (typeof rawPath !== "string") return theme.fg("error", "[invalid arg]");
27
+ if (typeof rawPath !== "string") return paint(theme, "bad", "[invalid arg]");
27
28
  const value = sanitizeLine(rawPath);
28
29
  if (!value) return theme.fg("toolOutput", "...");
29
- const styled = theme.fg("accent", shortenPath(value));
30
+ const styled = paint(theme, "label", shortenPath(value));
30
31
  if (!getCapabilities().hyperlinks) return styled;
31
32
  const abs = isAbsolute(value) ? value : resolve(cwd, value);
32
33
  return hyperlink(styled, pathToFileURL(abs).href);
@@ -63,7 +64,7 @@ export function formatDocCall(args: DocArgs, theme: Theme, context: { cwd: strin
63
64
  const options: string[] = [];
64
65
  if (typeof startIndex === "number" && startIndex > 0) options.push(`from ${formatTokens(startIndex)}`);
65
66
  if (typeof maxChars === "number") options.push(`max ${formatTokens(maxChars)}`);
66
- if (options.length > 0) text += theme.fg("toolOutput", ` (${options.join(", ")})`);
67
+ if (options.length > 0) text += paint(theme, "faint", ` (${options.join(", ")})`);
67
68
  return text;
68
69
  }
69
70
 
@@ -72,7 +73,7 @@ export function formatDocCall(args: DocArgs, theme: Theme, context: { cwd: strin
72
73
  * expanded, a sanitized preview of the extracted text.
73
74
  */
74
75
  export function formatDocResult(result: DocRenderResult, options: DocRenderOptions, theme: Theme): string {
75
- if (options.isPartial) return theme.fg("warning", "Reading...");
76
+ if (options.isPartial) return paint(theme, "warn", "Reading...");
76
77
 
77
78
  const isError = result.isError ?? false;
78
79
  const details = result.details as DocResult | undefined;
@@ -81,23 +82,23 @@ export function formatDocResult(result: DocRenderResult, options: DocRenderOptio
81
82
  const first = result.content[0];
82
83
  const message = first?.type === "text" && first.text ? first.text : "Read failed";
83
84
  const safe = sanitizeLine(message.startsWith("Error:") ? message : `Error: ${message}`);
84
- return theme.fg("error", safe);
85
+ return paint(theme, "bad", safe);
85
86
  }
86
87
 
87
88
  const summary = summarizeDoc(details);
88
- const sep = theme.fg("dim", SEPARATORS.item);
89
- let text = theme.fg("muted", summary.format) + sep + theme.fg("toolOutput", summary.detail);
90
- if (summary.note) text += theme.fg(summary.truncated ? "accent" : "dim", SEPARATORS.item + summary.note);
89
+ const sep = paint(theme, "faint", SEPARATORS.item);
90
+ let text = paint(theme, "meta", summary.format) + sep + theme.fg("toolOutput", summary.detail);
91
+ if (summary.note) text += paint(theme, summary.truncated ? "accent" : "faint", SEPARATORS.item + summary.note);
91
92
 
92
93
  if (details.text.length > 0) {
93
94
  if (options.expanded) {
94
95
  const { body, moreLines } = previewText(details.text, PREVIEW_LINES);
95
96
  // Blank line before the body, matching the built-ins' header/output gap.
96
97
  text += `\n\n${theme.fg("toolOutput", body)}`;
97
- if (moreLines > 0) text += `\n${theme.fg("muted", `... (${moreLines} more lines)`)}`;
98
+ if (moreLines > 0) text += `\n${paint(theme, "meta", `... (${moreLines} more lines)`)}`;
98
99
  } else {
99
100
  const hint = expandKey(theme);
100
- if (hint) text += theme.fg("muted", SEPARATORS.item) + hint;
101
+ if (hint) text += paint(theme, "meta", SEPARATORS.item) + hint;
101
102
  }
102
103
  }
103
104
 
@@ -15,7 +15,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { resolveEffectiveCwd } from "../../lib/env.ts";
16
16
  import { sanitizeLine, shortenPath } from "../../lib/format.ts";
17
17
  import { isInsideReal, realPathOfNearest } from "../../lib/path.ts";
18
- import { reuseText } from "../../lib/tui.ts";
18
+ import { reuseText } from "../../lib/tui/chrome.ts";
19
19
  import { isFormatEnabled, loadConfig } from "./config.ts";
20
20
  import type { DocExtractor } from "./extract/file.ts";
21
21
  import { detectFormat, supportedExtensions, unsupportedHint, type FormatId } from "./formats.ts";
@@ -4,7 +4,8 @@
4
4
  directory, opens the default browser at it, and prints the URL. The page is a
5
5
  two-pane file browser: a collapsible path tree on the left, directory listings
6
6
  and file views on the right, with image thumbnails, rendered Markdown,
7
- highlighted code, per-language icons, and a fuzzy quick-open palette. It is
7
+ highlighted code, per-language icons, a fuzzy quick-open palette, and a
8
+ read-only git diff view. It is
8
9
  bound to `127.0.0.1`
9
10
  and ships no dependencies — `node:http` plus one small vanilla script.
10
11
 
@@ -22,7 +23,9 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
22
23
  - **Two-pane UI.** A server-rendered tree, directory listings, and file pages,
23
24
  progressively enhanced by one first-party script (filter, lazy tree
24
25
  expansion, thumbnail fallback). Below 860px the tree collapses into a
25
- `Files` disclosure above the listing.
26
+ `Files` disclosure above the listing. A hairline progress bar tracks whichever
27
+ column is scrolling (the card body for a file or diff, the main column
28
+ otherwise), and the served folder names the root page.
26
29
  - **Rich previews.** Text, rendered Markdown, images (thumbnails), and
27
30
  binary/over-size download cards, with per-language icons.
28
31
  - **First-party syntax highlighting.** Code files render with keywords,
@@ -33,8 +36,11 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
33
36
  Python (`py`, `pyw`, `pyi`), Java, YAML (`yaml`, `yml`), and SQL. Other text
34
37
  types stay plain.
35
38
  - **Rendered Markdown.** `.md`/`.markdown` files render as headings, lists,
36
- tables, code, inline and reference links, and images, with a one-click
37
- **raw** view (`?raw=1`). Tables size their columns to the content and scroll
39
+ tables, code, inline and reference links, bare-URL autolinks, and images, with
40
+ a one-click **raw** view (`?raw=1`). The body follows GitHub's Markdown content
41
+ column: one centred measure of at most 1012px carrying headings, prose,
42
+ tables, code, and images, with wide tables scrolling inside it. Tables size
43
+ their columns to the content and scroll
38
44
  when wide, and named and numeric character references are decoded. Fenced code
39
45
  blocks are syntax-highlighted when the fence names a supported language.
40
46
  Headings get stable anchor ids, so in-document `#anchor` links and a table of
@@ -51,9 +57,24 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
51
57
  - **Quick open.** `/` (or `Ctrl/Cmd+K`, or the sidebar button) opens a fuzzy
52
58
  path finder over a capped index of the tree, with keyboard navigation and a
53
59
  highlighted match; Enter opens the file.
60
+ - **Keyboard.** `/` or `Ctrl/Cmd+K` opens quick open, `u` toggles line wrapping,
61
+ `r` switches rendered and raw Markdown, `n`/`p` step between a diff's hunks,
62
+ and `?` opens a shortcut sheet. A diff's hunk headers pin to the top of the
63
+ code viewport while its body scrolls.
54
64
  - **Read-only and loopback-scoped.** No editing, no writing, no LAN exposure.
55
65
  - **Git context.** A read-only header chip shows the checked-out branch (or
56
- detached head) and a dirty marker, and reflows on narrow screens.
66
+ detached head) and reflows on narrow screens. Uncommitted changes are stated
67
+ once, by the changes chip below; turn the diff view off (`gitDiff: false`) and
68
+ the header reports no changes at all.
69
+ - **Git diff view.** A `● N changes +A −D` chip links to `/diff`, a change
70
+ overview with `git diff --stat` and a per-file status table, and `/diff/<rel>`
71
+ renders one file's unified diff, whose head carries its own `+A −D` and hunk
72
+ count. Changed lines are syntax-highlighted with the
73
+ file's language, and dirty files and their directories carry a marker in the
74
+ listing and the tree. The scope is switchable between `head` / `staged` /
75
+ `worktree`; untracked files show as all-add, an unborn `HEAD` falls back to the
76
+ index, and a non-repository is a plain empty state. Read-only, capped, and
77
+ rendered with the shared diff colors.
57
78
  - **No dependencies.** `node:http` plus the inline sprite, CSS, and client
58
79
  script.
59
80
 
@@ -93,6 +114,8 @@ reflects only this session's server, with no cross-session sharing.
93
114
  | `/view/<rel>` | tree + file page (text, Markdown, image, binary, or over-size) |
94
115
  | `/view/<rel>?raw=1` | raw Markdown/text source instead of the rendered view |
95
116
  | `/raw/<rel>` | raw bytes, streamed; downloads and image sources |
117
+ | `/diff[?mode=head\|staged\|worktree]` | changed-file overview with `--stat` |
118
+ | `/diff/<rel>[?mode=…]` | one file's unified diff |
96
119
  | `/api/tree?path=<rel>` | JSON children for lazy tree expansion |
97
120
  | `/api/files?q=<query>` | JSON fuzzy path matches for the quick-open palette |
98
121
  | `/app.js` | the first-party client script |
@@ -115,7 +138,10 @@ document and the project document (project wins):
115
138
  "maxDirEntries": 2000, // listed entries before truncation
116
139
  "thumbnails": true,
117
140
  "markdown": true, // render Markdown files as formatted HTML
118
- "maxIndexEntries": 20000 // files collected for quick open before truncation
141
+ "maxIndexEntries": 20000, // files collected for quick open before truncation
142
+ "gitDiff": true, // offer the read-only diff view
143
+ "diffContext": 3, // unified diff context lines
144
+ "maxDiffLines": 4000 // diff lines rendered before truncation
119
145
  }
120
146
  ```
121
147
 
@@ -142,8 +168,11 @@ document and the project document (project wins):
142
168
  attributes; only the fixed `tok-*` class names reach the page.
143
169
  - **Caps.** `maxFileBytes` (inline rendering), `maxTextLines` (rendered lines),
144
170
  `maxThumbBytes` (thumbnails), and `maxDirEntries` (listings) bound every read.
145
- - **Git reads.** The header chip runs read-only git (`rev-parse`, `status
146
- --porcelain`) with a timeout and a short cache; any failure simply omits it.
171
+ - **Git reads.** The header chip and the diff view run read-only git
172
+ (`rev-parse`, `status --porcelain=v1 -z`, `diff --stat`, `diff -U<n>`) with a
173
+ timeout and a short cache; any failure simply omits the chip or the diff. A
174
+ per-file diff sets `GIT_LITERAL_PATHSPECS=1`, so a filename containing glob
175
+ characters is matched literally.
147
176
  - **Read-only.** Nothing is ever written.
148
177
 
149
178
  ## Limitations
@@ -237,6 +266,16 @@ document and the project document (project wins):
237
266
  `maxIndexEntries`, and is cached per server for 10 s by `createFileIndex`. The
238
267
  palette is a `dialog`/`combobox`/`listbox` with `aria-activedescendant` and
239
268
  `Esc` / arrow / `Enter` handling.
269
+ - **Diff view.** `git.ts` extends the read-only provider set with a TTL-cached
270
+ `git status --porcelain=v1 -z` and a per-mode `git diff`; `diff.ts` parses the
271
+ unified diff and the porcelain records into plain view models, so `router.ts`
272
+ and `html.ts` stay pure. `head` compares against `HEAD` (falling back to
273
+ `--cached` before the first commit), `staged` against the index, and
274
+ `worktree` against the index from the other side. An untracked file renders as
275
+ all-add through `readFileView`, so binary and over-size handling is shared
276
+ with the file view. `git diff --shortstat` supplies the chip totals and
277
+ `changeDirs` derives the directory markers; the changed lines are highlighted
278
+ per hunk by rebuilding the old and new sides for `lib/highlight`.
240
279
  - **Streaming responses.** The server streams `filePath` responses and
241
280
  suppresses bodies for `HEAD`, so large `/raw` files are not buffered.
242
281
  - **Root confinement over feature breadth.** The root is deliberately confined
@@ -254,6 +293,7 @@ document and the project document (project wins):
254
293
  | [`../../lib/highlight.ts`](../../lib/highlight.ts) | First-party escape-first syntax tokenizer for the code view (shared with `plan`) |
255
294
  | [`../../lib/web/index.ts`](../../lib/web/index.ts) | The shared Bytefly web brand system: tokens, base, components, layout, runtime, and markup |
256
295
  | `files.ts` | Classification (incl. Markdown), listings, text reads, sidebar tree |
296
+ | `diff.ts` | Pure unified-diff and porcelain-status parsing for the diff view |
257
297
  | `fuzzy.ts` | Fuzzy path matching and ranking for the quick-open palette |
258
298
  | `markdown.ts` | First-party escape-first Markdown-to-HTML renderer (fenced code highlighted) |
259
299
  | `git.ts` | Read-only branch/dirty provider for the header chip |
@@ -275,8 +315,12 @@ highlighting, safety, and path resolution),
275
315
  the icon mapping and sprite, HTML escaping/encoding/rendering, the client script
276
316
  and its serving, the platform browser commands with an injected spawn, the router
277
317
  (routes, `/api/tree`, `/api/files`, `?raw=1`, status codes, `Host`, `HEAD`, `/raw`
278
- headers)
318
+ headers, and the `/diff` view routes and mode validation)
279
319
  without a socket, a real `127.0.0.1:0` server, the session-port lifecycle (fixed
280
320
  on first start, reused, and re-rolled after a conflict), the git status provider
281
- (branch, dirty, detached, non-repo, failure, and TTL cache), and the command
321
+ (branch, dirty, detached, non-repo, failure, and TTL cache), the git diff parser
322
+ and provider (line kinds, line numbers, status and rename parsing, mode
323
+ filtering, untracked all-add, unborn-HEAD fallback, literal pathspec, and TTL
324
+ cache), the diff renderers (highlighted changed lines, marker sets, and chip
325
+ totals), and the command
282
326
  lifecycle with injected seams.
@@ -525,7 +525,96 @@ export const CLIENT_JS = `(function () {
525
525
  if (result) window.location.assign("/view/" + encodeSegments(result.path));
526
526
  }
527
527
 
528
+ // Keyboard: a shortcut sheet, the per-page actions the server rendered, and
529
+ // hunk-to-hunk navigation through a diff.
530
+ var keysDialog = document.getElementById("keys-dialog");
531
+ var keysButton = document.getElementById("keys-open");
532
+ var hunkAnchors = Array.prototype.slice.call(document.querySelectorAll("[data-hunk]"));
533
+ var progressFill = document.getElementById("progress-fill");
534
+
535
+ function toggleKeys(force) {
536
+ if (!keysDialog) return;
537
+ var open = force !== undefined ? force : keysDialog.hidden;
538
+ keysDialog.hidden = !open;
539
+ if (open) {
540
+ var close = document.getElementById("keys-close");
541
+ if (close) close.focus();
542
+ }
543
+ }
544
+
545
+ function toggleRaw() {
546
+ var next = document.querySelector(".bf-card__actions .bf-segmented a.bf-segmented__item:not([aria-current])");
547
+ if (next) window.location.assign(next.getAttribute("href") || next.href);
548
+ }
549
+
550
+ // The hunk whose header is pinned (or last passed), then one step either way.
551
+ function moveHunk(delta) {
552
+ if (hunkAnchors.length === 0) return;
553
+ var scroller = primaryScroller();
554
+ // The line a pinned hunk header sticks to: the code viewport's top inset
555
+ // by its padding. With no scrolling pane (narrow screens) the page is the
556
+ // viewport, so the anchor is the window top.
557
+ var lineTop = 8;
558
+ if (scroller) lineTop = scroller.getBoundingClientRect().top + (parseFloat(getComputedStyle(scroller).paddingTop) || 0);
559
+ var current = -1;
560
+ for (var index = 0; index < hunkAnchors.length; index++) {
561
+ if (hunkAnchors[index].getBoundingClientRect().top <= lineTop + 2) current = index;
562
+ }
563
+ var next = Math.max(0, Math.min(hunkAnchors.length - 1, current + delta));
564
+ if (scroller) scroller.scrollTop += hunkAnchors[next].getBoundingClientRect().top - lineTop;
565
+ else hunkAnchors[next].scrollIntoView({ block: "start" });
566
+ }
567
+
568
+ // The page's primary scroller: the card body for a file or diff, the main
569
+ // column otherwise.
570
+ function primaryScroller() {
571
+ var candidates = [
572
+ document.getElementById("main"),
573
+ document.querySelector(".bf-md"),
574
+ document.querySelector("pre.bf-code")
575
+ ];
576
+ var best = null;
577
+ var bestRange = 0;
578
+ candidates.forEach(function (node) {
579
+ if (!node) return;
580
+ var range = node.scrollHeight - node.clientHeight;
581
+ if (range > bestRange) { bestRange = range; best = node; }
582
+ });
583
+ return bestRange > 0 ? best : null;
584
+ }
585
+
586
+ function updateProgress() {
587
+ if (!progressFill) return;
588
+ var scroller = primaryScroller();
589
+ var doc = document.scrollingElement || document.documentElement;
590
+ var top = doc ? doc.scrollTop : 0;
591
+ var height = doc ? doc.scrollHeight : 0;
592
+ var view = doc ? doc.clientHeight : 0;
593
+ if (scroller) { top = scroller.scrollTop; height = scroller.scrollHeight; view = scroller.clientHeight; }
594
+ var max = height - view;
595
+ var percent = max > 0 ? Math.min(100, Math.max(0, (top / max) * 100)) : 0;
596
+ progressFill.style.width = percent + "%";
597
+ }
598
+
599
+ if (keysButton) keysButton.addEventListener("click", function () { toggleKeys(); });
600
+ if (keysDialog) {
601
+ keysDialog.addEventListener("click", function (event) {
602
+ if (event.target === keysDialog) toggleKeys(false);
603
+ });
604
+ var keysClose = document.getElementById("keys-close");
605
+ if (keysClose) keysClose.addEventListener("click", function () { toggleKeys(false); });
606
+ }
607
+
608
+ window.addEventListener("scroll", updateProgress, true);
609
+ window.addEventListener("resize", updateProgress);
610
+ updateProgress();
611
+
528
612
  document.addEventListener("keydown", function (event) {
613
+ if (event.key === "Escape" && keysDialog && !keysDialog.hidden) {
614
+ event.preventDefault();
615
+ toggleKeys(false);
616
+ return;
617
+ }
529
618
  if (paletteOpen()) {
530
619
  if (event.key === "Escape") {
531
620
  event.preventDefault();
@@ -533,15 +622,34 @@ export const CLIENT_JS = `(function () {
533
622
  }
534
623
  return;
535
624
  }
625
+ if (keysDialog && !keysDialog.hidden) return;
536
626
  var target = event.target;
537
627
  var editable = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
538
628
  if (editable) return;
539
- if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) {
629
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
540
630
  event.preventDefault();
541
631
  openPalette();
542
- } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
632
+ return;
633
+ }
634
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
635
+ if (event.key === "/") {
543
636
  event.preventDefault();
544
637
  openPalette();
638
+ } else if (event.key === "?") {
639
+ event.preventDefault();
640
+ toggleKeys();
641
+ } else if (event.key === "u" && wrapButton) {
642
+ event.preventDefault();
643
+ wrapButton.click();
644
+ } else if (event.key === "r" && document.querySelector(".bf-card__actions .bf-segmented")) {
645
+ event.preventDefault();
646
+ toggleRaw();
647
+ } else if (event.key === "n" && hunkAnchors.length > 0) {
648
+ event.preventDefault();
649
+ moveHunk(1);
650
+ } else if (event.key === "p" && hunkAnchors.length > 0) {
651
+ event.preventDefault();
652
+ moveHunk(-1);
545
653
  }
546
654
  });
547
655
 
@@ -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