@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The browse pages' unified-diff body: one line per row, with the gutter, the
3
+ * sign, and the hunk sections.
4
+ *
5
+ * Split from `html.ts` when that module reached its named ceiling: the diff body
6
+ * is the one part of the page renderers with its own rule — old and new sides are
7
+ * highlighted as two documents per hunk, so the tokenizer carries state within a
8
+ * hunk and never across the unchanged lines a hunk does not show.
9
+ */
10
+
11
+ import { escapeHtml } from "../../lib/escape.ts";
12
+ import { highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
13
+ import type { DiffLine } from "./diff.ts";
14
+
15
+ /**
16
+ * The inner HTML per diff line. Hunk bodies are highlighted by rebuilding the
17
+ * old side (context + deleted) and the new side (context + added), so the
18
+ * tokenizer carries state within a hunk; meta, hunk, and note lines stay plain.
19
+ * State does not cross hunks, since the unchanged lines between them are absent.
20
+ */
21
+ function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): string[] {
22
+ if (!language) return lines.map((line) => escapeHtml(line.text));
23
+ const out: string[] = new Array(lines.length);
24
+ let index = 0;
25
+ while (index < lines.length) {
26
+ const kind = lines[index].kind;
27
+ if (kind !== "ctx" && kind !== "add" && kind !== "del") {
28
+ out[index] = escapeHtml(lines[index].text);
29
+ index++;
30
+ continue;
31
+ }
32
+ const start = index;
33
+ const oldDoc: string[] = [];
34
+ const newDoc: string[] = [];
35
+ while (index < lines.length) {
36
+ const body = lines[index].kind;
37
+ if (body !== "ctx" && body !== "add" && body !== "del") break;
38
+ if (body === "ctx") {
39
+ oldDoc.push(lines[index].text);
40
+ newDoc.push(lines[index].text);
41
+ } else if (body === "add") {
42
+ newDoc.push(lines[index].text);
43
+ } else {
44
+ oldDoc.push(lines[index].text);
45
+ }
46
+ index++;
47
+ }
48
+ const oldHtml = highlightLines(oldDoc, language);
49
+ const newHtml = highlightLines(newDoc, language);
50
+ let oldIndex = 0;
51
+ let newIndex = 0;
52
+ for (let cursor = start; cursor < index; cursor++) {
53
+ const body = lines[cursor].kind;
54
+ if (body === "ctx") {
55
+ out[cursor] = newHtml[newIndex];
56
+ newIndex++;
57
+ oldIndex++;
58
+ } else if (body === "add") {
59
+ out[cursor] = newHtml[newIndex];
60
+ newIndex++;
61
+ } else {
62
+ out[cursor] = oldHtml[oldIndex];
63
+ oldIndex++;
64
+ }
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /** Render unified-diff lines with a two-column gutter and per-kind styling. */
71
+ export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string {
72
+ const html = highlightDiffText(lines, language);
73
+ // Every hunk is a section so its sticky header is bounded by its own body;
74
+ // the section also gives the keyboard a stable, non-sticky scroll target.
75
+ const out: string[] = [];
76
+ let hunks = 0;
77
+ let open = false;
78
+ lines.forEach((line, index) => {
79
+ if (line.kind === "hunk") {
80
+ if (open) out.push("</span>");
81
+ hunks += 1;
82
+ out.push(`<span class="bf-diff-hunk" id="hunk-${hunks}" data-hunk="${hunks}">`);
83
+ open = true;
84
+ }
85
+ const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
86
+ const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
87
+ const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
88
+ out.push(
89
+ `<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}">${gutter}${marker}${html[index]}</span>`,
90
+ );
91
+ });
92
+ if (open) out.push("</span>");
93
+ return out.join("");
94
+ }
@@ -8,11 +8,12 @@
8
8
  * the page-only `browse-*` rules from `styles.ts`.
9
9
  */
10
10
 
11
- import { GLYPHS } from "../../lib/tui/glyphs.ts";
12
- import { brandLockup, docHead, type KeyHint, keysSheet } from "../../lib/web/index.ts";
11
+ import { brandLockup, docHead, type KeyHint, keysSheet, themeToggle } from "../../lib/web/index.ts";
13
12
  import { escapeHtml } from "../../lib/escape.ts";
13
+ import { renderChanges, renderGit, renderLiveChrome, renderLiveStatus, type LivePage } from "./chrome.ts";
14
14
  import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode } from "./files.ts";
15
- import { DIFF_MODES, statusLetter, type ChangedFile, type DiffLine, type DiffMode } from "./diff.ts";
15
+ import { DIFF_MODES, statusLetter, type ChangedFile, type DiffMode } from "./diff.ts";
16
+ import { renderDiffLines } from "./diff-html.ts";
16
17
  import type { DiffFileView, GitStatus } from "./git.ts";
17
18
  import type { ChangeIndex } from "./live.ts";
18
19
  import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
@@ -36,16 +37,6 @@ function iconMarkup(spec: IconSpec, hidden = false): string {
36
37
  return `<svg class="browse-cat ${spec.colorClass}"${hidden ? " hidden" : ""} aria-hidden="true" title="${escapeHtml(spec.label)}"><use href="${iconHref(spec.icon)}"/></svg>`;
37
38
  }
38
39
 
39
- /** The header's git chip: the branch, or the detached head; changes are the changes chip's job. */
40
- function renderGit(git: GitStatus | undefined): string {
41
- if (!git) return "";
42
- const name = git.branch ?? git.head;
43
- if (!name) return "";
44
- const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
45
- const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
46
- return `<span class="bf-chip browse-git" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span><span class="browse-git__name">${escapeHtml(name)}</span></span>`;
47
- }
48
-
49
40
  /**
50
41
  * The changed-path index for markers, plus the totals shown in the chip. It is
51
42
  * the same index the live status feed sends, so the page and its updates agree
@@ -66,55 +57,6 @@ function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: bool
66
57
  : "";
67
58
  }
68
59
 
69
- /** The live chrome a page renders: how often its stream is sent a frame. */
70
- export interface LivePage {
71
- intervalMs: number;
72
- }
73
-
74
- /**
75
- * The header's connection chip. It starts hidden and says "connecting": the
76
- * client reveals and paints it from the event stream, so a page with JS off — or
77
- * with live status disabled — never shows a state it cannot keep true.
78
- *
79
- * It is deliberately not a `role="status"` region: the header is where a reader
80
- * looks for the page's state, not a place to announce every change to it.
81
- */
82
- function renderLiveChip(live: LivePage | undefined): string {
83
- if (!live) return "";
84
- return `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}" hidden><span class="browse-live__glyph" aria-hidden="true">${escapeHtml(GLYPHS.browse)}</span><span class="browse-live__text">connecting</span></span>`;
85
- }
86
-
87
- /**
88
- * The footer's live line, hidden until the first frame: uptime and requests are
89
- * server facts the rendered page does not know at build time.
90
- */
91
- function renderLiveStatus(): string {
92
- return `<span class="browse-live-line" id="live-status" hidden></span>`;
93
- }
94
-
95
- /**
96
- * The header's changes chip, linking to the diff overview. With live status on,
97
- * a hidden shell is always rendered so the chip can appear and disappear as the
98
- * working tree changes, without the client inserting elements into the header.
99
- */
100
- function renderChanges(summary: ChangeSummary | undefined, live: LivePage | undefined): string {
101
- const count = summary?.count ?? 0;
102
- if (count <= 0) {
103
- return live
104
- ? `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" hidden></a>`
105
- : "";
106
- }
107
- const label = `${count} change${count === 1 ? "" : "s"}`;
108
- const insertions = summary?.insertions ?? 0;
109
- const deletions = summary?.deletions ?? 0;
110
- const totals =
111
- insertions + deletions > 0
112
- ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${insertions}</span> <span class="browse-diff-del">−${deletions}</span></span>`
113
- : "";
114
- const aria = `${label} against HEAD${insertions + deletions > 0 ? `, ${insertions} insertions and ${deletions} deletions` : ""}`;
115
- return `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
116
- }
117
-
118
60
  function hrefFor(entry: { rel: string; isDir: boolean }): string {
119
61
  return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
120
62
  }
@@ -157,9 +99,9 @@ ${SPRITE}
157
99
  ${renderGit(layout.git)}
158
100
  ${renderChanges(layout.summary, layout.live)}
159
101
  <span class="bf-chip bf-chip--accent">read-only</span>
160
- ${renderLiveChip(layout.live)}
102
+ ${renderLiveChrome(layout.live)}
161
103
  <button type="button" class="bf-btn bf-btn--icon browse-keys-btn" id="keys-open" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
162
- <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>
104
+ ${themeToggle()}
163
105
  </div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
164
106
  <div class="bf-app__body">
165
107
  <details class="bf-app__sidebar browse-sidebar" open>
@@ -213,7 +155,7 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
213
155
  const href = hrefFor(node);
214
156
  const current = node.rel === currentRel ? ` aria-current="page"` : "";
215
157
  const twisty = isDir
216
- ? `<button type="button" class="bf-tree__tw bf-tree__tw--toggle" aria-expanded="${expanded}" aria-label="${escapeHtml(node.name)}">${expanded ? "▾" : "▸"}</button>`
158
+ ? `<button type="button" class="bf-tree__tw bf-tree__tw--toggle" tabindex="-1" aria-hidden="true">${expanded ? "▾" : "▸"}</button>`
217
159
  : `<span class="bf-tree__tw bf-tree__tw--spacer" aria-hidden="true"></span>`;
218
160
  const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
219
161
  const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
@@ -222,7 +164,9 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
222
164
  : "";
223
165
  const label = node.broken
224
166
  ? `<span class="browse-broken" title="broken symbolic link">${name}</span><span class="browse-link-mark">↩</span>`
225
- : `<a href="${href}">${name}</a>`;
167
+ : isDir
168
+ ? `<a href="${href}" aria-expanded="${expanded}" title="${escapeHtml(node.rel)}">${name}</a>`
169
+ : `<a href="${href}" title="${escapeHtml(node.rel)}">${name}</a>`;
226
170
  return `<li data-open="${expanded}"><div class="bf-tree__row"${current}${dataPath}>${twisty}${iconMarkup(spec)}${label}${changeMark(summary, node.rel, isDir)}</div>${children}</li>`;
227
171
  }
228
172
 
@@ -238,7 +182,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean,
238
182
  .join(" ");
239
183
  const link = entry.broken
240
184
  ? `<span class="browse-broken" title="broken symbolic link">${name}</span>`
241
- : `<a href="${hrefFor(entry)}">${name}</a>`;
185
+ : `<a href="${hrefFor(entry)}" title="${escapeHtml(entry.rel)}">${name}</a>`;
242
186
  const mark = entry.isSymlink
243
187
  ? `<span class="browse-link-mark" title="${entry.broken ? "broken symbolic link" : "symbolic link"}">↩</span>`
244
188
  : "";
@@ -337,6 +281,11 @@ function pageKeys(...extra: KeyHint[]): KeyHint[] {
337
281
  ];
338
282
  }
339
283
 
284
+ /** Whether the page renders lines with a number beside them: plain text, or raw Markdown. */
285
+ function numberedLines(page: FilePage): boolean {
286
+ return page.file.kind === "text" || (page.file.kind === "markdown" && page.markdownRaw === true);
287
+ }
288
+
340
289
  /** The card-head controls: Markdown mode, line wrapping, and copy actions. */
341
290
  function renderFileActions(page: FilePage): string {
342
291
  const isMarkdown = page.file.kind === "markdown";
@@ -357,7 +306,7 @@ function renderFileActions(page: FilePage): string {
357
306
  : `<a class="bf-segmented__item" href="${viewHref}?raw=1">raw</a>`;
358
307
  groups.push(`<span class="bf-segmented" role="group" aria-label="Markdown view">${rendered}${raw}</span>`);
359
308
  }
360
- if (page.file.kind === "text" || (isMarkdown && page.markdownRaw)) {
309
+ if (numberedLines(page)) {
361
310
  groups.push(
362
311
  `<button type="button" class="bf-btn" id="wrap-toggle" aria-pressed="false" title="Toggle line wrapping">wrap</button>`,
363
312
  );
@@ -370,6 +319,11 @@ function renderFileActions(page: FilePage): string {
370
319
  groups.push(
371
320
  `<button type="button" class="bf-btn" data-copy="path" data-value="${escapeHtml(page.rel)}" title="Copy the root-relative path">copy path</button>`,
372
321
  );
322
+ if (numberedLines(page)) {
323
+ groups.push(
324
+ `<button type="button" class="bf-btn" id="copy-reference" disabled title="Select lines in the file first">copy reference</button>`,
325
+ );
326
+ }
373
327
  return `<span class="bf-card__actions">${groups.join("")}</span>`;
374
328
  }
375
329
 
@@ -386,10 +340,11 @@ export function renderFilePage(page: FilePage): string {
386
340
 
387
341
  const isMarkdown = page.file.kind === "markdown";
388
342
  const rendered = isMarkdown && !page.markdownRaw;
389
- const wrappable = page.file.kind === "text" || (isMarkdown && page.markdownRaw);
343
+ const numbered = numberedLines(page);
390
344
  const keys = pageKeys(
391
345
  ...(isMarkdown ? [{ keys: "r", label: "Toggle rendered and raw" }] : []),
392
- ...(wrappable ? [{ keys: "u", label: "Toggle line wrapping" }] : []),
346
+ ...(numbered ? [{ keys: "u", label: "Toggle line wrapping" }] : []),
347
+ ...(numbered ? [{ keys: "y", label: "Copy a reference to the selected lines" }] : []),
393
348
  );
394
349
 
395
350
  let content: string;
@@ -401,7 +356,7 @@ export function renderFilePage(page: FilePage): string {
401
356
  });
402
357
  content = `<article class="bf-md">${html}</article>`;
403
358
  } else {
404
- content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
359
+ content = `<pre class="bf-code bf-code--numbered" data-path="${escapeHtml(page.rel)}"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
405
360
  }
406
361
  if (page.file.truncated)
407
362
  note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
@@ -445,87 +400,6 @@ function statusWord(letter: string): string {
445
400
  return STATUS_LABEL[letter] ?? "changed";
446
401
  }
447
402
 
448
- /**
449
- * The inner HTML per diff line. Hunk bodies are highlighted by rebuilding the
450
- * old side (context + deleted) and the new side (context + added), so the
451
- * tokenizer carries state within a hunk; meta, hunk, and note lines stay plain.
452
- * State does not cross hunks, since the unchanged lines between them are absent.
453
- */
454
- function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): string[] {
455
- if (!language) return lines.map((line) => escapeHtml(line.text));
456
- const out: string[] = new Array(lines.length);
457
- let index = 0;
458
- while (index < lines.length) {
459
- const kind = lines[index].kind;
460
- if (kind !== "ctx" && kind !== "add" && kind !== "del") {
461
- out[index] = escapeHtml(lines[index].text);
462
- index++;
463
- continue;
464
- }
465
- const start = index;
466
- const oldDoc: string[] = [];
467
- const newDoc: string[] = [];
468
- while (index < lines.length) {
469
- const body = lines[index].kind;
470
- if (body !== "ctx" && body !== "add" && body !== "del") break;
471
- if (body === "ctx") {
472
- oldDoc.push(lines[index].text);
473
- newDoc.push(lines[index].text);
474
- } else if (body === "add") {
475
- newDoc.push(lines[index].text);
476
- } else {
477
- oldDoc.push(lines[index].text);
478
- }
479
- index++;
480
- }
481
- const oldHtml = highlightLines(oldDoc, language);
482
- const newHtml = highlightLines(newDoc, language);
483
- let oldIndex = 0;
484
- let newIndex = 0;
485
- for (let cursor = start; cursor < index; cursor++) {
486
- const body = lines[cursor].kind;
487
- if (body === "ctx") {
488
- out[cursor] = newHtml[newIndex];
489
- newIndex++;
490
- oldIndex++;
491
- } else if (body === "add") {
492
- out[cursor] = newHtml[newIndex];
493
- newIndex++;
494
- } else {
495
- out[cursor] = oldHtml[oldIndex];
496
- oldIndex++;
497
- }
498
- }
499
- }
500
- return out;
501
- }
502
-
503
- /** Render unified-diff lines with a two-column gutter and per-kind styling. */
504
- export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string {
505
- const html = highlightDiffText(lines, language);
506
- // Every hunk is a section so its sticky header is bounded by its own body;
507
- // the section also gives the keyboard a stable, non-sticky scroll target.
508
- const out: string[] = [];
509
- let hunks = 0;
510
- let open = false;
511
- lines.forEach((line, index) => {
512
- if (line.kind === "hunk") {
513
- if (open) out.push("</span>");
514
- hunks += 1;
515
- out.push(`<span class="bf-diff-hunk" id="hunk-${hunks}" data-hunk="${hunks}">`);
516
- open = true;
517
- }
518
- const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
519
- const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
520
- const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
521
- out.push(
522
- `<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}">${gutter}${marker}${html[index]}</span>`,
523
- );
524
- });
525
- if (open) out.push("</span>");
526
- return out.join("");
527
- }
528
-
529
403
  /** The scope label for a mode. */
530
404
  function modeScope(mode: DiffMode): string {
531
405
  if (mode === "staged") return "staged vs HEAD";
@@ -9,6 +9,12 @@
9
9
  *
10
10
  * It does nothing at all unless the server rendered the connection chip, which
11
11
  * is what makes `live: false` and JS-off leave the page exactly as it was.
12
+ *
13
+ * The folder check is the other half: the stream is shared by every open page, so
14
+ * it cannot say what *this* page's rows show. A page that lists a folder carries
15
+ * the revision of what it drew, and asks the server for the folder's current one
16
+ * once per interval; a difference reveals a reload control instead of touching
17
+ * the document, because a reload is the only honest way to add a row.
12
18
  */
13
19
 
14
20
  export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the header chips, the tree and listing
@@ -19,9 +25,14 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
19
25
  var liveLine = document.getElementById("live-status");
20
26
  var liveGit = document.querySelector(".browse-git");
21
27
  var liveChanges = document.getElementById("browse-changes");
22
- var liveInterval = Number(liveChip.getAttribute("data-interval")) || 3000;
28
+ var liveInterval = Number(liveChip.getAttribute("data-interval")) || 5000;
23
29
  var liveStale = 0;
24
30
  var liveTone = "";
31
+ var liveRev = "";
32
+ var liveDir = liveChip.getAttribute("data-dir");
33
+ var liveDirRev = liveChip.getAttribute("data-rev");
34
+ var liveReload = document.getElementById("reload-hint");
35
+ var liveDirTimer = 0;
25
36
 
26
37
  function formatSpan(ms) {
27
38
  var seconds = Math.max(0, Math.round(ms / 1000));
@@ -88,7 +99,12 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
88
99
  });
89
100
  }
90
101
 
102
+ // One revision covers the chip and the markers: a frame that did not move the
103
+ // change set is not painted at all, so a tick on an idle tree costs no DOM
104
+ // work however many rows the page drew.
91
105
  function paintLiveChanges(changes) {
106
+ if (changes && changes.rev === liveRev) return;
107
+ liveRev = changes && changes.rev ? changes.rev : "";
92
108
  var count = changes ? changes.count : 0;
93
109
  if (liveChanges) {
94
110
  liveChanges.hidden = count <= 0;
@@ -136,6 +152,32 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
136
152
  paintLiveChanges(snapshot.changes);
137
153
  }
138
154
 
155
+ // The folder the page drew, checked against the disk on the frame cadence. It
156
+ // waits for the first frame, never runs in a hidden tab, and stops as soon as
157
+ // it has something to say: the control is the fix, and a reload ends the
158
+ // question.
159
+ function showLiveReload() {
160
+ window.clearInterval(liveDirTimer);
161
+ liveDirTimer = 0;
162
+ if (!liveReload || !liveReload.hidden) return;
163
+ liveReload.hidden = false;
164
+ liveReload.addEventListener("click", function () { window.location.reload(); });
165
+ }
166
+
167
+ function checkLiveDir() {
168
+ if (document.hidden || liveTone !== "success") return;
169
+ fetch("/api/dir-rev?path=" + encodeURIComponent(liveDir))
170
+ .then(function (response) {
171
+ if (!response.ok) throw new Error("check failed");
172
+ return response.json();
173
+ })
174
+ .then(function (answer) {
175
+ var rev = answer.rev;
176
+ if (typeof rev === "string" && rev !== liveDirRev) showLiveReload();
177
+ })
178
+ .catch(function () { /* the stream already reports a dead server */ });
179
+ }
180
+
139
181
  if (window.EventSource) {
140
182
  var liveStream = new EventSource("/events");
141
183
  liveStream.addEventListener("open", function () {
@@ -152,7 +194,11 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
152
194
  window.addEventListener("pagehide", function () {
153
195
  liveStream.close();
154
196
  window.clearTimeout(liveStale);
197
+ window.clearInterval(liveDirTimer);
155
198
  });
199
+ if (liveDir !== null && liveDirRev && liveReload) {
200
+ liveDirTimer = window.setInterval(checkLiveDir, liveInterval);
201
+ }
156
202
  }
157
203
  }
158
204
  `;
@@ -3,17 +3,20 @@
3
3
  *
4
4
  * A page that stays open holds one event stream; this module builds the snapshot
5
5
  * it is sent, the frame that carries it, and the channel that fans it out on a
6
- * tick. The git and diff providers already memoize, so a tick re-reads at most
7
- * one `git status`, and every read failure degrades to `null`: a status feed
8
- * must never be able to fail a page or the server that serves it.
6
+ * tick. A tick costs one `git status` and one `git diff --stat`: the providers'
7
+ * 2 s memo only coalesces a tick with a page render in the same window, so the
8
+ * cadence is what bounds the reads. Every read failure degrades to `null`: a
9
+ * status feed must never be able to fail a page or the server that serves it.
9
10
  *
10
11
  * The channel opens no socket of its own — the caller hands it a `write`/`end`
11
12
  * pair — so the whole feed is tested without one.
12
13
  */
13
14
 
14
15
  import { defaultInterval, defaultTimer } from "../../lib/timer.ts";
16
+ import { LIVE_MAX_CLIENTS } from "./config.ts";
15
17
  import { changeDirs } from "./diff.ts";
16
18
  import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
19
+ import { changeRevision } from "./rev.ts";
17
20
 
18
21
  /**
19
22
  * The changed-path index the pages render from: the totals the changes chip
@@ -50,12 +53,27 @@ export interface LiveChanges {
50
53
  deletions: number;
51
54
  files: string[];
52
55
  dirs: string[];
56
+ /**
57
+ * Identity of this change set, counts included. A page that painted the same
58
+ * revision repaints nothing when the next frame arrives.
59
+ */
60
+ rev: string;
61
+ }
62
+
63
+ /** A change index as it goes over the wire, with its revision. */
64
+ function changesOf(index: ChangeIndex): LiveChanges {
65
+ const changes = {
66
+ count: index.count,
67
+ insertions: index.insertions,
68
+ deletions: index.deletions,
69
+ files: [...index.files].sort(),
70
+ dirs: [...index.dirs].sort(),
71
+ };
72
+ return { ...changes, rev: changeRevision(changes) };
53
73
  }
54
74
 
55
75
  /** One status frame's payload. */
56
76
  export interface LiveSnapshot {
57
- /** Server wall clock when the snapshot was taken, in ms. */
58
- at: number;
59
77
  /** How long the server has been listening, in ms. */
60
78
  uptimeMs: number;
61
79
  /** Requests the server has answered so far, this one included. */
@@ -112,15 +130,7 @@ export function createLiveStatus(options: LiveStatusOptions): LiveStatusSource {
112
130
  uptimeMs: Math.max(0, at - options.startedAt),
113
131
  requests: options.requests(),
114
132
  git,
115
- changes: index
116
- ? {
117
- count: index.count,
118
- insertions: index.insertions,
119
- deletions: index.deletions,
120
- files: [...index.files].sort(),
121
- dirs: [...index.dirs].sort(),
122
- }
123
- : null,
133
+ changes: index ? changesOf(index) : null,
124
134
  };
125
135
  },
126
136
  };
@@ -158,6 +168,8 @@ export interface LiveChannelOptions {
158
168
  intervalMs: number;
159
169
  /** How long the last page's departure is held before `onIdle` is called, in ms. */
160
170
  idleMs: number;
171
+ /** How many pages may attach at once; defaults to `LIVE_MAX_CLIENTS`. */
172
+ maxClients?: number;
161
173
  /** Called once the last page has been gone for `idleMs`; omitted, the channel only idles. */
162
174
  onIdle?: () => void;
163
175
  /** Timer seam; defaults to an unref'd interval. */
@@ -172,6 +184,11 @@ export interface LiveChannel {
172
184
  attach(client: LiveClient): () => void;
173
185
  /** End every connected page, stop the ticker, and drop a pending idle stop. */
174
186
  close(): void;
187
+ /**
188
+ * Whether another page must be refused. The caller asks before it opens the
189
+ * stream, so a refusal never has to take back a head it already wrote.
190
+ */
191
+ atCapacity(): boolean;
175
192
  }
176
193
 
177
194
  /**
@@ -183,6 +200,7 @@ export interface LiveChannel {
183
200
  export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
184
201
  const timer = options.timer ?? defaultTicker;
185
202
  const timeout = options.timeout ?? defaultTimeout;
203
+ const maxClients = options.maxClients ?? LIVE_MAX_CLIENTS;
186
204
  const clients = new Set<LiveClient>();
187
205
  let cancel: (() => void) | undefined;
188
206
  let cancelIdle: (() => void) | undefined;
@@ -252,5 +270,8 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
252
270
  for (const client of clients) client.end();
253
271
  clients.clear();
254
272
  },
273
+ atCapacity() {
274
+ return clients.size >= maxClients;
275
+ },
255
276
  };
256
277
  }