@gavin-hu/my-pi-agent 0.21.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 (254) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +40 -26
  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 -24
  9. package/docs/extensions/board.md +361 -147
  10. package/docs/extensions/bytefly.md +152 -111
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +30 -31
  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 +166 -126
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +34 -18
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +72 -33
  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 +56 -15
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +5 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +4 -40
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +13 -11
  38. package/extensions/board/result.ts +30 -16
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +42 -10
  41. package/extensions/board/snapshot.ts +2 -1
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +131 -44
  44. package/extensions/board/types.ts +36 -32
  45. package/extensions/board/view/card.ts +229 -0
  46. package/extensions/board/view/client.ts +33 -15
  47. package/extensions/board/view/dialog.ts +169 -6
  48. package/extensions/board/view/empty.ts +47 -0
  49. package/extensions/board/view/keys.ts +13 -2
  50. package/extensions/board/view/live.ts +89 -21
  51. package/extensions/board/view/names.ts +13 -4
  52. package/extensions/board/view/page.ts +23 -9
  53. package/extensions/board/view/render.ts +208 -271
  54. package/extensions/board/view/styles.ts +123 -31
  55. package/extensions/board/view/team.ts +297 -76
  56. package/extensions/bytefly/block/fallback.ts +1 -1
  57. package/extensions/bytefly/doctor/runtime.ts +5 -2
  58. package/extensions/bytefly/footer/collect/label.ts +60 -0
  59. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  60. package/extensions/bytefly/footer/config.ts +4 -2
  61. package/extensions/bytefly/footer/register.ts +33 -4
  62. package/extensions/bytefly/footer/render/lines.ts +7 -4
  63. package/extensions/bytefly/footer/render/segments.ts +37 -10
  64. package/extensions/bytefly/footer/render/status.ts +0 -11
  65. package/extensions/bytefly/footer/types.ts +7 -0
  66. package/extensions/bytefly/header/config.ts +1 -1
  67. package/extensions/bytefly/index.ts +32 -6
  68. package/extensions/bytefly/mode/editor.ts +115 -0
  69. package/extensions/bytefly/panel/commands.ts +210 -120
  70. package/extensions/bytefly/panel/format.ts +4 -4
  71. package/extensions/bytefly/panel/settings.ts +73 -6
  72. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  73. package/extensions/bytefly/update/cache.ts +1 -1
  74. package/extensions/bytefly/update/register.ts +4 -4
  75. package/extensions/bytefly/working/config.ts +5 -7
  76. package/extensions/bytefly/working/format.ts +10 -13
  77. package/extensions/bytefly/working/register.ts +22 -27
  78. package/extensions/check/commands.ts +3 -2
  79. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  80. package/extensions/context/commands.ts +2 -2
  81. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  82. package/extensions/file-browse/index.ts +67 -36
  83. package/extensions/file-read/render.ts +4 -4
  84. package/extensions/goal/plan-route.ts +2 -2
  85. package/extensions/job/child.ts +527 -0
  86. package/extensions/job/clock.ts +142 -0
  87. package/extensions/job/commands.ts +4 -2
  88. package/extensions/job/config.ts +8 -1
  89. package/extensions/job/format.ts +22 -1
  90. package/extensions/job/log-writer.ts +13 -2
  91. package/extensions/job/registry.ts +63 -7
  92. package/extensions/job/render.ts +34 -0
  93. package/extensions/job/runtime.ts +296 -655
  94. package/extensions/job/session.ts +132 -19
  95. package/extensions/job/store.ts +7 -7
  96. package/extensions/job/tools.ts +3 -3
  97. package/extensions/job/types.ts +47 -0
  98. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
  99. package/extensions/job/waiters.ts +44 -1
  100. package/extensions/memory/commands.ts +4 -2
  101. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  102. package/extensions/plan/commands.ts +12 -9
  103. package/extensions/plan/format.ts +8 -2
  104. package/extensions/plan/git-bash.ts +115 -16
  105. package/extensions/plan/index.ts +53 -52
  106. package/extensions/plan/plan-index.ts +4 -2
  107. package/extensions/plan/plans.ts +5 -54
  108. package/extensions/plan/policy.ts +8 -10
  109. package/extensions/plan/review-lazy.ts +85 -0
  110. package/extensions/plan/review.ts +2 -2
  111. package/extensions/plan/runtime.ts +49 -39
  112. package/extensions/plan/sections.ts +1 -1
  113. package/extensions/plan/steps.ts +3 -3
  114. package/extensions/plan/todos.ts +1 -1
  115. package/extensions/plan/tools/cancel.ts +27 -62
  116. package/extensions/plan/tools/enter.ts +55 -81
  117. package/extensions/plan/tools/exit.ts +221 -238
  118. package/extensions/plan/tools/refusal.ts +18 -0
  119. package/extensions/plan/tools/steps.ts +75 -92
  120. package/extensions/plan/tools/write.ts +77 -120
  121. package/extensions/plan/tools.ts +188 -30
  122. package/extensions/plan/tui.ts +5 -5
  123. package/extensions/plan/types.ts +48 -5
  124. package/extensions/plan/web-client.ts +3 -0
  125. package/extensions/plan/web-lazy.ts +53 -0
  126. package/extensions/plan/web-markdown.ts +9 -67
  127. package/extensions/plan/web-payload.ts +3 -3
  128. package/extensions/plan/web-router.ts +18 -9
  129. package/extensions/plan/web.ts +1 -12
  130. package/extensions/question/tools.ts +6 -2
  131. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  132. package/extensions/rewind/commands/commands.ts +6 -3
  133. package/extensions/rewind/state/runtime.ts +1 -1
  134. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  135. package/extensions/rewind/view/review-tui.ts +4 -4
  136. package/extensions/serve/config.ts +10 -8
  137. package/extensions/serve/index.ts +17 -3
  138. package/extensions/serve/registry.ts +26 -17
  139. package/extensions/session-bus/commands.ts +11 -6
  140. package/extensions/session-bus/join-request.ts +2 -1
  141. package/extensions/session-bus/mail-watch.ts +26 -17
  142. package/extensions/session-bus/protocol.ts +15 -3
  143. package/extensions/session-bus/resolve.ts +5 -7
  144. package/extensions/session-bus/runtime.ts +40 -12
  145. package/extensions/session-bus/store.ts +101 -19
  146. package/extensions/session-bus/types.ts +19 -0
  147. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  148. package/extensions/subagent/agents.ts +1 -1
  149. package/extensions/subagent/commands.ts +4 -2
  150. package/extensions/subagent/format.ts +15 -15
  151. package/extensions/subagent/index.ts +1 -1
  152. package/extensions/subagent/render.ts +3 -3
  153. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  154. package/extensions/team/commands.ts +24 -7
  155. package/extensions/team/dispatch.ts +18 -4
  156. package/extensions/team/format.ts +17 -2
  157. package/extensions/team/index.ts +9 -8
  158. package/extensions/team/schema.ts +6 -5
  159. package/extensions/team/tools.ts +107 -45
  160. package/extensions/team/types.ts +3 -2
  161. package/extensions/todo/commands.ts +4 -2
  162. package/extensions/todo/format.ts +11 -0
  163. package/extensions/todo/render.ts +57 -0
  164. package/extensions/todo/runtime.ts +1 -1
  165. package/extensions/todo/tools.ts +1 -1
  166. package/extensions/todo/view/todo-screen.ts +122 -0
  167. package/extensions/todo/widget.ts +47 -0
  168. package/extensions/watch/commands.ts +4 -2
  169. package/extensions/watch/format.ts +36 -7
  170. package/extensions/watch/render.ts +57 -0
  171. package/extensions/watch/runtime.ts +27 -22
  172. package/extensions/watch/tools.ts +4 -4
  173. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  174. package/extensions/web-access/fetch/tool.ts +12 -4
  175. package/extensions/wechat/commands/commands.ts +18 -14
  176. package/extensions/wechat/index.ts +72 -37
  177. package/extensions/wechat/state/credentials.ts +1 -1
  178. package/extensions/wechat/state/state.ts +1 -1
  179. package/extensions/wechat/text/status.ts +112 -0
  180. package/extensions/wechat/types.ts +2 -2
  181. package/extensions/wechat/view/status-screen.ts +4 -103
  182. package/extensions/worktree/commands.ts +11 -9
  183. package/extensions/worktree/enter.ts +2 -2
  184. package/extensions/worktree/exit.ts +2 -2
  185. package/extensions/worktree/index.ts +5 -5
  186. package/extensions/worktree/screen.ts +3 -3
  187. package/extensions/worktree/status.ts +2 -2
  188. package/extensions/worktree/tools.ts +113 -116
  189. package/lib/README.md +27 -13
  190. package/lib/board/actions.ts +111 -0
  191. package/lib/board/artifact.ts +149 -0
  192. package/lib/board/card.ts +163 -52
  193. package/lib/board/config.ts +58 -0
  194. package/lib/board/contract.ts +113 -0
  195. package/lib/board/join.ts +38 -0
  196. package/lib/board/lanes.ts +182 -0
  197. package/lib/board/lead.ts +130 -0
  198. package/lib/board/limits.ts +78 -0
  199. package/lib/board/links.ts +31 -10
  200. package/lib/board/page-pointer.ts +1 -1
  201. package/lib/board/read.ts +83 -0
  202. package/lib/board/reason.ts +102 -0
  203. package/lib/board/round-text.ts +68 -6
  204. package/lib/board/round.ts +31 -15
  205. package/lib/board/rules.ts +151 -307
  206. package/lib/board/seat.ts +35 -0
  207. package/lib/board/session.ts +136 -54
  208. package/lib/board/skipped.ts +22 -0
  209. package/lib/board/steps.ts +487 -0
  210. package/lib/board/store.ts +87 -30
  211. package/lib/board/tone.ts +92 -0
  212. package/lib/board/view.ts +70 -58
  213. package/{extensions/watch → lib}/child.ts +40 -21
  214. package/lib/env.ts +65 -7
  215. package/lib/format.ts +13 -0
  216. package/lib/http/index.ts +14 -12
  217. package/lib/http/port.ts +15 -10
  218. package/lib/lazy.ts +45 -0
  219. package/lib/plan-entry.ts +39 -10
  220. package/lib/plans/paths.ts +82 -0
  221. package/lib/policy.ts +9 -6
  222. package/lib/report.ts +3 -2
  223. package/lib/requirements.ts +8 -8
  224. package/lib/session-label.ts +111 -5
  225. package/lib/session-roster.ts +43 -1
  226. package/lib/session-tool.ts +5 -4
  227. package/lib/stray-fields.ts +7 -2
  228. package/lib/tool-names.ts +5 -10
  229. package/lib/tui/README.md +23 -8
  230. package/lib/tui/chrome.ts +16 -15
  231. package/lib/tui/cursor.ts +18 -3
  232. package/lib/tui/glyphs.ts +9 -7
  233. package/lib/tui/tokens.ts +33 -2
  234. package/lib/tui/tool.ts +3 -3
  235. package/lib/web/README.md +21 -0
  236. package/lib/web/components.ts +5 -1
  237. package/lib/web/index.ts +1 -0
  238. package/lib/web/markdown.ts +83 -0
  239. package/lib/web/markup.ts +2 -1
  240. package/lib/web/runtime.ts +9 -10
  241. package/package.json +6 -1
  242. package/skills/init/SKILL.md +5 -5
  243. package/skills/next/SKILL.md +3 -5
  244. package/extensions/board/seat.ts +0 -28
  245. package/extensions/todo/tui.ts +0 -212
  246. package/lib/tui/index.ts +0 -34
  247. package/skills/project-docs/SKILL.md +0 -133
  248. package/skills/project-docs/references/audit.md +0 -131
  249. package/skills/project-docs/references/changelog.md +0 -105
  250. package/skills/project-docs/references/discovery.md +0 -127
  251. package/skills/project-docs/references/readme.md +0 -80
  252. package/skills/project-docs/references/release.md +0 -84
  253. package/skills/project-docs/references/roadmap.md +0 -93
  254. package/skills/project-docs/scripts/state.ts +0 -316
@@ -4,7 +4,7 @@
4
4
  * The Team tab lists teammates, and `session-bus` owns those records. Neither
5
5
  * extension may import the other, and the board must keep working when the bus is
6
6
  * not loaded — so the records are read where the bus already put them,
7
- * `<agentDir>/session-bus/<repo-key>/`, as files rather than through its code. The
7
+ * `<agent-dir>/session-bus/<repo-key>/`, as files rather than through its code. The
8
8
  * directory names, the record fields this reader trusts, and the liveness rule live
9
9
  * here once, and `session-bus` imports them, so a layout change cannot land in one
10
10
  * copy and not the other.
@@ -158,6 +158,48 @@ async function mailCount(dir: string, stem: string): Promise<number> {
158
158
  }
159
159
  }
160
160
 
161
+ /**
162
+ * One session's own label, found by the id it belongs to.
163
+ *
164
+ * The record is filed under the repository's key, which a caller without a board store
165
+ * would have to resolve with git first — and a footer asking for a name that may not
166
+ * exist should not cost two subprocesses at every session start. So the bus root is
167
+ * walked instead and the record is matched on its id: a session id belongs to one
168
+ * session, so the first record that names it is the answer wherever the bus filed it.
169
+ *
170
+ * Every miss is ordinary: a machine with no bus, or with records for other sessions
171
+ * only, answers `undefined` rather than an error.
172
+ */
173
+ export async function readSessionLabel(agentDir: string, id: string): Promise<string | undefined> {
174
+ const root = join(agentDir, BUS_DIR_NAME);
175
+ let keys: string[];
176
+ try {
177
+ keys = await readdir(root);
178
+ } catch {
179
+ return undefined;
180
+ }
181
+ for (const key of keys) {
182
+ const dir = join(root, key, SESSIONS_DIR_NAME);
183
+ let names: string[];
184
+ try {
185
+ names = await readdir(dir);
186
+ } catch {
187
+ continue;
188
+ }
189
+ for (const name of names) {
190
+ if (!name.endsWith(".json")) continue;
191
+ let parsed: unknown;
192
+ try {
193
+ parsed = JSON.parse(await readFile(join(dir, name), "utf-8"));
194
+ } catch {
195
+ continue;
196
+ }
197
+ if (isSessionPayload(parsed) && parsed.id === id) return parsed.label;
198
+ }
199
+ }
200
+ return undefined;
201
+ }
202
+
161
203
  /**
162
204
  * Every session the bus has recorded for one repository, or `undefined` when the bus
163
205
  * has never been used here.
@@ -2,7 +2,7 @@
2
2
  * The `session` tool, as the extensions that are not `session-bus` call it.
3
3
  *
4
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
5
+ * underneath, and neither may import it (`test/boundaries.test.ts`). So the one place
6
6
  * that turns a call into a typed answer lives here, next to the tool's name in
7
7
  * `lib/tool-names.ts` — two copies would be two copies of the details-shape
8
8
  * knowledge, and a bus that renamed a field would be broken in one of them.
@@ -59,9 +59,10 @@ export const TEAM_JOIN_REQUEST = "my-pi-agent/team-join/request";
59
59
  * What a join asks for.
60
60
  *
61
61
  * `label` is the name to be on the team as; without one the bus settles its own. `name`
62
- * is the separate question of whether that label also becomes Pi's session name — the
63
- * footer and `/resume` — and it is opt-in, because a join is not a rename: only
64
- * `/team join <label>` means "call me this from now on".
62
+ * is the separate question of whether that label also becomes Pi's session name — what
63
+ * `/resume` lists — and it is opt-in, because a join is not a rename: only
64
+ * `/team join <label>` means "call me this from now on". The footer needs no opt-in: it
65
+ * draws the label itself, so a plain join moves what the bar names this session.
65
66
  */
66
67
  export interface TeamJoinInput {
67
68
  label?: string;
@@ -18,6 +18,8 @@
18
18
  * Value-only, as `lib/` must be: the tables are the callers'.
19
19
  */
20
20
 
21
+ import { listOf } from "./format.ts";
22
+
21
23
  /** The fields each action reads, keyed in the order the actions are documented. */
22
24
  export type ActionFields = Readonly<Record<string, readonly string[]>>;
23
25
 
@@ -38,8 +40,11 @@ export function strayField(action: string, args: Record<string, unknown>, fields
38
40
  const named = (field: string): string => {
39
41
  const taking = Object.keys(fields).filter((candidate) => fields[candidate]?.includes(field));
40
42
  if (taking.length === 0) return `${field} belongs to no action`;
41
- const verbs = taking.map((one) => `\`${one}\``).join(" and ");
42
- return `${verbs} ${taking.length === 1 ? "takes" : "take"} ${stray.length === 1 ? "it" : field}`;
43
+ const verbs = taking.map((one) => `\`${one}\``);
44
+ // Two read as "a and b"; three or more take the serial comma, or the list stops saying
45
+ // where one verb ends and the next begins.
46
+ const list = verbs.length === 2 ? verbs.join(" and ") : listOf(verbs, "and");
47
+ return `${list} ${taking.length === 1 ? "takes" : "take"} ${stray.length === 1 ? "it" : field}`;
43
48
  };
44
49
  return `${action} does not act on ${stray.join(", ")}: ${stray.map(named).join("; ")}.`;
45
50
  }
package/lib/tool-names.ts CHANGED
@@ -17,11 +17,12 @@ export const TODO_TOOL = "todo";
17
17
  export const GOAL_TOOL = "goal";
18
18
 
19
19
  /**
20
- * The `enter_plan_mode` tool. `plan` registers it, and `goal` offers the same
21
- * phase to the user, so the name is shared: the request channel and the
22
- * registration probe both look it up.
20
+ * The `plan` tool. `plan` registers it and `goal` offers the same phase to the user,
21
+ * so the name is shared: the request channel and the registration probe both look
22
+ * it up. Its actions are the planning verbs, so one name replaces the five tools
23
+ * that used to spell them.
23
24
  */
24
- export const PLAN_ENTER_TOOL = "enter_plan_mode";
25
+ export const PLAN_TOOL = "plan";
25
26
 
26
27
  /** The `subagent` tool, which plan mode pins to read-only delegation. */
27
28
  export const SUBAGENT_TOOL = "subagent";
@@ -52,12 +53,6 @@ export const SUBAGENT_READ_ONLY_PARAM = "readOnly";
52
53
  */
53
54
  export const BOARD_TOOL = "board";
54
55
 
55
- /**
56
- * The one `board` action that reads. Plan mode admits it and refuses the rest, which
57
- * is the distinction the tool's `readOnlyHint` cannot carry: the same tool writes.
58
- */
59
- export const BOARD_READ_ACTION = "list";
60
-
61
56
  /**
62
57
  * The `read_doc` tool. `file-read` registers it, and the plan, rewind, and
63
58
  * subagent reader lists carry the name, so the document reader is one reader
package/lib/tui/README.md CHANGED
@@ -111,17 +111,32 @@ clipped mid-word at the widths the screen runs at.
111
111
 
112
112
  Resolve colors through a [`tokens.ts`](./tokens.ts) role
113
113
  (`paint(theme, role, text)`), never a literal ANSI sequence, hex value, or Pi
114
- token named inline; a theme swap must re-skin every surface. Use the shared
115
- `selectionMarker`, `GLYPHS`, `SEPARATORS`, and `STATUS_KEYS` instead of retyping
116
- their literals, `screenHint` / Pi's `keyHint` for the hint row, and
117
- `screenFooter` for the hint row with its blank and closing rule.
114
+ token named inline; a theme swap must re-skin every surface. The role table is
115
+ the one place a Pi token is named, its tool vocabulary (`toolTitle`,
116
+ `toolOutput`) included, so a renderer draws a tool block the way it draws a
117
+ list. Use the shared `selectionMarker`, `GLYPHS`, `SEPARATORS`, and `STATUS_KEYS`
118
+ instead of retyping their literals, `screenHint` / Pi's `keyHint` for the hint
119
+ row, and `screenFooter` for the hint row with its blank and closing rule.
118
120
 
119
121
  A list accents the focused row's label (`labelRole: selected ? "accent" :
120
122
  "label"`); the `❯` marker alone is not enough to tell which row is live. The
121
- hint row colors by role, not by taste: `accent` for the screen's primary action,
122
- `warning` for one that discards state (a revert, a kill, a stop, a clear, a
123
- forget), and `error` for one that ends the session. Keys that navigate, scroll,
124
- or select stay dim, so a colored key is one worth a second thought.
123
+ hint row colors by role, not by taste — `ScreenHint.color` is a `TuiRole`:
124
+ `accent` for the screen's primary action, `warn` for one that discards state (a
125
+ revert, a kill, a stop, a clear, a forget), and `bad` for one that ends the
126
+ session. Keys that navigate, scroll, or select stay `faint`, so a colored key is
127
+ one worth a second thought.
128
+
129
+ ## A hue identifies, a verdict judges
130
+
131
+ `ok` / `warn` / `bad` say how something *is*, and a surface that shows them is
132
+ making a judgement. `hue1`…`hue5` — blue, purple, pink, green, amber — say only
133
+ which category a row belongs to, which is what a table or a bar needs to be
134
+ told apart at a glance. They are the same tokens in places (green is `success`,
135
+ amber is `warning`), so the two vocabularies do not mix on one surface: a screen
136
+ that paints hues keeps its verdicts to the gauge, a status word, or a feedback
137
+ line, and a hue never lands in a slot that also carries a judgement. Color is
138
+ redundant there — the label, the order, and the printed number already say what
139
+ the hue repeats — so nothing may be encoded by hue alone.
125
140
 
126
141
  ## Width is columns
127
142
 
package/lib/tui/chrome.ts CHANGED
@@ -16,11 +16,12 @@
16
16
  * in, and the viewport options are explicit.
17
17
  */
18
18
 
19
- import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
19
+ import type { Theme } from "@earendil-works/pi-coding-agent";
20
20
  import { Text, truncateToWidth, visibleWidth, type EditorTheme, type OverlayOptions } from "@earendil-works/pi-tui";
21
21
  import { SEPARATORS } from "./glyphs.ts";
22
22
  import { BODY_INDENT } from "./indent.ts";
23
23
  import { plainRule } from "./rules.ts";
24
+ import { paint, type TuiRole } from "./tokens.ts";
24
25
 
25
26
  /**
26
27
  * `ctx.ui.custom` options that give a screen the whole terminal.
@@ -61,15 +62,15 @@ export function viewportRows(source: ViewportRowsSource | undefined, options: Vi
61
62
  }
62
63
 
63
64
  /**
64
- * One key-hint entry: plain dim text, or text in a chosen semantic color so a
65
- * primary action (approve, submit) stands out from the scroll hints.
65
+ * One key-hint entry: plain faint text, or text in a chosen role so a primary
66
+ * action (approve, submit) stands out from the scroll hints.
66
67
  */
67
- export type ScreenHint = string | { text: string; color?: ThemeColor };
68
+ export type ScreenHint = string | { text: string; color?: TuiRole };
68
69
 
69
70
  /**
70
71
  * A key-hint footer indented by `indent` columns (default `BODY_INDENT`, which
71
- * hangs it under a screen's title rule), joined with ` · `. Plain hints are dim;
72
- * an object hint uses its color (dim when omitted). Hints are added while they
72
+ * hangs it under a screen's title rule), joined with ` · `. Plain hints are
73
+ * `faint`; an object hint names a role (`faint` when omitted). Hints are added while they
73
74
  * fit, so a narrow terminal drops whole keys instead of truncating one in half.
74
75
  * The first hint is always kept; the result is clipped to `width`.
75
76
  */
@@ -78,8 +79,8 @@ export function screenHint(theme: Theme, width: number, hints: ScreenHint[], ind
78
79
  // Keep a column for the hint itself, however deep the caller indents.
79
80
  const lead = " ".repeat(Math.max(0, Math.min(Math.floor(indent), target - 1)));
80
81
  const render = (hint: ScreenHint): string =>
81
- typeof hint === "string" ? theme.fg("dim", hint) : theme.fg(hint.color ?? "dim", hint.text);
82
- const separator = theme.fg("dim", SEPARATORS.item);
82
+ typeof hint === "string" ? paint(theme, "faint", hint) : paint(theme, hint.color ?? "faint", hint.text);
83
+ const separator = paint(theme, "faint", SEPARATORS.item);
83
84
  const kept: ScreenHint[] = [];
84
85
  for (const hint of hints) {
85
86
  const candidate = [...kept, hint].map(render).join(separator);
@@ -196,16 +197,16 @@ export function toolDetails<T extends object>(details: unknown): T | undefined {
196
197
  return Object.keys(details).length > 0 ? (details as T) : undefined;
197
198
  }
198
199
 
199
- /** Minimal editor theme for an extension-owned inline editor, on semantic tokens. */
200
+ /** Minimal editor theme for an extension-owned inline editor, on semantic roles. */
200
201
  export function editorTheme(theme: Theme): EditorTheme {
201
202
  return {
202
- borderColor: (text) => theme.fg("borderMuted", text),
203
+ borderColor: (text) => paint(theme, "rule", text),
203
204
  selectList: {
204
- selectedPrefix: (text) => theme.fg("accent", text),
205
- selectedText: (text) => theme.fg("accent", text),
206
- description: (text) => theme.fg("muted", text),
207
- scrollInfo: (text) => theme.fg("dim", text),
208
- noMatch: (text) => theme.fg("warning", text),
205
+ selectedPrefix: (text) => paint(theme, "accent", text),
206
+ selectedText: (text) => paint(theme, "accent", text),
207
+ description: (text) => paint(theme, "meta", text),
208
+ scrollInfo: (text) => paint(theme, "faint", text),
209
+ noMatch: (text) => paint(theme, "warn", text),
209
210
  },
210
211
  };
211
212
  }
package/lib/tui/cursor.ts CHANGED
@@ -20,6 +20,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
20
20
  import { Key, matchesKey, truncateToWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
21
21
  import { viewportRows, type ViewportRowsSource } from "./chrome.ts";
22
22
  import { GLYPHS } from "./glyphs.ts";
23
+ import { paint } from "./tokens.ts";
23
24
 
24
25
  /**
25
26
  * Rows that fit for a list of `itemCount`, reserving one extra line when the
@@ -76,11 +77,11 @@ export function wheelDelta(event: TuiMouseEvent): number | undefined {
76
77
 
77
78
  /** The `❯ ` selection marker (accent) or a same-width blank. */
78
79
  export function selectionMarker(theme: Theme, selected: boolean): string {
79
- return selected ? theme.fg("accent", `${GLYPHS.prompt} `) : " ";
80
+ return selected ? paint(theme, "accent", `${GLYPHS.prompt} `) : " ";
80
81
  }
81
82
 
82
83
  /**
83
- * One dim, two-space-indented range row: `showing A–B of N` by default, or
84
+ * One faint, two-space-indented range row: `showing A–B of N` by default, or
84
85
  * `line A–B of N` for a log pane. Clipped to `width`.
85
86
  */
86
87
  export function formatRange(
@@ -90,7 +91,7 @@ export function formatRange(
90
91
  ): string {
91
92
  const label = range.label ?? "showing";
92
93
  const text = ` ${label} ${range.start + 1}–${range.end} of ${range.total}`;
93
- return truncateToWidth(theme.fg("dim", text), Math.max(1, width));
94
+ return truncateToWidth(paint(theme, "faint", text), Math.max(1, width));
94
95
  }
95
96
 
96
97
  /**
@@ -247,6 +248,20 @@ export function isCloseKey(data: string): boolean {
247
248
  return matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"));
248
249
  }
249
250
 
251
+ /** Shift+Tab arrives as this sequence on terminals without the Kitty protocol. */
252
+ export const SHIFT_TAB = "\u001b[Z";
253
+
254
+ /**
255
+ * Whether `data` is Shift+Tab, in either encoding.
256
+ *
257
+ * `matchesKey` decodes the Kitty and modifyOtherKeys forms but not the legacy
258
+ * `\u001b[Z` one, so every screen or editor that binds Shift+Tab needs both tests;
259
+ * this is that one test.
260
+ */
261
+ export function isShiftTab(data: string): boolean {
262
+ return data === SHIFT_TAB || matchesKey(data, Key.shift("tab"));
263
+ }
264
+
250
265
  /**
251
266
  * The next scroll offset for a standard navigation key, or `undefined` when
252
267
  * `data` is not a navigation key.
package/lib/tui/glyphs.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { keyText, type Theme } from "@earendil-works/pi-coding-agent";
11
+ import { paint } from "./tokens.ts";
11
12
 
12
13
  /**
13
14
  * Leading glyphs for status chips, footer segments, and transcript rails.
@@ -50,6 +51,7 @@ export const GLYPHS = {
50
51
  prev: "‹",
51
52
  next: "›",
52
53
  update: "↑",
54
+ autoMode: "▶",
53
55
  prompt: "❯",
54
56
  } as const;
55
57
 
@@ -82,25 +84,25 @@ export const STATUS_KEYS = {
82
84
  */
83
85
  export const EXPAND_KEYBINDING = "app.tools.expand";
84
86
 
85
- /** The bound expand key, styled dim, or `""` when the binding is unknown. */
87
+ /** The bound expand key, in the faint role, or `""` when the binding is unknown. */
86
88
  export function expandKey(theme: Theme): string {
87
89
  const key = keyText(EXPAND_KEYBINDING);
88
- return key ? theme.fg("dim", key) : "";
90
+ return key ? paint(theme, "faint", key) : "";
89
91
  }
90
92
 
91
93
  /**
92
- * Pi's key-hint format: the key dim, its description muted. An unbound key
94
+ * Pi's key-hint format: the key faint, its description meta. An unbound key
93
95
  * (`""`) yields `""`, so a hint never prints a stray description.
94
96
  */
95
97
  export function keyHintText(theme: Theme, key: string, description: string): string {
96
- return key ? theme.fg("dim", key) + theme.fg("muted", ` ${description}`) : "";
98
+ return key ? paint(theme, "faint", key) + paint(theme, "meta", ` ${description}`) : "";
97
99
  }
98
100
 
99
101
  /**
100
- * The built-in-style expand affordance: a muted `(ctrl+o to expand)` with the
101
- * key in dim. Degrades to `(to expand)` when the binding is unknown.
102
+ * The built-in-style expand affordance: a meta `(ctrl+o to expand)` with the
103
+ * key in faint. Degrades to `(to expand)` when the binding is unknown.
102
104
  */
103
105
  export function expandHint(theme: Theme): string {
104
106
  const hint = keyHintText(theme, keyText(EXPAND_KEYBINDING), "to expand)");
105
- return theme.fg("muted", "(") + (hint || theme.fg("muted", "to expand)"));
107
+ return paint(theme, "meta", "(") + (hint || paint(theme, "meta", "to expand)"));
106
108
  }
package/lib/tui/tokens.ts CHANGED
@@ -5,9 +5,16 @@
5
5
  * A component names a role (`title`, `meta`, `bad`) and the active theme
6
6
  * supplies the color, so switching themes re-skins every surface without a
7
7
  * component changing. The mapping is deliberately indirect: Pi's token set is
8
- * closed, so the kit only ever references it, never a literal color.
8
+ * closed, so the kit only ever references it, never a literal color — and this
9
+ * table is the one place it does, tool vocabulary included, so a remap here
10
+ * reaches every surface the kit and the renderers draw.
9
11
  *
10
12
  * Value-only: one frozen lookup table and pure helpers.
13
+ *
14
+ * Two vocabularies live here and they do not mix: `ok`/`warn`/`bad` are verdicts —
15
+ * they say how something *is* — and `hue1`…`hue5` are categories, which say only
16
+ * "this is not those". A surface that shows hues keeps its verdicts to the places
17
+ * that earn one, because a reader cannot tell a borrowed green from a judgement.
11
18
  */
12
19
 
13
20
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
@@ -35,7 +42,24 @@ export type TuiRole =
35
42
  /** Error. */
36
43
  | "bad"
37
44
  /** Neutral notice. */
38
- | "info";
45
+ | "info"
46
+ /**
47
+ * A category hue: identity, never a verdict.
48
+ *
49
+ * Blue, purple, pink, green, and amber are the shipped palette's five colourful
50
+ * tokens, so a categorical surface reads as a chart. Green and amber are the same
51
+ * tokens `ok` and `warn` resolve to — that is the cost of Pi's closed token set —
52
+ * which is why a hue never appears in a slot that also carries a judgement.
53
+ */
54
+ | "hue1"
55
+ | "hue2"
56
+ | "hue3"
57
+ | "hue4"
58
+ | "hue5"
59
+ /** A tool call's own name: the transcript block's only accent. */
60
+ | "toolTitle"
61
+ /** A tool result's body text. */
62
+ | "toolOutput";
39
63
 
40
64
  /** Role -> theme color. The single place the kit names a Pi token. */
41
65
  export const TUI_TOKENS: Readonly<Record<TuiRole, ThemeColor>> = {
@@ -50,6 +74,13 @@ export const TUI_TOKENS: Readonly<Record<TuiRole, ThemeColor>> = {
50
74
  warn: "warning",
51
75
  bad: "error",
52
76
  info: "muted",
77
+ hue1: "accent",
78
+ hue2: "mdListBullet",
79
+ hue3: "mdCode",
80
+ hue4: "success",
81
+ hue5: "warning",
82
+ toolTitle: "toolTitle",
83
+ toolOutput: "toolOutput",
53
84
  };
54
85
 
55
86
  /** Paint `text` in a role's color. */
package/lib/tui/tool.ts CHANGED
@@ -31,9 +31,9 @@ export interface ToolMessageResult {
31
31
  isError?: boolean;
32
32
  }
33
33
 
34
- /** The bold tool name in Pi's tool-title token, the block's only accent. */
34
+ /** The bold tool name in the toolTitle role, the block's only accent. */
35
35
  export function toolCallTitle(theme: Theme, name: string): string {
36
- return theme.fg("toolTitle", theme.bold(name));
36
+ return paint(theme, "toolTitle", theme.bold(name));
37
37
  }
38
38
 
39
39
  /**
@@ -275,7 +275,7 @@ export class TailResult<Row> implements Component {
275
275
  return lines;
276
276
  }
277
277
  if (input.earlier > 0) lines.push(clip(earlierHint(this.theme, input.earlier, input.earlierLabel), w));
278
- for (const line of input.lines) lines.push(this.theme.fg("toolOutput", clip(line, w)));
278
+ for (const line of input.lines) lines.push(paint(this.theme, "toolOutput", clip(line, w)));
279
279
  return lines;
280
280
  }
281
281
  }
package/lib/web/README.md CHANGED
@@ -42,6 +42,27 @@ What stays page-side: what a status *says*. `paintStatus` owns the text, its too
42
42
  the `bf-status--<tone>` class; the board composes `live · 3m` and appends its own class.
43
43
  A shared runtime that also decided the wording would be a second copy of the page's voice.
44
44
 
45
+ ## One inline Markdown renderer, spliced in
46
+
47
+ Two served pages read Markdown written by a model or a person: the plan review's document
48
+ and the board's card notes. Both need the same two answers — what an inline token is, and
49
+ which target a link may point at — so `markdown.ts` holds one body of each and exports
50
+ `INLINE_MARKDOWN_JS` for a bundle to splice. The review page's block half
51
+ ([`../../extensions/plan/web-markdown.ts`](../../extensions/plan/web-markdown.ts)) calls
52
+ `renderInline` for the text inside a block; the board calls it for each note. A page that
53
+ writes its own is how one of them starts accepting a `javascript:` target the other refuses.
54
+
55
+ The renderer appends a text node or one of four atoms — `code`, `strong`, `em`, `a` — and
56
+ never parses markup, so the board's rule that a stored field is never `innerHTML` holds with
57
+ Markdown in play. A link is made only when `safeHref` allows the target: `http`, `https`, or
58
+ `mailto`, no credentials, and no protocol-relative or disguised relative target, resolved
59
+ against the page's own URL at call time.
60
+
61
+ The body is JavaScript on purpose, like the runtime's: no annotation survives into the
62
+ string, and no backtick or `${` may appear in one. It is the inline half only — a card note
63
+ is one sanitized line, so inline is the whole of it, while blocks, tables, and fenced code
64
+ stay the review page's own half.
65
+
45
66
  ## Name a class once, in a table
46
67
 
47
68
  A page's document is TypeScript and its client is a string inside a template literal, so
@@ -28,6 +28,8 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
28
28
  .bf-btn:disabled{opacity:.5;cursor:default}
29
29
  .bf-chip{display:inline-flex;align-items:center;gap:5px;font-size:var(--fs-xs);color:var(--muted);white-space:nowrap}
30
30
  .bf-chip--accent{color:var(--accent);border:1px solid currentColor;border-radius:var(--radius-pill);padding:2px 10px}
31
+ /* A field the card carries rather than a state it is in: the same pill, worn quietly. */
32
+ .bf-chip--outline{color:var(--muted);border:1px solid currentColor;border-radius:var(--radius-pill);padding:2px 10px}
31
33
  .bf-chip--success{color:var(--success)}
32
34
  .bf-chip--warn{color:var(--warn)}
33
35
  .bf-chip--danger{color:var(--danger)}
@@ -35,7 +37,7 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
35
37
  .bf-segmented__item{appearance:none;border:0;border-radius:0;background:transparent;color:var(--text);font:var(--fs-xs) var(--font-ui);padding:5px var(--space-3);cursor:pointer}
36
38
  .bf-segmented__item+.bf-segmented__item{border-left:1px solid var(--border)}
37
39
  .bf-segmented__item:hover{color:var(--accent);text-decoration:none}
38
- .bf-segmented__item[aria-current="page"],.bf-segmented__item[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent)}
40
+ .bf-segmented__item[aria-current="page"],.bf-segmented__item[aria-selected="true"],.bf-segmented__item[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent)}
39
41
  .bf-input{border:0;background:transparent;color:var(--text);font:var(--fs-sm) var(--font-mono);width:100%;outline:none}
40
42
  .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
43
  /* The field is the control: ringing the input inside it drew a border within a border. */
@@ -73,6 +75,8 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
73
75
  .bf-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
74
76
  .bf-keys dt{white-space:nowrap}
75
77
  .bf-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
78
+ /* One line under the table, for a rule that is not a key: what an open dialog keeps. */
79
+ .bf-keys__note{margin:0;padding:0 var(--space-4) var(--space-4);color:var(--muted);font-size:var(--fs-xs)}
76
80
  .bf-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
77
81
  .bf-tree{font-family:var(--font-mono);font-size:var(--fs-sm);overflow-x:auto}
78
82
  .bf-tree ul{list-style:none;margin:0;padding-left:14px}
package/lib/web/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./base.ts";
8
8
  export * from "./client-runtime.ts";
9
9
  export * from "./components.ts";
10
10
  export * from "./layout.ts";
11
+ export * from "./markdown.ts";
11
12
  export * from "./markup.ts";
12
13
  export * from "./names.ts";
13
14
  export * from "./runtime.ts";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Inline Markdown, for every served page: one renderer and one link policy.
3
+ *
4
+ * Two pages read Markdown written by a model or a person — the plan review's document and
5
+ * the board's card notes — and each needs the same two answers: what an inline token is,
6
+ * and which target a link may point at. A second copy of either is how one page starts
7
+ * accepting a `javascript:` target the other refuses, so the body lives here and each page
8
+ * splices it into its own bundle.
9
+ *
10
+ * The renderer appends **text nodes and four elements only** — `code`, `strong`, `em`, and
11
+ * `a` — so plan, comment, and note text never becomes markup and no page needs `innerHTML`.
12
+ * A link is made only for a target `safeHref` allows: `http`, `https`, or `mailto`, no
13
+ * credentials, and no protocol-relative or disguised relative target.
14
+ *
15
+ * It is the inline half only. A card note is one sanitized line, so inline is the whole of
16
+ * it; the review page's blocks, tables, fenced code, and lists are its own half, and they
17
+ * call `renderInline` for the text inside each.
18
+ *
19
+ * The body is JavaScript: no annotation survives into a string, and no backtick or `${`
20
+ * may appear in one. `base` is the page's own URL at call time, which is what makes a
21
+ * target that only looks relative land as text.
22
+ */
23
+ export const INLINE_MARKDOWN_JS = String.raw` // A link is rendered only for an http(s) or mailto target. Resolving against
24
+ // the review origin is what catches a target that only looks relative: the
25
+ // URL parser strips a tab or newline, so "/\t/evil.com" is cross-origin once
26
+ // it is parsed, and a disguised target must stay text.
27
+ function safeHref(url, base) {
28
+ var trimmed = String(url).trim();
29
+ if (!trimmed || /^\/\//.test(trimmed)) return null;
30
+ var resolved;
31
+ try { resolved = new URL(trimmed, base); } catch (error) { return null; }
32
+ if (resolved.protocol === "mailto:") return trimmed;
33
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null;
34
+ if (resolved.username || resolved.password) return null;
35
+ if (resolved.origin === new URL(base).origin) return resolved.href;
36
+ return /^https?:\/\//i.test(trimmed) ? resolved.href : null;
37
+ }
38
+
39
+ // Inline token order matters: code, emphasis, and links are matched before a
40
+ // bare URL, so a URL inside a link target or a code span is never re-split.
41
+ var INLINE = /(\x60[^\x60]+\x60|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|(?:https?:\/\/|www\.)[^\s<]+)/g;
42
+
43
+ function renderInline(container, text) {
44
+ String(text).split(INLINE).forEach(function (part) {
45
+ if (!part) return;
46
+ var match;
47
+ if ((match = /^\x60([^\x60]+)\x60$/.exec(part))) {
48
+ var code = el("code"); code.textContent = match[1]; container.appendChild(code); return;
49
+ }
50
+ if ((match = /^\*\*([^*]+)\*\*$/.exec(part))) {
51
+ var strong = el("strong"); strong.textContent = match[1]; container.appendChild(strong); return;
52
+ }
53
+ if ((match = /^\*([^*]+)\*$/.exec(part))) {
54
+ var em = el("em"); em.textContent = match[1]; container.appendChild(em); return;
55
+ }
56
+ if ((match = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part))) {
57
+ var href = safeHref(match[2], location.href);
58
+ if (!href) { container.appendChild(document.createTextNode(part)); return; }
59
+ var link = el("a"); link.textContent = match[1]; link.href = href; link.target = "_blank"; link.rel = "noreferrer noopener";
60
+ container.appendChild(link); return;
61
+ }
62
+ // GFM extended autolink: a bare http(s) or www. URL in running text.
63
+ if ((match = /^(?:https?:\/\/|www\.)[^\s<]+$/.exec(part))) {
64
+ var url = match[0].replace(/[.,;:!?*_~]+$/, "");
65
+ while (url.charAt(url.length - 1) === ")" && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
66
+ url = url.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
67
+ }
68
+ var dot = url.indexOf(".");
69
+ var target = /^www\./i.test(url) ? "http://" + url : url;
70
+ var safe = dot > 0 && /\.[^./]/.test(url.slice(dot)) ? safeHref(target, location.href) : null;
71
+ if (safe) {
72
+ var auto = el("a");
73
+ auto.textContent = url;
74
+ auto.href = safe;
75
+ auto.target = "_blank";
76
+ auto.rel = "noreferrer noopener";
77
+ container.appendChild(auto);
78
+ return;
79
+ }
80
+ }
81
+ container.appendChild(document.createTextNode(part));
82
+ });
83
+ }`;
package/lib/web/markup.ts CHANGED
@@ -219,7 +219,7 @@ export interface KeyHint {
219
219
  * for the sheet, `#keys-close` for its own close button — and the trigger button
220
220
  * is the page's own concern, since the two pages name it differently.
221
221
  */
222
- export function keysSheet(rows: readonly KeyHint[]): string {
222
+ export function keysSheet(rows: readonly KeyHint[], note?: string): string {
223
223
  const kbd = (key: string): string => `<kbd class="bf-kbd">${escapeHtml(key)}</kbd>`;
224
224
  const body = rows
225
225
  .map((row) => {
@@ -231,6 +231,7 @@ export function keysSheet(rows: readonly KeyHint[]): string {
231
231
  <div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
232
232
  <div class="bf-dialog__head"><h2 id="keys-title">Keyboard shortcuts</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="keys-close">Close</button></div>
233
233
  <dl class="bf-keys">${body}</dl>
234
+ ${note === undefined ? "" : `<p class="bf-keys__note">${escapeHtml(note)}</p>`}
234
235
  </div>
235
236
  </div>`;
236
237
  }
@@ -156,20 +156,19 @@ export function dialogScript(options: DialogOptions): string {
156
156
  });
157
157
 
158
158
  // Tab cycles inside the dialog: leaving it would put focus behind the scrim,
159
- // where the page looks inert but is not.
159
+ // where the page looks inert but is not. Every Tab is redirected rather than only
160
+ // the two that wrap, because the panel is the first node and the *container* of the
161
+ // others: a browser's own backwards step from the last node has no sibling to land
162
+ // on and walks out of the dialog instead.
160
163
  dialog.addEventListener("keydown", function (event) {
161
164
  if (event.key !== "Tab") return;
162
165
  var nodes = focusables();
163
166
  if (nodes.length === 0) { event.preventDefault(); return; }
164
- var first = nodes[0];
165
- var last = nodes[nodes.length - 1];
166
- if (event.shiftKey && document.activeElement === first) {
167
- event.preventDefault();
168
- last.focus();
169
- } else if (!event.shiftKey && document.activeElement === last) {
170
- event.preventDefault();
171
- first.focus();
172
- }
167
+ var at = nodes.indexOf(document.activeElement);
168
+ var step = event.shiftKey ? -1 : 1;
169
+ var next = at === -1 ? (event.shiftKey ? nodes.length - 1 : 0) : (at + step + nodes.length) % nodes.length;
170
+ event.preventDefault();
171
+ nodes[next].focus();
173
172
  });
174
173
 
175
174
  // Capture, so Esc closes the dialog before a page handler peels another layer.