@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -31,6 +31,8 @@
31
31
  export class RenderCache {
32
32
  private width: number | null = null;
33
33
  private source: readonly string[] | undefined;
34
+ /** The last key's parts, for the value-keyed form; `undefined` until one is set. */
35
+ private parts: readonly unknown[] | undefined;
34
36
  private cached: string[] = [];
35
37
 
36
38
  /**
@@ -47,9 +49,36 @@ export class RenderCache {
47
49
  return this.cached;
48
50
  }
49
51
 
52
+ /**
53
+ * The lines from `build()`, for a component that derives them from a key.
54
+ *
55
+ * The identity form above chains on the inner component's array. A component with no
56
+ * inner component has nothing to chain on — a header's view and a footer's snapshot are
57
+ * rebuilt every frame — so it describes its inputs instead, and this compares them:
58
+ * primitives by value, and anything else by identity (a theme object is the case that
59
+ * matters, since its strings are what get cached).
60
+ *
61
+ * The key is only as complete as the caller makes it: a part left out is a frame that
62
+ * shows stale lines. Positions are compared, so an undefined part is a real one.
63
+ */
64
+ keyed(parts: readonly unknown[], build: () => string[]): string[] {
65
+ const previous = this.parts;
66
+ if (
67
+ previous !== undefined &&
68
+ previous.length === parts.length &&
69
+ previous.every((part, at) => part === parts[at])
70
+ ) {
71
+ return this.cached;
72
+ }
73
+ this.cached = build();
74
+ this.parts = [...parts];
75
+ return this.cached;
76
+ }
77
+
50
78
  /** Drop the memo; call from the component's `invalidate()`. */
51
79
  invalidate(): void {
52
80
  this.width = null;
81
+ this.parts = undefined;
53
82
  this.cached = [];
54
83
  }
55
84
  }
package/lib/tui/tool.ts CHANGED
@@ -22,6 +22,7 @@ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
22
22
  import { formatDuration, sanitizeLine, stripControlChars } from "../format.ts";
23
23
  import { clip, clipMiddle } from "./columns.ts";
24
24
  import { expandHint, SEPARATORS } from "./glyphs.ts";
25
+ import { RenderCache } from "./render-cache.ts";
25
26
  import { paint } from "./tokens.ts";
26
27
 
27
28
  /** The parts of a tool result a renderer reads. */
@@ -223,10 +224,18 @@ export type TailResultInput<Row> =
223
224
  * section, or the themed tail with an "earlier" note. The caller owns the row
224
225
  * renderer, so the extension's own row grammar — a peer-session tag, a status
225
226
  * glyph — stays with its vocabulary.
227
+ *
228
+ * `render` runs on every frame Pi draws, for every block in the transcript, and
229
+ * this block is the shared one behind the board's card, a session's peer list and
230
+ * a shaped tail — each of which re-derived its rows and clipped every line per
231
+ * frame before this memo. The lines depend on the input, the theme and the width,
232
+ * and none of them changes without `setInput`, `invalidate`, or a new width, so
233
+ * the memo is keyed on the width and dropped by those two calls.
226
234
  */
227
235
  export class TailResult<Row> implements Component {
228
236
  private input: TailResultInput<Row>;
229
237
  private theme: Theme;
238
+ private readonly cache = new RenderCache();
230
239
 
231
240
  constructor(input: TailResultInput<Row>, theme: Theme) {
232
241
  this.input = input;
@@ -237,12 +246,19 @@ export class TailResult<Row> implements Component {
237
246
  setInput(input: TailResultInput<Row>, theme: Theme): void {
238
247
  this.input = input;
239
248
  this.theme = theme;
249
+ this.cache.invalidate();
240
250
  }
241
251
 
242
- invalidate(): void {}
252
+ invalidate(): void {
253
+ this.cache.invalidate();
254
+ }
243
255
 
244
256
  render(width: number): string[] {
245
257
  const w = Math.max(1, width);
258
+ return this.cache.lines(w, undefined, () => this.build(w));
259
+ }
260
+
261
+ private build(w: number): string[] {
246
262
  const lines: string[] = [""];
247
263
  const input = this.input;
248
264
  if (input.kind === "list") {
@@ -264,6 +280,19 @@ export class TailResult<Row> implements Component {
264
280
  }
265
281
  }
266
282
 
283
+ /**
284
+ * Whether the component the transcript handed back is this kit's tail block.
285
+ *
286
+ * `TailResult` carries its row type and `instanceof` erases it. The caller is the
287
+ * one that knows which rows it drew, so the claim lives here as a guard rather
288
+ * than as a cast in each extension that reuses its block through
289
+ * `context.lastComponent` — and a component from another tool can only reach that
290
+ * check by being this class, which is why the erased type is safe to assert.
291
+ */
292
+ export function isTailResult<Row>(value: unknown): value is TailResult<Row> {
293
+ return value instanceof TailResult;
294
+ }
295
+
267
296
  /** The render context fields the elapsed timer needs. */
268
297
  export interface ToolRenderClock {
269
298
  /** Pi's per-call render state, opaque here: this module only ever casts it. */
package/lib/web/README.md CHANGED
@@ -21,6 +21,47 @@ counts its own stylesheet (`pages.test.ts`, `web.test.ts`). A page rule is
21
21
  namespaced (`review-*`, `browse-*`) and overrides a kit class rather than
22
22
  re-declaring it.
23
23
 
24
+ ## One browser runtime, spliced in
25
+
26
+ Three surfaces render pages from a bundle held as a template literal, and each had written
27
+ the same three helpers for itself: an element builder, a "did this key land in a field?"
28
+ test, and a status line's paint. No type checker and no AST scan compares text inside a
29
+ string, so the plan's `el(tag, className)` and the board's `el(tag, className, text)` were
30
+ two builders, and the plan compared `INPUT` where the board lowercased the tag.
31
+
32
+ `client-runtime.ts` holds one body of each, as ordinary TypeScript, and exports
33
+ `CLIENT_RUNTIME_JS` — the four functions as source, in the order a client reads them. A
34
+ page carries it the way it carries `plural` and its class table: the source crosses the
35
+ boundary, and the page runs the function instead of a copy of it.
36
+
37
+ The bodies are JavaScript on purpose — no annotation survives into the string, and no
38
+ backtick or `${` may appear in one. The DOM is reached through `globalThis`, because this
39
+ project compiles without a DOM lib; the cast names the guarantee the page makes.
40
+
41
+ What stays page-side: what a status *says*. `paintStatus` owns the text, its tooltip, and
42
+ the `bf-status--<tone>` class; the board composes `live · 3m` and appends its own class.
43
+ A shared runtime that also decided the wording would be a second copy of the page's voice.
44
+
45
+ ## Name a class once, in a table
46
+
47
+ A page's document is TypeScript and its client is a string inside a template literal, so
48
+ the two halves cannot import from each other: a class name used to be a literal in each,
49
+ and one assembled at runtime (`"board-card__reason" + kind`) could not be found by
50
+ reading either half. That is how a class reaches a stylesheet with no rule behind it —
51
+ an unstyled element, and nothing fails.
52
+
53
+ So a page declares its vocabulary once, in a `ClassNames` table beside its own modules,
54
+ and both halves read it: the document interpolates `${N.toolbar}`, the bundle carries
55
+ `namesScript(table)`. A name is then a symbol, and a test can compare the set a page
56
+ wears with the set its stylesheet draws, in both directions.
57
+
58
+ `namesScript` is the boundary, and it throws rather than trusting a caller: a value must
59
+ be `KEBAB` — lowercase, hyphenated — because the table is spliced into a template
60
+ literal, and a backtick would end the string while `${` would interpolate at build time.
61
+ The global it binds must be a JavaScript `IDENTIFIER`. The table's *keys* are the page's
62
+ own English; its *values* are what CSS matches, and nothing else in the page may spell
63
+ one.
64
+
24
65
  ## Color is a role
25
66
 
26
67
  `WEB_THEME_TOKENS` is derived from `themes/bytefly-dark.json` and
@@ -99,8 +140,17 @@ choice. Neither page reloads for a theme.
99
140
 
100
141
  ## Markup atoms and escaping
101
142
 
102
- `docHead`, `brandLockup`, `themeToggle`, `chip`, `button`, and `keysSheet` return
103
- strings. Every dynamic **value** goes through `escapeHtml` from
143
+ `docHead`, `brandLockup`, `markIcon`, `tabTitle`, `themeToggle`, `chip`, `button`, and
144
+ `keysSheet` return strings. `docHead` links the mark as the tab icon — two SVG
145
+ data URIs from the same blocks the lockup draws, the dark one plain and the light
146
+ one behind `prefers-color-scheme` — because an icon is its own document and
147
+ cannot read the page's tokens.
148
+
149
+ `tabTitle` is where the three served pages' tab titles agree: `<page> · <subject> ·
150
+ <count>`, the page's name lowercase and the same word its `brandLockup` carries, a part
151
+ the page does not have left out rather than drawn empty. The board's count is live, so
152
+ its page repaints the title on every frame and the body crosses into the bundle as
153
+ `TAB_TITLE_JS`, the way `plural` does — one shape for the string, not two. Every dynamic **value** goes through `escapeHtml` from
104
154
  [`../escape.ts`](../escape.ts); the fields documented as trusted (`css`, an
105
155
  `icon` glyph, an attribute key, a client id) are code-authored constants, never
106
156
  model or file text. A page that renders its own document follows the same rule.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The browser runtime both page clients splice into their bundles: the element builder,
3
+ * the "is the user typing?" test, the status line, and the JSON POST.
4
+ *
5
+ * Two surfaces had written each of these for themselves, and because a bundle is a
6
+ * template literal no type checker and no AST scan ever compared them: the plan's
7
+ * `el(tag, className)` and the board's `el(tag, className, text)` were two builders, and
8
+ * the plan's `typing` compared `INPUT` where the board's lowercased the tag. Both are
9
+ * now one body, written here as real TypeScript, and each client carries the *source*
10
+ * (`CLIENT_RUNTIME_JS`) the same way it carries `plural` and the class table.
11
+ *
12
+ * The functions are plain JavaScript on purpose: their text is spliced into a bundle, so
13
+ * no annotation may survive into the string and no backtick or `${` may appear in a body.
14
+ * The DOM is reached through `globalThis`, because this project compiles without a DOM
15
+ * lib — the cast names the guarantee the page makes: it is a browser, and it has one.
16
+ */
17
+
18
+ /**
19
+ * The pieces of a DOM node the runtime touches, and the ones a page client needs beyond
20
+ * it. The page supplies the real thing; this is the shape its bundle is typed against.
21
+ */
22
+ export interface ClientNode {
23
+ className: string;
24
+ textContent: string | null;
25
+ title: string;
26
+ hidden: boolean;
27
+ href: string;
28
+ id: string;
29
+ style: { width: string };
30
+ type: string;
31
+ scrollTop: number;
32
+ contains(other: ClientNode | null): boolean;
33
+ focus(): void;
34
+ classList: { add(name: string): void; remove(name: string): void; toggle(name: string, on?: boolean): void };
35
+ setAttribute(name: string, value: string): void;
36
+ getAttribute(name: string): string | null;
37
+ removeAttribute(name: string): void;
38
+ appendChild(child: ClientNode): void;
39
+ remove(): void;
40
+ replaceChildren(): void;
41
+ querySelector(selector: string): ClientNode | null;
42
+ querySelectorAll(selector: string): ArrayLike<ClientNode>;
43
+ scrollIntoView?(options?: { block?: string }): void;
44
+ addEventListener(type: string, listener: (event: { target?: unknown }) => void): void;
45
+ }
46
+
47
+ /** What `document.createElement` has to answer for these helpers. */
48
+ interface RuntimeDocument {
49
+ createElement(tag: string): ClientNode;
50
+ }
51
+
52
+ /**
53
+ * One element with an optional class and text.
54
+ *
55
+ * `text` is set as `textContent`, never parsed: a card title and a plan's prose are both
56
+ * model- or user-written, and the one property that cannot execute is the one to use.
57
+ */
58
+ export function el(tag: string, className?: string, text?: string | null): ClientNode {
59
+ // Reached through `globalThis` and cast here, because this project compiles without a
60
+ // DOM lib; the cast names what the page guarantees — it is a browser, and it has one.
61
+ const node = (globalThis as unknown as { document: RuntimeDocument }).document.createElement(tag);
62
+ if (className) node.className = className;
63
+ if (text !== undefined && text !== null) node.textContent = String(text);
64
+ return node;
65
+ }
66
+
67
+ /**
68
+ * Whether a key event landed in a field, where every key is text rather than a shortcut.
69
+ *
70
+ * The tag is lowercased once, so a document parsed as XHTML (`tagName` upper case) and a
71
+ * page built by `createElement` (`tagName` lower case) both answer the same.
72
+ */
73
+ export function typing(target: { tagName?: string; isContentEditable?: boolean }): boolean {
74
+ if (!target || !target.tagName) return target?.isContentEditable === true;
75
+ const tag = target.tagName.toLowerCase();
76
+ return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable === true;
77
+ }
78
+
79
+ /**
80
+ * Paint a status line: its text, the same text as its tooltip, and its tone's class.
81
+ *
82
+ * The status slot is the page's own id, so the caller passes the node; what is shared is
83
+ * the rule that a tone is a `bf-status--*` class and that the text is also the title (a
84
+ * clamped line has no other way to be read).
85
+ */
86
+ export function paintStatus(node: ClientNode, text: string, kind?: string): void {
87
+ node.textContent = text || "";
88
+ node.title = text || "";
89
+ node.className = kind ? "bf-status bf-status--" + kind : "bf-status";
90
+ }
91
+
92
+ /** One answer from the server: whether it accepted, its status, and its parsed body. */
93
+ interface PostResult {
94
+ ok: boolean;
95
+ status: number;
96
+ body: unknown;
97
+ }
98
+
99
+ /**
100
+ * POST JSON and read the reply's body, whatever the status.
101
+ *
102
+ * A page that only honoured the happy path would report `server returned 409` where the
103
+ * server had said *why*, so the body is parsed on both branches and the caller decides.
104
+ */
105
+ export function postJson(url: string, payload: unknown): Promise<PostResult> {
106
+ return fetch(url, {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify(payload),
110
+ }).then((response: { ok: boolean; status: number; json(): Promise<unknown> }) =>
111
+ response.json().then(
112
+ (body: unknown): PostResult => ({ ok: response.ok, status: response.status, body }),
113
+ (): PostResult => ({ ok: response.ok, status: response.status, body: null }),
114
+ ),
115
+ );
116
+ }
117
+
118
+ /**
119
+ * The prefix this page was served under, as the server wrote it on `<html data-base>`.
120
+ *
121
+ * A session serves several pages on one origin, so a bundle cannot assume it owns the
122
+ * root: `""` at the origin's root, `/files` under a mount. The attribute is read every
123
+ * time rather than cached at load, because a fragment runs in whatever order the bundle
124
+ * lists it and none of them may depend on another having run first.
125
+ */
126
+ export function pageBase(): string {
127
+ // Cast for the same reason as `el`: this project compiles without a DOM lib, and the
128
+ // page guarantees a document with a root element.
129
+ const root = (
130
+ globalThis as unknown as { document: { documentElement: { getAttribute(name: string): string | null } } }
131
+ ).document.documentElement;
132
+ return root.getAttribute("data-base") ?? "";
133
+ }
134
+
135
+ /**
136
+ * {@link pageBase} on its own, for a bundle to splice at its **outermost** scope.
137
+ *
138
+ * A page's bundle is a stack of fragments, and a fragment's helper is only visible to the
139
+ * ones nested inside it — so this one goes where every fragment can reach it, which is not
140
+ * where the rest of the runtime goes.
141
+ */
142
+ export const CLIENT_BASE_JS = String(pageBase);
143
+
144
+ /**
145
+ * The runtime, as a bundle receives it: the four bodies, in the order a client reads them.
146
+ *
147
+ * `test` holds each to its module body and proves the string is safe to splice.
148
+ */
149
+ export const CLIENT_RUNTIME_JS = [el, typing, paintStatus, postJson].map(String).join("\n\n");
package/lib/web/index.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * The Bytefly web brand system: tokens, base rules, components, layout,
3
- * runtime, and markup atoms. Import from `lib/web/index.ts` rather than a
4
- * leaf module so the surface reads as one kit.
3
+ * runtime, markup atoms, and the class-name table a page's bundle carries. Import
4
+ * from `lib/web/index.ts` rather than a leaf module so the surface reads as one kit.
5
5
  */
6
6
 
7
7
  export * from "./base.ts";
8
+ export * from "./client-runtime.ts";
8
9
  export * from "./components.ts";
9
10
  export * from "./layout.ts";
10
11
  export * from "./markup.ts";
12
+ export * from "./names.ts";
11
13
  export * from "./runtime.ts";
12
14
  export * from "./tokens.ts";
package/lib/web/markup.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { escapeHtml } from "../escape.ts";
10
10
  import { THEME_JS_PATH } from "./runtime.ts";
11
+ import { WEB_THEME_TOKENS } from "./tokens.ts";
11
12
 
12
13
  /** Options for {@link docHead}. */
13
14
  export interface DocHeadOptions {
@@ -15,6 +16,46 @@ export interface DocHeadOptions {
15
16
  title: string;
16
17
  /** Trusted CSS assembled by the page. */
17
18
  css: string;
19
+ /**
20
+ * The mount prefix this page answers under, e.g. `/files`; `""` for the origin's
21
+ * root. A page's scripts and links are emitted under it, because a session serves
22
+ * several pages on one origin.
23
+ */
24
+ base?: string;
25
+ }
26
+
27
+ /**
28
+ * A page's browser-tab title: `<page> · <subject> · <count>`.
29
+ *
30
+ * One convention across the served pages, so a strip of tabs reads as one product. The
31
+ * page's name comes first, lowercase and the same word its header's `brandLockup`
32
+ * carries; then what the page is showing; then the count a reader opens the tab for. A
33
+ * part the page does not have is left out rather than drawn empty, so a board with
34
+ * nothing open reads `board · my-pi-agent` and a plan reads `plan review · <its title>`.
35
+ */
36
+ export function tabTitle(product: string, subject?: string, count?: string): string {
37
+ const kept = [product, subject, count];
38
+ return kept.filter((part) => part !== undefined && part !== "").join(" · ");
39
+ }
40
+
41
+ /**
42
+ * The same body, as the browser receives it.
43
+ *
44
+ * The board's count is live, so its tab is repainted on every frame and this rule has to
45
+ * cross into the bundle. It is written as concatenation with no backtick and no
46
+ * interpolation, because the bundle is a template the source is spliced into.
47
+ */
48
+ export const TAB_TITLE_JS = String(tabTitle);
49
+
50
+ /**
51
+ * The `data-base` attribute a page's `<html>` carries, which its own bundle reads
52
+ * (`base()` in `client-runtime.ts`) to build every URL it emits.
53
+ *
54
+ * `""` — the origin's root — writes nothing at all, so a page served alone is markup
55
+ * without a mount to name.
56
+ */
57
+ export function baseAttribute(base: string): string {
58
+ return base === "" ? "" : ` data-base="${escapeHtml(base)}"`;
18
59
  }
19
60
 
20
61
  /**
@@ -27,8 +68,70 @@ export function docHead(options: DocHeadOptions): string {
27
68
  <meta name="viewport" content="width=device-width, initial-scale=1">
28
69
  <meta name="color-scheme" content="dark light">
29
70
  <title>${escapeHtml(options.title)}</title>
71
+ ${markIcon()}
30
72
  <style>${options.css}</style>
31
- <script src="${THEME_JS_PATH}"></script>`;
73
+ <script src="${options.base ?? ""}${THEME_JS_PATH}"></script>`;
74
+ }
75
+
76
+ /**
77
+ * The mark's blocks: `█▀█` over `█▀ █`, as `[x, height]` per row in a 4x2 grid.
78
+ *
79
+ * One shape, three places — the header lockup, the tab icon, and the TUI header's
80
+ * half-blocks — so it is written once. The rows differ: the top fills x=2, the bottom
81
+ * leaves it empty and fills x=3, which is what makes the mark read as two glyphs.
82
+ */
83
+ const MARK_ROWS: ReadonlyArray<{ group: "top" | "bottom"; rects: ReadonlyArray<readonly [number, number]> }> = [
84
+ {
85
+ group: "top",
86
+ rects: [
87
+ [0, 1],
88
+ [1, 0.5],
89
+ [2, 1],
90
+ ],
91
+ },
92
+ {
93
+ group: "bottom",
94
+ rects: [
95
+ [0, 1],
96
+ [1, 0.5],
97
+ [3, 1],
98
+ ],
99
+ },
100
+ ];
101
+
102
+ /** One row of the mark, as `<rect>`s starting at `y`. */
103
+ function markRowRects(row: (typeof MARK_ROWS)[number], y: number): string {
104
+ return row.rects.map(([x, height]) => `<rect x="${x}" y="${y}" width="1" height="${height}"/>`).join("");
105
+ }
106
+
107
+ /**
108
+ * The mark as a standalone SVG, in one appearance's colours.
109
+ *
110
+ * A favicon is its own document: it cannot read the page's tokens, so the fills are
111
+ * resolved here from the same palette the page paints with, and each appearance gets
112
+ * its own icon.
113
+ */
114
+ function markSvg(fills: { top: string; bottom: string }): string {
115
+ const rows = MARK_ROWS.map((row, index) => `<g fill="${fills[row.group]}">${markRowRects(row, index)}</g>`);
116
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 2">${rows.join("")}</svg>`;
117
+ }
118
+
119
+ /**
120
+ * The tab icon: the mark, in the appearance the reader's system is in.
121
+ *
122
+ * The dark icon is the plain `rel="icon"` — the page paints dark at `:root`, and a
123
+ * browser that reads no `media` query on an icon keeps it. The light one is the
124
+ * override, so a light tab does not carry a mark tuned for a dark one.
125
+ */
126
+ export function markIcon(): string {
127
+ const dark = encodeURIComponent(
128
+ markSvg({ top: WEB_THEME_TOKENS.dark["--accent"], bottom: WEB_THEME_TOKENS.dark["--warn"] }),
129
+ );
130
+ const light = encodeURIComponent(
131
+ markSvg({ top: WEB_THEME_TOKENS.light["--accent"], bottom: WEB_THEME_TOKENS.light["--warn"] }),
132
+ );
133
+ return `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${dark}">
134
+ <link rel="icon" type="image/svg+xml" media="(prefers-color-scheme: light)" href="data:image/svg+xml,${light}">`;
32
135
  }
33
136
 
34
137
  /**
@@ -37,7 +140,8 @@ export function docHead(options: DocHeadOptions): string {
37
140
  * as a 4x2 grid so the accent and warning rows stay crisp at any zoom.
38
141
  */
39
142
  export function brandLockup(product: string): string {
40
- return `<span class="bf-brand"><svg class="bf-mark" viewBox="0 0 4 2" aria-hidden="true"><g class="bf-mark__top"><rect x="0" y="0" width="1" height="1"/><rect x="1" y="0" width="1" height="0.5"/><rect x="2" y="0" width="1" height="1"/></g><g class="bf-mark__bottom"><rect x="0" y="1" width="1" height="1"/><rect x="1" y="1" width="1" height="0.5"/><rect x="3" y="1" width="1" height="1"/></g></svg><span class="bf-wordmark">bytefly</span><span class="bf-context">${escapeHtml(product)}</span></span>`;
143
+ const rows = MARK_ROWS.map((row, index) => `<g class="bf-mark__${row.group}">${markRowRects(row, index)}</g>`);
144
+ return `<span class="bf-brand"><svg class="bf-mark" viewBox="0 0 4 2" aria-hidden="true">${rows.join("")}</svg><span class="bf-wordmark">bytefly</span><span class="bf-context">${escapeHtml(product)}</span></span>`;
41
145
  }
42
146
 
43
147
  /**
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A page's class names, as a table a bundle can carry.
3
+ *
4
+ * A browser page in this package is a TypeScript document plus a client script held in
5
+ * a template literal, so the two halves cannot import from each other: a class name is a
6
+ * string in one and a string in the other, and a name built by concatenation
7
+ * (`"board-card__reason" + kind`) cannot be found by reading either. A table fixes both:
8
+ * the document and the bundle read the same values, and a test can compare the set the
9
+ * page wears with the set its stylesheet draws.
10
+ *
11
+ * The bundle carries the table as **source**, so every value must survive being spliced
12
+ * into a template literal: a backtick would end the string, and `${` would interpolate
13
+ * at build time. `KEBAB` is that contract, and {@link namesScript} throws rather than
14
+ * trusting a caller — a bad name is a build error here, not a mystery on the page.
15
+ */
16
+
17
+ /** A page's vocabulary: a key per thing it dresses, and the class it wears. */
18
+ export type ClassNames = Readonly<Record<string, string>>;
19
+
20
+ /** What a class name may be: lowercase, hyphenated, and nothing a script could misread. */
21
+ export const KEBAB = /^[a-z][a-z0-9_-]*$/;
22
+
23
+ /** What the global the table is bound to may be: a JavaScript identifier, and nothing else. */
24
+ export const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
25
+
26
+ /** The declared global the table is bound to in a page's bundle. */
27
+ export const NAMES_GLOBAL = "N";
28
+
29
+ /**
30
+ * The table as one script statement, for a client bundle to start with.
31
+ *
32
+ * `JSON.stringify` is the serializer because the values are already strings that pass
33
+ * {@link KEBAB}; the check before it is what makes that true, and its message names the
34
+ * offending key and value so the fix is obvious.
35
+ */
36
+ export function namesScript(table: ClassNames, global: string = NAMES_GLOBAL): string {
37
+ for (const [key, value] of Object.entries(table)) {
38
+ if (!KEBAB.test(value)) throw new Error(`the class name for "${key}" is not a kebab-case name: "${value}"`);
39
+ }
40
+ if (!IDENTIFIER.test(global)) throw new Error(`"${global}" is not a name a script can declare`);
41
+ return `var ${global} = ${JSON.stringify(table)};`;
42
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.19.0",
4
- "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, file-read, check, memory, context, session-bus, board, team, wechat, and bytefly.",
3
+ "version": "0.21.0",
4
+ "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, serve, subagent, job, watch, web-access, file-browse, file-open, file-read, check, memory, context, session-bus, board, team, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -92,6 +92,7 @@
92
92
  },
93
93
  "pi": {
94
94
  "extensions": [
95
+ "./extensions/serve/index.ts",
95
96
  "./extensions/worktree/index.ts",
96
97
  "./extensions/rewind/index.ts",
97
98
  "./extensions/question/index.ts",
@@ -1,36 +0,0 @@
1
- /**
2
- * The board's one setting: the port its page is pinned to.
3
- *
4
- * The `board` section of the shared `bytefly.json` (global then project, project
5
- * wins). `0` — the default — takes one free port for the session and remembers it,
6
- * which is what a session that never bookmarks its URL wants; a positive value pins
7
- * the page, so a bookmarked URL survives a restart. A pinned port that is taken
8
- * fails the start rather than moving the page out from under the bookmark.
9
- *
10
- * Pin it in the project document when more than one repository runs a board: a
11
- * global value is one port for every board on the machine.
12
- */
13
-
14
- import { clampInteger, loadConfigSection } from "../../lib/config.ts";
15
-
16
- /** The `board` section. */
17
- export interface BoardConfig {
18
- /** TCP port on `127.0.0.1`; `0` picks one for the session, a positive value pins it. */
19
- port: number;
20
- }
21
-
22
- export const DEFAULT_BOARD_CONFIG: BoardConfig = { port: 0 };
23
-
24
- /** Validate and clamp a raw config object over the defaults. */
25
- export function normalizeBoardConfig(
26
- raw: Record<string, unknown> | undefined,
27
- base: BoardConfig = DEFAULT_BOARD_CONFIG,
28
- ): BoardConfig {
29
- if (!raw) return base;
30
- return { port: clampInteger(raw.port, base.port, 0, 65_535) };
31
- }
32
-
33
- /** Load the effective config for a working directory. */
34
- export function loadBoardConfig(cwd: string): BoardConfig {
35
- return loadConfigSection(cwd, "board", DEFAULT_BOARD_CONFIG, normalizeBoardConfig);
36
- }