@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
@@ -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,7 +19,8 @@ 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
26
  import { renderMarkdownDocument, type MarkdownHeading } from "./markdown.ts";
@@ -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
  }
@@ -86,10 +121,67 @@ ${WEB_COMPONENTS_CSS}
86
121
  .browse-md-outline a.lvl2{padding-left:var(--space-5)}
87
122
  .browse-md-outline a.lvl3{padding-left:var(--space-7)}
88
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
+ }
89
175
  @media (max-width:520px){
90
176
  .bf-toolbar .bf-theme-toggle{margin-left:auto}
91
177
  }`;
92
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
+
93
185
  interface Layout {
94
186
  title: string;
95
187
  rootLabel: string;
@@ -98,7 +190,29 @@ interface Layout {
98
190
  breadcrumbs: string;
99
191
  main: string;
100
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[];
101
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>`;
102
216
  }
103
217
 
104
218
  /** A full two-pane page. */
@@ -118,25 +232,28 @@ ${SPRITE}
118
232
  ${brandLockup("browse")}
119
233
  <span class="bf-context browse-root">${escapeHtml(layout.rootLabel)}</span>
120
234
  ${renderGit(layout.git)}
235
+ ${renderChanges(layout.summary)}
121
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>
122
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>
123
- </div></header>
239
+ </div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
124
240
  <div class="bf-app__body">
125
241
  <details class="bf-app__sidebar" open>
126
242
  <summary class="bf-panel__summary">Files</summary>
127
243
  <aside class="bf-panel browse-side">
128
244
  <button type="button" class="browse-quick-open" id="quick-open" aria-haspopup="dialog"><span>quick open</span><kbd>/</kbd></button>
129
245
  <label class="bf-field">⌕ <input class="bf-input" id="filter" type="search" placeholder="filter paths…" aria-label="Filter paths"></label>
130
- <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>
131
247
  <p class="bf-note" id="tree-empty" hidden>No matches.</p>
132
248
  </aside>
133
249
  </details>
134
- <main class="bf-app__main" id="main" tabindex="-1">
250
+ <main class="bf-app__main${layout.mainClass ? ` ${layout.mainClass}` : ""}" id="main" tabindex="-1">
135
251
  <nav class="bf-crumbs" aria-label="Breadcrumb">${layout.breadcrumbs}</nav>
136
252
  ${layout.main}
137
253
  </main>
138
254
  </div>
139
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) : ""}
140
257
  <script src="/app.js" defer></script>
141
258
  </body>
142
259
  </html>
@@ -158,11 +275,11 @@ export function renderBreadcrumbs(rel: string): string {
158
275
  return parts.join(`<span class="bf-crumbs__sep">/</span>`);
159
276
  }
160
277
 
161
- function renderTree(node: TreeNode, currentRel: string): string {
162
- return `<ul>${renderTreeNode(node, currentRel)}</ul>`;
278
+ function renderTree(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
279
+ return `<ul>${renderTreeNode(node, currentRel, summary)}</ul>`;
163
280
  }
164
281
 
165
- function renderTreeNode(node: TreeNode, currentRel: string): string {
282
+ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
166
283
  const expanded = node.children.length > 0;
167
284
  const isDir = node.isDir;
168
285
  const spec = isDir ? FOLDER_ICON : languageOf(node.name);
@@ -174,15 +291,15 @@ function renderTreeNode(node: TreeNode, currentRel: string): string {
174
291
  const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
175
292
  const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
176
293
  const children = expanded
177
- ? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel)).join("")}</ul>`
294
+ ? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel, summary)).join("")}</ul>`
178
295
  : "";
179
296
  const label = node.broken
180
297
  ? `<span class="browse-broken" title="broken symbolic link">${name}</span><span class="browse-link-mark">↩</span>`
181
298
  : `<a href="${href}">${name}</a>`;
182
- 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>`;
183
300
  }
184
301
 
185
- function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean): string {
302
+ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean, summary?: ChangeSummary): string {
186
303
  const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
187
304
  const showThumb = thumbnails && entry.isImage && !entry.broken && entry.size <= maxThumbBytes;
188
305
  const visual = showThumb
@@ -199,7 +316,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean):
199
316
  ? `<span class="browse-link-mark" title="${entry.broken ? "broken symbolic link" : "symbolic link"}">↩</span>`
200
317
  : "";
201
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)}"`;
202
- 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>`;
203
320
  }
204
321
 
205
322
  export interface DirectoryPage {
@@ -210,16 +327,19 @@ export interface DirectoryPage {
210
327
  thumbnails: boolean;
211
328
  maxThumbBytes: number;
212
329
  git?: GitStatus;
330
+ /** Changed-path index for the header chip and markers. */
331
+ summary?: ChangeSummary;
213
332
  }
214
333
 
215
334
  /** Render a directory listing page. */
216
335
  export function renderDirectoryPage(page: DirectoryPage): string {
217
- 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) || "/";
218
338
  const heading = `<h1 class="bf-title browse-heading" id="page-title">${escapeHtml(folderName)}</h1>`;
219
339
  let body = `<table class="bf-table browse-listing" aria-labelledby="page-title">
220
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>
221
341
  <tbody>
222
- ${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")}
223
343
  </tbody>
224
344
  </table>`;
225
345
  if (page.listing.entries.length === 0) body = `<div class="bf-empty">This folder is empty.</div>`;
@@ -227,14 +347,16 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
227
347
  body += `<p class="bf-note">Showing the first ${page.listing.entries.length} of ${page.listing.total} entries.</p>`;
228
348
 
229
349
  return renderLayout({
230
- title: `browse · ${page.rel || "/"}`,
350
+ title: `browse · ${page.rel || folderName}`,
231
351
  rootLabel: page.rootLabel,
232
352
  tree: page.tree,
233
353
  currentRel: page.rel,
234
354
  breadcrumbs: renderBreadcrumbs(page.rel),
355
+ keys: pageKeys(),
235
356
  main: ` ${heading}\n ${body}`,
236
357
  footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`,
237
358
  git: page.git,
359
+ summary: page.summary,
238
360
  });
239
361
  }
240
362
 
@@ -247,6 +369,10 @@ export interface FilePage {
247
369
  git?: GitStatus;
248
370
  /** Show the Markdown source instead of the rendered HTML. */
249
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;
250
376
  }
251
377
 
252
378
  /** The root-relative directory of a file, for resolving its Markdown links. */
@@ -260,6 +386,18 @@ function renderCodeLines(lines: string[], language?: HighlightLanguage): string
260
386
  return rendered.map((html) => `<span class="bf-code__line">${html.length > 0 ? html : " "}</span>`).join("");
261
387
  }
262
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
+
263
401
  /** An "on this page" outline from the headings the Markdown renderer emitted. */
264
402
  function renderOutline(headings: MarkdownHeading[]): string {
265
403
  if (headings.length < 4) return "";
@@ -280,6 +418,11 @@ function renderFileActions(page: FilePage): string {
280
418
  const rawHref = `/raw/${encodePath(page.rel)}`;
281
419
  const viewHref = `/view/${encodePath(page.rel)}`;
282
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
+ }
283
426
  if (isMarkdown) {
284
427
  const rendered = page.markdownRaw
285
428
  ? `<a class="bf-segmented__item" href="${viewHref}">rendered</a>`
@@ -310,16 +453,26 @@ export function renderFilePage(page: FilePage): string {
310
453
  const name = page.rel.slice(page.rel.lastIndexOf("/") + 1);
311
454
  const spec = languageOf(name);
312
455
  const language = page.file.kind === "text" ? detectLanguage(name) : undefined;
313
- 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)}`;
314
459
  const download = `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`;
315
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>`;
316
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
+
317
470
  let content: string;
318
471
  let note = "";
319
472
  if (page.file.kind === "text" || page.file.kind === "markdown") {
320
473
  if (page.file.kind === "markdown" && !page.markdownRaw) {
321
- const rendered = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
322
- content = `${renderOutline(rendered.headings)}<article class="bf-md">${rendered.html}</article>`;
474
+ const document = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
475
+ content = `${renderOutline(document.headings)}<article class="bf-md">${document.html}</article>`;
323
476
  } else {
324
477
  content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
325
478
  }
@@ -339,9 +492,222 @@ export function renderFilePage(page: FilePage): string {
339
492
  tree: page.tree,
340
493
  currentRel: page.rel,
341
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,
342
707
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
343
- footerNote: escapeHtml(page.file.mime),
708
+ footerNote: `diff · ${escapeHtml(modeScope(page.mode))}`,
344
709
  git: page.git,
710
+ summary: page.summary,
345
711
  });
346
712
  }
347
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);
@@ -31,6 +31,22 @@ import { encodePath } from "./paths.ts";
31
31
  const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
32
32
  const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
33
33
  const HTTP_SCHEME = /^https?:/i;
34
+ /** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */
35
+ const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i;
36
+
37
+ /** GFM starts an autolink at the line start or after whitespace or one of `*_~(`. */
38
+ function autolinkBoundary(text: string, index: number): boolean {
39
+ return index === 0 || /[\s*_~(]/.test(text[index - 1]);
40
+ }
41
+
42
+ /** Drop the trailing punctuation GFM excludes, and an unclosed `)`. */
43
+ function trimAutolink(url: string): string {
44
+ let end = url.replace(/[.,;:!?*_~]+$/, "");
45
+ while (end.endsWith(")") && (end.match(/\(/g)?.length ?? 0) < (end.match(/\)/g)?.length ?? 0)) {
46
+ end = end.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
47
+ }
48
+ return end;
49
+ }
34
50
  /** A link reference definition: `[id]: dest "title"`. */
35
51
  const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/;
36
52
 
@@ -800,6 +816,23 @@ function renderInline(text: string, ctx: Context): string {
800
816
  }
801
817
  }
802
818
 
819
+ // GFM extended autolink: a bare `https://` or `www.` URL in running text.
820
+ if ((char === "h" || char === "H" || char === "w" || char === "W") && autolinkBoundary(text, i)) {
821
+ const match = AUTOLINK.exec(text.slice(i));
822
+ if (match) {
823
+ const label = trimAutolink(match[0]);
824
+ const dots = label.indexOf(".");
825
+ // Require a domain, so a bare scheme is not a link.
826
+ if (dots > 0 && /\.[^./]/.test(label.slice(dots))) {
827
+ const href = /^www\./i.test(label) ? `http://${label}` : label;
828
+ const external = HTTP_SCHEME.test(href) ? ` target="_blank" rel="noopener noreferrer"` : "";
829
+ out += `<a href="${escapeHtml(href)}"${external}>${escapeHtml(label)}</a>`;
830
+ i += label.length;
831
+ continue;
832
+ }
833
+ }
834
+ }
835
+
803
836
  if ((char === "*" || char === "_") && text[i + 1] === char) {
804
837
  const close = text.indexOf(char + char, i + 2);
805
838
  if (close > i + 2) {