@gavin-hu/my-pi-agent 0.20.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 (106) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/docs/extensions/README.md +1 -0
  4. package/docs/extensions/board.md +57 -42
  5. package/docs/extensions/bytefly.md +85 -89
  6. package/docs/extensions/file-browse.md +36 -41
  7. package/docs/extensions/plan.md +31 -14
  8. package/docs/extensions/serve.md +129 -0
  9. package/docs/extensions/session-bus.md +1 -1
  10. package/docs/extensions/team.md +11 -11
  11. package/extensions/board/commands.ts +27 -14
  12. package/extensions/board/index.ts +14 -2
  13. package/extensions/board/join.ts +31 -10
  14. package/extensions/board/page.ts +53 -83
  15. package/extensions/board/result.ts +1 -24
  16. package/extensions/board/rows.ts +1 -1
  17. package/extensions/board/schema.ts +9 -3
  18. package/extensions/board/snapshot.ts +7 -6
  19. package/extensions/board/tools.ts +27 -15
  20. package/extensions/board/transport/router.ts +3 -5
  21. package/extensions/board/transport/server.ts +59 -28
  22. package/extensions/board/types.ts +0 -10
  23. package/extensions/board/view/client.ts +3 -2
  24. package/extensions/board/view/live.ts +1 -7
  25. package/extensions/board/view/page.ts +16 -5
  26. package/extensions/board/view/render.ts +22 -17
  27. package/extensions/board/view/styles.ts +7 -1
  28. package/extensions/bytefly/footer/config.ts +3 -6
  29. package/extensions/bytefly/footer/render/footer.ts +18 -4
  30. package/extensions/bytefly/footer/render/key.ts +46 -0
  31. package/extensions/bytefly/footer/render/layout.ts +13 -3
  32. package/extensions/bytefly/footer/render/lines.ts +4 -4
  33. package/extensions/bytefly/footer/render/segments.ts +10 -34
  34. package/extensions/bytefly/header/component.ts +4 -20
  35. package/extensions/check/render.ts +10 -1
  36. package/extensions/file-browse/config/config.ts +4 -19
  37. package/extensions/file-browse/index.ts +27 -48
  38. package/extensions/file-browse/live/live.ts +1 -1
  39. package/extensions/file-browse/transport/router.ts +12 -4
  40. package/extensions/file-browse/transport/server.ts +61 -28
  41. package/extensions/file-browse/view/chrome.ts +4 -4
  42. package/extensions/file-browse/view/client.ts +3 -1
  43. package/extensions/file-browse/view/html.ts +73 -40
  44. package/extensions/file-browse/view/live-client.ts +2 -2
  45. package/extensions/file-browse/view/markdown.ts +13 -14
  46. package/extensions/file-browse/view/palette.ts +2 -2
  47. package/extensions/file-browse/view/raw-html.ts +9 -9
  48. package/extensions/file-browse/view/tree.ts +1 -1
  49. package/extensions/file-browse/view/urls.ts +20 -6
  50. package/extensions/goal/tui.ts +12 -2
  51. package/extensions/job/tools.ts +19 -7
  52. package/extensions/job/tui.ts +2 -28
  53. package/extensions/plan/commands.ts +2 -2
  54. package/extensions/plan/feedback.ts +62 -16
  55. package/extensions/plan/index.ts +7 -1
  56. package/extensions/plan/plan-text.ts +114 -0
  57. package/extensions/plan/runtime.ts +12 -2
  58. package/extensions/plan/tools.ts +2 -2
  59. package/extensions/plan/web-blocks.ts +76 -5
  60. package/extensions/plan/web-chrome.ts +4 -0
  61. package/extensions/plan/web-client.ts +99 -2
  62. package/extensions/plan/web-collab.ts +4 -4
  63. package/extensions/plan/web-comments.ts +39 -8
  64. package/extensions/plan/web-composer.ts +14 -3
  65. package/extensions/plan/web-keys.ts +149 -25
  66. package/extensions/plan/web-markdown.ts +1 -1
  67. package/extensions/plan/web-outline.ts +22 -2
  68. package/extensions/plan/web-page.ts +56 -86
  69. package/extensions/plan/web-payload.ts +188 -0
  70. package/extensions/plan/web-plan.ts +23 -13
  71. package/extensions/plan/web-settle.ts +14 -7
  72. package/extensions/plan/web-share-session.ts +24 -55
  73. package/extensions/plan/web-share.ts +19 -37
  74. package/extensions/plan/web-write.ts +112 -0
  75. package/extensions/plan/web.ts +77 -130
  76. package/extensions/serve/config.ts +37 -0
  77. package/extensions/serve/index.ts +71 -0
  78. package/extensions/serve/registry.ts +251 -0
  79. package/extensions/session-bus/join-request.ts +16 -9
  80. package/extensions/session-bus/result.ts +1 -27
  81. package/extensions/session-bus/tools.ts +5 -4
  82. package/extensions/team/commands.ts +3 -1
  83. package/extensions/todo/tui.ts +21 -3
  84. package/lib/README.md +5 -4
  85. package/lib/board/page-pointer.ts +29 -4
  86. package/lib/board/session.ts +84 -18
  87. package/lib/board/store.ts +34 -8
  88. package/lib/board/view.ts +20 -14
  89. package/lib/bus-request.ts +83 -0
  90. package/lib/http/index.ts +11 -7
  91. package/lib/http/mount.ts +142 -0
  92. package/lib/http/port.ts +25 -0
  93. package/lib/http/serve.ts +170 -46
  94. package/lib/http/server.ts +3 -3
  95. package/lib/plan-entry.ts +9 -13
  96. package/lib/requirements.ts +2 -2
  97. package/lib/session-label.ts +23 -7
  98. package/lib/session-tool.ts +37 -21
  99. package/lib/tui/README.md +6 -3
  100. package/lib/tui/glyphs.ts +2 -4
  101. package/lib/tui/render-cache.ts +29 -0
  102. package/lib/tui/tool.ts +30 -1
  103. package/lib/web/client-runtime.ts +27 -1
  104. package/lib/web/markup.ts +18 -1
  105. package/package.json +3 -2
  106. package/extensions/board/config.ts +0 -36
@@ -1,37 +1,41 @@
1
1
  /**
2
- * The board page's server: one loopback listener, the snapshot pushed to every
3
- * attached tab, and the idle grace that closes the page when the last one leaves.
2
+ * The board page's mount: the prefix it holds on the session's one server, the snapshot
3
+ * pushed to every attached tab, and the idle grace that gives the prefix back when the
4
+ * last one leaves.
4
5
  *
5
- * `lib/http` owns the socket — binding, the body cap, the SSE head, and the
6
- * idempotent close — so this module owns only the board's own decisions: when a
7
- * frame is pushed (only when a card's revision moved), who receives it, and what
8
- * stops the ticker.
6
+ * `lib/http` owns the socket and `serve` owns the session's port, so this module owns only
7
+ * the board's own decisions: where it answers (`BOARD_BASE`), when a frame is pushed (only
8
+ * when a card's revision moved), who receives it, and what stops the ticker.
9
9
  */
10
10
 
11
+ import { BOARD_BASE } from "../../../lib/board/page-pointer.ts";
12
+ import type { BoardView } from "../../../lib/board/view.ts";
11
13
  import {
12
14
  type EventSink,
15
+ type HttpMount,
13
16
  LOOPBACK_HOSTS,
14
- type LoopbackServer,
17
+ type MountSource,
18
+ type MountSpec,
15
19
  mintToken,
16
- serveLoopback,
17
20
  tokenMatches,
18
21
  } from "../../../lib/http/index.ts";
19
22
  import type { IdleReason } from "../../../lib/http/sink.ts";
20
23
  import { defaultInterval, type RepeatTimer } from "../../../lib/timer.ts";
21
- import type { BoardView } from "../../../lib/board/view.ts";
22
24
  import type { PageView } from "../view/page.ts";
23
25
  import { type BoardRouterContext, handleBoardRequest } from "./router.ts";
24
26
 
25
27
  /** How often the page is offered a frame; a frame is sent only when the signature moved. */
26
- export const PUSH_MS = 1_000;
28
+ const PUSH_MS = 1_000;
27
29
 
28
30
  /** Largest request body the page accepts: a card and a note are small. */
29
- export const MAX_BODY_BYTES = 64 * 1_024;
31
+ const MAX_BODY_BYTES = 64 * 1_024;
30
32
 
31
33
  /** What the page is built from. The lifecycle (`page.ts`) supplies all of it. */
32
34
  export interface BoardServerOptions {
33
- /** Ports tried in order: the session's remembered port, then an OS-chosen one. */
34
- ports: readonly number[];
35
+ /** Where the prefix comes from: the session's server, or a suite's own. */
36
+ mount: MountSource;
37
+ /** The Pi session asking, so the prefix lands on this session's socket. */
38
+ session: string;
35
39
  /** The page's token; minted here when the caller has none, so the note carries one. */
36
40
  token?: string;
37
41
  page: (view: PageView) => Promise<string>;
@@ -41,8 +45,17 @@ export interface BoardServerOptions {
41
45
  onIdle?: (reason: IdleReason) => void;
42
46
  /** Grace after the last stream leaves; 0 or absent waits for `close()`. */
43
47
  idleMs?: number;
44
- /** Grace for a page no tab ever attached to; absent leaves the server open. */
48
+ /** Grace for a page no tab ever attached to; absent leaves the mount open. */
45
49
  firstIdleMs?: number;
50
+ /**
51
+ * A read that failed while building a frame.
52
+ *
53
+ * A snapshot can fail for reasons the page does not control — the board directory removed
54
+ * or made unreadable under a running page — and a ticker nobody awaits must not turn that
55
+ * into an unhandled rejection in the session. The frame is dropped, the next tick tries
56
+ * again, and this is the one place the failure is said. Absent, the page stays quiet.
57
+ */
58
+ onError?: (error: unknown) => void;
46
59
  /** The interval seam, so a test drives the ticker instead of waiting. */
47
60
  interval?: RepeatTimer;
48
61
  }
@@ -59,10 +72,15 @@ export interface BoardServer {
59
72
  close: () => Promise<void>;
60
73
  }
61
74
 
62
- export async function createBoardServer(options: BoardServerOptions): Promise<BoardServer> {
75
+ /** The page, or the reason the session's server has no prefix for it. */
76
+ export type BoardServerResult = { kind: "served"; server: BoardServer } | { kind: "refused"; reason: string };
77
+
78
+ export async function createBoardServer(options: BoardServerOptions): Promise<BoardServerResult> {
63
79
  const token = options.token ?? mintToken();
64
80
  const clients = new Set<EventSink>();
65
81
  const interval = options.interval ?? defaultInterval;
82
+ /** A frame that could not be built is not sent; the next tick tries again. */
83
+ const report = (error: unknown): void => options.onError?.(error);
66
84
  let pushed = "";
67
85
 
68
86
  const ctx: BoardRouterContext = {
@@ -84,8 +102,10 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
84
102
  for (const sink of clients) sink.write(frame);
85
103
  };
86
104
 
87
- const server: LoopbackServer = await serveLoopback({
88
- ports: options.ports,
105
+ /** The prefix this page asks for: the session's server holds it, not this module. */
106
+ const spec: Omit<MountSpec, "handler"> = {
107
+ session: options.session,
108
+ prefix: BOARD_BASE,
89
109
  maxBodyBytes: MAX_BODY_BYTES,
90
110
  ...(options.idleMs === undefined || options.onIdle === undefined
91
111
  ? {}
@@ -96,6 +116,9 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
96
116
  onIdle: options.onIdle,
97
117
  },
98
118
  }),
119
+ };
120
+ const outcome = await options.mount({
121
+ ...spec,
99
122
  async handler(request) {
100
123
  const result = await handleBoardRequest(ctx, request.method, request.url, request.headers);
101
124
  if (!result.stream) return { result };
@@ -105,10 +128,13 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
105
128
  clients.add(sink);
106
129
  // A tab that just attached gets the current frame without waiting for a
107
130
  // card to change — otherwise a quiet board looks like a dead page.
108
- void options.view().then((view) => {
109
- pushed = view.sig;
110
- sink.write(`data: ${JSON.stringify(view)}\n\n`);
111
- });
131
+ void options
132
+ .view()
133
+ .then((view) => {
134
+ pushed = view.sig;
135
+ sink.write(`data: ${JSON.stringify(view)}\n\n`);
136
+ })
137
+ .catch(report);
112
138
  return () => {
113
139
  clients.delete(sink);
114
140
  };
@@ -116,8 +142,10 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
116
142
  };
117
143
  },
118
144
  });
145
+ if (outcome.kind === "refused") return { kind: "refused", reason: outcome.reason };
146
+ const mount: HttpMount = outcome.mount;
119
147
 
120
- const stopTicker = interval(PUSH_MS, () => void push());
148
+ const stopTicker = interval(PUSH_MS, () => void push().catch(report));
121
149
  let closed = false;
122
150
  const close = async (): Promise<void> => {
123
151
  if (closed) return;
@@ -125,14 +153,17 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
125
153
  stopTicker();
126
154
  for (const sink of clients) sink.end();
127
155
  clients.clear();
128
- await server.close();
156
+ await mount.release();
129
157
  };
130
158
 
131
159
  return {
132
- port: server.port,
133
- url: `http://127.0.0.1:${server.port}/?token=${token}`,
134
- watchers: () => clients.size,
135
- pushed: () => pushed,
136
- close,
160
+ kind: "served",
161
+ server: {
162
+ port: mount.port,
163
+ url: `${mount.origin}${mount.base}/?token=${token}`,
164
+ watchers: () => clients.size,
165
+ pushed: () => pushed,
166
+ close,
167
+ },
137
168
  };
138
169
  }
@@ -84,16 +84,6 @@ export const ACTION_FIELDS: Record<BoardAction, readonly string[]> = {
84
84
  drop: ["card"],
85
85
  };
86
86
 
87
- /**
88
- * The actions that change the board: everything but a read.
89
- *
90
- * One definition, read by the tool (does this call need a teammate?) and by plan
91
- * mode (may this call run while planning?), so "a read" cannot mean two things.
92
- */
93
- export const BOARD_MUTATING_ACTIONS: ReadonlySet<BoardAction> = new Set(
94
- BOARD_ACTIONS.filter((action) => action !== "list"),
95
- );
96
-
97
87
  /** A result the transcript renders and the page's routes mirror. */
98
88
  export interface BoardDetails {
99
89
  action: BoardAction;
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { MATCHES_QUERY_JS } from "../../../lib/board/card.ts";
17
- import { CLIENT_RUNTIME_JS } from "../../../lib/web/index.ts";
17
+ import { CLIENT_BASE_JS, CLIENT_RUNTIME_JS } from "../../../lib/web/index.ts";
18
18
  import { PLURAL_JS } from "../../../lib/board/round-text.ts";
19
19
  import { FORMAT_AGE_JS } from "../../../lib/format.ts";
20
20
  import { namesScript, TAB_TITLE_JS, THEME_TOGGLE_JS, dialogScript, keysDialogScript } from "../../../lib/web/index.ts";
@@ -32,7 +32,8 @@ import { BOARD_TEAM_JS } from "./team.ts";
32
32
  * shape, what a `query` matches, and how an age reads: the browser has no import graph,
33
33
  * and a second copy of any of them is exactly what this bundle is not allowed to hold.
34
34
  */
35
- export const BOARD_CLIENT_JS = `${namesScript(BOARD_CLASS)}
35
+ export const BOARD_CLIENT_JS = `${CLIENT_BASE_JS}
36
+ ${namesScript(BOARD_CLASS)}
36
37
  ${renderJs({ plural: PLURAL_JS, tabTitle: TAB_TITLE_JS, matches: MATCHES_QUERY_JS, age: FORMAT_AGE_JS, runtime: CLIENT_RUNTIME_JS })}
37
38
  ${BOARD_TEAM_JS}
38
39
  ${BOARD_DIALOG_JS}
@@ -11,12 +11,6 @@ export const BOARD_LIVE_JS = String.raw`(function () {
11
11
  "use strict";
12
12
  var board = window.bfBoard || (window.bfBoard = {});
13
13
  var token = new URLSearchParams(window.location.search).get("token") || "";
14
- var LANE_LABEL = { todo: "To do", doing: "Doing", blocked: "Blocked", done: "Done" };
15
-
16
- /** The lane's word, for the dialog's move buttons. */
17
- board.laneLabel = function (lane) {
18
- return LANE_LABEL[lane] || lane;
19
- };
20
14
 
21
15
  /** The badge's text and its kit status role: connecting, live, reconnecting. */
22
16
  function badge(state, at) {
@@ -161,7 +155,7 @@ export const BOARD_LIVE_JS = String.raw`(function () {
161
155
 
162
156
  /** The live channel: a reconnect is the browser's own retry, not a second request. */
163
157
  function stream() {
164
- var source = new EventSource("/events?token=" + encodeURIComponent(token));
158
+ var source = new EventSource(pageBase() + "/events?token=" + encodeURIComponent(token));
165
159
  source.onopen = function () {
166
160
  badge("live", board.lastFrame);
167
161
  };
@@ -13,7 +13,15 @@
13
13
  * contract test holds every class to the kit or to this page's stylesheet.
14
14
  */
15
15
 
16
- import { brandLockup, button, docHead, keysSheet, tabTitle, themeToggle } from "../../../lib/web/index.ts";
16
+ import {
17
+ baseAttribute,
18
+ brandLockup,
19
+ button,
20
+ docHead,
21
+ keysSheet,
22
+ tabTitle,
23
+ themeToggle,
24
+ } from "../../../lib/web/index.ts";
17
25
  import { BOARD_KEYS } from "./keys.ts";
18
26
  import { BOARD_CLASS as N } from "./names.ts";
19
27
  import { BOARD_CSS } from "./styles.ts";
@@ -28,12 +36,15 @@ export type PageView = "board" | "team";
28
36
  * shared link opens the tab it names. The document has no form and no write affordance:
29
37
  * a page that cannot change a card cannot offer a button the server would refuse.
30
38
  */
31
- export function renderBoardPage(input: { view: PageView }): string {
39
+ export function renderBoardPage(input: { view: PageView; base?: string }): string {
32
40
  const team = input.view === "team";
41
+ // The page answers under the session's mount, so its own script and theme are named
42
+ // from that prefix rather than from the origin's root.
43
+ const base = input.base ?? "";
33
44
  return `<!doctype html>
34
- <html lang="en" data-view="${team ? "team" : "board"}">
45
+ <html lang="en" data-view="${team ? "team" : "board"}"${baseAttribute(base)}>
35
46
  <head>
36
- ${docHead({ title: tabTitle("board"), css: BOARD_CSS })}
47
+ ${docHead({ title: tabTitle("board"), css: BOARD_CSS, base })}
37
48
  </head>
38
49
  <body class="bf-app">
39
50
  <h1 class="sr-only">board — the shared feature list for this repository</h1>
@@ -111,7 +122,7 @@ ${docHead({ title: tabTitle("board"), css: BOARD_CSS })}
111
122
  </div>
112
123
 
113
124
  ${keysSheet(BOARD_KEYS)}
114
- <script src="/app.js" defer></script>
125
+ <script src="${base}/app.js" defer></script>
115
126
  </body>
116
127
  </html>
117
128
  `;
@@ -68,13 +68,12 @@ __RUNTIME__
68
68
  /**
69
69
  * One button that calls back, so nothing on the page is a click handler in markup.
70
70
  *
71
- * 'plain' is the bordered kit button, 'ghost' the text one: a card's primary
72
- * action (Claim, Move) is bordered and the rest are text, which is what makes one
73
- * of three controls on a card the obvious one.
71
+ * The class comes from the caller: a card's title and a lane's head are buttons drawn
72
+ * by the page's own rule, not by the kit's chip-with-border look — and a card draws no
73
+ * action button at all, since claiming, moving, and editing are the tools' work.
74
74
  */
75
- function actionButton(label, onClick, variant) {
76
- var kind = variant === "plain" ? "" : variant ? " bf-btn--" + variant : " bf-btn--ghost";
77
- var node = el("button", "bf-btn" + kind, label);
75
+ function actionButton(className, label, onClick) {
76
+ var node = el("button", className, label);
78
77
  node.type = "button";
79
78
  node.addEventListener("click", onClick);
80
79
  return node;
@@ -153,7 +152,11 @@ __RUNTIME__
153
152
  var shown = card.paths.slice(0, limit);
154
153
  for (var i = 0; i < shown.length; i++) {
155
154
  var item = el("li", "");
156
- item.appendChild(chip(shown[i]));
155
+ var hint = chip(shown[i]);
156
+ // A path longer than the card elides, and the whole of it is on the hover title: the
157
+ // card is often the only place its hints are read before opening the record.
158
+ hint.title = shown[i];
159
+ item.appendChild(hint);
157
160
  list.appendChild(item);
158
161
  }
159
162
  if (card.paths.length > limit) {
@@ -173,10 +176,9 @@ __RUNTIME__
173
176
 
174
177
  var head = el("p", N.cardHead);
175
178
  head.appendChild(el("span", N.cardId, "#" + card.id));
176
- var title = actionButton(card.title, function () {
179
+ var title = actionButton(N.cardTitle, card.title, function () {
177
180
  board.openCard(card.id);
178
181
  });
179
- title.className = N.cardTitle;
180
182
  head.appendChild(title);
181
183
  item.appendChild(head);
182
184
 
@@ -248,14 +250,19 @@ __RUNTIME__
248
250
  // A footer that only counts read cards would say "2 cards" over a board holding
249
251
  // three files, so the unreadable ones are named beside the size.
250
252
  where.textContent =
251
- "store " + view.where + " · " + plural(view.counts.total, "card") + " · " + size(view.bytes) +
253
+ view.repo + " · store " + view.where + " · " + plural(view.counts.total, "card") + " · " + size(view.bytes) +
252
254
  (view.skipped > 0 ? " · " + plural(view.skipped, "unreadable") : "");
255
+ // The name is what the line has room for; the path is what tells two clones of one
256
+ // repository apart when the name is the same.
257
+ where.title = view.root;
253
258
  }
254
- // The tab has to tell two boards apart, and a board's own name is the last segment of
255
- // its store: one board per repository, so that key is the subject. The count is what
256
- // a browser full of tabs is read for, and an empty board says nothing rather than 0.
259
+ // The tab has to tell two boards apart, and a board's subject is its repository: one
260
+ // board per repository, named the way the directory holding it is. The store key is a
261
+ // hash, which no reader can say out loud, so it is not what a tab leads with. The count
262
+ // is what a browser full of tabs is read for, and an empty board says nothing rather
263
+ // than 0.
257
264
  var open = view.counts.total - view.counts.done;
258
- document.title = tabTitle("board", view.where.split("/").pop(), open > 0 ? open + " open" : undefined);
265
+ document.title = tabTitle("board", view.repo, open > 0 ? open + " open" : undefined);
259
266
  }
260
267
 
261
268
  /** 1 card, 2 cards — the one body, handed in by the bundle's assembler. */
@@ -289,10 +296,9 @@ __RUNTIME__
289
296
  section = el("section", N.lane);
290
297
  section.setAttribute("data-status", lane.status);
291
298
  var head = el("h2", N.laneHead);
292
- var toggle = actionButton(LANE_LABEL[lane.status] || lane.status, function () {
299
+ var toggle = actionButton(N.laneToggle, LANE_LABEL[lane.status] || lane.status, function () {
293
300
  board.toggleLane(lane.status);
294
301
  });
295
- toggle.className = N.laneToggle;
296
302
  toggle.setAttribute("aria-expanded", "true");
297
303
  toggle.setAttribute("aria-controls", "lane-" + lane.status);
298
304
  toggle.id = "lane-head-" + lane.status;
@@ -508,7 +514,6 @@ __RUNTIME__
508
514
  board.el = el;
509
515
  board.typing = typing;
510
516
  board.chip = chip;
511
- board.actionButton = actionButton;
512
517
  board.ago = ago;
513
518
  board.findCard = findCard;
514
519
  board.drawnCards = drawnCards;
@@ -77,7 +77,7 @@ ${WEB_COMPONENTS_CSS}
77
77
  .board-card[hidden]{display:none}
78
78
  .board-card__head{display:flex;align-items:baseline;gap:var(--space-2);margin:0;min-width:0}
79
79
  .board-card__id{font:var(--fs-xs) var(--font-mono);color:var(--dim)}
80
- .board-card__title{margin:0;padding:0;border:0;background:none;text-align:left;cursor:pointer;font:var(--fw-semibold) var(--fs-base)/var(--lh-snug) var(--font-ui);color:var(--text);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
80
+ .board-card__title{margin:0;padding:0;border:0;background:none;text-align:left;cursor:pointer;font:var(--fw-semibold) var(--fs-base)/var(--lh-snug) var(--font-ui);color:var(--text);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
81
81
  .board-card__title:hover{color:var(--accent)}
82
82
  /* Finished work recedes: it is history, and the live lanes should not compete with it. */
83
83
  .board-card[data-status="done"] .board-card__title{color:var(--muted)}
@@ -85,6 +85,12 @@ ${WEB_COMPONENTS_CSS}
85
85
  .board-card[data-status="done"] .bf-chip--accent{color:var(--muted);border-color:var(--border)}
86
86
  .board-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);margin:0;font:var(--fs-xs) var(--font-ui);color:var(--muted)}
87
87
  .board-card__paths{display:flex;flex-wrap:wrap;gap:var(--space-1);margin:0;padding:0;list-style:none}
88
+ /* A chip is one line and nowrap by design, so an owner label or a path longer than the card
89
+ would spill past its border and across the lane beside it: clipped to the card instead,
90
+ with the whole of it on the hover title the renderer sets. The list item has to be the one
91
+ that shrinks, because a flex item's automatic minimum is its chip's width. */
92
+ .board-card__paths li{min-width:0;max-width:100%}
93
+ .board-card .bf-chip{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis}
88
94
  .board-card__reason{margin:0;font:var(--fs-sm)/var(--lh-snug) var(--font-ui);color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
89
95
  .board-card__reason--blocked{color:var(--danger)}
90
96
  .board-card__reason--unplanned{color:var(--warn)}
@@ -28,16 +28,13 @@ export const CONFIG = {
28
28
  branch: GLYPHS.branch,
29
29
  detached: GLYPHS.detached,
30
30
  worktree: GLYPHS.worktree,
31
- board: GLYPHS.board,
32
- browse: GLYPHS.browse,
31
+ http: GLYPHS.http,
33
32
  delegation: GLYPHS.delegation,
34
33
  },
35
34
  /** Status key the worktree extension uses; routed to line 1's right zone. */
36
35
  worktreeStatusKey: STATUS_KEYS.worktree,
37
- /** Status key the board extension publishes its page port under. */
38
- boardStatusKey: STATUS_KEYS.board,
39
- /** Status key the file-browse extension uses; routed to line 1's right zone. */
40
- browseStatusKey: STATUS_KEYS.browse,
36
+ /** Status key `serve` publishes the session's one page port under. */
37
+ httpStatusKey: STATUS_KEYS.http,
41
38
  /** Status key whose ` · detail` suffix (the plan file name) is dropped. */
42
39
  planStatusKey: STATUS_KEYS.planMode,
43
40
  /**
@@ -9,14 +9,25 @@
9
9
 
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
11
  import type { Component } from "@earendil-works/pi-tui";
12
+ import { RenderCache } from "../../../../lib/tui/render-cache.ts";
13
+ import { snapshotKey } from "./key.ts";
12
14
  import { renderLine } from "./layout.ts";
13
15
  import { buildLines } from "./lines.ts";
14
16
  import type { FooterData, StatusSnapshot, TuiLike } from "../types.ts";
15
17
 
16
- /** A two-line, width-adaptive status bar. */
18
+ /**
19
+ * A two-line, width-adaptive status bar.
20
+ *
21
+ * The bar is derived from a snapshot the reader rebuilds every frame, so it memoizes on a
22
+ * key rather than on the snapshot's identity: the width, the theme object (its strings are
23
+ * what the memo holds), and `snapshotKey`'s walk over every field the bar could draw. A
24
+ * frame where nothing moved costs the key and a comparison; a frame that moved pays the key
25
+ * as well, which is why the key is measured against the build it saves.
26
+ */
17
27
  class StatusBar implements Component {
18
28
  /** Unsubscribes the branch watcher; set by `createFooter`. */
19
29
  dispose?: () => void;
30
+ private readonly cache = new RenderCache();
20
31
 
21
32
  constructor(
22
33
  private readonly read: () => StatusSnapshot,
@@ -25,15 +36,18 @@ class StatusBar implements Component {
25
36
  ) {}
26
37
 
27
38
  invalidate(): void {
28
- // The bar recomputes from a fresh snapshot each render, so there is
29
- // nothing cached to clear. Theme-dependent strings are built in render.
39
+ // Pi calls this on a theme change; the key holds the theme's identity too, so this
40
+ // is what drops themed strings a rebuild would otherwise keep.
41
+ this.cache.invalidate();
30
42
  }
31
43
 
32
44
  render(width: number): string[] {
33
45
  const snapshot = this.read();
34
46
  const theme = this.getTheme();
35
47
  const target = Math.max(1, width);
36
- return buildLines(snapshot, theme, this.home).map((line) => renderLine(line, target, theme));
48
+ return this.cache.keyed([target, theme, snapshotKey(snapshot)], () =>
49
+ buildLines(snapshot, theme, this.home).map((line) => renderLine(line, target, theme)),
50
+ );
37
51
  }
38
52
  }
39
53
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A key for one status snapshot: everything the bar draws, and nothing about identity.
3
+ *
4
+ * The footer repaints on every frame, and its snapshot is a fresh object each time — the
5
+ * reader rebuilds it from the session and the host, and prices only what moved — so a memo
6
+ * cannot key on the snapshot itself. It keys on this string instead, and this string is
7
+ * derived from the snapshot's own shape: object keys sorted, a `Map` walked as sorted
8
+ * entries, a list by its items, a primitive by its value.
9
+ *
10
+ * That derivation is the point. A hand-written list of the fields a bar reads is exactly
11
+ * the kind of list that goes stale silently — a field added to `StatusSnapshot` and
12
+ * forgotten here is a bar that keeps showing the old value until something else moves —
13
+ * whereas a walk over the object cannot miss a field that exists.
14
+ *
15
+ * Lengths prefix strings and collection sizes, so no two different snapshots can produce
16
+ * one key by running values together.
17
+ */
18
+
19
+ import type { StatusSnapshot } from "../types.ts";
20
+
21
+ /** The key for one snapshot: equal strings mean a frame would draw the same bar. */
22
+ export function snapshotKey(snapshot: StatusSnapshot): string {
23
+ return keyPart(snapshot);
24
+ }
25
+
26
+ /** One value's part of the key, tagged by kind so `1` and `"1"` cannot collide. */
27
+ function keyPart(value: unknown): string {
28
+ if (value === undefined) return "~";
29
+ if (value === null) return "n";
30
+ if (typeof value === "number") return String(value);
31
+ if (typeof value === "boolean") return value ? "t" : "f";
32
+ if (typeof value === "string") return `${value.length}:${value}`;
33
+ if (value instanceof Map) {
34
+ // Sorted, because a Map's insertion order is not a fact about what the bar draws.
35
+ const entries = [...value.entries()].map(([key, entry]) => `${keyPart(String(key))}=${keyPart(entry)}`);
36
+ entries.sort();
37
+ return `m${value.size}{${entries.join(",")}}`;
38
+ }
39
+ if (Array.isArray(value)) return `a${value.length}[${value.map(keyPart).join(",")}]`;
40
+ if (typeof value === "object") {
41
+ const record = value as Record<string, unknown>;
42
+ const keys = Object.keys(record).sort();
43
+ return `o${keys.length}{${keys.map((key) => `${key}:${keyPart(record[key])}`).join(",")}}`;
44
+ }
45
+ return String(value);
46
+ }
@@ -125,16 +125,26 @@ export function renderLine(spec: LineSpec, width: number, theme: Theme): string
125
125
  const target = Math.max(1, width);
126
126
  const states = makeStates(spec);
127
127
 
128
+ // Each pass keeps the line it measured, so a decision never costs a second assembly and
129
+ // a line that already fits never reaches `truncateToWidth` — which walks the string
130
+ // character by character (14 µs on a footer line, against 0.2 µs for the width scan) and
131
+ // is the whole reason this function was measurable per frame.
132
+ let line = assemble(states, target);
133
+ let measured = visibleWidth(line);
128
134
  let guard = 0;
129
- while (visibleWidth(assemble(states, target)) > target && guard++ < states.length * 8 + 16) {
135
+ while (measured > target && guard++ < states.length * 8 + 16) {
130
136
  if (!reduceOnce(states)) break;
137
+ line = assemble(states, target);
138
+ measured = visibleWidth(line);
131
139
  }
132
140
  // Only once every segment is at its floor do we shrink separators, so the
133
141
  // truncation ellipsis never replaces a separator's content and adjacent
134
142
  // segments always keep at least one column of padding between them.
135
- while (visibleWidth(assemble(states, target)) > target) {
143
+ while (measured > target) {
136
144
  if (!collapseSeparator(states)) break;
145
+ line = assemble(states, target);
146
+ measured = visibleWidth(line);
137
147
  }
138
148
 
139
- return truncateToWidth(assemble(states, target), target, paint(theme, "faint", "…"));
149
+ return measured > target ? truncateToWidth(line, target, paint(theme, "faint", "…")) : line;
140
150
  }
@@ -2,7 +2,7 @@
2
2
  * Turn a `StatusSnapshot` into the two footer lines.
3
3
  *
4
4
  * Line 1 — state: left `pwd` and the session name, right the alert, the mode
5
- * chips, then `⎇ branch · ⑂ worktree · ⊞ 5043 · ◉ 4909`. Line 2 — meters: left
5
+ * chips, then `⎇ branch · ⑂ worktree · ◉ 4780`. Line 2 — meters: left
6
6
  * the context gauge and usage, right the model and thinking level.
7
7
  *
8
8
  * The split is one job per row. The state chips change while a session runs, so
@@ -46,9 +46,9 @@ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string
46
46
  modesSegment(snapshot, theme),
47
47
  branchSegment(snapshot, theme),
48
48
  worktreeSegment(snapshot, theme),
49
- // Last, so the two ports form one run behind the location chips. A page starting
50
- // or stopping then changes the end of the line, and `⎇ branch · ⑂ worktree` stays
51
- // the adjacent pair a reader scans for where the work lands.
49
+ // Last, so the page port rides behind the location chips. A page starting or
50
+ // stopping then changes the end of the line, and `⎇ branch · ⑂ worktree` stays the
51
+ // adjacent pair a reader scans for where the work lands.
52
52
  portsSegment(snapshot, theme),
53
53
  ]),
54
54
  };
@@ -101,41 +101,23 @@ export function worktreeSegment(snapshot: StatusSnapshot, theme: Theme): Segment
101
101
  }
102
102
 
103
103
  /**
104
- * The pages this session is serving, as one cluster.
104
+ * The session's page port, while anything is served on it.
105
105
  *
106
- * The board and file-browse chips say one thing — a page is up on this port — so they
107
- * share a segment, a weight, and a floor. Read separately they were a mode chip and a
108
- * port chip: `▤ 5043` rode the mode weight and outlived `◉ 4909` by four columns, which
109
- * made two facts of the same kind look like two kinds of fact. Shared, they also drop
110
- * together, and the surviving form is the pair of glyphs, so a bar too narrow for a port
111
- * still says a page is up.
106
+ * One server serves every page of a session, so there is one number to draw: the chip
107
+ * the `serve` extension publishes, with the glyph alone as the form that survives a bar
108
+ * too narrow for a port.
112
109
  */
113
110
  export function portsSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
114
- const open = [
115
- { key: CONFIG.boardStatusKey, icon: CONFIG.icons.board },
116
- { key: CONFIG.browseStatusKey, icon: CONFIG.icons.browse },
117
- ]
118
- .filter((entry) => snapshot.statuses.has(entry.key))
119
- .map((entry) => ({
120
- icon: paint(theme, "ok", entry.icon),
121
- port: chipLabel(snapshot.statuses.get(entry.key) ?? ""),
122
- }));
123
- if (open.length === 0) return null;
124
- const joined = (separator: string): string =>
125
- open
126
- .map((entry) => (entry.port ? `${entry.icon} ${paint(theme, "accent", entry.port)}` : entry.icon))
127
- .join(separator);
128
- // Widest first: the item separator, then a single space, then the glyphs alone. A
129
- // one-page session has two forms; two pages sharing a padded bar have three.
130
- const forms = [
131
- ...new Set([joined(faint(theme, CONFIG.separators.item)), joined(" "), open.map((entry) => entry.icon).join(" ")]),
132
- ];
111
+ const chip = snapshot.statuses.get(CONFIG.httpStatusKey);
112
+ if (chip === undefined) return null;
113
+ const icon = paint(theme, "ok", CONFIG.icons.http);
114
+ const port = chipLabel(chip);
133
115
  return {
134
116
  id: "ports",
135
117
  weight: 3,
136
118
  droppable: true,
137
119
  separator: faint(theme, CONFIG.separators.item),
138
- forms,
120
+ forms: [...new Set([port ? `${icon} ${paint(theme, "accent", port)}` : icon, icon])],
139
121
  };
140
122
  }
141
123
 
@@ -171,13 +153,7 @@ export function alertSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
171
153
 
172
154
  export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
173
155
  const others = [...snapshot.statuses.entries()]
174
- .filter(
175
- ([key]) =>
176
- key !== CONFIG.worktreeStatusKey &&
177
- key !== CONFIG.boardStatusKey &&
178
- key !== CONFIG.browseStatusKey &&
179
- !isAlert(key),
180
- )
156
+ .filter(([key]) => key !== CONFIG.worktreeStatusKey && key !== CONFIG.httpStatusKey && !isAlert(key))
181
157
  .sort(([a], [b]) => a.localeCompare(b))
182
158
  .map(([key, value]) => {
183
159
  const status = sanitize(value);