@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
@@ -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,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,22 @@ 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
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.
57
76
  - **No dependencies.** `node:http` plus the inline sprite, CSS, and client
58
77
  script.
59
78
 
@@ -93,6 +112,8 @@ reflects only this session's server, with no cross-session sharing.
93
112
  | `/view/<rel>` | tree + file page (text, Markdown, image, binary, or over-size) |
94
113
  | `/view/<rel>?raw=1` | raw Markdown/text source instead of the rendered view |
95
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 |
96
117
  | `/api/tree?path=<rel>` | JSON children for lazy tree expansion |
97
118
  | `/api/files?q=<query>` | JSON fuzzy path matches for the quick-open palette |
98
119
  | `/app.js` | the first-party client script |
@@ -115,7 +136,10 @@ document and the project document (project wins):
115
136
  "maxDirEntries": 2000, // listed entries before truncation
116
137
  "thumbnails": true,
117
138
  "markdown": true, // render Markdown files as formatted HTML
118
- "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
119
143
  }
120
144
  ```
121
145
 
@@ -142,8 +166,11 @@ document and the project document (project wins):
142
166
  attributes; only the fixed `tok-*` class names reach the page.
143
167
  - **Caps.** `maxFileBytes` (inline rendering), `maxTextLines` (rendered lines),
144
168
  `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.
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.
147
174
  - **Read-only.** Nothing is ever written.
148
175
 
149
176
  ## Limitations
@@ -237,6 +264,16 @@ document and the project document (project wins):
237
264
  `maxIndexEntries`, and is cached per server for 10 s by `createFileIndex`. The
238
265
  palette is a `dialog`/`combobox`/`listbox` with `aria-activedescendant` and
239
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`.
240
277
  - **Streaming responses.** The server streams `filePath` responses and
241
278
  suppresses bodies for `HEAD`, so large `/raw` files are not buffered.
242
279
  - **Root confinement over feature breadth.** The root is deliberately confined
@@ -254,6 +291,7 @@ document and the project document (project wins):
254
291
  | [`../../lib/highlight.ts`](../../lib/highlight.ts) | First-party escape-first syntax tokenizer for the code view (shared with `plan`) |
255
292
  | [`../../lib/web/index.ts`](../../lib/web/index.ts) | The shared Bytefly web brand system: tokens, base, components, layout, runtime, and markup |
256
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 |
257
295
  | `fuzzy.ts` | Fuzzy path matching and ranking for the quick-open palette |
258
296
  | `markdown.ts` | First-party escape-first Markdown-to-HTML renderer (fenced code highlighted) |
259
297
  | `git.ts` | Read-only branch/dirty provider for the header chip |
@@ -275,8 +313,12 @@ highlighting, safety, and path resolution),
275
313
  the icon mapping and sprite, HTML escaping/encoding/rendering, the client script
276
314
  and its serving, the platform browser commands with an injected spawn, the router
277
315
  (routes, `/api/tree`, `/api/files`, `?raw=1`, status codes, `Host`, `HEAD`, `/raw`
278
- headers)
316
+ headers, and the `/diff` view routes and mode validation)
279
317
  without a socket, a real `127.0.0.1:0` server, the session-port lifecycle (fixed
280
318
  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
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
282
324
  lifecycle with injected seams.
@@ -525,7 +525,89 @@ 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 current = -1;
554
+ for (var index = 0; index < hunkAnchors.length; index++) {
555
+ if (hunkAnchors[index].getBoundingClientRect().top <= 8) current = index;
556
+ }
557
+ var next = Math.max(0, Math.min(hunkAnchors.length - 1, current + delta));
558
+ hunkAnchors[next].scrollIntoView({ block: "start" });
559
+ }
560
+
561
+ // The page's primary scroller: the card body for a file or diff, the main
562
+ // column otherwise.
563
+ function primaryScroller() {
564
+ var candidates = [
565
+ document.getElementById("main"),
566
+ document.querySelector(".bf-md"),
567
+ document.querySelector("pre.bf-code")
568
+ ];
569
+ var best = null;
570
+ var bestRange = 0;
571
+ candidates.forEach(function (node) {
572
+ if (!node) return;
573
+ var range = node.scrollHeight - node.clientHeight;
574
+ if (range > bestRange) { bestRange = range; best = node; }
575
+ });
576
+ return bestRange > 0 ? best : null;
577
+ }
578
+
579
+ function updateProgress() {
580
+ if (!progressFill) return;
581
+ var scroller = primaryScroller();
582
+ var doc = document.scrollingElement || document.documentElement;
583
+ var top = doc ? doc.scrollTop : 0;
584
+ var height = doc ? doc.scrollHeight : 0;
585
+ var view = doc ? doc.clientHeight : 0;
586
+ if (scroller) { top = scroller.scrollTop; height = scroller.scrollHeight; view = scroller.clientHeight; }
587
+ var max = height - view;
588
+ var percent = max > 0 ? Math.min(100, Math.max(0, (top / max) * 100)) : 0;
589
+ progressFill.style.width = percent + "%";
590
+ }
591
+
592
+ if (keysButton) keysButton.addEventListener("click", function () { toggleKeys(); });
593
+ if (keysDialog) {
594
+ keysDialog.addEventListener("click", function (event) {
595
+ if (event.target === keysDialog) toggleKeys(false);
596
+ });
597
+ var keysClose = document.getElementById("keys-close");
598
+ if (keysClose) keysClose.addEventListener("click", function () { toggleKeys(false); });
599
+ }
600
+
601
+ window.addEventListener("scroll", updateProgress, true);
602
+ window.addEventListener("resize", updateProgress);
603
+ updateProgress();
604
+
528
605
  document.addEventListener("keydown", function (event) {
606
+ if (event.key === "Escape" && keysDialog && !keysDialog.hidden) {
607
+ event.preventDefault();
608
+ toggleKeys(false);
609
+ return;
610
+ }
529
611
  if (paletteOpen()) {
530
612
  if (event.key === "Escape") {
531
613
  event.preventDefault();
@@ -533,15 +615,34 @@ export const CLIENT_JS = `(function () {
533
615
  }
534
616
  return;
535
617
  }
618
+ if (keysDialog && !keysDialog.hidden) return;
536
619
  var target = event.target;
537
620
  var editable = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
538
621
  if (editable) return;
539
- if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) {
622
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
540
623
  event.preventDefault();
541
624
  openPalette();
542
- } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
625
+ return;
626
+ }
627
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
628
+ if (event.key === "/") {
543
629
  event.preventDefault();
544
630
  openPalette();
631
+ } else if (event.key === "?") {
632
+ event.preventDefault();
633
+ toggleKeys();
634
+ } else if (event.key === "u" && wrapButton) {
635
+ event.preventDefault();
636
+ wrapButton.click();
637
+ } else if (event.key === "r" && document.querySelector(".bf-card__actions .bf-segmented")) {
638
+ event.preventDefault();
639
+ toggleRaw();
640
+ } else if (event.key === "n" && hunkAnchors.length > 0) {
641
+ event.preventDefault();
642
+ moveHunk(1);
643
+ } else if (event.key === "p" && hunkAnchors.length > 0) {
644
+ event.preventDefault();
645
+ moveHunk(-1);
545
646
  }
546
647
  });
547
648