@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
@@ -1,54 +1,61 @@
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";
22
+ import type { IdleReason } from "../../../lib/http/sink.ts";
19
23
  import { defaultInterval, type RepeatTimer } from "../../../lib/timer.ts";
20
- import type { BoardView } from "../../../lib/board/view.ts";
21
24
  import type { PageView } from "../view/page.ts";
22
- import {
23
- type Authority,
24
- type BoardRouterContext,
25
- handleBoardRequest,
26
- type PageResult,
27
- type PageWrite,
28
- } from "./router.ts";
25
+ import { type BoardRouterContext, handleBoardRequest } from "./router.ts";
29
26
 
30
27
  /** How often the page is offered a frame; a frame is sent only when the signature moved. */
31
- export const PUSH_MS = 1_000;
28
+ const PUSH_MS = 1_000;
32
29
 
33
30
  /** Largest request body the page accepts: a card and a note are small. */
34
- export const MAX_BODY_BYTES = 64 * 1_024;
31
+ const MAX_BODY_BYTES = 64 * 1_024;
35
32
 
36
33
  /** What the page is built from. The lifecycle (`page.ts`) supplies all of it. */
37
34
  export interface BoardServerOptions {
38
- /** Ports tried in order: the session's remembered port, then an OS-chosen one. */
39
- ports: readonly number[];
40
- /** The credential that may change a card; minted here when the caller has none. */
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;
39
+ /** The page's token; minted here when the caller has none, so the note carries one. */
41
40
  token?: string;
42
- /** The credential a viewer carries: every read, and no write. */
43
- viewToken?: string;
44
- page: (view: PageView, authority: Authority) => Promise<string>;
41
+ page: (view: PageView) => Promise<string>;
45
42
  script: string;
46
43
  view: () => Promise<BoardView>;
47
- write: (request: PageWrite) => Promise<PageResult>;
48
- /** Called when the last tab has been gone for the grace period. */
49
- onIdle?: () => void;
44
+ /** Called when the page went idle: no tab ever opened, or the last one left. */
45
+ onIdle?: (reason: IdleReason) => void;
50
46
  /** Grace after the last stream leaves; 0 or absent waits for `close()`. */
51
47
  idleMs?: number;
48
+ /** Grace for a page no tab ever attached to; absent leaves the mount open. */
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;
52
59
  /** The interval seam, so a test drives the ticker instead of waiting. */
53
60
  interval?: RepeatTimer;
54
61
  }
@@ -56,10 +63,8 @@ export interface BoardServerOptions {
56
63
  /** A running page. */
57
64
  export interface BoardServer {
58
65
  port: number;
59
- /** The URL the lead opens: the page, with the credential that may change it. */
66
+ /** The URL for this page: one address, because there is one thing a page may do. */
60
67
  url: string;
61
- /** The URL a teammate opens: the same page, read-only. */
62
- viewUrl: string;
63
68
  /** How many tabs are attached, which is what `/board status` reports. */
64
69
  watchers: () => number;
65
70
  /** The signature of the last frame pushed, so a caller can tell it is live. */
@@ -67,26 +72,23 @@ export interface BoardServer {
67
72
  close: () => Promise<void>;
68
73
  }
69
74
 
70
- 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> {
71
79
  const token = options.token ?? mintToken();
72
- const viewToken = options.viewToken ?? mintToken();
73
80
  const clients = new Set<EventSink>();
74
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);
75
84
  let pushed = "";
76
85
 
77
86
  const ctx: BoardRouterContext = {
78
- // Two credentials, one page: the lead's may change a card, a teammate's may only
79
- // read it. Which of the two a request carries is the whole authorisation story.
80
- authority: (given) => {
81
- const value = given ?? "";
82
- if (tokenMatches(token, value)) return "write";
83
- return tokenMatches(viewToken, value) ? "view" : undefined;
84
- },
87
+ read: (given) => tokenMatches(token, given ?? ""),
85
88
  hosts: () => LOOPBACK_HOSTS,
86
89
  page: options.page,
87
90
  script: options.script,
88
91
  view: options.view,
89
- write: options.write,
90
92
  };
91
93
 
92
94
  const push = async (): Promise<void> => {
@@ -100,14 +102,25 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
100
102
  for (const sink of clients) sink.write(frame);
101
103
  };
102
104
 
103
- const server: LoopbackServer = await serveLoopback({
104
- 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,
105
109
  maxBodyBytes: MAX_BODY_BYTES,
106
110
  ...(options.idleMs === undefined || options.onIdle === undefined
107
111
  ? {}
108
- : { idle: { ms: options.idleMs, onIdle: options.onIdle } }),
112
+ : {
113
+ idle: {
114
+ ms: options.idleMs,
115
+ ...(options.firstIdleMs === undefined ? {} : { firstMs: options.firstIdleMs }),
116
+ onIdle: options.onIdle,
117
+ },
118
+ }),
119
+ };
120
+ const outcome = await options.mount({
121
+ ...spec,
109
122
  async handler(request) {
110
- const result = await handleBoardRequest(ctx, request.method, request.url, request.headers, request.body);
123
+ const result = await handleBoardRequest(ctx, request.method, request.url, request.headers);
111
124
  if (!result.stream) return { result };
112
125
  return {
113
126
  result,
@@ -115,10 +128,13 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
115
128
  clients.add(sink);
116
129
  // A tab that just attached gets the current frame without waiting for a
117
130
  // card to change — otherwise a quiet board looks like a dead page.
118
- void options.view().then((view) => {
119
- pushed = view.sig;
120
- sink.write(`data: ${JSON.stringify(view)}\n\n`);
121
- });
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);
122
138
  return () => {
123
139
  clients.delete(sink);
124
140
  };
@@ -126,8 +142,10 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
126
142
  };
127
143
  },
128
144
  });
145
+ if (outcome.kind === "refused") return { kind: "refused", reason: outcome.reason };
146
+ const mount: HttpMount = outcome.mount;
129
147
 
130
- const stopTicker = interval(PUSH_MS, () => void push());
148
+ const stopTicker = interval(PUSH_MS, () => void push().catch(report));
131
149
  let closed = false;
132
150
  const close = async (): Promise<void> => {
133
151
  if (closed) return;
@@ -135,15 +153,17 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
135
153
  stopTicker();
136
154
  for (const sink of clients) sink.end();
137
155
  clients.clear();
138
- await server.close();
156
+ await mount.release();
139
157
  };
140
158
 
141
159
  return {
142
- port: server.port,
143
- url: `http://127.0.0.1:${server.port}/?token=${token}`,
144
- viewUrl: `http://127.0.0.1:${server.port}/?token=${viewToken}`,
145
- watchers: () => clients.size,
146
- pushed: () => pushed,
147
- 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
+ },
148
168
  };
149
169
  }
@@ -25,12 +25,65 @@ export const BOARD_ACTIONS = [
25
25
  "note",
26
26
  "plan",
27
27
  "edit",
28
+ "archive",
28
29
  "drop",
29
30
  ] as const;
30
31
 
31
32
  /** One action. */
32
33
  export type BoardAction = (typeof BOARD_ACTIONS)[number];
33
34
 
35
+ /**
36
+ * What each verb does, in one sentence.
37
+ *
38
+ * The tool description is built from this table and the parameter's `action`
39
+ * description points at that sentence, so the verbs are stated once. A new action is
40
+ * a row here and an entry in {@link BOARD_ACTIONS}; `test` holds the two together.
41
+ */
42
+ export const BOARD_ACTION_DOCS: Record<BoardAction, string> = {
43
+ list:
44
+ "shows the board — `status` filters one lane, `mine` only what this session holds, " +
45
+ "`query` matches a title, a holder, a path hint, the plan, or a note's text, `card` reads that one card " +
46
+ "instead of the board, and `round` adds the lead's rollup of owners, unassigned, unplanned, blocked, and " +
47
+ "idle, which always covers the whole board — and a listing that names no lane leaves out the `done` cards, " +
48
+ "saying how many it left out",
49
+ add: "creates a `todo` card",
50
+ claim: "takes one for this session",
51
+ release: "gives it back",
52
+ reclaim: "takes it from whoever holds it",
53
+ move: "changes lane",
54
+ note: "appends to its history",
55
+ plan: "records the plan file being executed",
56
+ edit: "fixes what the card says — title, path hints, priority, and what it waits for",
57
+ archive:
58
+ "sets a finished card aside, out of the board and still readable, or every finished card when no `card` is named",
59
+ drop: "deletes a `todo`, a `done`, or one you hold",
60
+ };
61
+
62
+ /**
63
+ * The fields each action acts on: the behaviour, where {@link BOARD_ACTION_DOCS} is prose.
64
+ *
65
+ * The parameters are one flat object, so a field is published to every action at once;
66
+ * that is only harmless while each action reads its own. A call carrying a field its
67
+ * action never reads is refused against this table, because the alternative is what
68
+ * `add` did with `dependsOn`: accepted, ignored, and reported as recorded.
69
+ *
70
+ * `test` holds the other direction — every parameter but `action` is claimed here — so
71
+ * a new field cannot be published without an action that acts on it.
72
+ */
73
+ export const ACTION_FIELDS: Record<BoardAction, readonly string[]> = {
74
+ list: ["card", "status", "mine", "query", "round", "archived"],
75
+ add: ["title", "paths", "priority", "note"],
76
+ claim: ["card"],
77
+ release: ["card"],
78
+ reclaim: ["card"],
79
+ move: ["card", "status", "blockedBy"],
80
+ note: ["card", "text"],
81
+ plan: ["card", "path"],
82
+ edit: ["card", "title", "paths", "priority", "dependsOn"],
83
+ archive: ["card"],
84
+ drop: ["card"],
85
+ };
86
+
34
87
  /** A result the transcript renders and the page's routes mirror. */
35
88
  export interface BoardDetails {
36
89
  action: BoardAction;
@@ -44,6 +97,10 @@ export interface BoardDetails {
44
97
  round?: RoundSummary;
45
98
  /** The id a `drop` removed. */
46
99
  dropped?: string;
100
+ /** The ids an `archive` set aside, in the order it moved them. */
101
+ archived?: string[];
102
+ /** Cards an `archive` sweep could not set aside, and why: a move that changed underneath it. */
103
+ failed?: Array<{ card: string; error: string }>;
47
104
  /** A refusal's reason, which the renderer draws instead of the details. */
48
105
  error?: string;
49
106
  }
@@ -1,35 +1,46 @@
1
1
  /**
2
- * The client bundle: the five modules that paint, round up, write, bind keys, and
3
- * follow the frame stream, plus the kit's own theme toggle and one `dialogScript`
4
- * per modal, and the one entry point that starts them.
2
+ * The client bundle: the modules that paint, round up, open a card, bind keys, and
3
+ * follow the frame stream, plus the kit's own theme toggle and the two dialog scripts,
4
+ * and the one entry point that starts them.
5
5
  *
6
6
  * Served as one string, because a page that opens six requests before it can paint
7
7
  * is a page that looks broken for a moment. Nothing here is typed — no compiler sees
8
8
  * a template string — so the split is by job and the class-contract test reads the
9
- * result: `render.ts` paints, `team.ts` draws the round, `actions.ts` posts and owns
10
- * the two dialogs, `keys.ts` binds, and `live.ts` owns the tabs and the stream.
9
+ * result: `render.ts` paints, `team.ts` draws the round, `dialog.ts` opens one card,
10
+ * `keys.ts` binds, and `live.ts` owns the tabs and the stream.
11
11
  *
12
12
  * Load order is a dependency: `team` takes helpers from `render`, and `live` starts
13
13
  * last, after every `board.*` handler it wires exists.
14
14
  */
15
15
 
16
- import { THEME_TOGGLE_JS, dialogScript, keysDialogScript } from "../../../lib/web/index.ts";
17
- import { BOARD_ACTIONS_JS } from "./actions.ts";
16
+ import { MATCHES_QUERY_JS } from "../../../lib/board/card.ts";
17
+ import { CLIENT_BASE_JS, CLIENT_RUNTIME_JS } from "../../../lib/web/index.ts";
18
+ import { PLURAL_JS } from "../../../lib/board/round-text.ts";
19
+ import { FORMAT_AGE_JS } from "../../../lib/format.ts";
20
+ import { namesScript, TAB_TITLE_JS, THEME_TOGGLE_JS, dialogScript, keysDialogScript } from "../../../lib/web/index.ts";
21
+ import { BOARD_CLASS } from "./names.ts";
22
+ import { BOARD_DIALOG_JS } from "./dialog.ts";
18
23
  import { BOARD_KEYS_JS } from "./keys.ts";
19
24
  import { BOARD_LIVE_JS } from "./live.ts";
20
- import { BOARD_RENDER_JS } from "./render.ts";
25
+ import { renderJs } from "./render.ts";
21
26
  import { BOARD_TEAM_JS } from "./team.ts";
22
27
 
23
- /** The whole client, in load order: paint, round, write, keys, wiring, then start. */
24
- export const BOARD_CLIENT_JS = `${BOARD_RENDER_JS}
28
+ /**
29
+ * The whole client, in load order: paint, round, the card dialog, keys, wiring, start.
30
+ *
31
+ * Four rules cross the boundary as source — the count's wording, the tab title's
32
+ * shape, what a `query` matches, and how an age reads: the browser has no import graph,
33
+ * and a second copy of any of them is exactly what this bundle is not allowed to hold.
34
+ */
35
+ export const BOARD_CLIENT_JS = `${CLIENT_BASE_JS}
36
+ ${namesScript(BOARD_CLASS)}
37
+ ${renderJs({ plural: PLURAL_JS, tabTitle: TAB_TITLE_JS, matches: MATCHES_QUERY_JS, age: FORMAT_AGE_JS, runtime: CLIENT_RUNTIME_JS })}
25
38
  ${BOARD_TEAM_JS}
26
- ${BOARD_ACTIONS_JS}
39
+ ${BOARD_DIALOG_JS}
27
40
  ${BOARD_KEYS_JS}
28
41
  ${BOARD_LIVE_JS}
29
42
  ${THEME_TOGGLE_JS}
30
- ${dialogScript({ dialogId: "board-new-dialog", globalName: "bfNew" })}
31
43
  ${dialogScript({ dialogId: "board-card-dialog", closeId: "card-dialog-close", globalName: "bfCard" })}
32
- ${dialogScript({ dialogId: "board-confirm", globalName: "bfConfirm" })}
33
44
  ${keysDialogScript("keys-open")}
34
45
  (function () {
35
46
  "use strict";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The card dialog: one card, whole, as a read view.
3
+ *
4
+ * Opening, closing, and forgetting which card is open is the whole of it. The page
5
+ * cannot change a card, so this module has no request, no confirmation, no banner and
6
+ * no footer actions — the dialogs it used to fill are gone from the document, and the
7
+ * only way a card changes is a tool.
8
+ *
9
+ * The one thing it must not do is leave a stale panel behind: a dialog the kit closed —
10
+ * `Esc`, a backdrop click, its own close button — forgets `openId`, so the next frame
11
+ * stops filling something nobody can see.
12
+ */
13
+
14
+ export const BOARD_DIALOG_JS = String.raw`(function () {
15
+ "use strict";
16
+ var board = window.bfBoard || (window.bfBoard = {});
17
+
18
+ /** Fill and open the card dialog. */
19
+ board.openCard = function (id) {
20
+ var view = board.view;
21
+ if (!view) return;
22
+ var card = board.findCard(view, id);
23
+ if (!card || !window.bfCard) return;
24
+ board.openId = id;
25
+ board.fillDialog(card, view);
26
+ window.bfCard.open();
27
+ };
28
+
29
+ board.closeCard = function () {
30
+ if (window.bfCard) window.bfCard.close();
31
+ };
32
+
33
+ board.dialogOpen = function () {
34
+ return window.bfCard ? window.bfCard.isOpen() : false;
35
+ };
36
+
37
+ var cardDialog = document.getElementById("board-card-dialog");
38
+ if (cardDialog) {
39
+ new MutationObserver(function () {
40
+ if (cardDialog.hidden) board.openId = undefined;
41
+ }).observe(cardDialog, { attributes: true, attributeFilter: ["hidden"] });
42
+ }
43
+ })();`;
@@ -7,16 +7,15 @@
7
7
  * module exists to prevent, since no type checker sees the bundle.
8
8
  *
9
9
  * The vocabulary is the TUI's where it means the same thing (`j`/`k`, `?`, `Esc`);
10
- * `n`, `/` and `t` are this page's own.
10
+ * `/` and `t` are this page's own. There is no key that changes a card: the page reads.
11
11
  */
12
12
 
13
13
  import type { KeyHint } from "../../../lib/web/index.ts";
14
14
 
15
15
  /** One binding: the key or keys, and what pressing it does. */
16
16
  export const BOARD_KEYS: readonly KeyHint[] = [
17
- { keys: "n", label: "New card — open the dialog and focus the title field" },
18
17
  { keys: "/", label: "Filter the cards" },
19
- { keys: ["j", "k"], label: "Next or previous card" },
18
+ { keys: ["j", "k"], label: "Next or previous card, or the next card in an open dialog" },
20
19
  { keys: "Enter", label: "Open the focused card" },
21
20
  { keys: "t", label: "Switch Board and Team" },
22
21
  { keys: "Esc", label: "Close a dialog, or clear the filter" },
@@ -35,23 +34,35 @@ export const BOARD_KEYS_JS = String.raw`(function () {
35
34
  "use strict";
36
35
  var board = window.bfBoard || (window.bfBoard = {});
37
36
 
38
- /** Whether the event landed in a field, where every key is text. */
39
- function typing(target) {
40
- if (!target || !target.tagName) return false;
41
- var tag = target.tagName.toLowerCase();
42
- return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable === true;
43
- }
37
+ /** The render bundle's runtime: one typing test for the whole page. */
38
+ var typing = board.typing;
44
39
 
45
40
  /** The card nodes in reading order, so j/k walk the board as it is drawn. */
46
41
  function cards() {
47
- var seen = [];
48
- var nodes = document.querySelectorAll(".board-card");
49
- for (var i = 0; i < nodes.length; i++) if (!nodes[i].hidden) seen.push(nodes[i]);
50
- return seen;
42
+ return board.drawnCards();
43
+ }
44
+
45
+ /** Move the open dialog to the next or previous card; false when no dialog is open. */
46
+ function walk(delta) {
47
+ if (!board.dialogOpen || !board.dialogOpen()) return false;
48
+ var drawn = cards();
49
+ if (drawn.length === 0) return true;
50
+ // The open card is the one marked open, not the focused node: focus sits on the
51
+ // dialog's own panel, which is not one of the cards.
52
+ var at = -1;
53
+ for (var i = 0; i < drawn.length; i++) {
54
+ if (drawn[i].getAttribute("data-card") === String(board.openId)) at = i;
55
+ }
56
+ var next = at < 0 ? (delta > 0 ? 0 : drawn.length - 1) : (at + delta + drawn.length) % drawn.length;
57
+ board.openCard(drawn[next].getAttribute("data-card"));
58
+ return true;
51
59
  }
52
60
 
53
61
  /** Move focus to the next or previous drawn card, and say so for a screen reader. */
54
62
  function step(delta) {
63
+ // An open dialog walks instead of moving focus: the record stays whole, and Esc
64
+ // still returns to the board where it was.
65
+ if (walk(delta)) return;
55
66
  var drawn = cards();
56
67
  if (drawn.length === 0) return;
57
68
  var at = drawn.indexOf(document.activeElement);
@@ -78,9 +89,9 @@ export const BOARD_KEYS_JS = String.raw`(function () {
78
89
  board.closeCard();
79
90
  return;
80
91
  }
81
- // The composer is a kit dialog, so the kit's own Esc closed it in the capture
82
- // phase already; a filter with text is what Escape is for here. An empty one
83
- // gives the keyboard back to the page by leaving the field.
92
+ // What is left for Escape is the filter: a field with text loses it, and the field
93
+ // itself then gives the keyboard back to the page. The keys sheet and the card
94
+ // dialog are above this, in the order a reader is likely to be inside them.
84
95
  if (board.hasFilter()) {
85
96
  board.clearFilter();
86
97
  if (typing(target)) target.blur();
@@ -93,17 +104,10 @@ export const BOARD_KEYS_JS = String.raw`(function () {
93
104
  return;
94
105
  }
95
106
  if (typing(target)) {
96
- if (event.key === "Enter" && target.id === "card-dialog-note") board.addNote();
97
107
  if (event.key === "Enter" && target.id === "board-filter") board.applyFilter();
98
108
  return;
99
109
  }
100
- if (event.key === "n") {
101
- // Two modals over one board is a trap whose edge the reader cannot see, and a
102
- // viewer's page has no composer to open at all.
103
- if (!board.canWrite() || board.dialogOpen() || board.composerOpen()) return;
104
- event.preventDefault();
105
- board.focusComposer();
106
- } else if (event.key === "/") {
110
+ if (event.key === "/") {
107
111
  event.preventDefault();
108
112
  board.focusFilter();
109
113
  } else if (event.key === "j") {