@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
@@ -8,7 +8,7 @@
8
8
  * `browse-*` rules below are the page-only layer.
9
9
  */
10
10
 
11
- import { GLYPHS } from "../../lib/ui.ts";
11
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
12
12
  import {
13
13
  brandLockup,
14
14
  docHead,
@@ -19,10 +19,11 @@ import {
19
19
  } from "../../lib/web/index.ts";
20
20
  import { escapeHtml } from "../../lib/escape.ts";
21
21
  import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode } from "./files.ts";
22
- import type { GitStatus } from "./git.ts";
22
+ import { DIFF_MODES, statusLetter, type ChangedFile, type DiffLine, type DiffMode } from "./diff.ts";
23
+ import type { DiffFileView, GitStatus } from "./git.ts";
23
24
  import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
24
25
  import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
25
- import { renderMarkdown } from "./markdown.ts";
26
+ import { renderMarkdownDocument, type MarkdownHeading } from "./markdown.ts";
26
27
  import { encodePath } from "./paths.ts";
27
28
 
28
29
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -51,6 +52,40 @@ function renderGit(git: GitStatus | undefined): string {
51
52
  return `<span class="bf-chip browse-git${git.dirty ? " browse-git--dirty" : ""}" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span>${escapeHtml(name)}${dot}</span>`;
52
53
  }
53
54
 
55
+ /** The changed-path index for markers, plus the totals shown in the chip. */
56
+ export interface ChangeSummary {
57
+ count: number;
58
+ insertions: number;
59
+ deletions: number;
60
+ files: ReadonlySet<string>;
61
+ dirs: ReadonlySet<string>;
62
+ }
63
+
64
+ /** Whether a path carries a change marker: a changed file, or a directory above one. */
65
+ export function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
66
+ if (!summary || !rel) return false;
67
+ return isDir ? summary.dirs.has(rel) : summary.files.has(rel);
68
+ }
69
+
70
+ /** A small dot marking a changed file or a directory that contains one. */
71
+ function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: boolean): string {
72
+ return isChanged(summary, rel, isDir)
73
+ ? `<span class="browse-change" title="uncommitted changes" aria-label="uncommitted changes">●</span>`
74
+ : "";
75
+ }
76
+
77
+ /** The header's changes chip, linking to the diff overview when there are changes. */
78
+ function renderChanges(summary: ChangeSummary | undefined): string {
79
+ if (!summary || summary.count <= 0) return "";
80
+ const label = `${summary.count} change${summary.count === 1 ? "" : "s"}`;
81
+ const totals =
82
+ summary.insertions + summary.deletions > 0
83
+ ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${summary.insertions}</span> <span class="browse-diff-del">−${summary.deletions}</span></span>`
84
+ : "";
85
+ const aria = `${label} against HEAD${summary.insertions + summary.deletions > 0 ? `, ${summary.insertions} insertions and ${summary.deletions} deletions` : ""}`;
86
+ return `<a class="bf-chip browse-diff" href="/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
87
+ }
88
+
54
89
  function hrefFor(entry: { rel: string; isDir: boolean }): string {
55
90
  return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
56
91
  }
@@ -78,11 +113,75 @@ ${WEB_COMPONENTS_CSS}
78
113
  .browse-broken{color:var(--muted);text-decoration:line-through}
79
114
  .browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
80
115
  .browse-when{display:none}
116
+ .browse-md-outline{max-width:80ch;margin:0 0 var(--space-3) var(--space-4);border:1px solid var(--border);border-radius:var(--radius-md);font-size:var(--fs-sm)}
117
+ .browse-md-outline>summary{padding:var(--space-2) var(--space-3);cursor:pointer;color:var(--muted);font-family:var(--font-mono);font-size:var(--fs-xs)}
118
+ .browse-md-outline ul{list-style:none;margin:0;padding:0 var(--space-3) var(--space-2)}
119
+ .browse-md-outline a{display:block;padding:2px 0}
120
+ .browse-md-outline a.lvl1{padding-left:var(--space-3)}
121
+ .browse-md-outline a.lvl2{padding-left:var(--space-5)}
122
+ .browse-md-outline a.lvl3{padding-left:var(--space-7)}
81
123
  body.palette-open{overflow:hidden}
124
+ .browse-diff{font-family:var(--font-mono);gap:5px}
125
+ .browse-diff:hover{color:var(--accent);text-decoration:none}
126
+ .browse-diff__dot{color:var(--warn);font-size:9px;line-height:1}
127
+ .browse-change{color:var(--warn);font-size:9px;line-height:1;flex:0 0 auto}
128
+ .browse-diff-totals{font-family:var(--font-mono)}
129
+ .browse-diff-add{color:var(--success)}
130
+ .browse-diff-del{color:var(--danger)}
131
+ .browse-diff-scope{color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-medium)}
132
+ .browse-diff-toolbar{display:flex;align-items:center;gap:var(--space-3);margin:0 0 var(--space-3);flex-wrap:wrap}
133
+ .browse-diff-stat{margin:0 0 var(--space-3);font:var(--fs-xs)/1.5 var(--font-mono);color:var(--muted);white-space:pre-wrap;overflow-x:auto}
134
+ .browse-diff th.browse-diff-status{width:72px}
135
+ .browse-diff th.browse-diff-word{width:130px}
136
+ .browse-diff-badge{display:inline-block;min-width:16px;text-align:center;font:var(--fw-semibold) var(--fs-xs) var(--font-mono);border-radius:var(--radius-sm);padding:1px 5px;border:1px solid var(--border)}
137
+ .browse-diff-badge--M{color:var(--warn);border-color:currentColor}
138
+ .browse-diff-badge--A,.browse-diff-badge--Q{color:var(--success);border-color:currentColor}
139
+ .browse-diff-badge--D{color:var(--danger);border-color:currentColor}
140
+ .browse-diff-badge--R,.browse-diff-badge--C{color:var(--accent);border-color:currentColor}
141
+ .browse-diff-orig{color:var(--muted);font-size:var(--fs-xs)}
142
+ .browse-diff-word{color:var(--muted);font-size:var(--fs-sm)}
143
+ .browse-diff-no{display:inline-block;width:46px;padding-right:var(--space-2);text-align:right;color:var(--dim);user-select:none}
144
+ .browse-diff-sign{display:inline-block;width:16px;text-align:center;color:var(--muted);user-select:none}
145
+ .bf-diff-line--add{background:var(--success-soft)}
146
+ .bf-diff-line--add .browse-diff-sign{color:var(--success)}
147
+ .bf-diff-line--del{background:var(--danger-soft)}
148
+ .bf-diff-line--del .browse-diff-sign{color:var(--danger)}
149
+ .bf-diff-line--hunk{color:var(--accent);background:var(--canvas)}
150
+ .bf-diff-line--meta{color:var(--muted)}
151
+ .bf-diff-line--note{color:var(--muted);font-style:italic}
152
+ /* A hunk header pins to the top of the diff viewport while its body scrolls. */
153
+ .bf-diff .bf-diff-line--hunk{position:sticky;top:0;z-index:1}
154
+ /* Reading progress, pinned to the sticky header's bottom edge. */
155
+ .browse-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
156
+ .browse-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
157
+ /* File and diff pages: a measured reading column inside the card. */
158
+ /* GitHub's markdown body width: one content column that headings, prose,
159
+ tables, code, and images all live in, with wide tables scrolling inside it. */
160
+ .browse-measure{--browse-measure:1012px}
161
+ .browse-measure .bf-md{max-width:var(--browse-measure);margin-inline:auto;padding-inline:0;padding-block:var(--space-4) var(--space-6)}
162
+ .browse-measure .browse-md-outline{max-width:var(--browse-measure);margin:0 auto var(--space-3)}
163
+ .bf-card__meta{font-variant-numeric:tabular-nums}
164
+ .browse-diff-summary{font-family:var(--font-mono);white-space:nowrap}
165
+ .browse-diff-summary .browse-diff-add{color:var(--success)}
166
+ .browse-diff-summary .browse-diff-del{color:var(--danger)}
167
+ .browse-hunks{color:var(--muted)}
168
+ .browse-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
169
+ .browse-keys dt{white-space:nowrap}
170
+ .browse-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
171
+ .browse-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
172
+ @media (max-width:820px){
173
+ .browse-root{order:9;flex:1 1 100%;direction:ltr;text-align:left}
174
+ }
82
175
  @media (max-width:520px){
83
176
  .bf-toolbar .bf-theme-toggle{margin-left:auto}
84
177
  }`;
85
178
 
179
+ /** One row of the keyboard sheet: the key or chord, and what it does. */
180
+ export interface KeyHint {
181
+ keys: string;
182
+ label: string;
183
+ }
184
+
86
185
  interface Layout {
87
186
  title: string;
88
187
  rootLabel: string;
@@ -91,7 +190,29 @@ interface Layout {
91
190
  breadcrumbs: string;
92
191
  main: string;
93
192
  footerNote: string;
193
+ /** Extra classes on `main`, for page-scoped rules. */
194
+ mainClass?: string;
195
+ /** Rows for the `?` keyboard sheet; omitted when the page has no shortcuts. */
196
+ keys?: KeyHint[];
94
197
  git?: GitStatus;
198
+ /** Changed-path index for the header chip and the tree markers; omitted when clean. */
199
+ summary?: ChangeSummary;
200
+ }
201
+
202
+ /** The `?` keyboard sheet, hidden until the client opens it. */
203
+ function renderKeysSheet(keys: KeyHint[]): string {
204
+ const rows = keys
205
+ .map(
206
+ (key) =>
207
+ `<div><dt><kbd class="browse-kbd">${escapeHtml(key.keys)}</kbd></dt><dd>${escapeHtml(key.label)}</dd></div>`,
208
+ )
209
+ .join("");
210
+ return `<div class="bf-dialog" id="keys-dialog" hidden>
211
+ <div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
212
+ <div class="bf-dialog__head"><h2 id="keys-title">Keyboard shortcuts</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="keys-close">Close</button></div>
213
+ <dl class="browse-keys">${rows}</dl>
214
+ </div>
215
+ </div>`;
95
216
  }
96
217
 
97
218
  /** A full two-pane page. */
@@ -111,25 +232,28 @@ ${SPRITE}
111
232
  ${brandLockup("browse")}
112
233
  <span class="bf-context browse-root">${escapeHtml(layout.rootLabel)}</span>
113
234
  ${renderGit(layout.git)}
235
+ ${renderChanges(layout.summary)}
114
236
  <span class="bf-chip bf-chip--accent">read-only</span>
237
+ <button type="button" class="bf-btn bf-btn--icon browse-keys-btn" id="keys-open" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
115
238
  <button type="button" class="bf-btn bf-btn--icon bf-theme-toggle" id="theme-toggle" aria-label="Toggle light and dark theme" title="Toggle light and dark theme"><span class="theme-glyph" aria-hidden="true">☾</span></button>
116
- </div></header>
239
+ </div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
117
240
  <div class="bf-app__body">
118
241
  <details class="bf-app__sidebar" open>
119
242
  <summary class="bf-panel__summary">Files</summary>
120
243
  <aside class="bf-panel browse-side">
121
244
  <button type="button" class="browse-quick-open" id="quick-open" aria-haspopup="dialog"><span>quick open</span><kbd>/</kbd></button>
122
245
  <label class="bf-field">⌕ <input class="bf-input" id="filter" type="search" placeholder="filter paths…" aria-label="Filter paths"></label>
123
- <nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel)}</nav>
246
+ <nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel, layout.summary)}</nav>
124
247
  <p class="bf-note" id="tree-empty" hidden>No matches.</p>
125
248
  </aside>
126
249
  </details>
127
- <main class="bf-app__main" id="main" tabindex="-1">
250
+ <main class="bf-app__main${layout.mainClass ? ` ${layout.mainClass}` : ""}" id="main" tabindex="-1">
128
251
  <nav class="bf-crumbs" aria-label="Breadcrumb">${layout.breadcrumbs}</nav>
129
252
  ${layout.main}
130
253
  </main>
131
254
  </div>
132
255
  <footer class="bf-app__footer">served from ${escapeHtml(layout.rootLabel)} · read-only · ${layout.footerNote}</footer>
256
+ ${layout.keys && layout.keys.length > 0 ? renderKeysSheet(layout.keys) : ""}
133
257
  <script src="/app.js" defer></script>
134
258
  </body>
135
259
  </html>
@@ -151,11 +275,11 @@ export function renderBreadcrumbs(rel: string): string {
151
275
  return parts.join(`<span class="bf-crumbs__sep">/</span>`);
152
276
  }
153
277
 
154
- function renderTree(node: TreeNode, currentRel: string): string {
155
- return `<ul>${renderTreeNode(node, currentRel)}</ul>`;
278
+ function renderTree(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
279
+ return `<ul>${renderTreeNode(node, currentRel, summary)}</ul>`;
156
280
  }
157
281
 
158
- function renderTreeNode(node: TreeNode, currentRel: string): string {
282
+ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
159
283
  const expanded = node.children.length > 0;
160
284
  const isDir = node.isDir;
161
285
  const spec = isDir ? FOLDER_ICON : languageOf(node.name);
@@ -167,15 +291,15 @@ function renderTreeNode(node: TreeNode, currentRel: string): string {
167
291
  const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
168
292
  const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
169
293
  const children = expanded
170
- ? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel)).join("")}</ul>`
294
+ ? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel, summary)).join("")}</ul>`
171
295
  : "";
172
296
  const label = node.broken
173
297
  ? `<span class="browse-broken" title="broken symbolic link">${name}</span><span class="browse-link-mark">↩</span>`
174
298
  : `<a href="${href}">${name}</a>`;
175
- return `<li data-open="${expanded}"><div class="bf-tree__row"${current}${dataPath}>${twisty}${iconMarkup(spec)}${label}</div>${children}</li>`;
299
+ return `<li data-open="${expanded}"><div class="bf-tree__row"${current}${dataPath}>${twisty}${iconMarkup(spec)}${label}${changeMark(summary, node.rel, isDir)}</div>${children}</li>`;
176
300
  }
177
301
 
178
- function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean): string {
302
+ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean, summary?: ChangeSummary): string {
179
303
  const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
180
304
  const showThumb = thumbnails && entry.isImage && !entry.broken && entry.size <= maxThumbBytes;
181
305
  const visual = showThumb
@@ -192,7 +316,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean):
192
316
  ? `<span class="browse-link-mark" title="${entry.broken ? "broken symbolic link" : "symbolic link"}">↩</span>`
193
317
  : "";
194
318
  const attrs = `data-name="${escapeHtml(entry.name)}" data-path="${escapeHtml(entry.rel)}" data-dir="${entry.isDir ? "1" : "0"}" data-size="${entry.size}" data-mtime="${Math.round(entry.mtimeMs)}"`;
195
- return `<tr${classes ? ` class="${classes}"` : ""} ${attrs}><td><span class="bf-table__name">${visual}${link}${mark}</span></td><td class="bf-table__size">${entry.isDir ? "—" : escapeHtml(formatSize(entry.size))}</td><td class="bf-table__when">${entry.isDir ? "—" : escapeHtml(formatDate(entry.mtimeMs))}</td></tr>`;
319
+ return `<tr${classes ? ` class="${classes}"` : ""} ${attrs}><td><span class="bf-table__name">${visual}${link}${changeMark(summary, entry.rel, entry.isDir)}${mark}</span></td><td class="bf-table__size">${entry.isDir ? "—" : escapeHtml(formatSize(entry.size))}</td><td class="bf-table__when">${entry.isDir ? "—" : escapeHtml(formatDate(entry.mtimeMs))}</td></tr>`;
196
320
  }
197
321
 
198
322
  export interface DirectoryPage {
@@ -203,16 +327,19 @@ export interface DirectoryPage {
203
327
  thumbnails: boolean;
204
328
  maxThumbBytes: number;
205
329
  git?: GitStatus;
330
+ /** Changed-path index for the header chip and markers. */
331
+ summary?: ChangeSummary;
206
332
  }
207
333
 
208
334
  /** Render a directory listing page. */
209
335
  export function renderDirectoryPage(page: DirectoryPage): string {
210
- const folderName = page.rel ? page.rel.slice(page.rel.lastIndexOf("/") + 1) : "/";
336
+ // At the root there is no folder name to show, so the served folder names it.
337
+ const folderName = page.rel ? lastSegment(page.rel) : lastSegment(page.rootLabel) || "/";
211
338
  const heading = `<h1 class="bf-title browse-heading" id="page-title">${escapeHtml(folderName)}</h1>`;
212
339
  let body = `<table class="bf-table browse-listing" aria-labelledby="page-title">
213
340
  <thead><tr><th scope="col" aria-sort="ascending"><button type="button" class="bf-sort" data-sort="name" title="Sort by name">Name</button></th><th scope="col" class="bf-table__size"><button type="button" class="bf-sort" data-sort="size" title="Sort by size">Size</button></th><th scope="col" class="bf-table__when"><button type="button" class="bf-sort" data-sort="mtime" title="Sort by modified time">Modified</button></th></tr></thead>
214
341
  <tbody>
215
- ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails)).join("\n")}
342
+ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails, page.summary)).join("\n")}
216
343
  </tbody>
217
344
  </table>`;
218
345
  if (page.listing.entries.length === 0) body = `<div class="bf-empty">This folder is empty.</div>`;
@@ -220,14 +347,16 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
220
347
  body += `<p class="bf-note">Showing the first ${page.listing.entries.length} of ${page.listing.total} entries.</p>`;
221
348
 
222
349
  return renderLayout({
223
- title: `browse · ${page.rel || "/"}`,
350
+ title: `browse · ${page.rel || folderName}`,
224
351
  rootLabel: page.rootLabel,
225
352
  tree: page.tree,
226
353
  currentRel: page.rel,
227
354
  breadcrumbs: renderBreadcrumbs(page.rel),
355
+ keys: pageKeys(),
228
356
  main: ` ${heading}\n ${body}`,
229
357
  footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`,
230
358
  git: page.git,
359
+ summary: page.summary,
231
360
  });
232
361
  }
233
362
 
@@ -240,6 +369,10 @@ export interface FilePage {
240
369
  git?: GitStatus;
241
370
  /** Show the Markdown source instead of the rendered HTML. */
242
371
  markdownRaw?: boolean;
372
+ /** True when this file has uncommitted changes, for the changes action. */
373
+ changed?: boolean;
374
+ /** Changed-path index for the header chip and the tree markers. */
375
+ summary?: ChangeSummary;
243
376
  }
244
377
 
245
378
  /** The root-relative directory of a file, for resolving its Markdown links. */
@@ -253,12 +386,43 @@ function renderCodeLines(lines: string[], language?: HighlightLanguage): string
253
386
  return rendered.map((html) => `<span class="bf-code__line">${html.length > 0 ? html : " "}</span>`).join("");
254
387
  }
255
388
 
389
+ /** The last segment of a root-relative path, or the path itself when it has none. */
390
+ function lastSegment(path: string): string {
391
+ const trimmed = path.replace(/[/\\]+$/, "");
392
+ const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
393
+ return slash === -1 ? trimmed : trimmed.slice(slash + 1);
394
+ }
395
+
396
+ /** The shortcuts every page shares, plus whatever the page adds. */
397
+ function pageKeys(...extra: KeyHint[]): KeyHint[] {
398
+ return [{ keys: "/ ⌘K", label: "Quick open a file" }, ...extra, { keys: "?", label: "This sheet" }];
399
+ }
400
+
401
+ /** An "on this page" outline from the headings the Markdown renderer emitted. */
402
+ function renderOutline(headings: MarkdownHeading[]): string {
403
+ if (headings.length < 4) return "";
404
+ const base = Math.min(...headings.map((heading) => heading.level));
405
+ const items = headings
406
+ .map((heading) => {
407
+ const depth = Math.min(heading.level - base, 3);
408
+ const label = heading.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_~]/g, "");
409
+ return `<li><a class="lvl${depth}" href="#${heading.id}">${escapeHtml(label)}</a></li>`;
410
+ })
411
+ .join("");
412
+ return `<details class="browse-md-outline"><summary>On this page</summary><ul>${items}</ul></details>`;
413
+ }
414
+
256
415
  /** The card-head controls: Markdown mode, line wrapping, and copy actions. */
257
416
  function renderFileActions(page: FilePage): string {
258
417
  const isMarkdown = page.file.kind === "markdown";
259
418
  const rawHref = `/raw/${encodePath(page.rel)}`;
260
419
  const viewHref = `/view/${encodePath(page.rel)}`;
261
420
  const groups: string[] = [];
421
+ if (page.changed) {
422
+ groups.push(
423
+ `<a class="bf-btn" href="/diff/${encodePath(page.rel)}" title="View this file's uncommitted changes">changes</a>`,
424
+ );
425
+ }
262
426
  if (isMarkdown) {
263
427
  const rendered = page.markdownRaw
264
428
  ? `<a class="bf-segmented__item" href="${viewHref}">rendered</a>`
@@ -275,7 +439,7 @@ function renderFileActions(page: FilePage): string {
275
439
  }
276
440
  if (page.file.kind === "text" || isMarkdown) {
277
441
  groups.push(
278
- `<button type="button" class="bf-btn" data-copy="content" data-href="${escapeHtml(rawHref)}" title="Copy the file contents">copy</button>`,
442
+ `<button type="button" class="bf-btn" data-copy="content" data-href="${escapeHtml(rawHref)}" title="Copy the file contents">copy contents</button>`,
279
443
  );
280
444
  }
281
445
  groups.push(
@@ -289,17 +453,29 @@ export function renderFilePage(page: FilePage): string {
289
453
  const name = page.rel.slice(page.rel.lastIndexOf("/") + 1);
290
454
  const spec = languageOf(name);
291
455
  const language = page.file.kind === "text" ? detectLanguage(name) : undefined;
292
- const meta = `${spec.label} · ${formatSize(page.file.size)} · ${formatDate(page.file.mtimeMs)} · ${page.file.mime}`;
456
+ // Text is always served as octet-stream, so the response type says nothing
457
+ // about the file; the language label, size, and date are the useful metadata.
458
+ const meta = `${spec.label} · ${formatSize(page.file.size)} · ${formatDate(page.file.mtimeMs)}`;
293
459
  const download = `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`;
294
460
  const head = `<div class="bf-card__head">${iconMarkup(spec)}<h1 class="bf-card__title">${escapeHtml(name)}</h1><span class="bf-card__meta">${escapeHtml(meta)}</span><span class="bf-spacer"></span>${renderFileActions(page)}${download}</div>`;
295
461
 
462
+ const isMarkdown = page.file.kind === "markdown";
463
+ const rendered = isMarkdown && !page.markdownRaw;
464
+ const wrappable = page.file.kind === "text" || (isMarkdown && page.markdownRaw);
465
+ const keys = pageKeys(
466
+ ...(isMarkdown ? [{ keys: "r", label: "Toggle rendered and raw" }] : []),
467
+ ...(wrappable ? [{ keys: "u", label: "Toggle line wrapping" }] : []),
468
+ );
469
+
296
470
  let content: string;
297
471
  let note = "";
298
472
  if (page.file.kind === "text" || page.file.kind === "markdown") {
299
- content =
300
- page.file.kind === "markdown" && !page.markdownRaw
301
- ? `<div class="bf-md">${renderMarkdown(page.file.lines.join("\n"), markdownBaseDir(page.rel))}</div>`
302
- : `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
473
+ if (page.file.kind === "markdown" && !page.markdownRaw) {
474
+ const document = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
475
+ content = `${renderOutline(document.headings)}<article class="bf-md">${document.html}</article>`;
476
+ } else {
477
+ content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
478
+ }
303
479
  if (page.file.truncated)
304
480
  note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
305
481
  } else if (page.file.kind === "image") {
@@ -316,9 +492,222 @@ export function renderFilePage(page: FilePage): string {
316
492
  tree: page.tree,
317
493
  currentRel: page.rel,
318
494
  breadcrumbs: renderBreadcrumbs(page.rel),
495
+ mainClass: rendered ? "browse-page browse-measure" : "browse-page",
496
+ keys,
497
+ main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
498
+ footerNote: escapeHtml(page.rel),
499
+ git: page.git,
500
+ summary: page.summary,
501
+ });
502
+ }
503
+
504
+ /** Map a status letter to an accessible label. */
505
+ const STATUS_LABEL: Record<string, string> = {
506
+ M: "modified",
507
+ A: "added",
508
+ D: "deleted",
509
+ R: "renamed",
510
+ C: "copied",
511
+ T: "type changed",
512
+ U: "unmerged",
513
+ "?": "untracked",
514
+ };
515
+
516
+ function statusWord(letter: string): string {
517
+ return STATUS_LABEL[letter] ?? "changed";
518
+ }
519
+
520
+ /**
521
+ * The inner HTML per diff line. Hunk bodies are highlighted by rebuilding the
522
+ * old side (context + deleted) and the new side (context + added), so the
523
+ * tokenizer carries state within a hunk; meta, hunk, and note lines stay plain.
524
+ * State does not cross hunks, since the unchanged lines between them are absent.
525
+ */
526
+ function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): string[] {
527
+ if (!language) return lines.map((line) => escapeHtml(line.text));
528
+ const out: string[] = new Array(lines.length);
529
+ let index = 0;
530
+ while (index < lines.length) {
531
+ const kind = lines[index].kind;
532
+ if (kind !== "ctx" && kind !== "add" && kind !== "del") {
533
+ out[index] = escapeHtml(lines[index].text);
534
+ index++;
535
+ continue;
536
+ }
537
+ const start = index;
538
+ const oldDoc: string[] = [];
539
+ const newDoc: string[] = [];
540
+ while (index < lines.length) {
541
+ const body = lines[index].kind;
542
+ if (body !== "ctx" && body !== "add" && body !== "del") break;
543
+ if (body === "ctx") {
544
+ oldDoc.push(lines[index].text);
545
+ newDoc.push(lines[index].text);
546
+ } else if (body === "add") {
547
+ newDoc.push(lines[index].text);
548
+ } else {
549
+ oldDoc.push(lines[index].text);
550
+ }
551
+ index++;
552
+ }
553
+ const oldHtml = highlightLines(oldDoc, language);
554
+ const newHtml = highlightLines(newDoc, language);
555
+ let oldIndex = 0;
556
+ let newIndex = 0;
557
+ for (let cursor = start; cursor < index; cursor++) {
558
+ const body = lines[cursor].kind;
559
+ if (body === "ctx") {
560
+ out[cursor] = newHtml[newIndex];
561
+ newIndex++;
562
+ oldIndex++;
563
+ } else if (body === "add") {
564
+ out[cursor] = newHtml[newIndex];
565
+ newIndex++;
566
+ } else {
567
+ out[cursor] = oldHtml[oldIndex];
568
+ oldIndex++;
569
+ }
570
+ }
571
+ }
572
+ return out;
573
+ }
574
+
575
+ /** Render unified-diff lines with a two-column gutter and per-kind styling. */
576
+ export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string {
577
+ const html = highlightDiffText(lines, language);
578
+ let hunks = 0;
579
+ return lines
580
+ .map((line, index) => {
581
+ const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
582
+ const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
583
+ const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
584
+ // Hunk headers carry an anchor so the keyboard can jump between them.
585
+ const anchor = line.kind === "hunk" ? ` id="hunk-${++hunks}" data-hunk="${hunks}"` : "";
586
+ return `<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}"${anchor}>${gutter}${marker}${html[index]}</span>`;
587
+ })
588
+ .join("");
589
+ }
590
+
591
+ /** The scope label for a mode. */
592
+ function modeScope(mode: DiffMode): string {
593
+ if (mode === "staged") return "staged vs HEAD";
594
+ if (mode === "worktree") return "unstaged";
595
+ return "vs HEAD";
596
+ }
597
+
598
+ /** The `head | staged | worktree` segmented control; `build` returns each href. */
599
+ function renderModeControl(mode: DiffMode, build: (mode: DiffMode) => string): string {
600
+ const items = DIFF_MODES.map((candidate) => {
601
+ const current = candidate === mode ? ` aria-current="page"` : "";
602
+ return `<a class="bf-segmented__item" href="${escapeHtml(build(candidate))}"${current}>${candidate}</a>`;
603
+ }).join("");
604
+ return `<span class="bf-segmented" role="group" aria-label="Diff mode">${items}</span>`;
605
+ }
606
+
607
+ export interface DiffPage {
608
+ rootLabel: string;
609
+ mode: DiffMode;
610
+ files: ChangedFile[];
611
+ stat: string;
612
+ tree: TreeNode;
613
+ git?: GitStatus;
614
+ summary?: ChangeSummary;
615
+ }
616
+
617
+ /** The change overview: `git diff --stat` plus every changed path. */
618
+ export function renderDiffPage(page: DiffPage): string {
619
+ const modeHref = (mode: DiffMode) => (mode === "head" ? "/diff" : `/diff?mode=${mode}`);
620
+ const body =
621
+ page.files.length === 0
622
+ ? `<div class="bf-empty"><p class="bf-empty__title">No changes ${escapeHtml(modeScope(page.mode))}.</p><p class="bf-empty__sub">The working tree matches this scope.</p></div>`
623
+ : `<table class="bf-table browse-diff" aria-labelledby="page-title">
624
+ <thead><tr><th scope="col" class="browse-diff-status">Status</th><th scope="col">Path</th><th scope="col" class="browse-diff-word">Changes</th></tr></thead>
625
+ <tbody>
626
+ ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
627
+ </tbody>
628
+ </table>`;
629
+ const stat = page.stat ? `<pre class="browse-diff-stat">${escapeHtml(page.stat)}</pre>` : "";
630
+ return renderLayout({
631
+ title: "browse · changes",
632
+ rootLabel: page.rootLabel,
633
+ tree: page.tree,
634
+ currentRel: "",
635
+ breadcrumbs: renderBreadcrumbs(""),
636
+ mainClass: "browse-page",
637
+ keys: pageKeys(),
638
+ main: ` <h1 class="bf-title browse-heading" id="page-title">Changes <span class="browse-diff-scope">${escapeHtml(modeScope(page.mode))}</span></h1>\n <div class="browse-diff-toolbar">${renderModeControl(page.mode, modeHref)}</div>\n ${stat}\n ${body}`,
639
+ footerNote: `${page.files.length} changed`,
640
+ git: page.git,
641
+ summary: page.summary,
642
+ });
643
+ }
644
+
645
+ function renderChangedRow(file: ChangedFile, mode: DiffMode): string {
646
+ const letter = statusLetter(file);
647
+ const label = statusWord(letter);
648
+ const badge = `browse-diff-badge browse-diff-badge--${letter === "?" ? "Q" : letter}`;
649
+ const orig = file.origRel ? ` <span class="browse-diff-orig">from ${escapeHtml(file.origRel)}</span>` : "";
650
+ const href = `/diff/${encodePath(file.rel)}${mode === "head" ? "" : `?mode=${mode}`}`;
651
+ return `<tr data-path="${escapeHtml(file.rel)}"><td><span class="${badge}" aria-label="${escapeHtml(label)}">${escapeHtml(letter)}</span></td><td><span class="bf-table__name">${iconMarkup(languageOf(file.rel))}<a href="${href}">${escapeHtml(file.rel)}</a>${orig}</span></td><td class="browse-diff-word">${escapeHtml(label)}</td></tr>`;
652
+ }
653
+
654
+ export interface FileDiffPage {
655
+ rootLabel: string;
656
+ rel: string;
657
+ mode: DiffMode;
658
+ diff: DiffFileView;
659
+ tree: TreeNode;
660
+ git?: GitStatus;
661
+ summary?: ChangeSummary;
662
+ }
663
+
664
+ /** A single file's unified diff, with the scope control and file actions. */
665
+ export function renderFileDiffPage(page: FileDiffPage): string {
666
+ const name = page.rel.slice(page.rel.lastIndexOf("/") + 1);
667
+ const spec = languageOf(name);
668
+ const base = `/diff/${encodePath(page.rel)}`;
669
+ const actions = [
670
+ renderModeControl(page.mode, (mode) => (mode === "head" ? base : `${base}?mode=${mode}`)),
671
+ `<a class="bf-btn" href="/view/${encodePath(page.rel)}" title="Open the file">view file</a>`,
672
+ `<button type="button" class="bf-btn" id="wrap-toggle" aria-pressed="false" title="Toggle line wrapping">wrap</button>`,
673
+ `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`,
674
+ `<button type="button" class="bf-btn" data-copy="path" data-value="${escapeHtml(page.rel)}" title="Copy the root-relative path">copy path</button>`,
675
+ ];
676
+ // A per-file change summary and hunk count, so the scope is visible before scrolling.
677
+ const adds = page.diff.lines.filter((line) => line.kind === "add").length;
678
+ const dels = page.diff.lines.filter((line) => line.kind === "del").length;
679
+ const hunks = page.diff.lines.filter((line) => line.kind === "hunk").length;
680
+ const summary =
681
+ hunks > 0
682
+ ? `<span class="bf-card__meta browse-diff-summary"><span class="browse-diff-add">+${adds}</span> <span class="browse-diff-del">−${dels}</span></span><span class="bf-card__meta browse-hunks">${hunks} hunk${hunks === 1 ? "" : "s"}</span>`
683
+ : "";
684
+ const keys = pageKeys(
685
+ { keys: "u", label: "Toggle line wrapping" },
686
+ ...(hunks > 0 ? [{ keys: "n p", label: "Next and previous change" }] : []),
687
+ );
688
+ const head = `<div class="bf-card__head">${iconMarkup(spec)}<h1 class="bf-card__title">${escapeHtml(name)}</h1><span class="bf-card__meta">${escapeHtml(modeScope(page.mode))}</span>${summary}<span class="bf-spacer"></span><span class="bf-card__actions">${actions.join("")}</span></div>`;
689
+ let content: string;
690
+ let note = "";
691
+ if (page.diff.binary) {
692
+ content = `<div class="bf-empty"><p class="bf-empty__title">Binary or unreadable change.</p><p class="bf-empty__sub">No text diff is shown for this file.</p><a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a></div>`;
693
+ } else if (page.diff.lines.length === 0) {
694
+ content = `<div class="bf-empty"><p class="bf-empty__title">No changes for this file.</p><p class="bf-empty__sub">It is unchanged in the selected scope.</p></div>`;
695
+ } else {
696
+ content = `<pre class="bf-code bf-diff"><code>${renderDiffLines(page.diff.lines, detectLanguage(name))}</code></pre>`;
697
+ if (page.diff.truncated) note = `<p class="bf-note">Showing the first ${page.diff.lines.length} diff lines.</p>`;
698
+ }
699
+ return renderLayout({
700
+ title: `browse · ${page.rel}`,
701
+ rootLabel: page.rootLabel,
702
+ tree: page.tree,
703
+ currentRel: page.rel,
704
+ breadcrumbs: renderBreadcrumbs(page.rel),
705
+ mainClass: "browse-page",
706
+ keys,
319
707
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
320
- footerNote: escapeHtml(page.file.mime),
708
+ footerNote: `diff · ${escapeHtml(modeScope(page.mode))}`,
321
709
  git: page.git,
710
+ summary: page.summary,
322
711
  });
323
712
  }
324
713
 
@@ -13,9 +13,10 @@ import { realpathSync } from "node:fs";
13
13
  import { stat } from "node:fs/promises";
14
14
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
15
  import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
16
- import { GLYPHS, STATUS_KEYS } from "../../lib/ui.ts";
16
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
17
+ import { paint } from "../../lib/tui/tokens.ts";
17
18
  import { loadConfig, randomPort } from "./config.ts";
18
- import { createGitStatus } from "./git.ts";
19
+ import { createGitDiff, createGitStatus } from "./git.ts";
19
20
  import { openInBrowser } from "../../lib/browser.ts";
20
21
  import { HttpError, resolveRequestPath } from "./paths.ts";
21
22
  import { createFileServer, type FileServer } from "./server.ts";
@@ -45,7 +46,7 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
45
46
  const value =
46
47
  port === undefined
47
48
  ? undefined
48
- : `${ctx.ui.theme.fg("success", GLYPHS.browse)} ${ctx.ui.theme.fg("accent", String(port))}`;
49
+ : `${paint(ctx.ui.theme, "ok", GLYPHS.browse)} ${paint(ctx.ui.theme, "accent", String(port))}`;
49
50
  ctx.ui.setStatus(STATUS_KEYS.browse, value);
50
51
  } catch {
51
52
  // A UI without a theme or setStatus must not break the command.
@@ -110,7 +111,17 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
110
111
  await stop(ctx);
111
112
  const create = deps.createServer ?? createFileServer;
112
113
  try {
113
- const server = await create({ root, config: { ...config, port }, git: createGitStatus(pi, root) });
114
+ const server = await create({
115
+ root,
116
+ config: { ...config, port },
117
+ git: createGitStatus(pi, root),
118
+ diff: createGitDiff(pi, root, {
119
+ context: config.diffContext,
120
+ maxLines: config.maxDiffLines,
121
+ maxFileBytes: config.maxFileBytes,
122
+ maxTextLines: config.maxTextLines,
123
+ }),
124
+ });
114
125
  if (unpinned) sessionPort = port;
115
126
  running = { server };
116
127
  setBrowseStatus(ctx, server.port);