@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -1,33 +1,18 @@
1
1
  /**
2
- * Board's side of the team: the one call that puts this session on it.
2
+ * The board's side of the team: how a join reads on a `/board` line.
3
3
  *
4
4
  * The user's surface is `/board` and `team`; the session bus is the transport under
5
- * them. So the board joins on the user's behalf — when the page opens, and again the
6
- * first time a card is worked — and never asks anybody to run a bus action.
5
+ * them. So the board joins on the user's behalf — when the page opens, and the first
6
+ * time this session claims a card — and never asks anybody to run a bus action.
7
7
  *
8
- * The seam exists for the same reason `team`'s dispatch does: a board suite drives a
9
- * recording join instead of a bus, and the shipped implementation is one in-process
10
- * request through `lib/session-tool.ts` (a command context cannot call a tool).
8
+ * The join itself is `lib/board/join.ts`'s `joinOnce`, because a claim joins from either
9
+ * tool; this module holds what is the board's own: what a `/board` line says about it.
11
10
  */
12
11
 
13
12
  import type { JoinResult } from "../../lib/session-tool.ts";
14
- import { DEFAULT_SEAT } from "./seat.ts";
15
13
 
16
- /**
17
- * The join itself is `lib/session-tool.ts`'s, because `team` asks for it too; this
18
- * module only holds what is the board's — the label it lands on, and how the outcome
19
- * reads on a `/board` line.
20
- */
21
14
  export type { TeamJoin } from "../../lib/session-tool.ts";
22
15
 
23
- /**
24
- * The label `/board` lands on: the page's seat when this session serves it, and
25
- * nothing when it opened somebody else's page, so a session keeps its own name.
26
- */
27
- export function joinLabel(served: boolean): string | undefined {
28
- return served ? DEFAULT_SEAT : undefined;
29
- }
30
-
31
16
  /**
32
17
  * What the join did, as a clause for a `/board` line.
33
18
  *
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The board's page, loaded when `/board` first needs it.
3
+ *
4
+ * The page module owns the HTTP surface, the snapshots, and the view; none of it
5
+ * is needed to register the command or the tool. The factory therefore returns a
6
+ * proxy over `lib/lazy`: every command path loads the real page once, and
7
+ * `closeAll` on shutdown only closes a page that was already opened, so a
8
+ * session that never touched the board imports none of it.
9
+ *
10
+ * The loader is a seam: a test drives the proxy without a socket.
11
+ */
12
+
13
+ import type { BoardSession } from "../../lib/board/session.ts";
14
+ import { lazy } from "../../lib/lazy.ts";
15
+ import type { BoardPage, BoardPageDeps } from "./page.ts";
16
+
17
+ /** The page module, as the proxy loads it. */
18
+ export type BoardPageModule = typeof import("./page.ts");
19
+
20
+ /** How the proxy reaches the page module; production uses the dynamic import. */
21
+ export type BoardPageLoader = () => Promise<BoardPageModule>;
22
+
23
+ export function createLazyBoardPage(
24
+ session: BoardSession,
25
+ deps: BoardPageDeps,
26
+ load: BoardPageLoader = () => import("./page.ts"),
27
+ ): BoardPage {
28
+ const page = lazy(async () => (await load()).createBoardPage(session, deps));
29
+ return {
30
+ start: async (ctx, view) => (await page.get()).start(ctx, view),
31
+ stop: async () => (await page.get()).stop(),
32
+ status: async () => (await page.get()).status(),
33
+ dropStale: async (ctx) => (await page.get()).dropStale(ctx),
34
+ // Shutdown must not start a load: a page that never opened has nothing to close.
35
+ closeAll: async () => {
36
+ const opened = page.peek();
37
+ if (opened !== undefined) await opened.closeAll();
38
+ },
39
+ };
40
+ }
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The page's lifecycle: one server per session, started by `/board` and closed by
2
+ * The page's lifecycle: one mount per session, started by `/board` and given back by
3
3
  * `/board stop`, by the idle grace once the last tab has been gone for a minute, or by
4
4
  * `session_shutdown`.
5
5
  *
6
- * Only the lifecycle lives here — the state machine, the port it remembers, the chip
7
- * that names it, and the three closes. What the page *reads* is `snapshot.ts`, and it
8
- * writes nothing: how a request reaches the snapshot is `transport/`, the router knows
9
- * nothing of the page's markup, and this is still the one place they meet.
6
+ * Only the lifecycle lives here — the state machine, the prefix it holds, the note that
7
+ * names it, and the three closes. The session's port belongs to `serve`, so nothing here
8
+ * picks one. What the page *reads* is `snapshot.ts`, and it writes nothing: how a request
9
+ * reaches the snapshot is `transport/`, the router knows nothing of the page's markup, and
10
+ * this is still the one place they meet.
10
11
  *
11
12
  * All state lives in this closure, not at module scope: Pi loads each extension with an
12
13
  * isolated module cache, so a module variable would be one copy per extension.
@@ -15,11 +16,10 @@
15
16
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
17
  import type { BoardStore } from "../../lib/board/store.ts";
17
18
  import { openInBrowser } from "../../lib/browser.ts";
18
- import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
19
- import { paint } from "../../lib/tui/tokens.ts";
20
19
  import type { RepeatTimer } from "../../lib/timer.ts";
21
20
  import type { BoardSession } from "../../lib/board/session.ts";
22
21
  import {
22
+ BOARD_BASE,
23
23
  clearPointer,
24
24
  PAGE_FILE,
25
25
  type PagePointer,
@@ -29,25 +29,17 @@ import {
29
29
  settlePointer,
30
30
  urlOf,
31
31
  } from "../../lib/board/page-pointer.ts";
32
- import { mintToken } from "../../lib/http/index.ts";
32
+ import { mintToken, type MountSource } from "../../lib/http/index.ts";
33
+ import { messageOf } from "../../lib/errors.ts";
33
34
  import type { IdleReason } from "../../lib/http/sink.ts";
34
35
  import { join } from "node:path";
35
- import { type BoardConfig, loadBoardConfig } from "./config.ts";
36
- import { pageActor } from "./seat.ts";
36
+ import type { Actor } from "../../lib/board/rules.ts";
37
+ import { pageActor } from "../../lib/board/seat.ts";
37
38
  import { viewOf } from "./snapshot.ts";
38
39
  import { type BoardServer, createBoardServer } from "./transport/server.ts";
39
40
  import { BOARD_CLIENT_JS } from "./view/client.ts";
40
41
  import { type PageView, renderBoardPage } from "./view/page.ts";
41
42
 
42
- /**
43
- * A free port in the range the other loopback pages use, so a session's pages cluster.
44
- * Only the unpinned page draws one: `board.port` in `bytefly.json` states the port a
45
- * bookmarked URL keeps.
46
- */
47
- export function randomPort(): number {
48
- return 4780 + Math.floor(Math.random() * 1000);
49
- }
50
-
51
43
  /**
52
44
  * How long the page waits for a tab after the last one leaves.
53
45
  *
@@ -81,6 +73,8 @@ export interface BoardPageStatus {
81
73
  seat?: string;
82
74
  port?: number;
83
75
  url?: string;
76
+ /** The repository's name, so `status` says which board the page is. */
77
+ repo?: string;
84
78
  where?: string;
85
79
  /** How many tabs are attached, while this session serves the page. */
86
80
  watchers?: number;
@@ -94,14 +88,17 @@ export interface BoardPageStatus {
94
88
  * page* is a third answer a `running: false` could not express.
95
89
  */
96
90
  export type PageStart =
97
- | { kind: "served"; seat: string; port: number; url: string; where: string }
98
- | { kind: "attached"; seat: string; port: number; url: string; where: string }
91
+ | { kind: "served"; seat: string; port: number; url: string; repo: string; where: string }
92
+ | { kind: "attached"; seat: string; port: number; url: string; repo: string; where: string }
99
93
  | { kind: "failed"; reason: string };
100
94
 
101
95
  /** Test seams and the one place a caller may redirect the browser. */
102
96
  export interface BoardPageDeps {
103
97
  open?: (url: string) => void;
104
- pickPort?: () => number;
98
+ /** Where the prefix comes from: the session's server, or a suite's own. */
99
+ mount: MountSource;
100
+ /** The Pi session this page serves as, so the prefix lands on its socket. */
101
+ session: () => string;
105
102
  interval?: RepeatTimer;
106
103
  /** Grace after the last tab leaves; defaults to {@link IDLE_STOP_MS}. */
107
104
  idleMs?: number;
@@ -109,14 +106,13 @@ export interface BoardPageDeps {
109
106
  firstIdleMs?: number;
110
107
  /** Whether a page another session left a note about is answering; a test drives it. */
111
108
  probe?: ProbeFetch;
112
- /** How the `board` section answers; a test drives it without a config file. */
113
- config?: (cwd: string) => BoardConfig;
114
109
  }
115
110
 
116
111
  /** The page as `/board` and `session_shutdown` drive it. */
117
112
  export interface BoardPage {
118
113
  start: (ctx: ExtensionContext, view?: PageView) => Promise<PageStart>;
119
- stop: (ctx: ExtensionContext) => Promise<boolean>;
114
+ /** Give the prefix back; the session's server closes with its last mount. */
115
+ stop: () => Promise<boolean>;
120
116
  /** What `/board status` prints: served here, watched, watched and dead, or absent. */
121
117
  status: () => Promise<BoardPageStatus>;
122
118
  /** Drop the note of a page that has stopped answering. False when it still answers. */
@@ -124,36 +120,34 @@ export interface BoardPage {
124
120
  closeAll: () => Promise<void>;
125
121
  }
126
122
 
127
- export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {}): BoardPage {
128
- let running: { server: BoardServer; store: BoardStore; token: string } | undefined;
123
+ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps): BoardPage {
124
+ let running: { server: BoardServer; store: BoardStore; token: string; seat: Actor } | undefined;
129
125
  /** The page another session serves, when this session opened that one instead. */
130
126
  let attachedTo: PagePointer | undefined;
131
- let sessionPort: number | undefined;
132
- // The page is a seat, not a session: one name and one id for its whole life, so a card
133
- // a browser claimed is still the seat's after the session that served it ends.
134
- const seat = pageActor();
135
-
136
127
  const storeOf = async (ctx: ExtensionContext): Promise<BoardStore> => runtime.store(ctx.cwd);
137
128
 
138
129
  /**
139
130
  * Close the page this session serves, and take its note with it.
140
131
  *
141
- * One close for both callers: `/board stop` has a context and clears the chip, and a
142
- * clean `session_shutdown` has none. The note must leave either way — a note nobody
132
+ * One close for both callers: `/board stop` has a context and clears the status line, and
133
+ * a clean `session_shutdown` has none. The note must leave either way — a note nobody
143
134
  * answers makes every open tab reconnect forever and costs the next `/board` a probe
144
135
  * and a takeover before it serves.
145
136
  */
146
- const closeRunning = async (ctx?: ExtensionContext): Promise<boolean> => {
137
+ const closeRunning = async (): Promise<boolean> => {
147
138
  if (running === undefined) return false;
148
139
  const { server, store, token } = running;
149
140
  running = undefined;
150
141
  await server.close();
151
142
  await clearPointer(store.dir, token);
152
- if (ctx !== undefined) clearChip(ctx);
143
+ // A page that closed is no seat: the session's next card is written as itself. This is
144
+ // the one place all three closes pass — `/board stop`, the idle grace, and shutdown — so
145
+ // a page nobody is looking at cannot leave the session serving in name only.
146
+ runtime.serve(undefined);
153
147
  return true;
154
148
  };
155
149
 
156
- const stop = (ctx: ExtensionContext): Promise<boolean> => closeRunning(ctx);
150
+ const stop = (): Promise<boolean> => closeRunning();
157
151
 
158
152
  const start = async (ctx: ExtensionContext, view: PageView = "board"): Promise<PageStart> => {
159
153
  // `/board` is how the page is summoned, and a page whose tab was closed is one
@@ -162,11 +156,9 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
162
156
  (deps.open ?? openInBrowser)(view === "board" ? running.server.url : `${running.server.url}&view=team`);
163
157
  return describe(running);
164
158
  }
165
- const config = (deps.config ?? loadBoardConfig)(ctx.cwd);
166
- const pinned = config.port > 0 ? config.port : undefined;
167
159
  /** One refusal for every way a start can fail, named by what failed. */
168
160
  const failed = (error: unknown): PageStart => {
169
- const reason = failureReason(error, pinned);
161
+ const reason = error instanceof Error ? error.message : String(error);
170
162
  ctx.ui.notify(`board: the page did not start (${reason})`, "error");
171
163
  return { kind: "failed", reason };
172
164
  };
@@ -179,6 +171,11 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
179
171
  } catch (error) {
180
172
  return failed(error);
181
173
  }
174
+ // The page is a seat, not a session: one name and one id for its whole life, so a card a
175
+ // browser claimed is still the seat's after the session that served it ends. That name is
176
+ // the board's lead, so serving the page is what makes a session the lead — a repository
177
+ // that names one gets a page served under that name, and the gate accepts it.
178
+ const seat = pageActor(store.config().lead);
182
179
  const open = (url: string) => (deps.open ?? openInBrowser)(view === "board" ? url : `${url}&view=team`);
183
180
  /** Watch the page another session serves: its note is the URL, its seat the name. */
184
181
  const attach = (pointer: PagePointer): PageStart => {
@@ -189,6 +186,7 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
189
186
  seat: pointer.seat.label,
190
187
  port: pointer.port,
191
188
  url: urlOf(pointer),
189
+ repo: store.name(),
192
190
  where: store.where(),
193
191
  };
194
192
  };
@@ -199,34 +197,44 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
199
197
  attachedTo = undefined;
200
198
  const actor = () => seat;
201
199
  const now = () => runtime.now();
202
- // Read above, so a pinned port names itself in a refusal; the port itself is
203
- // remembered for the session so a closed and reopened page lands where it was.
204
- const preferred = pinned ?? sessionPort ?? (deps.pickPort ?? randomPort)();
205
200
  // Minted here rather than by the server: the note this session leaves has to carry it,
206
201
  // and that is what a second session reads to attach instead of serving its own page.
207
202
  const token = mintToken();
208
- let server: BoardServer | undefined;
203
+ /** Whether this page has already said it cannot read the board. */
204
+ let blind = false;
205
+ // A frame the page cannot build is a dropped frame, not a crash: the ticker asks again
206
+ // in a second. Said once, because a failure that persists would otherwise become one
207
+ // notice per tick — and unsaid, a page that stopped following the board looks frozen.
208
+ const warnBlind = (error: unknown): void => {
209
+ if (blind) return;
210
+ blind = true;
211
+ ctx.ui.notify(`board: the page cannot read the board (${messageOf(error)}) — it will keep trying`, "warning");
212
+ };
209
213
  try {
210
- server = await createBoardServer({
211
- // A pinned port is tried alone: falling back would leave the bookmark pointing
212
- // at a page that moved, which is the failure the setting exists to prevent.
213
- ports: pinned === undefined ? [preferred, 0] : [pinned],
214
+ const started = await createBoardServer({
215
+ mount: deps.mount,
216
+ session: deps.session(),
214
217
  token,
215
218
  ...(deps.interval === undefined ? {} : { interval: deps.interval }),
219
+ onError: warnBlind,
216
220
  idleMs: deps.idleMs ?? IDLE_STOP_MS,
217
221
  firstIdleMs: deps.firstIdleMs ?? IDLE_FIRST_MS,
218
222
  onIdle: (reason) => void stopIdle(ctx, reason),
219
- page: async (view) => renderBoardPage({ view }),
223
+ page: async (view) => renderBoardPage({ view, base: BOARD_BASE, repo: store.name(), root: store.root() }),
220
224
  script: BOARD_CLIENT_JS,
221
225
  view: () =>
222
226
  viewOf({
223
227
  store,
224
228
  actor,
225
- session: () => runtime.who(ctx, ctx.cwd),
226
229
  now,
227
230
  busDir: () => runtime.busDir(ctx.cwd),
228
231
  }),
229
232
  });
233
+ if (started.kind === "refused") {
234
+ ctx.ui.notify(`board: the page did not start (${started.reason})`, "error");
235
+ return { kind: "failed", reason: started.reason };
236
+ }
237
+ const server = started.server;
230
238
  // The note and the seat: written last, on the port the server actually bound, and only if no
231
239
  // other session got there first: the loser of that race attaches to the page that
232
240
  // won rather than serving a second page nobody can find. A note that answers
@@ -263,22 +271,17 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
263
271
  ctx.ui.notify(`board: the page did not start (${reason})`, "error");
264
272
  return { kind: "failed", reason };
265
273
  }
266
- sessionPort = server.port;
267
- running = { server, store, token };
268
- setChip(ctx, server.port);
274
+ running = { server, store, token, seat };
269
275
  open(server.url);
270
276
  return describe(running);
271
277
  } catch (error) {
272
- // A bind that failed has no server to close; a seat that could not be written does,
273
- // and leaving it bound would hold a port no note names — invisible until shutdown.
274
- await server?.close();
275
278
  return failed(error);
276
279
  }
277
280
  };
278
281
 
279
282
  /** The idle grace closes the page and says which grace it was, once. */
280
283
  const stopIdle = async (ctx: ExtensionContext, reason: IdleReason): Promise<void> => {
281
- if (!(await stop(ctx))) return;
284
+ if (!(await stop())) return;
282
285
  ctx.ui.notify(
283
286
  reason === "first"
284
287
  ? "board: stopped — no tab ever opened; /board starts it again"
@@ -287,11 +290,12 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
287
290
  );
288
291
  };
289
292
 
290
- const describe = (state: { server: BoardServer; store: BoardStore }): PageStart => ({
293
+ const describe = (state: { server: BoardServer; store: BoardStore; seat: Actor }): PageStart => ({
291
294
  kind: "served",
292
- seat: seat.label,
295
+ seat: state.seat.label,
293
296
  port: state.server.port,
294
297
  url: state.server.url,
298
+ repo: state.store.name(),
295
299
  where: state.store.where(),
296
300
  });
297
301
 
@@ -307,9 +311,10 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
307
311
  if (running !== undefined) {
308
312
  return {
309
313
  running: true,
310
- seat: seat.label,
314
+ seat: running.seat.label,
311
315
  port: running.server.port,
312
316
  url: running.server.url,
317
+ repo: running.store.name(),
313
318
  where: running.store.where(),
314
319
  watchers: running.server.watchers(),
315
320
  };
@@ -353,36 +358,3 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
353
358
  },
354
359
  };
355
360
  }
356
-
357
- /**
358
- * Why a start failed, naming the setting when the pinned port was the reason: a
359
- * bind error's own text says the address, not the key that chose it.
360
- */
361
- function failureReason(error: unknown, pinned: number | undefined): string {
362
- // The bind path rejects with the `listen` error, so `code` is there to read.
363
- const code = (error as NodeJS.ErrnoException | undefined)?.code;
364
- if (pinned !== undefined && code === "EADDRINUSE") {
365
- return `port ${pinned} is in use — change "port" in the bytefly.json board section, or stop what answers on it`;
366
- }
367
- return error instanceof Error ? error.message : String(error);
368
- }
369
-
370
- /** The chip naming the port, cleared with an empty text. */
371
- function setChip(ctx: ExtensionContext, port: number): void {
372
- try {
373
- ctx.ui.setStatus(
374
- STATUS_KEYS.board,
375
- `${paint(ctx.ui.theme, "ok", GLYPHS.board)} ${paint(ctx.ui.theme, "accent", String(port))}`,
376
- );
377
- } catch {
378
- // A host without a status line is not a reason to keep the page from starting.
379
- }
380
- }
381
-
382
- function clearChip(ctx: ExtensionContext): void {
383
- try {
384
- ctx.ui.setStatus(STATUS_KEYS.board, undefined);
385
- } catch {
386
- // Same as above: nothing to clear.
387
- }
388
- }
@@ -8,19 +8,20 @@
8
8
  */
9
9
 
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
- import type { Component } from "@earendil-works/pi-tui";
12
11
  import { type Card, cardRef } from "../../lib/board/card.ts";
12
+ import { LANE_RULES } from "../../lib/board/lanes.ts";
13
13
  import { sanitizeLine } from "../../lib/format.ts";
14
14
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
15
- import { TailResult, type TailResultInput, type TailSection } from "../../lib/tui/tool.ts";
15
+ import type { TailResultInput, TailSection, TailRowLine } from "../../lib/tui/tool.ts";
16
16
  import { plural } from "../../lib/board/round-text.ts";
17
17
  import { paint } from "../../lib/tui/tokens.ts";
18
- import { boardRowLine } from "./rows.ts";
18
+ import { boardRowLineFor } from "./rows.ts";
19
+ import { TONE_ROLE } from "./tone.ts";
19
20
  import type { BoardDetails } from "./types.ts";
20
21
 
21
- /** One section of the expanded block. */
22
- function section(label: string | undefined, cards: readonly Card[]): TailSection<Card> {
23
- return { label, rows: cards, more: 0, rowLine: boardRowLine };
22
+ /** One section of the expanded block, drawn as the session that asked for it. */
23
+ function section(label: string | undefined, cards: readonly Card[], rowLine: TailRowLine<Card>): TailSection<Card> {
24
+ return { label, rows: cards, more: 0, rowLine };
24
25
  }
25
26
 
26
27
  /**
@@ -33,19 +34,25 @@ function section(label: string | undefined, cards: readonly Card[]): TailSection
33
34
  */
34
35
  export function resultInput(details: BoardDetails): TailResultInput<Card> | undefined {
35
36
  if (details.action !== "list") return undefined;
36
- if (details.card) return { kind: "list", rows: [details.card], more: 0, rowLine: boardRowLine };
37
+ // Only a read narrowed to this session's own cards marks them, and it marks them with
38
+ // the rule the filter used: the rows and the filter agree about what "mine" means.
39
+ const rowLine = boardRowLineFor(details.mine === true ? details.me : undefined);
40
+ if (details.card) return { kind: "list", rows: [details.card], more: 0, rowLine };
37
41
  const round = details.round;
38
42
  if (!round) {
39
43
  const cards = details.cards ?? [];
40
- return cards.length === 0 ? undefined : { kind: "list", rows: cards, more: 0, rowLine: boardRowLine };
44
+ return cards.length === 0 ? undefined : { kind: "list", rows: cards, more: 0, rowLine };
41
45
  }
42
46
  const sections: TailSection<Card>[] = [];
43
- for (const group of round.owners) sections.push(section(group.label, group.cards));
44
- if (round.unassigned.length > 0) sections.push(section("Unassigned", round.unassigned));
45
- if (round.unplanned.length > 0) sections.push(section("Unplanned", round.unplanned));
46
- if (round.blocked.length > 0) sections.push(section("Blocked", round.blocked));
47
- if (round.idle.length > 0) sections.push(section("Idle", round.idle));
48
- if (round.done.length > 0) sections.push(section("Done", round.done));
47
+ for (const group of round.owners) sections.push(section(group.label, group.cards, rowLine));
48
+ if (round.unassigned.length > 0) sections.push(section("Unassigned", round.unassigned, rowLine));
49
+ if (round.unplanned.length > 0) sections.push(section("Unplanned", round.unplanned, rowLine));
50
+ // The lead's queue: a contract nobody has signed is a card the flow stops on, so it is
51
+ // drawn in the order `formatRound` prints it rather than left to the text above.
52
+ if (round.unapproved.length > 0) sections.push(section("Awaiting approval", round.unapproved, rowLine));
53
+ if (round.blocked.length > 0) sections.push(section("Blocked", round.blocked, rowLine));
54
+ if (round.idle.length > 0) sections.push(section("Idle", round.idle, rowLine));
55
+ if (round.done.length > 0) sections.push(section("Done", round.done, rowLine));
49
56
  return sections.length === 0 ? undefined : { kind: "sections", sections };
50
57
  }
51
58
 
@@ -78,31 +85,15 @@ export function collapsedLine(theme: Theme, details: BoardDetails): string {
78
85
  return `${paint(theme, "label", plural(count, "card"))} ${SEPARATORS.item} ${plural(owners, "owner")} ${SEPARATORS.item} ${me}`;
79
86
  }
80
87
 
81
- /** `#4 doing · Billing webhook accepts orders`, clipped by the caller's row helper. */
88
+ /**
89
+ * `#4 execute · Billing webhook accepts orders`, clipped by the caller's row helper.
90
+ *
91
+ * The lane wears the colour the page gives it — `LANE_RULES`' role, through the tone
92
+ * bridge — rather than one accent for all five.
93
+ */
82
94
  function cardLine(theme: Theme, card: Card): string {
83
95
  const id = paint(theme, "meta", cardRef(card.id));
84
- const lane = paint(theme, "accent", card.status);
85
- return `${id} ${lane} ${SEPARATORS.item} ${paint(theme, "label", sanitizeLine(card.title))}`;
86
- }
87
-
88
- /** The expanded block, as a component the transcript can reuse across renders. */
89
- export class BoardResult implements Component {
90
- private readonly view: TailResult<Card>;
91
-
92
- constructor(input: TailResultInput<Card>, theme: Theme) {
93
- this.view = new TailResult(input, theme);
94
- }
95
-
96
- /** Update in place so the transcript reuses this component. */
97
- setInput(input: TailResultInput<Card>, theme: Theme): void {
98
- this.view.setInput(input, theme);
99
- }
100
-
101
- invalidate(): void {
102
- this.view.invalidate();
103
- }
104
-
105
- render(width: number): string[] {
106
- return this.view.render(width);
107
- }
96
+ const lane = paint(theme, TONE_ROLE[LANE_RULES[card.lane].role], card.lane);
97
+ const title = paint(theme, "label", sanitizeLine(card.title));
98
+ return `${id} ${lane} ${SEPARATORS.item} ${title}`;
108
99
  }
@@ -4,45 +4,82 @@
4
4
  * Every field comes from a card file, which the model wrote and a person may have
5
5
  * edited, so it is sanitized here at the boundary that reaches the terminal, and
6
6
  * every row is clipped to the width it was given by the shared row helpers.
7
+ *
8
+ * The colours come from `lib/board/tone.ts` — the table the page reads too — through
9
+ * `TONE_ROLE`, so a lane's word in the transcript is the lane's colour on the page. Each
10
+ * word is painted on its own, which means the separator between two words is painted as
11
+ * well: a bare ` · ` would fall back to the terminal's default foreground between two
12
+ * coloured words.
7
13
  */
8
14
 
9
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
- import { type Card, cardRef, DEFAULT_PRIORITY, priorityOf } from "../../lib/board/card.ts";
16
+ import { type Card, cardRef, DEFAULT_PRIORITY, holds, priorityOf } from "../../lib/board/card.ts";
17
+ import { LANE_RULES } from "../../lib/board/lanes.ts";
18
+ import type { Actor } from "../../lib/board/rules.ts";
19
+ import { type BoardTone, REASON_TONE } from "../../lib/board/tone.ts";
11
20
  import { sanitizeLine } from "../../lib/format.ts";
21
+ import { clip, splitRow } from "../../lib/tui/columns.ts";
12
22
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
13
- import { listRow } from "../../lib/tui/rows.ts";
23
+ import { paint } from "../../lib/tui/tokens.ts";
14
24
  import type { TailRowLine } from "../../lib/tui/tool.ts";
25
+ import { TONE_ROLE } from "./tone.ts";
15
26
 
16
- /** Who holds a card, as the row's trailing text. */
17
- function holderOf(card: Card): string {
18
- if (card.owner === undefined) return "unassigned";
19
- return sanitizeLine(card.owner.label);
27
+ /** One fact the row states about a card, and the tone it wears. */
28
+ interface Fact {
29
+ text: string;
30
+ tone: BoardTone;
20
31
  }
21
32
 
22
33
  /**
23
- * The lane, and — for work in flight — whether it was planned first, and — when it is
24
- * not the ordinary one — how urgent it is.
34
+ * Who holds a card, the lane it sits in, and — for work in flight — whether it was planned
35
+ * first, with the priority when it is not the ordinary one.
25
36
  *
26
37
  * A row carries the lane because a flat listing has no section heading to say it, and
27
- * "planned" because a `doing` card nobody planned is the one a lead looks at. The
28
- * priority rides along for the same reason: inside a lane the order is the priority,
29
- * and an order nobody can see reads as an accident.
38
+ * "planned" because a card nobody planned is the one a lead looks at. The priority rides
39
+ * along for the same reason: inside a lane the order is the priority, and an order nobody
40
+ * can see reads as an accident.
30
41
  */
31
- function stateOf(card: Card): string[] {
32
- const urgency = priorityOf(card) === DEFAULT_PRIORITY ? [] : [priorityOf(card)];
33
- if (card.status === "todo" || card.status === "done") return [...urgency, card.status];
34
- return [...urgency, card.status, card.plan === undefined ? "unplanned" : "planned"];
42
+ function factsOf(card: Card): Fact[] {
43
+ const facts: Fact[] = [
44
+ {
45
+ text: card.owner === undefined ? "unassigned" : sanitizeLine(card.owner.label),
46
+ tone: "muted",
47
+ },
48
+ ];
49
+ const priority = priorityOf(card);
50
+ if (priority !== DEFAULT_PRIORITY) facts.push({ text: priority, tone: "muted" });
51
+ facts.push({ text: card.lane, tone: LANE_RULES[card.lane].role });
52
+ // The pool has no plan to speak of and a finished card is history, so only a lane
53
+ // somebody is working in says whether the work was planned first. An unplanned card
54
+ // wears the tone the page's reason line gives the same fact.
55
+ const rule = LANE_RULES[card.lane];
56
+ if (!rule.ownerless && !rule.terminal) {
57
+ facts.push(
58
+ card.planPath === undefined
59
+ ? { text: "unplanned", tone: REASON_TONE.contract }
60
+ : { text: "planned", tone: "muted" },
61
+ );
62
+ }
63
+ return facts;
35
64
  }
36
65
 
37
- /** One card: the id and title, with its state trailing. */
38
- export function cardRowLine(card: Card, width: number, theme: Theme, options: { self?: boolean } = {}): string {
39
- return listRow(theme, width, {
40
- marker: false,
41
- label: `${cardRef(card.id)} ${sanitizeLine(card.title)}`,
42
- labelRole: options.self === true ? "accent" : "label",
43
- trailing: [holderOf(card), ...stateOf(card)].join(SEPARATORS.item),
44
- });
66
+ /** One card: the id and title, with its facts trailing. */
67
+ function cardRowText(card: Card, width: number, theme: Theme, self: boolean): string {
68
+ const head = paint(theme, self ? "accent" : "label", `${cardRef(card.id)} ${sanitizeLine(card.title)}`);
69
+ const gap = paint(theme, "meta", SEPARATORS.item);
70
+ const trailing = factsOf(card)
71
+ .map((fact) => paint(theme, TONE_ROLE[fact.tone], fact.text))
72
+ .join(gap);
73
+ return splitRow(head, clip(trailing, width), width);
45
74
  }
46
75
 
47
- /** The row renderer every board section uses. */
48
- export const boardRowLine: TailRowLine<Card> = (card, width, theme) => cardRowLine(card, width, theme);
76
+ /**
77
+ * The row renderer every board section uses, for the session reading it.
78
+ *
79
+ * `me` matters: a `list` narrowed to this session's own cards should look like it, so the
80
+ * holder's rows wear the accent the page gives the card the reader is looking at. Passing
81
+ * no actor draws every row the same, which is what a card produced by an action gets.
82
+ */
83
+ export function boardRowLineFor(me?: Actor): TailRowLine<Card> {
84
+ return (card, width, theme) => cardRowText(card, width, theme, me !== undefined && holds(card, me));
85
+ }