@gavin-hu/my-pi-agent 0.8.0 → 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 +9 -8
  2. package/README.zh-CN.md +8 -8
  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 +62 -14
  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 +412 -23
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +98 -5
  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 +2 -2
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -5,33 +5,29 @@
5
5
  * The component is derived: it rebuilds the merged model from the picker
6
6
  * runtime and the current reports on every render, keeps the focused row's key
7
7
  * across rebuilds, and requests a repaint only after an input changes state.
8
- * Screen chrome and scroll math come from `lib/tui.ts` and `lib/list-cursor.ts`.
9
- * A row without a report renders no glyph and no summary, never a false `ok`.
8
+ * The screen is drawn from the design system — a label-first `titleRule`, one
9
+ * `listRow` grammar, one `checkbox` — and its scroll math from
10
+ * `lib/tui/cursor.ts`. A row without a report renders no glyph and no summary,
11
+ * never a false `ok`.
10
12
  */
11
13
 
12
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
- import {
14
- Key,
15
- matchesKey,
16
- truncateToWidth,
17
- visibleWidth,
18
- type Component,
19
- type TuiMouseEvent,
20
- type TuiMouseEventResult,
21
- } from "@earendil-works/pi-tui";
15
+ import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
22
16
  import { sanitizeLine } from "../../../lib/format.ts";
23
- import {
24
- fitRows,
25
- formatRange,
26
- isCloseKey,
27
- ListCursor,
28
- navIntent,
29
- selectionMarker,
30
- wheelDelta,
31
- } from "../../../lib/list-cursor.ts";
17
+ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../../lib/tui/chrome.ts";
18
+ import { clip, fitCells } from "../../../lib/tui/columns.ts";
19
+ import { fitRows, formatRange, isCloseKey, ListCursor, navIntent, wheelDelta } from "../../../lib/tui/cursor.ts";
20
+ import { checkbox, emptyState, listRow } from "../../../lib/tui/rows.ts";
21
+ import { titleRule } from "../../../lib/tui/rules.ts";
22
+ import { paint } from "../../../lib/tui/tokens.ts";
32
23
  import type { ExtensionReport } from "../../../lib/requirements.ts";
33
- import { dockListFrame, screenHint, type ViewportRowsSource } from "../../../lib/tui.ts";
34
- import { detailLines as reportDetailLines, reportSeverity, reportSummary, severityGlyph } from "../doctor/format.ts";
24
+ import {
25
+ detailLines as reportDetailLines,
26
+ reportSeverity,
27
+ reportSummary,
28
+ severityGlyphChar,
29
+ severityRole,
30
+ } from "../doctor/format.ts";
35
31
  import type { ExtensionsPickerRuntime } from "../picker/runtime.ts";
36
32
  import { summarySegments } from "./format.ts";
37
33
  import { buildPanelModel } from "./join.ts";
@@ -283,50 +279,37 @@ export class PanelComponent implements Component {
283
279
  return { handled: true };
284
280
  }
285
281
 
282
+ /** The headline: whole segments only, so a narrow screen drops a count. */
286
283
  private summary(width: number, model: PanelModel): string {
287
284
  const segments = summarySegments(model, this.options.runtime.scope(), {
288
285
  filter: this.options.filter,
289
286
  compact: width < 72,
290
287
  });
291
- const separator = this.theme.fg("dim", " · ");
292
- const kept: string[] = [];
293
- for (const segment of segments) {
294
- const styled = this.theme.fg(segment.color, segment.text);
295
- if (kept.length > 0 && visibleWidth(` ${[...kept, styled].join(separator)}`) > width) break;
296
- kept.push(styled);
297
- }
298
- return truncateToWidth(` ${kept.join(separator)}`, width, "…");
288
+ const separator = paint(this.theme, "faint", " · ");
289
+ const styled = segments.map((segment) => paint(this.theme, segment.color, segment.text));
290
+ return clip(` ${fitCells(styled, Math.max(1, width - 2), separator)}`, width);
299
291
  }
300
292
 
301
293
  private groupLine(entry: Extract<PanelEntry, { kind: "group" }>, width: number): string {
302
- const label = this.theme.fg("muted", sanitizeLine(entry.label));
303
- const count = this.theme.fg("dim", `(${entry.count})`);
304
- return truncateToWidth(` ${label} ${count}`, width, "…");
294
+ const label = paint(this.theme, "meta", sanitizeLine(entry.label));
295
+ const count = paint(this.theme, "faint", `(${entry.count})`);
296
+ return clip(` ${label} ${count}`, width);
305
297
  }
306
298
 
307
299
  private rowLine(row: PanelRow, selected: boolean, width: number): string {
308
- const checkbox = row.item
309
- ? row.item.enabled
310
- ? this.theme.fg("success", "[x]")
311
- : this.theme.fg("dim", "[ ]")
312
- : this.theme.fg("dim", " ");
313
300
  const severity = row.report ? reportSeverity(row.report) : undefined;
314
- const glyph = severity ? severityGlyph(severity, this.theme) : " ";
315
- const label = sanitizeLine(row.label);
316
- const styled = selected
317
- ? this.theme.fg("accent", label)
318
- : row.item && !row.item.enabled
319
- ? this.theme.fg("dim", label)
320
- : label;
321
- const summary = row.report ? reportSummary(row.report) : "";
322
- const head = `${selectionMarker(this.theme, selected)}${checkbox} ${glyph} `;
323
- if (!summary) return truncateToWidth(`${head}${styled}`, width, "…");
324
-
325
- const available = Math.max(1, width - visibleWidth(head) - visibleWidth(summary) - 1);
326
- const name = truncateToWidth(styled, available, "…");
327
- const pad = " ".repeat(Math.max(0, available - visibleWidth(name)));
328
- const tone = severity === "missing" ? "error" : severity === "degraded" ? "warning" : "dim";
329
- return truncateToWidth(`${head}${name}${pad} ${this.theme.fg(tone, summary)}`, width, "…");
301
+ return listRow(this.theme, width, {
302
+ selected,
303
+ // A reported-only row has no enable pattern, but still reserves the box
304
+ // column so the list stays aligned.
305
+ leading: row.item ? checkbox(this.theme, row.item.enabled) : " ",
306
+ glyph: severity ? severityGlyphChar(severity) : " ",
307
+ glyphRole: severity ? severityRole(severity) : "faint",
308
+ label: sanitizeLine(row.label),
309
+ labelRole: selected ? "accent" : row.item && !row.item.enabled ? "faint" : "label",
310
+ trailing: row.report ? reportSummary(row.report) : undefined,
311
+ trailingRole: severity === "missing" ? "bad" : severity === "degraded" ? "warn" : "faint",
312
+ });
330
313
  }
331
314
 
332
315
  /** The focused row's label, findings, and the pending-changes note. */
@@ -334,19 +317,14 @@ export class PanelComponent implements Component {
334
317
  const lines: string[] = [];
335
318
  if (row?.report) {
336
319
  const tail = reportSummary(row.report);
337
- lines.push(
338
- truncateToWidth(
339
- ` ${this.theme.fg("muted", sanitizeLine(row.label))} ${this.theme.fg("dim", "—")} ${this.theme.fg(row.report.enabled ? "muted" : "warning", tail)}`,
340
- width,
341
- "…",
342
- ),
343
- );
320
+ const head = `${paint(this.theme, "meta", sanitizeLine(row.label))} ${paint(this.theme, "faint", "—")} ${paint(this.theme, row.report.enabled ? "meta" : "warn", tail)}`;
321
+ lines.push(clip(` ${head}`, width));
344
322
  if (budget > 0) lines.push(...reportDetailLines(row.report, this.theme, width, budget, this.showDetail));
345
323
  } else if (row?.item) {
346
- lines.push(truncateToWidth(` ${this.theme.fg("muted", sanitizeLine(row.item.path))}`, width, "…"));
324
+ lines.push(clip(` ${paint(this.theme, "meta", sanitizeLine(row.item.path))}`, width));
347
325
  }
348
326
  if (this.options.runtime.isDirty()) {
349
- lines.push(truncateToWidth(` ${this.theme.fg("warning", "Saved · reload to apply")}`, width, "…"));
327
+ lines.push(clip(` ${paint(this.theme, "warn", "Saved · reload to apply")}`, width));
350
328
  }
351
329
  return lines;
352
330
  }
@@ -356,14 +334,13 @@ export class PanelComponent implements Component {
356
334
  const model = this.model();
357
335
  const entries = entriesOf(model);
358
336
  const summary = this.summary(w, model);
337
+ const header = titleRule(this.theme, w, "bytefly");
359
338
 
360
339
  if (entries.length === 0) {
361
340
  return dockListFrame({
362
- theme: this.theme,
363
- width: w,
364
- title: "bytefly",
341
+ header,
365
342
  summary,
366
- body: [truncateToWidth(` ${this.theme.fg("dim", "No extensions found.")}`, w)],
343
+ body: emptyState(this.theme, w, { label: "No extensions found." }),
367
344
  hint: screenHint(this.theme, w, ["Esc close"]),
368
345
  });
369
346
  }
@@ -394,9 +371,7 @@ export class PanelComponent implements Component {
394
371
  }
395
372
 
396
373
  return dockListFrame({
397
- theme: this.theme,
398
- width: w,
399
- title: "bytefly",
374
+ header,
400
375
  summary,
401
376
  body,
402
377
  range: this.cursor.clipped(entries.length)
@@ -2,11 +2,12 @@
2
2
  * Tuning for bytefly's turn separator.
3
3
  *
4
4
  * Edit this file to change how the line between completed turns looks. The
5
- * glyphs and colors are plain theme tokens, so the line follows the active
6
- * theme automatically.
5
+ * glyph and the colors are plain design-system roles, so the line follows the
6
+ * active theme automatically.
7
7
  */
8
8
 
9
- import { GLYPHS } from "../../../lib/ui.ts";
9
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
10
+ import type { TuiRole } from "../../../lib/tui/tokens.ts";
10
11
 
11
12
  export const CONFIG = {
12
13
  /** Custom entry type. Changing it orphans separators recorded by an older build. */
@@ -17,8 +18,15 @@ export const CONFIG = {
17
18
  labelPrefix: "turn",
18
19
  /** Spaces on each side of the label. */
19
20
  pad: 1,
20
- /** Theme token for the dashes. */
21
- dashColor: "border",
22
- /** Theme token for the label. */
23
- labelColor: "muted",
24
- } as const;
21
+ /** Role for the dashes. */
22
+ dashRole: "ruleStrong",
23
+ /** Role for the label. */
24
+ labelRole: "meta",
25
+ } as const satisfies {
26
+ customType: string;
27
+ glyph: string;
28
+ labelPrefix: string;
29
+ pad: number;
30
+ dashRole: TuiRole;
31
+ labelRole: TuiRole;
32
+ };
@@ -1,50 +1,23 @@
1
1
  /**
2
2
  * Pure formatting for bytefly's turn separator line.
3
3
  *
4
- * `separatorParts` decides the layout from a plain turn number and a column
5
- * count; `separatorLine` applies theme colors. Neither touches Pi state, so
6
- * both are cheap to unit test.
4
+ * The centered `╌╌╌ turn N ╌╌╌` layout comes from the design system's
5
+ * `centeredRule`, so this module only names the turn; the shared rule owns the
6
+ * centering, the degradation, and the colors.
7
7
  */
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
- import { visibleWidth } from "@earendil-works/pi-tui";
10
+ import { centeredRule } from "../../../lib/tui/rules.ts";
11
11
  import { CONFIG } from "./config.ts";
12
- import type { SeparatorParts } from "./types.ts";
13
12
 
14
- /** ` turn N ` — the label printed in the middle of the line. */
15
- export function separatorLabel(turn: number): string {
16
- const pad = " ".repeat(CONFIG.pad);
17
- return `${pad}${CONFIG.labelPrefix} ${turn}${pad}`;
18
- }
19
-
20
- /**
21
- * Split `width` columns into centered dashes and a label.
22
- *
23
- * When the label does not fit, the line degrades to a plain dashed rule so it
24
- * never overflows the transcript.
25
- */
26
- export function separatorParts(turn: number, width: number): SeparatorParts {
27
- const target = Math.max(1, Math.floor(width));
28
- const label = separatorLabel(turn);
29
- const labelWidth = visibleWidth(label);
30
-
31
- if (labelWidth >= target) {
32
- return { left: CONFIG.glyph.repeat(target), label: "", right: "" };
33
- }
34
-
35
- const remaining = target - labelWidth;
36
- const left = Math.floor(remaining / 2);
37
- const right = remaining - left;
38
- return {
39
- left: CONFIG.glyph.repeat(left),
40
- label,
41
- right: CONFIG.glyph.repeat(right),
42
- };
43
- }
44
-
45
- /** One rendered line: colored dashes around a colored label. */
13
+ /** One rendered line: the centered turn rule, colored by role. */
46
14
  export function separatorLine(turn: number, width: number, theme: Theme): string[] {
47
- const { left, label, right } = separatorParts(turn, width);
48
- const dashes = (text: string) => (text ? theme.fg(CONFIG.dashColor, text) : "");
49
- return [dashes(left) + (label ? theme.fg(CONFIG.labelColor, label) : "") + dashes(right)];
15
+ return [
16
+ centeredRule(theme, width, `${CONFIG.labelPrefix} ${turn}`, {
17
+ glyph: CONFIG.glyph,
18
+ pad: CONFIG.pad,
19
+ fillRole: CONFIG.dashRole,
20
+ labelRole: CONFIG.labelRole,
21
+ }),
22
+ ];
50
23
  }
@@ -10,13 +10,3 @@ export interface TurnSeparatorData {
10
10
  /** 1-based index of the completed user turn this separator follows. */
11
11
  turn: number;
12
12
  }
13
-
14
- /** A separator line split into its dashes and label. */
15
- export interface SeparatorParts {
16
- /** Dashes before the label. */
17
- left: string;
18
- /** The centred `turn N` label. */
19
- label: string;
20
- /** Dashes after the label. */
21
- right: string;
22
- }
@@ -218,7 +218,7 @@ config defaults, and error listing all derive from it.
218
218
  | 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
219
  | State | None persisted. Per-result state lives in the tool-result `details` / `structuredContent`; the renderer reads only that. |
220
220
  | 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`). |
221
+ | 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
222
  | Config | The `document` section of the shared `bytefly.json` via `loadConfigSection` (`~/.pi/agent`, `<cwd>/.pi`). |
223
223
 
224
224
  ## Design notes
@@ -14,7 +14,8 @@ 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
 
@@ -23,10 +24,10 @@ export const PREVIEW_LINES = 20;
23
24
 
24
25
  /** Render the argument path: accent, `~`-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, "accent", 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);
@@ -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,12 +36,17 @@ 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
- blocks are syntax-highlighted when the fence names a supported language. Raw
40
- HTML in the source is escaped, never executed, and external images are shown
41
- as links rather than fetched.
45
+ blocks are syntax-highlighted when the fence names a supported language.
46
+ Headings get stable anchor ids, so in-document `#anchor` links and a table of
47
+ contents work, and a document with four or more headings gets an **On this
48
+ page** outline. Raw HTML in the source is escaped, never executed, and
49
+ external images are shown as links rather than fetched.
42
50
  - **Sortable listings.** The Name / Size / Modified headers sort the current
43
51
  directory, directories first, and the choice is remembered per browser.
44
52
  - **Copy and wrap.** Copy a file's root-relative path or its contents, and
@@ -49,9 +57,22 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
49
57
  - **Quick open.** `/` (or `Ctrl/Cmd+K`, or the sidebar button) opens a fuzzy
50
58
  path finder over a capped index of the tree, with keyboard navigation and a
51
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.
52
64
  - **Read-only and loopback-scoped.** No editing, no writing, no LAN exposure.
53
65
  - **Git context.** A read-only header chip shows the checked-out branch (or
54
66
  detached head) and a dirty marker, and reflows on narrow screens.
67
+ - **Git diff view.** A `● N changes +A −D` chip links to `/diff`, a change
68
+ overview with `git diff --stat` and a per-file status table, and `/diff/<rel>`
69
+ renders one file's unified diff, whose head carries its own `+A −D` and hunk
70
+ count. Changed lines are syntax-highlighted with the
71
+ file's language, and dirty files and their directories carry a marker in the
72
+ listing and the tree. The scope is switchable between `head` / `staged` /
73
+ `worktree`; untracked files show as all-add, an unborn `HEAD` falls back to the
74
+ index, and a non-repository is a plain empty state. Read-only, capped, and
75
+ rendered with the shared diff colors.
55
76
  - **No dependencies.** `node:http` plus the inline sprite, CSS, and client
56
77
  script.
57
78
 
@@ -91,6 +112,8 @@ reflects only this session's server, with no cross-session sharing.
91
112
  | `/view/<rel>` | tree + file page (text, Markdown, image, binary, or over-size) |
92
113
  | `/view/<rel>?raw=1` | raw Markdown/text source instead of the rendered view |
93
114
  | `/raw/<rel>` | raw bytes, streamed; downloads and image sources |
115
+ | `/diff[?mode=head\|staged\|worktree]` | changed-file overview with `--stat` |
116
+ | `/diff/<rel>[?mode=…]` | one file's unified diff |
94
117
  | `/api/tree?path=<rel>` | JSON children for lazy tree expansion |
95
118
  | `/api/files?q=<query>` | JSON fuzzy path matches for the quick-open palette |
96
119
  | `/app.js` | the first-party client script |
@@ -113,7 +136,10 @@ document and the project document (project wins):
113
136
  "maxDirEntries": 2000, // listed entries before truncation
114
137
  "thumbnails": true,
115
138
  "markdown": true, // render Markdown files as formatted HTML
116
- "maxIndexEntries": 20000 // files collected for quick open before truncation
139
+ "maxIndexEntries": 20000, // files collected for quick open before truncation
140
+ "gitDiff": true, // offer the read-only diff view
141
+ "diffContext": 3, // unified diff context lines
142
+ "maxDiffLines": 4000 // diff lines rendered before truncation
117
143
  }
118
144
  ```
119
145
 
@@ -140,8 +166,11 @@ document and the project document (project wins):
140
166
  attributes; only the fixed `tok-*` class names reach the page.
141
167
  - **Caps.** `maxFileBytes` (inline rendering), `maxTextLines` (rendered lines),
142
168
  `maxThumbBytes` (thumbnails), and `maxDirEntries` (listings) bound every read.
143
- - **Git reads.** The header chip runs read-only git (`rev-parse`, `status
144
- --porcelain`) with a timeout and a short cache; any failure simply omits it.
169
+ - **Git reads.** The header chip and the diff view run read-only git
170
+ (`rev-parse`, `status --porcelain=v1 -z`, `diff --stat`, `diff -U<n>`) with a
171
+ timeout and a short cache; any failure simply omits the chip or the diff. A
172
+ per-file diff sets `GIT_LITERAL_PATHSPECS=1`, so a filename containing glob
173
+ characters is matched literally.
145
174
  - **Read-only.** Nothing is ever written.
146
175
 
147
176
  ## Limitations
@@ -209,8 +238,12 @@ document and the project document (project wins):
209
238
  the full `[text][id]`, collapsed `[text][]`, and shortcut `[text]` forms, and
210
239
  numeric and common named character references are resolved. The Rendered/Raw
211
240
  switch is a server-side `?raw=1`
212
- link, so it works without JavaScript. Set `markdown: false` to fall back to
213
- the plain-text view. A fenced code block whose info string names a supported
241
+ link, so it works without JavaScript. Headings are demoted one level so the
242
+ card title stays the page's only `h1`, and each gets a slug `id` (unique in
243
+ the document); `renderMarkdownDocument` returns the heading list and `html.ts`
244
+ renders an **On this page** disclosure for four or more headings. Set
245
+ `markdown: false` to fall back to the plain-text view. A fenced code block
246
+ whose info string names a supported
214
247
  language is highlighted through `lib/highlight.ts` (either an extension or a
215
248
  long-form alias, `fenceLanguage`); an absent or unknown language stays plain.
216
249
  - **Syntax highlighting.** `lib/highlight.ts` is a first-party, escape-first
@@ -231,6 +264,16 @@ document and the project document (project wins):
231
264
  `maxIndexEntries`, and is cached per server for 10 s by `createFileIndex`. The
232
265
  palette is a `dialog`/`combobox`/`listbox` with `aria-activedescendant` and
233
266
  `Esc` / arrow / `Enter` handling.
267
+ - **Diff view.** `git.ts` extends the read-only provider set with a TTL-cached
268
+ `git status --porcelain=v1 -z` and a per-mode `git diff`; `diff.ts` parses the
269
+ unified diff and the porcelain records into plain view models, so `router.ts`
270
+ and `html.ts` stay pure. `head` compares against `HEAD` (falling back to
271
+ `--cached` before the first commit), `staged` against the index, and
272
+ `worktree` against the index from the other side. An untracked file renders as
273
+ all-add through `readFileView`, so binary and over-size handling is shared
274
+ with the file view. `git diff --shortstat` supplies the chip totals and
275
+ `changeDirs` derives the directory markers; the changed lines are highlighted
276
+ per hunk by rebuilding the old and new sides for `lib/highlight`.
234
277
  - **Streaming responses.** The server streams `filePath` responses and
235
278
  suppresses bodies for `HEAD`, so large `/raw` files are not buffered.
236
279
  - **Root confinement over feature breadth.** The root is deliberately confined
@@ -248,6 +291,7 @@ document and the project document (project wins):
248
291
  | [`../../lib/highlight.ts`](../../lib/highlight.ts) | First-party escape-first syntax tokenizer for the code view (shared with `plan`) |
249
292
  | [`../../lib/web/index.ts`](../../lib/web/index.ts) | The shared Bytefly web brand system: tokens, base, components, layout, runtime, and markup |
250
293
  | `files.ts` | Classification (incl. Markdown), listings, text reads, sidebar tree |
294
+ | `diff.ts` | Pure unified-diff and porcelain-status parsing for the diff view |
251
295
  | `fuzzy.ts` | Fuzzy path matching and ranking for the quick-open palette |
252
296
  | `markdown.ts` | First-party escape-first Markdown-to-HTML renderer (fenced code highlighted) |
253
297
  | `git.ts` | Read-only branch/dirty provider for the header chip |
@@ -269,8 +313,12 @@ highlighting, safety, and path resolution),
269
313
  the icon mapping and sprite, HTML escaping/encoding/rendering, the client script
270
314
  and its serving, the platform browser commands with an injected spawn, the router
271
315
  (routes, `/api/tree`, `/api/files`, `?raw=1`, status codes, `Host`, `HEAD`, `/raw`
272
- headers)
316
+ headers, and the `/diff` view routes and mode validation)
273
317
  without a socket, a real `127.0.0.1:0` server, the session-port lifecycle (fixed
274
318
  on first start, reused, and re-rolled after a conflict), the git status provider
275
- (branch, dirty, detached, non-repo, failure, and TTL cache), and the command
319
+ (branch, dirty, detached, non-repo, failure, and TTL cache), the git diff parser
320
+ and provider (line kinds, line numbers, status and rename parsing, mode
321
+ filtering, untracked all-add, unborn-HEAD fallback, literal pathspec, and TTL
322
+ cache), the diff renderers (highlighted changed lines, marker sets, and chip
323
+ totals), and the command
276
324
  lifecycle with injected seams.