@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 (274) hide show
  1. package/README.md +61 -45
  2. package/README.zh-CN.md +54 -42
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +554 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
  14. package/docs/extensions/plan.md +976 -0
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/notify/types.ts +1 -1
  51. package/extensions/bytefly/panel/commands.ts +40 -7
  52. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  53. package/extensions/bytefly/panel/settings.ts +11 -21
  54. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  55. package/extensions/bytefly/picker/discovery.ts +0 -2
  56. package/extensions/bytefly/picker/types.ts +0 -2
  57. package/extensions/bytefly/update/check.ts +39 -0
  58. package/extensions/bytefly/update/format.ts +15 -0
  59. package/extensions/bytefly/update/register.ts +73 -34
  60. package/extensions/bytefly/update/state.ts +6 -3
  61. package/extensions/context/tui.ts +1 -1
  62. package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
  63. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  64. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  65. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  66. package/extensions/file-browse/index.ts +4 -4
  67. package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
  68. package/extensions/file-browse/live/rev.ts +42 -0
  69. package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
  70. package/extensions/file-browse/transport/server.ts +110 -0
  71. package/extensions/file-browse/view/chrome.ts +83 -0
  72. package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
  73. package/extensions/file-browse/view/diff-html.ts +94 -0
  74. package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
  75. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  76. package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
  77. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  78. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  79. package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
  80. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  81. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  82. package/extensions/file-read/schema.ts +2 -1
  83. package/extensions/goal/plan-route.ts +8 -2
  84. package/extensions/job/index.ts +15 -7
  85. package/extensions/memory/store.ts +12 -5
  86. package/extensions/plan/blocks.ts +23 -2
  87. package/extensions/plan/commands.ts +77 -21
  88. package/extensions/plan/feedback.ts +409 -63
  89. package/extensions/plan/index.ts +7 -5
  90. package/extensions/plan/plan-index.ts +44 -10
  91. package/extensions/plan/plan-text.ts +51 -24
  92. package/extensions/plan/plans.ts +162 -57
  93. package/extensions/plan/policy.ts +20 -12
  94. package/extensions/plan/review-prompt.ts +93 -0
  95. package/extensions/plan/review.ts +51 -95
  96. package/extensions/plan/runtime.ts +22 -10
  97. package/extensions/plan/sections.ts +112 -0
  98. package/extensions/plan/steps.ts +63 -95
  99. package/extensions/plan/tools/enter.ts +8 -3
  100. package/extensions/plan/tools/exit.ts +13 -19
  101. package/extensions/plan/tools/write.ts +8 -3
  102. package/extensions/plan/tools.ts +1 -1
  103. package/extensions/plan/tui.ts +29 -15
  104. package/extensions/plan/types.ts +29 -3
  105. package/extensions/plan/web-chrome.ts +2 -1
  106. package/extensions/plan/web-client.ts +122 -178
  107. package/extensions/plan/web-collab.ts +207 -0
  108. package/extensions/plan/web-comments.ts +269 -0
  109. package/extensions/plan/web-page.ts +96 -17
  110. package/extensions/plan/web-router.ts +109 -47
  111. package/extensions/plan/web-share-session.ts +191 -0
  112. package/extensions/plan/web-share.ts +169 -0
  113. package/extensions/plan/web-streams.ts +124 -0
  114. package/extensions/plan/web-tokens.ts +63 -0
  115. package/extensions/plan/web.ts +245 -151
  116. package/extensions/question/dialogs.ts +91 -1
  117. package/extensions/question/tools.ts +2 -1
  118. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  119. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  120. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  121. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  122. package/extensions/rewind/index.ts +3 -3
  123. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  124. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  125. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  126. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  127. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  128. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  129. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  130. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  131. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  132. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  133. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  134. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  135. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  136. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  137. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  138. package/extensions/session-bus/commands.ts +113 -0
  139. package/extensions/session-bus/format.ts +101 -0
  140. package/extensions/session-bus/index.ts +75 -0
  141. package/extensions/session-bus/join-request.ts +40 -0
  142. package/extensions/session-bus/mail-watch.ts +134 -0
  143. package/extensions/session-bus/notice.ts +86 -0
  144. package/extensions/session-bus/overlap.ts +36 -0
  145. package/extensions/session-bus/protocol.ts +104 -0
  146. package/extensions/session-bus/resolve.ts +67 -0
  147. package/extensions/session-bus/result.ts +208 -0
  148. package/extensions/session-bus/rows.ts +171 -0
  149. package/extensions/session-bus/runtime.ts +466 -0
  150. package/extensions/session-bus/store.ts +365 -0
  151. package/extensions/session-bus/tools.ts +192 -0
  152. package/extensions/session-bus/tui.ts +394 -0
  153. package/extensions/session-bus/types.ts +98 -0
  154. package/extensions/subagent/agents.ts +9 -8
  155. package/extensions/subagent/invocation.ts +4 -2
  156. package/extensions/team/commands.ts +112 -0
  157. package/extensions/team/dispatch.ts +133 -0
  158. package/extensions/team/format.ts +105 -0
  159. package/extensions/team/index.ts +33 -0
  160. package/extensions/team/schema.ts +29 -0
  161. package/extensions/team/tools.ts +246 -0
  162. package/extensions/team/types.ts +48 -0
  163. package/extensions/watch/index.ts +15 -9
  164. package/extensions/web-access/fetch/page.ts +4 -1
  165. package/extensions/web-access/fetch/structured.ts +447 -0
  166. package/extensions/web-access/fetch/tool.ts +2 -1
  167. package/extensions/web-access/search/tool.ts +2 -1
  168. package/extensions/wechat/bridge.ts +76 -92
  169. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  170. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  171. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  172. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  173. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  174. package/extensions/wechat/dialog/channels.ts +136 -0
  175. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
  176. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
  177. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  178. package/extensions/wechat/index.ts +10 -10
  179. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  180. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  181. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
  182. package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
  183. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
  184. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  185. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  186. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  187. package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
  188. package/extensions/wechat/tools.ts +29 -12
  189. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  190. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
  191. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  192. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  193. package/extensions/wechat/transport/outbound.ts +142 -0
  194. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  195. package/extensions/wechat/types.ts +10 -1
  196. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  197. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  198. package/extensions/worktree/git.ts +5 -0
  199. package/extensions/worktree/guard.ts +3 -5
  200. package/extensions/worktree/registry.ts +11 -5
  201. package/extensions/worktree/status.ts +1 -3
  202. package/lib/README.md +12 -6
  203. package/lib/board/card.ts +290 -0
  204. package/lib/board/links.ts +117 -0
  205. package/lib/board/page-pointer.ts +256 -0
  206. package/lib/board/round-text.ts +72 -0
  207. package/lib/board/round.ts +89 -0
  208. package/lib/board/rules.ts +304 -0
  209. package/lib/board/session.ts +105 -0
  210. package/lib/board/store.ts +310 -0
  211. package/lib/board/view.ts +150 -0
  212. package/lib/errors.ts +12 -4
  213. package/lib/format.ts +13 -0
  214. package/lib/http/guard.ts +112 -0
  215. package/lib/http/index.ts +16 -5
  216. package/lib/http/router.ts +40 -13
  217. package/lib/http/serve.ts +234 -0
  218. package/lib/http/server.ts +29 -13
  219. package/lib/http/sink.ts +110 -0
  220. package/lib/http/token.ts +38 -0
  221. package/lib/interaction.ts +10 -0
  222. package/lib/notice.ts +29 -2
  223. package/lib/plan-entry.ts +7 -2
  224. package/lib/policy.ts +12 -2
  225. package/lib/qr.ts +64 -0
  226. package/lib/repo-key.ts +74 -0
  227. package/lib/report.ts +52 -33
  228. package/lib/requirements.ts +7 -9
  229. package/lib/session-label.ts +64 -0
  230. package/lib/session-roster.ts +130 -0
  231. package/lib/session-tool.ts +184 -0
  232. package/lib/tool-names.ts +25 -2
  233. package/lib/tui/README.md +16 -2
  234. package/lib/tui/dock-screen.ts +85 -17
  235. package/lib/tui/glyphs.ts +2 -0
  236. package/lib/tui/line-pane.ts +15 -4
  237. package/lib/tui/tool.ts +23 -4
  238. package/lib/web/README.md +5 -2
  239. package/lib/web/components.ts +6 -1
  240. package/lib/web/runtime.ts +7 -1
  241. package/lib/web/tokens.ts +25 -2
  242. package/package.json +27 -14
  243. package/skills/init/SKILL.md +80 -45
  244. package/skills/init/references/agents-md-quality.md +26 -2
  245. package/skills/init/references/interview-questions.md +36 -14
  246. package/skills/next/SKILL.md +77 -51
  247. package/skills/project-docs/SKILL.md +133 -0
  248. package/skills/project-docs/references/audit.md +131 -0
  249. package/skills/project-docs/references/changelog.md +105 -0
  250. package/skills/project-docs/references/discovery.md +127 -0
  251. package/skills/project-docs/references/readme.md +80 -0
  252. package/skills/project-docs/references/release.md +84 -0
  253. package/skills/project-docs/references/roadmap.md +93 -0
  254. package/skills/project-docs/scripts/state.ts +316 -0
  255. package/skills/refactor/SKILL.md +100 -125
  256. package/skills/refactor/references/handoff.md +35 -0
  257. package/skills/refactor/references/signals.md +54 -0
  258. package/skills/refactor/scripts/inventory.ts +587 -0
  259. package/extensions/file-browse/README.md +0 -467
  260. package/extensions/file-browse/server.ts +0 -134
  261. package/extensions/plan/README.md +0 -709
  262. package/extensions/plan/config.ts +0 -27
  263. package/extensions/wechat/outbound.ts +0 -100
  264. package/extensions/wechat/qrcode.ts +0 -46
  265. package/skills/changelog/SKILL.md +0 -148
  266. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  267. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  268. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  269. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  270. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  271. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  272. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  273. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  274. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The `session` tool, as the extensions that are not `session-bus` call it.
3
+ *
4
+ * `board` and `team` are the user's surface for the team; the bus is the transport
5
+ * underneath, and neither may import it (`test/structure.test.ts`). So the one place
6
+ * that turns a call into a typed answer lives here, next to the tool's name in
7
+ * `lib/tool-names.ts` — two copies would be two copies of the details-shape
8
+ * knowledge, and a bus that renamed a field would be broken in one of them.
9
+ *
10
+ * There are two transports, and the difference is the context, not the caller's
11
+ * choice: `join` travels as an in-process request on `pi.events` because a *command*
12
+ * context has no `ctx.executeTool` — the same constraint `lib/plan-entry.ts` exists
13
+ * for — while `peers` and `send` are nested tool calls, which only ever run inside a
14
+ * tool. One function either way, so a caller never has to know which it is holding.
15
+ *
16
+ * Every function returns a result and none throws: "the bus is not loaded", "a plan
17
+ * is open", and "that label is taken" are all answers a caller reports, not reasons
18
+ * to abandon a board write or a card operation that has nothing to do with the bus.
19
+ *
20
+ * This module is the *caller* side only. Reading the bus's records from disk is
21
+ * `session-roster.ts`, which is what works when the bus is not loaded at all.
22
+ */
23
+
24
+ import type { ExtensionAPI, ExtensionContext, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
25
+ import { messageOf } from "./errors.ts";
26
+ import { watchingOf } from "./session-roster.ts";
27
+ import { SESSION_TOOL } from "./tool-names.ts";
28
+
29
+ /**
30
+ * The `session send` text limit, which is also the longest handoff or offer.
31
+ *
32
+ * It is copied from the bus's protocol rather than imported — the dependency runs one
33
+ * way — and it lives here so `team` and `board` clip to one number instead of two.
34
+ */
35
+ export const MAX_MESSAGE_TEXT = 4_000;
36
+
37
+ /** Whether a session is listening for mail, and whether it raises a turn. */
38
+ export interface Watching {
39
+ armed: boolean;
40
+ wake: boolean;
41
+ }
42
+
43
+ /** One session the bus lists. */
44
+ export interface Peer {
45
+ id: string;
46
+ label: string;
47
+ /** Absent when the record does not say, which is not the same as "not listening". */
48
+ listening?: boolean;
49
+ }
50
+
51
+ /** What a join did: the label it landed on, and whether mail will reach the session. */
52
+ export type JoinResult = { ok: true; label: string; watching: Watching } | { ok: false; error: string };
53
+
54
+ /** EventBus channel on which a caller asks `session-bus` to join the team for it. */
55
+ export const TEAM_JOIN_REQUEST = "my-pi-agent/team-join/request";
56
+
57
+ /** What a caller sends on {@link TEAM_JOIN_REQUEST}. */
58
+ export interface TeamJoinRequest {
59
+ /** The caller's context; the bus starts and names through it. */
60
+ ctx: ExtensionContext;
61
+ /** What to join as and how to listen. */
62
+ input: { label?: string; wake?: boolean; listen?: boolean };
63
+ /** Called once, when the bus has answered. */
64
+ respond(result: JoinResult): void;
65
+ }
66
+
67
+ /** What a send did. */
68
+ export type SendResult = { ok: true; delivered: string[] } | { ok: false; error: string };
69
+
70
+ /** One nested call's answer: the details on success, the sentence on failure. */
71
+ type Answer = { details: Record<string, unknown> } | { error: string };
72
+
73
+ /**
74
+ * Call the `session` tool once.
75
+ *
76
+ * A nested call reports failure in its result rather than by rejecting; a rejection
77
+ * is still caught, because a bus that is not registered at all throws before the
78
+ * tool runs and the caller deserves the sentence rather than an exception.
79
+ */
80
+ async function call(ctx: ExtensionToolContext, args: Record<string, unknown>): Promise<Answer> {
81
+ const outcome = await ctx.executeTool(SESSION_TOOL, args).catch((error: unknown) => ({
82
+ isError: true as const,
83
+ result: { content: [{ type: "text", text: messageOf(error) }], details: undefined },
84
+ }));
85
+ if (outcome.isError) return { error: nestedMessage(outcome.result) };
86
+ const details = outcome.result.details;
87
+ return { details: typeof details === "object" && details !== null ? (details as Record<string, unknown>) : {} };
88
+ }
89
+
90
+ /** The message a failed nested call carries, or a sentence that says only that it failed. */
91
+ function nestedMessage(result: { content?: Array<{ type: string; text?: string }> } | undefined): string {
92
+ const text = result?.content?.find((block) => block.type === "text")?.text;
93
+ return text !== undefined && text !== "" ? text.replace(/^Error:\s*/, "") : "the session bus did not answer.";
94
+ }
95
+
96
+ /**
97
+ * How an extension joins the team, whatever kind of context it is holding.
98
+ *
99
+ * Two extensions ask — `board` when the page opens or a card is worked, `team` for
100
+ * `/team join` — and both hold a command or a tool context, so the request is the one
101
+ * transport that works from either.
102
+ */
103
+ export interface TeamJoin {
104
+ /** Join the team, naming this session when `label` is given. Never throws. */
105
+ join(ctx: ExtensionContext, label?: string): Promise<JoinResult>;
106
+ }
107
+
108
+ /** The production join, bound to one extension's `pi`. */
109
+ export function createTeamJoin(pi: Pick<ExtensionAPI, "events" | "getAllTools">): TeamJoin {
110
+ return {
111
+ join: (ctx, label) => join(pi, ctx, label === undefined ? {} : { label }),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Join the team: on the bus, named when a label is given, and listening for mail.
117
+ *
118
+ * One call for all three, because that is one act — see `join` in
119
+ * `extensions/session-bus` — and one transport for both kinds of caller: the request
120
+ * travels on `pi.events` and the bus answers it.
121
+ *
122
+ * With no bus loaded there is no listener, so the answer is the sentence the caller
123
+ * reports rather than a promise that never settles: the `session` tool's presence is
124
+ * the same check `lib/plan-entry.ts` makes, and the listener always answers.
125
+ */
126
+ export async function join(
127
+ pi: Pick<ExtensionAPI, "events" | "getAllTools">,
128
+ ctx: ExtensionContext,
129
+ input: { label?: string; wake?: boolean; listen?: boolean } = {},
130
+ ): Promise<JoinResult> {
131
+ if (!pi.getAllTools().some((tool) => tool.name === SESSION_TOOL)) {
132
+ return { ok: false, error: "the session bus is not loaded" };
133
+ }
134
+ return new Promise<JoinResult>((resolve) => {
135
+ let settled = false;
136
+ const respond = (result: JoinResult): void => {
137
+ if (settled) return;
138
+ settled = true;
139
+ resolve(result);
140
+ };
141
+ pi.events.emit(TEAM_JOIN_REQUEST, { ctx, input, respond } satisfies TeamJoinRequest);
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Every session the bus lists, or `undefined` when it cannot answer.
147
+ *
148
+ * Named `listPeers` rather than `peers` so it cannot be mistaken for a caller's own
149
+ * formatting helper — `wechat`'s doctor has one — and a caller reads the verb.
150
+ *
151
+ * A caller renders "the record does not say" differently from "nobody is here", so a
152
+ * failed read is not an empty list.
153
+ */
154
+ export async function listPeers(ctx: ExtensionToolContext): Promise<Peer[] | undefined> {
155
+ const answer = await call(ctx, { action: "status" });
156
+ if ("error" in answer) return undefined;
157
+ const status = answer.details.status;
158
+ if (typeof status !== "object" || status === null) return undefined;
159
+ const sessions = (status as { sessions?: unknown }).sessions;
160
+ if (!Array.isArray(sessions)) return undefined;
161
+ const peers: Peer[] = [];
162
+ for (const entry of sessions) {
163
+ if (typeof entry !== "object" || entry === null) continue;
164
+ const { id, label, watching } = entry as Record<string, unknown>;
165
+ if (typeof id !== "string" || typeof label !== "string") continue;
166
+ const state = watchingOf(watching);
167
+ peers.push({ id, label, ...(state === undefined ? {} : { listening: state.armed }) });
168
+ }
169
+ return peers;
170
+ }
171
+
172
+ /** Send one message, and report the labels a message file was written for. */
173
+ export async function send(
174
+ ctx: ExtensionToolContext,
175
+ input: { kind: "handoff" | "event"; to: string; text: string },
176
+ ): Promise<SendResult> {
177
+ const answer = await call(ctx, { action: "send", kind: input.kind, to: input.to, text: input.text });
178
+ if ("error" in answer) return { ok: false, error: answer.error };
179
+ const delivered = answer.details.delivered;
180
+ return {
181
+ ok: true,
182
+ delivered: Array.isArray(delivered) ? delivered.filter((label): label is string => typeof label === "string") : [],
183
+ };
184
+ }
package/lib/tool-names.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * Tool names are otherwise owned by the extension that registers the tool, and
5
5
  * no extension imports another's modules (see this directory's README). When an
6
6
  * orchestrating tool calls another extension's tool through `ctx.executeTool`,
7
- * or reads another extension's registered parameter schema, the shared name
8
- * lives here so a rename breaks the build on both sides instead of silently
7
+ * reads another extension's registered parameter schema, lists the name in a
8
+ * read-only policy, or passes it on a child's `--tools` allowlist, the shared
9
+ * name lives here so a rename breaks the build on both sides instead of silently
9
10
  * disabling the integration.
10
11
  */
11
12
 
@@ -31,9 +32,31 @@ export const SUBAGENT_TOOL = "subagent";
31
32
  */
32
33
  export const ASK_USER_QUESTION_TOOL = "ask_user_question";
33
34
 
35
+ /**
36
+ * The `session` tool. `session-bus` registers it, and `team` calls it through
37
+ * `ctx.executeTool` to send a handoff, so a rename breaks both sides.
38
+ */
39
+ export const SESSION_TOOL = "session";
40
+
34
41
  /**
35
42
  * Parameter on the `subagent` tool that forces every spawned agent to a
36
43
  * read-only tool set. Plan mode sets it on every call while planning, and
37
44
  * refuses a `subagent` whose schema does not declare it.
38
45
  */
39
46
  export const SUBAGENT_READ_ONLY_PARAM = "readOnly";
47
+
48
+ /**
49
+ * The `read_doc` tool. `file-read` registers it, and the plan, rewind, and
50
+ * subagent reader lists carry the name, so the document reader is one reader
51
+ * everywhere instead of three spellings.
52
+ */
53
+ export const READ_DOC_TOOL = "read_doc";
54
+
55
+ /**
56
+ * The `web_search` tool. `web-access` registers it; plan's and subagent's reader
57
+ * lists carry the name, so a rename cannot silently drop the web reader.
58
+ */
59
+ export const WEB_SEARCH_TOOL = "web_search";
60
+
61
+ /** The `web_fetch` tool, shared the same way as {@link WEB_SEARCH_TOOL}. */
62
+ export const WEB_FETCH_TOOL = "web_fetch";
package/lib/tui/README.md CHANGED
@@ -35,8 +35,8 @@ Reach for a `lib/tui/` primitive before composing your own: `plainRule` /
35
35
  `fitCells` for two columns, `LinePane` for a scrollable log, `ListCursor` /
36
36
  `IdCursor` for selection, `byRunningFirst` for a live list's order,
37
37
  `DockListScreen` for a list that opens a pane, `TailResult` for a tool result
38
- that lists rows or tails output, `screenFooter` for a screen's own last three
39
- rows, and `ConfirmState` before a destructive action.
38
+ that lists rows, heads several row sections, or tails output, `screenFooter` for a
39
+ screen's own last three rows, and `ConfirmState` before a destructive action.
40
40
 
41
41
  ## Docked list screens
42
42
 
@@ -81,6 +81,20 @@ what a plain list wants — do not hand-roll the frame to get one of them:
81
81
  (`c` collect, `x` cancel). Return `true` for the keys it claimed; everything
82
82
  else falls through to the pane's scroll keys.
83
83
 
84
+ A pane is a log by default: it opens at the tail and its subtitle says
85
+ `following` or `paused`. Two more fields cover the other body:
86
+
87
+ - `DetailContents.text` — prose instead of `lines`, wrapped to the width the pane
88
+ renders at and re-wrapped on a resize. One source line is one paragraph; a blank
89
+ line stays blank. `lines` remains the right shape for output that is already
90
+ split.
91
+ - `DetailContents.tail: false` — a prose or list pane opens at the top and never
92
+ claims to follow anything. Leave it out for a log.
93
+
94
+ A screen whose list is one of several scopes overrides `headerLabel()` (the
95
+ header rule's name) and `emptyLabel()` (the empty-list line), so `Tab` can change
96
+ what is listed without a second renderer; both default to the screen's options.
97
+
84
98
  A destructive action asks through [`confirm.ts`](./confirm.ts):
85
99
  `ConfirmState.ask(prompt, accept)` holds the question, `resolve(data)` accepts on
86
100
  `y`/`Y`/Enter, dismisses on `n`/`N`/Esc/`q`, and returns `undefined` for any
@@ -20,11 +20,13 @@
20
20
 
21
21
  import type { Theme } from "@earendil-works/pi-coding-agent";
22
22
  import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
23
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
23
24
  import { detailBlockRows, dockListFrame, screenHint, type ScreenHint, type ViewportRowsSource } from "./chrome.ts";
24
25
  import { clip, fitCells } from "./columns.ts";
25
26
  import { ConfirmState } from "./confirm.ts";
26
27
  import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "./cursor.ts";
27
28
  import { SEPARATORS } from "./glyphs.ts";
29
+ import { BODY_INDENT } from "./indent.ts";
28
30
  import { LinePane } from "./line-pane.ts";
29
31
  import { emptyState } from "./rows.ts";
30
32
  import { titleRule } from "./rules.ts";
@@ -43,12 +45,23 @@ const DETAIL_CHROME_ROWS = 6;
43
45
  export interface DetailContents {
44
46
  /** Pane title, e.g. `j1 build`. */
45
47
  title: string;
46
- /** The body lines, already sanitized by the caller. */
47
- lines: string[];
48
+ /** The body lines, already sanitized by the caller. Omit for a `text` pane. */
49
+ lines?: string[];
50
+ /**
51
+ * Prose body, built once at open and wrapped to the render width on every
52
+ * frame. Takes precedence over `lines`. A resize re-wraps; a blank line in the
53
+ * source stays a blank line in the pane.
54
+ */
55
+ text?: string;
48
56
  /** More lines exist above the ones supplied. */
49
57
  more?: boolean;
50
58
  /** Subtitle under the title, e.g. the log file path. */
51
59
  subtitle?: string;
60
+ /**
61
+ * Whether the pane follows its tail. Default true, which is a log's behavior;
62
+ * false opens a prose or list pane at the top and never says `following`.
63
+ */
64
+ tail?: boolean;
52
65
  /**
53
66
  * Rows above the scrollable body that depend on the width: key/value facts,
54
67
  * a legend, a divider. Read fresh every frame with the live width and already
@@ -101,6 +114,9 @@ export abstract class DockListScreen<T> implements Component {
101
114
  private readonly confirmState = new ConfirmState();
102
115
  private feedback: string | undefined;
103
116
  private detailFacts: ((width: number) => string[]) | undefined;
117
+ /** Prose body for the open pane, wrapped to the width it is rendered at. */
118
+ private detailText: string | undefined;
119
+ private detailWrap: { width: number; lines: string[] } | undefined;
104
120
 
105
121
  constructor(protected readonly options: DockListScreenOptions) {}
106
122
 
@@ -163,6 +179,19 @@ export abstract class DockListScreen<T> implements Component {
163
179
  this.options.requestRender();
164
180
  }
165
181
 
182
+ /**
183
+ * The header rule's label. A screen whose list is one of several scopes
184
+ * overrides it, so the rule names what is on screen.
185
+ */
186
+ protected headerLabel(): string {
187
+ return this.options.title;
188
+ }
189
+
190
+ /** The empty-list line; a screen whose scopes differ overrides it. */
191
+ protected emptyLabel(): string {
192
+ return this.options.emptyList;
193
+ }
194
+
166
195
  /** Show one line of feedback in place of the hints. */
167
196
  protected notify(text: string): void {
168
197
  this.feedback = text;
@@ -182,6 +211,12 @@ export abstract class DockListScreen<T> implements Component {
182
211
  // Facts stay with the pane unless this update supplies its own, so a poll that
183
212
  // only refreshes the body cannot drop the facts the row opened with.
184
213
  if (contents.facts) this.detailFacts = contents.facts;
214
+ // `text` and `tail` belong to the row that opened the pane; an update either
215
+ // replaces the prose or settles the pane back onto a line buffer.
216
+ if (contents.text !== undefined || contents.lines !== undefined) {
217
+ this.detailText = contents.text;
218
+ this.detailWrap = undefined;
219
+ }
185
220
  const changed =
186
221
  contents.subtitle === undefined
187
222
  ? this.pane.update({ title: contents.title, lines: contents.lines, more: contents.more })
@@ -201,6 +236,7 @@ export abstract class DockListScreen<T> implements Component {
201
236
 
202
237
  /** Re-read the open detail pane, so a poll cannot show stale lines. */
203
238
  refreshDetail(id: string): void {
239
+ if (this.detailText !== undefined) return;
204
240
  const source = this.detailById?.(id);
205
241
  if (source && this.pane.update({ lines: source.lines, more: source.more })) this.options.requestRender();
206
242
  }
@@ -242,10 +278,30 @@ export abstract class DockListScreen<T> implements Component {
242
278
  const contents = this.detailFor(row);
243
279
  this.mode = "detail";
244
280
  this.detailFacts = contents.facts;
245
- this.pane.open(this.idOf(row), contents);
281
+ this.detailText = contents.text;
282
+ this.detailWrap = undefined;
283
+ this.pane.open(
284
+ this.idOf(row),
285
+ {
286
+ title: contents.title,
287
+ lines: contents.text === undefined ? (contents.lines ?? []) : [],
288
+ more: contents.more,
289
+ subtitle: contents.subtitle,
290
+ },
291
+ contents.tail ?? true,
292
+ );
246
293
  this.options.requestRender();
247
294
  }
248
295
 
296
+ /** Leave detail mode and drop everything that belonged to the open row. */
297
+ private closeDetail(): void {
298
+ this.mode = "list";
299
+ this.pane.close();
300
+ this.detailFacts = undefined;
301
+ this.detailText = undefined;
302
+ this.detailWrap = undefined;
303
+ }
304
+
249
305
  private handleListKey(data: string): void {
250
306
  if (isCloseKey(data) || data === "q") {
251
307
  this.options.onClose();
@@ -294,9 +350,7 @@ export abstract class DockListScreen<T> implements Component {
294
350
 
295
351
  private handleDetailKey(data: string): void {
296
352
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
297
- this.mode = "list";
298
- this.pane.close();
299
- this.detailFacts = undefined;
353
+ this.closeDetail();
300
354
  this.options.requestRender();
301
355
  return;
302
356
  }
@@ -307,11 +361,11 @@ export abstract class DockListScreen<T> implements Component {
307
361
  }
308
362
 
309
363
  /** The footer: a pending confirmation, one line of feedback, or the hints. */
310
- private hint(width: number, rows: readonly T[]): string {
364
+ private footer(width: number, hints: ScreenHint[]): string {
311
365
  const pending = this.confirmState.pending;
312
366
  if (pending) return clip(` ${paint(this.theme, "warn", pending.prompt)}`, width);
313
367
  if (this.feedback) return clip(` ${paint(this.theme, "meta", this.feedback)}`, width);
314
- return screenHint(this.theme, width, this.listHints(rows));
368
+ return screenHint(this.theme, width, hints);
315
369
  }
316
370
 
317
371
  private summary(rows: readonly T[], width: number): string {
@@ -343,7 +397,7 @@ export abstract class DockListScreen<T> implements Component {
343
397
  const body: string[] = [];
344
398
  let range: string | undefined;
345
399
  if (rows.length === 0) {
346
- body.push(...emptyState(this.theme, w, { label: this.options.emptyList }));
400
+ body.push(...emptyState(this.theme, w, { label: this.emptyLabel() }));
347
401
  } else {
348
402
  const end = Math.min(rows.length, this.cursor.scroll + this.cursor.visible);
349
403
  for (let i = this.cursor.scroll; i < end; i++) body.push(this.rowLine(rows[i], w, i === index));
@@ -355,12 +409,12 @@ export abstract class DockListScreen<T> implements Component {
355
409
  return dockListFrame({
356
410
  theme: this.theme,
357
411
  width: w,
358
- header: titleRule(this.theme, w, this.options.title),
412
+ header: titleRule(this.theme, w, this.headerLabel()),
359
413
  summary: this.summary(rows, w),
360
414
  body,
361
415
  range,
362
416
  detail: block.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w)),
363
- hint: this.hint(w, rows),
417
+ hint: this.footer(w, this.listHints(rows)),
364
418
  });
365
419
  }
366
420
 
@@ -368,6 +422,7 @@ export abstract class DockListScreen<T> implements Component {
368
422
  this.syncDetail();
369
423
  // A live pane whose row is gone has already closed itself: draw the list.
370
424
  if (this.mode !== "detail") return this.renderList(w);
425
+ this.syncText(w);
371
426
  const facts = this.detailFacts ? this.detailFacts(w) : [];
372
427
  const visible = fitRows(
373
428
  this.options.viewportRowsSource,
@@ -397,14 +452,15 @@ export abstract class DockListScreen<T> implements Component {
397
452
  summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
398
453
  body,
399
454
  range,
400
- hint: screenHint(this.theme, w, this.detailHints(this.detailRow)),
455
+ hint: this.footer(w, this.detailHints(this.detailRow)),
401
456
  });
402
457
  }
403
458
 
404
459
  /**
405
460
  * Re-read a live pane in place. This runs inside a render, so it must not ask
406
461
  * for another frame; a source that disappeared closes the pane instead of
407
- * showing lines the list no longer has a row for.
462
+ * showing lines the list no longer has a row for. A prose pane is skipped here:
463
+ * `syncText` owns its lines, and this still closes it when the row is gone.
408
464
  */
409
465
  private syncDetail(): void {
410
466
  if (!this.liveDetail()) return;
@@ -412,11 +468,23 @@ export abstract class DockListScreen<T> implements Component {
412
468
  if (!id) return;
413
469
  const source = this.detailById?.(id);
414
470
  if (!source) {
415
- this.mode = "list";
416
- this.pane.close();
417
- this.detailFacts = undefined;
471
+ this.closeDetail();
418
472
  return;
419
473
  }
420
- this.pane.update({ lines: source.lines, more: source.more });
474
+ if (this.detailText === undefined) this.pane.update({ lines: source.lines, more: source.more });
475
+ }
476
+
477
+ /**
478
+ * Wrap a prose body to the width the pane actually renders at, once per width.
479
+ * The pane's body lines are built at open, so this is the one source that can
480
+ * follow a resize.
481
+ */
482
+ private syncText(width: number): void {
483
+ if (this.detailText === undefined) return;
484
+ const inner = Math.max(1, width - BODY_INDENT);
485
+ if (this.detailWrap?.width === inner) return;
486
+ const lines = this.detailText.split("\n").flatMap((line) => (line === "" ? [""] : wrapTextWithAnsi(line, inner)));
487
+ this.detailWrap = { width: inner, lines };
488
+ this.pane.update({ lines });
421
489
  }
422
490
  }
package/lib/tui/glyphs.ts CHANGED
@@ -30,6 +30,7 @@ export const GLYPHS = {
30
30
  jobsFailure: "✗",
31
31
  stopped: "⊘",
32
32
  browse: "◉",
33
+ board: "▤",
33
34
  delegation: "↳",
34
35
  wechat: "✆",
35
36
  gaugeFull: "▰",
@@ -71,6 +72,7 @@ export const STATUS_KEYS = {
71
72
  watch: "watch",
72
73
  watchMatches: "watch-matches",
73
74
  browse: "browse",
75
+ board: "board",
74
76
  rewind: "rewind",
75
77
  agents: "agents",
76
78
  wechat: "wechat",
@@ -45,6 +45,11 @@ export class LinePane {
45
45
  visible = 1;
46
46
  /** Whether the view is parked at the tail; set false once the user scrolls up. */
47
47
  follow = true;
48
+ /**
49
+ * Whether this pane follows its tail. A log does; a prose or list pane does not:
50
+ * it opens at the top and never reports `following`/`paused`.
51
+ */
52
+ tail = true;
48
53
 
49
54
  private buffer: string[] = [];
50
55
  private label = "";
@@ -66,16 +71,21 @@ export class LinePane {
66
71
  return Math.max(0, this.buffer.length - this.visible);
67
72
  }
68
73
 
69
- /** Open the pane on a row, reset to the tail. */
70
- open(id: string, source: PaneSource): void {
74
+ /** Open the pane on a row, reset to the tail for a log, or to the top for prose. */
75
+ open(id: string, source: PaneSource, tail = true): void {
71
76
  this.id = id;
72
77
  this.buffer = [];
73
78
  this.label = "";
74
79
  this.subtitle = undefined;
75
80
  this.more = false;
76
81
  this.scroll = 0;
77
- this.follow = true;
82
+ this.tail = tail;
83
+ this.follow = tail;
78
84
  this.update(source);
85
+ if (!tail) {
86
+ this.follow = false;
87
+ this.scroll = 0;
88
+ }
79
89
  }
80
90
 
81
91
  /** Close the pane and drop its buffer. */
@@ -87,6 +97,7 @@ export class LinePane {
87
97
  this.more = false;
88
98
  this.scroll = 0;
89
99
  this.follow = true;
100
+ this.tail = true;
90
101
  }
91
102
 
92
103
  /**
@@ -127,7 +138,7 @@ export class LinePane {
127
138
  const parts: string[] = [];
128
139
  if (this.subtitle) parts.push(this.subtitle);
129
140
  if (this.more && this.buffer.length > 0) parts.push(`showing last ${this.buffer.length} lines`);
130
- if (this.buffer.length > 0) parts.push(this.follow ? "following" : "paused");
141
+ if (this.tail && this.buffer.length > 0) parts.push(this.follow ? "following" : "paused");
131
142
  return parts.join(" · ");
132
143
  }
133
144
 
package/lib/tui/tool.ts CHANGED
@@ -200,18 +200,29 @@ export function earlierHint(theme: Theme, hidden: number, label: string): string
200
200
  /** The row renderer a {@link TailResult} list uses: the extension's row grammar at a width. */
201
201
  export type TailRowLine<Row> = (row: Row, width: number, theme: Theme) => string;
202
202
 
203
- /** What a {@link TailResult} draws: the caller's rows plus a hidden count, or a bounded tail. */
203
+ /** One group of rows inside a {@link TailResult}, under an optional faint heading. */
204
+ export interface TailSection<Row> {
205
+ /** A faint heading above the rows, e.g. `Claims`; omit for the block's primary list. */
206
+ label?: string;
207
+ rows: readonly Row[];
208
+ more: number;
209
+ rowLine: TailRowLine<Row>;
210
+ }
211
+
212
+ /** What a {@link TailResult} draws: the caller's rows plus a hidden count, a headed row list, or a bounded tail. */
204
213
  export type TailResultInput<Row> =
205
214
  | { kind: "list"; rows: readonly Row[]; more: number; rowLine: TailRowLine<Row> }
215
+ | { kind: "sections"; sections: readonly TailSection<Row>[] }
206
216
  | { kind: "lines"; lines: readonly string[]; earlier: number; earlierLabel: string };
207
217
 
208
218
  /**
209
219
  * The transcript result block a tool that lists rows or tails output shares.
210
220
  *
211
221
  * One leading blank line per the block grammar, then either the caller's rows
212
- * (already ordered and capped) with a "more" note, or the themed tail with an
213
- * "earlier" note. The caller owns the row renderer, so the extension's own row
214
- * grammar — a peer-session tag, a status glyph — stays with its vocabulary.
222
+ * (already ordered and capped) with a "more" note, one headed group of rows per
223
+ * section, or the themed tail with an "earlier" note. The caller owns the row
224
+ * renderer, so the extension's own row grammar — a peer-session tag, a status
225
+ * glyph — stays with its vocabulary.
215
226
  */
216
227
  export class TailResult<Row> implements Component {
217
228
  private input: TailResultInput<Row>;
@@ -239,6 +250,14 @@ export class TailResult<Row> implements Component {
239
250
  if (input.more > 0) lines.push(clip(` ${overflowHint(this.theme, input.more)}`, w));
240
251
  return lines;
241
252
  }
253
+ if (input.kind === "sections") {
254
+ for (const section of input.sections) {
255
+ if (section.label) lines.push(clip(paint(this.theme, "faint", section.label), w));
256
+ for (const row of section.rows) lines.push(section.rowLine(row, w, this.theme));
257
+ if (section.more > 0) lines.push(clip(` ${overflowHint(this.theme, section.more)}`, w));
258
+ }
259
+ return lines;
260
+ }
242
261
  if (input.earlier > 0) lines.push(clip(earlierHint(this.theme, input.earlier, input.earlierLabel), w));
243
262
  for (const line of input.lines) lines.push(this.theme.fg("toolOutput", clip(line, w)));
244
263
  return lines;
package/lib/web/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The tokens, base rules, app shell, `bf-*` components, HTML atoms, and client
4
4
  runtime the two loopback pages draw with: the plan review
5
5
  ([`../../extensions/plan/web-page.ts`](../../extensions/plan/web-page.ts)) and
6
- file browse ([`../../extensions/file-browse/styles.ts`](../../extensions/file-browse/styles.ts)).
6
+ file browse ([`../../extensions/file-browse/view/styles.ts`](../../extensions/file-browse/view/styles.ts)).
7
7
  The module-by-module inventory is the `web/` row of [`../README.md`](../README.md);
8
8
  this file is the conventions those modules exist to make easy.
9
9
 
@@ -34,7 +34,10 @@ are computed rather than mapped:
34
34
  - `--scrim` and `--shadow` — per appearance, because a dimming layer and a lifted
35
35
  panel need their own alpha rather than a palette entry.
36
36
 
37
- Components read tokens only: no literal hex or `rgba()` in `components.ts`, and a
37
+ `--raised` is the one derived neutral: `--surface` mixed 6% (dark) or 3% (light)
38
+ toward `--text`, for a block that has to read as a plane above the page rather than
39
+ as an outline — the light step is smaller because a darker accent on a lighter step
40
+ falls below AA. Components read tokens only: no literal hex or `rgba()` in `components.ts`, and a
38
41
  test fails one. Contrast is asserted, not assumed — text, muted, and accent at
39
42
  4.5:1 on both surfaces, the primary label at 4.5:1 on the accent fill, `--dim` at
40
43
  3:1 because it carries the line-number gutter, and the status colours at 3:1.
@@ -18,6 +18,8 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
18
18
  .bf-btn:hover{border-color:var(--accent);color:var(--accent);text-decoration:none}
19
19
  .bf-btn[aria-pressed="true"]{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
20
20
  .bf-btn--primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
21
+ .bf-btn--danger{border-color:var(--danger);color:var(--danger)}
22
+ .bf-btn--danger:hover{border-color:var(--danger);background:var(--danger);color:var(--on-accent)}
21
23
  .bf-btn--primary:hover{filter:brightness(1.06);color:var(--on-accent)}
22
24
  .bf-btn--ghost{border-color:transparent;background:none;padding:0;font:inherit;color:inherit}
23
25
  .bf-btn--ghost:hover{color:var(--accent)}
@@ -36,6 +38,9 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
36
38
  .bf-segmented__item[aria-current="page"],.bf-segmented__item[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent)}
37
39
  .bf-input{border:0;background:transparent;color:var(--text);font:var(--fs-sm) var(--font-mono);width:100%;outline:none}
38
40
  .bf-field{display:flex;align-items:center;gap:var(--space-1);background:var(--canvas);border:1px solid var(--border);border-radius:var(--radius-md);padding:var(--space-1) var(--space-2);color:var(--muted);font-size:var(--fs-sm)}
41
+ /* The field is the control: ringing the input inside it drew a border within a border. */
42
+ .bf-field:focus-within{outline:var(--focus-ring) solid var(--accent);outline-offset:2px}
43
+ .bf-field .bf-input:focus-visible{outline:none}
39
44
  .bf-textarea{width:100%;min-height:72px;padding:var(--space-2);font:inherit;color:var(--text);background:var(--canvas);border:1px solid var(--border);border-radius:var(--radius-md);resize:vertical}
40
45
  .bf-panel{min-width:0;overflow:auto;overscroll-behavior:contain}
41
46
  .bf-panel__summary{display:none}
@@ -60,7 +65,7 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
60
65
  .bf-dialog mark{background:transparent;color:var(--warn);font-weight:var(--fw-semibold)}
61
66
  .bf-dialog__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)}
62
67
  .bf-dialog__head h2{margin:0;font-size:var(--fs-lg)}
63
- .bf-dialog__body{padding:var(--space-3) var(--space-4);font-size:var(--fs-sm);line-height:var(--lh-relaxed)}
68
+ .bf-dialog__body{padding:var(--space-3) var(--space-4);font-size:var(--fs-sm);line-height:var(--lh-relaxed);overflow:auto;min-height:0}
64
69
  .bf-dialog__body p{margin:0 0 var(--space-2)}
65
70
  .bf-dialog__body p:last-child{margin:0}
66
71
  .bf-dialog__hint{color:var(--muted)}
@@ -123,9 +123,15 @@ export function dialogScript(options: DialogOptions): string {
123
123
  // A button inside a closed dialog has no boxes, so the rects are the test that
124
124
  // survives a hidden attribute, a collapsed panel, and a stylesheet swap alike.
125
125
  function focusables() {
126
- return Array.prototype.filter.call(dialog.querySelectorAll('${FOCUSABLE}'), function (node) {
126
+ var nodes = Array.prototype.filter.call(dialog.querySelectorAll('${FOCUSABLE}'), function (node) {
127
127
  return node.getClientRects && node.getClientRects().length > 0;
128
128
  });
129
+ // A panel that names itself focusable is where focus lands on open, so the close
130
+ // button is not the first thing a dialog says with its ring. It stays first in the
131
+ // list, which is what keeps Shift+Tab wrapping inside instead of escaping the scrim.
132
+ var panel = dialog.querySelector('[role="dialog"][tabindex]');
133
+ if (panel && panel.getClientRects && panel.getClientRects().length > 0) nodes.unshift(panel);
134
+ return nodes;
129
135
  }
130
136
  function toggle(force) {
131
137
  var open = force === undefined ? dialog.hidden : force;