@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
@@ -16,7 +16,7 @@ import type { CompactionResult, Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
17
17
  import { messageOf } from "../../lib/errors.ts";
18
18
  import { formatTokens } from "../../lib/format.ts";
19
- import { screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
19
+ import { screenFooter, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
20
20
  import { ConfirmState } from "../../lib/tui/confirm.ts";
21
21
  import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll } from "../../lib/tui/cursor.ts";
22
22
  import { gaugeBar, gaugeRole } from "../../lib/tui/gauge.ts";
@@ -26,7 +26,7 @@ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
26
26
  import { formatBudget, formatCompaction, formatDrift } from "./format.ts";
27
27
  import type { ContextBucket, ContextReport } from "./types.ts";
28
28
 
29
- /** Fixed rows around the scrollable body: title, gauge, budget, blank, blank, footer, blank. */
29
+ /** Fixed rows around the scrollable body: title, gauge, budget, blank, blank, footer, closing rule. */
30
30
  const CHROME_ROWS = 7;
31
31
 
32
32
  /** Cells in the composition bar. */
@@ -137,16 +137,28 @@ export class ContextComponent implements Component {
137
137
  const { percent, tokens, window } = this.report;
138
138
  if (window <= 0) return truncateToWidth(` ${paint(this.theme, "meta", "no model context window")}`, width);
139
139
  const role = gaugeRole(percent);
140
+ // The bar and the printed percentage come from one rounded value, so a
141
+ // label reading `0%` can never sit beside a filled cell. The tone stays on
142
+ // the exact percent, so the warn and danger thresholds do not move.
143
+ const shown = percent === null ? null : Math.round(percent);
140
144
  const value = tokens === null ? `? / ${formatTokens(window)}` : `${formatTokens(tokens)} / ${formatTokens(window)}`;
141
- const bar = gaugeBar(this.theme, { percent, blocks: 10 });
142
- const label = percent === null ? "unknown" : `${Math.round(percent)}%`;
145
+ const bar = gaugeBar(this.theme, { percent: shown, blocks: 10 });
146
+ const label = shown === null ? "unknown" : `${shown}%`;
143
147
  return truncateToWidth(` ${paint(this.theme, role, value)} ${bar} ${paint(this.theme, role, label)}`, width);
144
148
  }
145
149
 
150
+ /** The composition header: the denominator the percent column is a share of. */
151
+ private compositionLine(width: number): string {
152
+ const label = paint(this.theme, "label", "Composition");
153
+ const total = paint(this.theme, "meta", `${formatTokens(this.report.estimate)} estimate`);
154
+ const full = ` ${label} · ${total}`;
155
+ return visibleWidth(full) <= width ? full : truncateToWidth(` ${label}`, width);
156
+ }
157
+
146
158
  /** One composition row; optional columns drop as the terminal narrows. */
147
159
  private bucketLine(bucket: ContextBucket, width: number): string {
148
160
  const indent = " ".repeat(bucket.depth > 0 ? 4 : 2);
149
- const labelWidth = bucket.depth > 0 ? 16 : 18;
161
+ const labelWidth = bucket.depth > 0 ? 20 : 22;
150
162
  const label = padCell(bucket.label, labelWidth);
151
163
  const tokens = formatTokens(bucket.tokens).padStart(6);
152
164
  const share = shareCell(bucket.share);
@@ -167,13 +179,17 @@ export class ContextComponent implements Component {
167
179
  return truncateToWidth(head, width);
168
180
  }
169
181
 
170
- /** The scrollable body: composition, drift, compaction, and the largest blocks. */
182
+ /** The scrollable body: composition, drift, compaction, and the largest messages. */
171
183
  private buildBody(width: number): string[] {
172
184
  const report = this.report;
173
- const lines: string[] = report.buckets.map((bucket) => this.bucketLine(bucket, width));
185
+ const lines: string[] = [];
186
+ if (report.buckets.length > 0) {
187
+ lines.push(this.compositionLine(width));
188
+ for (const bucket of report.buckets) lines.push(this.bucketLine(bucket, width));
189
+ }
174
190
 
175
191
  const drift = formatDrift(report);
176
- if (drift) lines.push("", truncateToWidth(` ${paint(this.theme, "warn", drift)}`, width));
192
+ if (drift) lines.push("", truncateToWidth(` ${paint(this.theme, "meta", drift)}`, width));
177
193
 
178
194
  lines.push(
179
195
  "",
@@ -184,7 +200,7 @@ export class ContextComponent implements Component {
184
200
  );
185
201
 
186
202
  if (report.largest.length > 0) {
187
- lines.push("", ` ${paint(this.theme, "label", "Largest blocks")}`);
203
+ lines.push("", ` ${paint(this.theme, "label", "Largest messages")}`);
188
204
  for (const block of report.largest) {
189
205
  const tokens = formatTokens(block.tokens).padStart(6);
190
206
  const role = padCell(block.role, 11);
@@ -230,7 +246,7 @@ export class ContextComponent implements Component {
230
246
  if (this.body.length > this.visible) {
231
247
  lines.push(formatRange(this.theme, w, { start: this.scroll, end, total: this.body.length }));
232
248
  }
233
- lines.push("", this.footerLine(w), "");
249
+ lines.push(...screenFooter(this.theme, w, this.footerLine(w)));
234
250
  return lines.map((line) => truncateToWidth(line, w));
235
251
  }
236
252
 
@@ -79,7 +79,7 @@ export interface CompactionHistory {
79
79
  last: CompactionSummary | null;
80
80
  }
81
81
 
82
- /** One of the largest single blocks in the projected context. */
82
+ /** One of the largest single messages in the projected context. */
83
83
  export interface LargestBlock {
84
84
  /** Estimated tokens. */
85
85
  tokens: number;
@@ -99,7 +99,7 @@ export interface ContextReport {
99
99
  percent: number | null;
100
100
  /** `window - tokens`, `null` when `tokens` is unknown. */
101
101
  free: number | null;
102
- /** `window - reserve - tokens`, `null` when `tokens` is unknown. */
102
+ /** `window - reserve - tokens`, `null` when `tokens` is unknown or automatic compaction is off. */
103
103
  headroom: number | null;
104
104
  /** The resolved `reserveTokens` for the active model. */
105
105
  reserve: number;
@@ -115,8 +115,10 @@ export interface ContextReport {
115
115
  estimate: number;
116
116
  /** `tokens - estimate` when the host's estimate is known, else `null`. */
117
117
  drift: number | null;
118
+ /** True when the projection holds no assistant message: Pi's total is its message-only fallback. */
119
+ beforeFirstReply: boolean;
118
120
  /** The branch's compaction history. */
119
121
  compaction: CompactionHistory;
120
- /** The largest projected blocks, biggest first. */
122
+ /** The largest projected messages, biggest first. */
121
123
  largest: LargestBlock[];
122
124
  }
@@ -56,8 +56,9 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
56
56
  external images are shown as links rather than fetched.
57
57
  - **Sortable listings.** The Name / Size / Modified headers sort the current
58
58
  directory, directories first, and the choice is remembered per browser.
59
- - **Copy and wrap.** Copy a file's root-relative path or its contents, and
60
- toggle line wrapping for text and raw views.
59
+ - **Copy and wrap.** Copy a file's root-relative path or its contents, copy a
60
+ `path:12-30` reference for the lines you selected, and toggle line wrapping for
61
+ text and raw views.
61
62
  - **Theme toggle.** A header button switches between the dark and light
62
63
  palettes and remembers the choice per browser; until it is used, the palette
63
64
  follows the system `prefers-color-scheme`.
@@ -66,7 +67,8 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
66
67
  highlighted match; Enter opens the file.
67
68
  - **Keyboard.** `/` or `Ctrl/Cmd+K` opens quick open, `-` folds or expands
68
69
  every folder in the tree, `u` toggles line wrapping, `r` switches rendered and
69
- raw Markdown, `n`/`p` step between a diff's hunks, and `?` opens a shortcut
70
+ raw Markdown, `y` copies a reference to the selected lines, `n`/`p` step
71
+ between a diff's hunks, and `?` opens a shortcut
70
72
  sheet. A diff's hunk headers pin to the top of the code viewport while its
71
73
  body scrolls.
72
74
  - **Read-only and loopback-scoped.** No editing, no writing, no LAN exposure.
@@ -88,11 +90,13 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
88
90
  connecting (accent), stalled (amber, after three missed frames), or offline
89
91
  (red); the branch chip, the `● N changes +A −D` chip, and the change markers in
90
92
  the tree and the listing update in place; and the footer line reports `up 2m
91
- 14s · 37 requests`. The chip names its state and never counts, so nothing is
92
- repainted between frames. One Server-Sent Events stream per page carries it, on
93
- the same loopback server, so nothing extra is opened and nothing is written;
94
- that stream is also how the server notices the last page closing its window,
95
- after which it stops listening.
93
+ 14s · 37 requests`. The chip names its state and never counts, and a frame
94
+ whose change set did not move repaints nothing at all. One Server-Sent Events
95
+ stream per page carries it, on the same loopback server, so nothing extra is
96
+ opened and nothing is written; that stream is also how the server notices the
97
+ last page closing its window, after which it stops listening. A page that lists
98
+ a folder also checks that folder, and offers a `⟳ reload` control when a file
99
+ has appeared or gone on disk since it drew its rows.
96
100
  - **No dependencies.** `node:http` plus the inline sprite, CSS, and client
97
101
  script.
98
102
 
@@ -136,10 +140,24 @@ errors. The chip names its state rather than counting, so it is repainted only
136
140
  when that state changes. The same frame carries the branch (or detached head), the
137
141
  uncommitted-change totals and paths, the server's uptime, and the requests it
138
142
  has answered, so the branch chip, the changes chip, the markers, and the footer
139
- line are painted from one snapshot rather than four requests. Losing the stream
140
- leaves everything the page last showed in place: it is read-only, so a stale
141
- label is never a stale action. `"live": false` turns the whole surface off — no
142
- chip, no stream, no `/events` route — and so does JavaScript off.
143
+ line are painted from one snapshot rather than four requests. Each change set
144
+ also carries a short revision, and a frame whose revision the page already
145
+ painted does no work at all — so an idle tree costs no DOM, however many rows the
146
+ page drew. Losing the stream leaves everything the page last showed in place: it
147
+ is read-only, so a stale label is never a stale action. `"live": false` turns the
148
+ whole surface off — no chip, no stream, no `/events` route — and so does
149
+ JavaScript off.
150
+
151
+ A page that lists a folder carries the revision of the names it drew, and asks
152
+ `/api/dir-rev` for the folder's current one on the frame cadence — one directory
153
+ read every five seconds by default, skipped while the tab is hidden and stopped
154
+ for good once it has an answer. A difference reveals `⟳ reload` beside the live
155
+ chip, which reloads the page; the rows themselves are never touched, because a
156
+ page that could grow a row is a page that writes. A folder whose listing was
157
+ truncated, and a file, `/diff`, or error page, has nothing to compare and shows
158
+ no control. At most eight pages may hold the stream at once: a ninth is refused
159
+ and its chip reads `offline`, which keeps one local page from pinning the server
160
+ — and the git read behind each frame — alive.
143
161
 
144
162
  The stream is also how the server knows a page is still there: when the last one
145
163
  leaves, the port is released after ten seconds and the chip is cleared, because
@@ -147,6 +165,32 @@ nothing is left to serve. That is why a page with JavaScript off — or with liv
147
165
  status off — never counts as a reader, and the server then waits for
148
166
  `/browse stop` or the end of the session.
149
167
 
168
+ ### The page
169
+
170
+ The page is keyboard-first, and the tree is one tab stop rather than two per
171
+ folder:
172
+
173
+ | Key | What it does in the tree |
174
+ |---|---|
175
+ | `↑` / `↓` | Move between the rows on screen |
176
+ | `→` | Open a folder (reading its children first if needed), or enter it |
177
+ | `←` | Close the folder, or step out to its parent |
178
+ | `Space` | Open or close the focused folder |
179
+ | `Enter` | Follow the focused row's link |
180
+ | `Home` / `End` | First or last row on screen |
181
+
182
+ The folder's own link carries `aria-expanded`, so a screen reader hears its
183
+ state once; the twisty beside it is a pointer control kept out of the tab order.
184
+ A mouse click moves the tab stop to that row, so `Tab` comes back to where you
185
+ were. Elsewhere the page keeps its own keys: `/` or `Ctrl`/`Cmd`+`K` for quick
186
+ open, `-` to fold, `u` to wrap, `r` for raw Markdown, `n`/`p` between diff hunks,
187
+ and `?` for the sheet, whose dialog now traps `Tab` and returns focus to its
188
+ button.
189
+
190
+ Printing is supported: the paper gets the light palette whatever the screen is
191
+ showing, the chrome and the tree are dropped, and a file or diff prints whole
192
+ instead of inside its scroll box.
193
+
150
194
  ## Routes
151
195
 
152
196
  | Route | Result |
@@ -186,7 +230,7 @@ document and the project document (project wins):
186
230
  "diffContext": 3, // unified diff context lines
187
231
  "maxDiffLines": 4000, // diff lines rendered before truncation
188
232
  "live": true, // stream live status to open pages
189
- "liveIntervalMs": 3000 // ms between status frames while a page is connected
233
+ "liveIntervalMs": 5000 // ms between status frames while a page is connected
190
234
  }
191
235
  ```
192
236
 
@@ -223,11 +267,18 @@ document and the project document (project wins):
223
267
  attributes; only the fixed `tok-*` class names reach the page.
224
268
  - **Event stream.** `/events` is a `GET` on the same loopback server, behind
225
269
  the same method and `Host` checks, and it only reads: the frame is the branch,
226
- the change paths, an uptime, and a request count, all of which the page it
227
- feeds already renders. It carries no page policy (`text/event-stream` is not a
228
- document), sends `Cache-Control: no-store`, and cannot be cached or replayed.
229
- A tick runs only while a page is attached, and the timer is `unref`ed, so an
230
- idle server does no work and a forgotten tab cannot hold the process open.
270
+ the change paths and their revision, an uptime, and a request count, all of
271
+ which the page it feeds already renders. It carries no page policy
272
+ (`text/event-stream` is not a document), sends `Cache-Control: no-store`, and
273
+ cannot be cached or replayed. A tick runs only while a page is attached, and
274
+ the timer is `unref`ed, so an idle server does no work and a forgotten tab
275
+ cannot hold the process open. At most eight pages may attach; a ninth is
276
+ answered `204`, the one status an event source never reconnects after.
277
+ - **Directory check.** `/api/dir-rev` answers one folder's listing revision, so
278
+ the page can tell whether its rows are stale. It goes through the same path
279
+ guard as every other route and reads one directory; its answer is a hash of the
280
+ names, not the names themselves, so it discloses nothing the page above it does
281
+ not already show.
231
282
  - **Caps.** `maxFileBytes` (inline rendering), `maxTextLines` (rendered lines),
232
283
  `maxThumbBytes` (thumbnails), and `maxDirEntries` (listings) bound every read.
233
284
  - **Git reads.** The header chip and the diff view run read-only git
@@ -245,9 +296,11 @@ document and the project document (project wins):
245
296
  approximated rather than parsed.
246
297
  - Live status updates what the page already contains: the header chips, the
247
298
  markers on the rows it rendered, and the footer line. A file that appears on
248
- disk joins the listing on the next reload, the `/diff` table and a file page's
249
- `changes` action stay page-load snapshots, and the branch chip is only patched
250
- when the page was served inside a repository.
299
+ disk does not join the listing by itself — the page offers `⟳ reload` instead —
300
+ and the check covers the folder the page lists, by name, not the folders inside
301
+ it, a listed file's contents, the `/diff` table, or a file page's `changes`
302
+ action, which stay page-load snapshots. The branch chip is only patched when
303
+ the page was served inside a repository.
251
304
  - `/browse` has no auth; a malicious local page could probe the port. The `Host`
252
305
  allowlist and the absence of CORS headers mitigate reads, and the server is
253
306
  read-only.
@@ -288,6 +341,18 @@ document and the project document (project wins):
288
341
  method and `Host`, parses the URL, matches a route, and runs every path
289
342
  through `paths.ts`. It returns a plain `ServeResponse`, so routing is tested
290
343
  without a socket; `server.ts` is the only part that binds a port.
344
+ - **One snapshot, one frame.** The status source is shared by every attached
345
+ page, so a tick costs one `git status`, one `git diff --stat`, and one
346
+ serialization however many tabs are open. The frame carries a revision of the
347
+ change set, and the client compares it before painting, which is why an idle
348
+ page does no DOM work. The cadence is what bounds those reads, so the default
349
+ is five seconds rather than matching the providers' two-second render cache.
350
+ - **A poll, not a watcher.** The folder check is one request from the page to
351
+ `/api/dir-rev`, not an `fs.watch`: recursive watching is the one part of
352
+ `node:fs` whose availability and event semantics differ per platform, and it
353
+ would add a long-lived resource to start and release around `close()`. The
354
+ check compares names, so it is a directory read rather than a stat pass, and it
355
+ stops at the first difference because the control, not the checker, is the fix.
291
356
  - **Progressive UI.** The page is server-rendered HTML with an inline style
292
357
  block and inline SVG icons. `client.ts` enhances it: the filter hides
293
358
  non-matching rows while keeping ancestors, disclosure triangles lazily
@@ -399,7 +464,9 @@ document and the project document (project wins):
399
464
  | `urls.ts` | Shared link/image URL policy: scheme allowlist and `/view`/`/raw` rewriting |
400
465
  | `git.ts` | Read-only branch/dirty provider for the header chip |
401
466
  | `icons.ts` | Inline SVG sprite and the per-language mapping |
402
- | `client.ts` | The `/app.js` source (tree toggle, filter, sort, wrap, copy, quick open) |
467
+ | `client.ts` | The `/app.js` source: sort, wrap, copy, hunk navigation, reading progress, and the page keymap |
468
+ | `tree.ts` | The sidebar block: path filter, fold-all, lazy tree loading, and the tree's keyboard model (interpolated into `client.ts`) |
469
+ | `palette.ts` | The quick-open palette: fuzzy search, the combobox wiring, and the `window.bfPalette` contract (interpolated into `client.ts`) |
403
470
  | `live-client.ts` | The client's live status block: the event stream, the chips, the markers, and the footer line (interpolated into `client.ts`) |
404
471
  | `live.ts` | The live status snapshot, its frame, and the fan-out channel that ticks it |
405
472
  | `styles.ts` | The page-only `browse-*` stylesheet (the `bf-*` kit comes from `lib/web`) |
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The header chrome for the browse pages.
3
+ *
4
+ * The chips the toolbar carries — the git branch, the change totals, the live
5
+ * connection state — and the footer's live line. Everything here is pure string
6
+ * building over an escaped view model: a branch name, a count, and a status word
7
+ * are all text a repository can choose, so each one crosses `escapeHtml` before
8
+ * it reaches the page. The page shell and the page bodies stay in `html.ts`.
9
+ */
10
+
11
+ import { escapeHtml } from "../../lib/escape.ts";
12
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
13
+ import type { GitStatus } from "./git.ts";
14
+ import type { ChangeIndex } from "./live.ts";
15
+
16
+ /** The header's git chip: the branch, or the detached head; changes are the changes chip's job. */
17
+ export function renderGit(git: GitStatus | undefined): string {
18
+ if (!git) return "";
19
+ const name = git.branch ?? git.head;
20
+ if (!name) return "";
21
+ const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
22
+ const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
23
+ 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>`;
24
+ }
25
+
26
+ /** The live chrome a page renders: how often its stream is sent a frame. */
27
+ export interface LivePage {
28
+ intervalMs: number;
29
+ /** Root-relative folder the page listed; absent on a page with no listing to check. */
30
+ dir?: string;
31
+ /** Revision of the names that listing drew, which the reload control compares against. */
32
+ rev?: string;
33
+ }
34
+
35
+ /**
36
+ * The header's live controls: the connection chip, and the reload control the
37
+ * client reveals once the folder the page drew has changed on disk. Both start
38
+ * hidden, so a page with JS off — or with live status disabled — never shows a
39
+ * state or a control it cannot keep true; the reload control is not rendered at
40
+ * all when the page has no listing revision to compare.
41
+ *
42
+ * The chip is deliberately not a `role="status"` region: the header is where a
43
+ * reader looks for the page's state, not a place to announce every change to it.
44
+ */
45
+ export function renderLiveChrome(live: LivePage | undefined): string {
46
+ if (!live) return "";
47
+ const rev =
48
+ live.rev === undefined ? "" : ` data-dir="${escapeHtml(live.dir ?? "")}" data-rev="${escapeHtml(live.rev)}"`;
49
+ const chip = `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}"${rev} hidden><span class="browse-live__glyph" aria-hidden="true">${escapeHtml(GLYPHS.browse)}</span><span class="browse-live__text">connecting</span></span>`;
50
+ if (live.rev === undefined) return chip;
51
+ return `${chip}<button type="button" class="bf-chip bf-chip--warn browse-reload" id="reload-hint" title="This folder changed on disk" hidden>⟳ reload</button>`;
52
+ }
53
+
54
+ /**
55
+ * The footer's live line, hidden until the first frame: uptime and requests are
56
+ * server facts the rendered page does not know at build time.
57
+ */
58
+ export function renderLiveStatus(): string {
59
+ return `<span class="browse-live-line" id="live-status" hidden></span>`;
60
+ }
61
+
62
+ /**
63
+ * The header's changes chip, linking to the diff overview. With live status on,
64
+ * a hidden shell is always rendered so the chip can appear and disappear as the
65
+ * working tree changes, without the client inserting elements into the header.
66
+ */
67
+ export function renderChanges(summary: ChangeIndex | undefined, live: LivePage | undefined): string {
68
+ const count = summary?.count ?? 0;
69
+ if (count <= 0) {
70
+ return live
71
+ ? `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" hidden></a>`
72
+ : "";
73
+ }
74
+ const label = `${count} change${count === 1 ? "" : "s"}`;
75
+ const insertions = summary?.insertions ?? 0;
76
+ const deletions = summary?.deletions ?? 0;
77
+ const totals =
78
+ insertions + deletions > 0
79
+ ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${insertions}</span> <span class="browse-diff-del">−${deletions}</span></span>`
80
+ : "";
81
+ const aria = `${label} against HEAD${insertions + deletions > 0 ? `, ${insertions} insertions and ${deletions} deletions` : ""}`;
82
+ 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>`;
83
+ }