@gavin-hu/my-pi-agent 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -208,6 +208,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
208
208
  },
209
209
  todo: {},
210
210
  goal: {},
211
+ serve: { notes: ["Without it `/board`, `/browse`, and `/plan review` refuse, naming it."] },
211
212
  board: {},
212
213
  team: { notes: ["`assign` needs `session-bus` loaded to deliver a handoff; without it the card is untouched."] },
213
214
  check: {
@@ -256,8 +257,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
256
257
  "file-open": {
257
258
  notes: [
258
259
  "The `/open` command and its notification need an interactive UI; the `open_path` tool works without one.",
259
- "Launching the OS handler is best-effort: a launcher that fails to start, or exits non-zero on macOS and Linux, is reported; Windows `explorer` can exit non-zero after a successful open.",
260
- "The tool result names the target even when the launch fails.",
260
+ "Launching the OS handler is best-effort — a launcher that fails to start, or exits non-zero on macOS and Linux, is reported, Windows `explorer` can exit non-zero after a successful open, and the tool result names the target either way.",
261
261
  ],
262
262
  },
263
263
  memory: {
@@ -322,8 +322,8 @@ export interface ProbeSeams {
322
322
  readJson: (path: string) => JsonReadResult;
323
323
  dirs: DirSeams;
324
324
  platform: NodeJS.Platform;
325
- /** Whether the package will actually load this extension this session. */
326
- isEnabled: (extension: string) => boolean;
325
+ /** Why this session is not loading the extension, or undefined when it loads it. */
326
+ disabledReason: (extension: string) => string | undefined;
327
327
  /** Load an optional package; defaults to a real dynamic import. */
328
328
  loadModule?: (name: string) => Promise<unknown>;
329
329
  /** Resolve the nested-pi command; defaults to {@link getPiInvocation}. */
@@ -718,11 +718,11 @@ function configFindings(extension: string, spec: ExtensionRequirements, seams: P
718
718
  /** Probe one extension against its declaration and the current environment. */
719
719
  export async function probeExtension(extension: string, seams: ProbeSeams): Promise<ExtensionReport> {
720
720
  const spec = REQUIREMENTS[extension];
721
- const enabled = seams.isEnabled(extension);
721
+ const disabledReason = seams.disabledReason(extension);
722
722
  if (!spec) {
723
723
  return {
724
724
  extension,
725
- enabled,
725
+ enabled: disabledReason === undefined,
726
726
  findings: [
727
727
  {
728
728
  extension,
@@ -733,16 +733,16 @@ export async function probeExtension(extension: string, seams: ProbeSeams): Prom
733
733
  ],
734
734
  };
735
735
  }
736
- if (!enabled) {
736
+ if (disabledReason !== undefined) {
737
737
  return {
738
738
  extension,
739
- enabled,
739
+ enabled: false,
740
740
  findings: [
741
741
  {
742
742
  extension,
743
743
  check: "state",
744
744
  severity: "info",
745
- detail: "disabled through PI_DISABLED_EXTENSIONS",
745
+ detail: disabledReason,
746
746
  },
747
747
  ],
748
748
  };
@@ -755,7 +755,7 @@ export async function probeExtension(extension: string, seams: ProbeSeams): Prom
755
755
  for (const note of spec.notes ?? []) {
756
756
  findings.push({ extension, check: "platform", severity: "info", detail: note });
757
757
  }
758
- return { extension, enabled, findings };
758
+ return { extension, enabled: true, findings };
759
759
  }
760
760
 
761
761
  /** Probes in flight at once: enough to hide spawn latency, not a burst. */
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * The session label: the one name another session addresses this one by.
3
3
  *
4
- * `session-bus` settles a label for every session — the Pi session name, else the
5
- * working directory's basename, else the id — and the board records the same name
6
- * on a card it claims, so the two surfaces agree on who "billing-core" is without
7
- * the board reading the bus. The rules are here rather than in either extension
8
- * because a label is a shared vocabulary: it is displayed, it is a send target, and
9
- * it is stored in a card file that outlives one session.
4
+ * Two vocabularies share this module, and neither copies the other. `session-bus`
5
+ * settles its own label for a session that did not ask for one — the Pi session name,
6
+ * else the working directory's basename, else the id. The board names a session for
7
+ * the team instead: the page's seat, or a preset teammate name drawn from the id, or
8
+ * `team-agent-` and the id's tail when every preset one is taken — one name on every
9
+ * surface, and one that cannot drift on a rename. The rules are here rather than in
10
+ * either extension because a label is a shared vocabulary: it is displayed, it is a
11
+ * send target, and it is stored in a card file that outlives one session.
10
12
  *
11
13
  * Value-only: no state, no I/O.
12
14
  */
@@ -42,12 +44,90 @@ export function shortId(id: string): string {
42
44
  * The bus appends a suffix when two live sessions share one; that decision belongs
43
45
  * to the registry, so this is only the name a session would pick for itself.
44
46
  */
45
- export function defaultLabel(sessionName: string | undefined, cwd: string, id: string): string {
47
+ export function defaultLabel(sessionName: string | undefined, cwd: string, id = "session"): string {
46
48
  const directory = cwd.split(/[\\/]/).filter(Boolean).at(-1);
47
49
  const base = normalizeLabel(sessionName, normalizeLabel(directory, "session"));
48
50
  return normalizeLabel(base, `session-${shortId(id)}`);
49
51
  }
50
52
 
53
+ /** The prefix of the team's name for a session that is not the page's seat. */
54
+ export const TEAM_AGENT_PREFIX = "team-agent-";
55
+
56
+ /**
57
+ * The team's name for a session: the prefix and the id's tail.
58
+ *
59
+ * A team is addressed by id, not by a name a user chose: an id is unique, so two
60
+ * sessions cannot settle on one name, and a rename cannot strand the card that
61
+ * recorded it. The seat is the one name that is a role instead.
62
+ */
63
+ export function agentLabel(id: string): string {
64
+ return normalizeLabel(`${TEAM_AGENT_PREFIX}${shortId(id)}`, "team-agent");
65
+ }
66
+
67
+ /**
68
+ * The preset teammate names a session with no name of its own draws from.
69
+ *
70
+ * Familiar English names, one or two syllables each, short enough to type as a `to`
71
+ * target: a teammate should read as a person, and `lily` says more than
72
+ * `team-agent-9f21ac` to the lead who hands it a card.
73
+ */
74
+ export const TEAMMATE_NAMES = [
75
+ "tom",
76
+ "amy",
77
+ "lily",
78
+ "jack",
79
+ "lucy",
80
+ "mike",
81
+ "kate",
82
+ "sam",
83
+ "bob",
84
+ "anna",
85
+ "helen",
86
+ "peter",
87
+ "alice",
88
+ "emma",
89
+ "jenny",
90
+ "mary",
91
+ "linda",
92
+ "susan",
93
+ "tina",
94
+ "ben",
95
+ "may",
96
+ "rose",
97
+ "david",
98
+ "leo",
99
+ ] as const;
100
+
101
+ /** A 32-bit FNV-1a hash, so one id always starts the draw at the same name. */
102
+ function hashOf(text: string): number {
103
+ let hash = 0x811c9dc5;
104
+ for (let index = 0; index < text.length; index += 1) {
105
+ hash ^= text.charCodeAt(index);
106
+ hash = Math.imul(hash, 0x01000193);
107
+ }
108
+ return hash >>> 0;
109
+ }
110
+
111
+ /**
112
+ * The preset name a session with none of its own joins under.
113
+ *
114
+ * Its id hashes to a starting point in the list, and a name in `taken` walks on to the
115
+ * next free one; every name taken is `undefined`, so the caller keeps a fallback that
116
+ * cannot collide. Drawn from the id rather than at random, because the board writes a
117
+ * card before it joins: one session has to draw one name on every call and after a
118
+ * restart, so the card, the join, and the roster say the same word. `taken` is
119
+ * compared case-insensitively, the way a label is addressed.
120
+ */
121
+ export function presetNameFor(id: string, taken: Iterable<string> = []): string | undefined {
122
+ const held = new Set([...taken].map((label) => label.toLowerCase()));
123
+ const start = hashOf(id) % TEAMMATE_NAMES.length;
124
+ for (let step = 0; step < TEAMMATE_NAMES.length; step += 1) {
125
+ const name = TEAMMATE_NAMES[(start + step) % TEAMMATE_NAMES.length];
126
+ if (!held.has(name)) return name;
127
+ }
128
+ return undefined;
129
+ }
130
+
51
131
  /**
52
132
  * Pi's session name, which is the first choice for a label.
53
133
  *
@@ -62,3 +142,45 @@ export function sessionNameOf(ctx: Pick<ExtensionContext, "sessionManager">): st
62
142
  return undefined;
63
143
  }
64
144
  }
145
+
146
+ /**
147
+ * The session's id, or undefined when the host does not report one.
148
+ *
149
+ * Read beside the name because they are the same fact from two sides: a name is how a
150
+ * person reads a session, an id is how one is filed — a bus record, a card's owner, a
151
+ * resume. A host that refuses the call is a session with no id, not an error.
152
+ */
153
+ export function sessionIdOf(ctx: Pick<ExtensionContext, "sessionManager">): string | undefined {
154
+ try {
155
+ const id = ctx.sessionManager.getSessionId();
156
+ return typeof id === "string" && id !== "" ? id : undefined;
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * `pi.events` channel: a session settled the label its peers address it by.
164
+ *
165
+ * Here rather than in `lib/events.ts`, the way `lib/plan-entry.ts` and `lib/http/mount.ts`
166
+ * keep their own: a label is this module's vocabulary, and both sides of the channel — the
167
+ * bus that settles one and the footer that draws it — already load this module, so the
168
+ * literal costs a registration graph nothing and cannot be re-typed on one side.
169
+ */
170
+ export const SESSION_LABEL_CHANNEL = "my-pi-agent/session-label";
171
+
172
+ /**
173
+ * Payload on {@link SESSION_LABEL_CHANNEL}: one session's name on the bus.
174
+ *
175
+ * Emitted by `session-bus` whenever it settles or yields a label, and read by bytefly's
176
+ * footer, which draws it instead of Pi's own session name: the two part company the moment
177
+ * the board seats a session under a preset teammate name, or a collision appends an id. The
178
+ * id rides along because the channel reaches every session in the process, and only the one
179
+ * named here is the reader's own.
180
+ */
181
+ export interface SessionLabelEvent {
182
+ /** Pi session id the label belongs to. */
183
+ id: string;
184
+ /** The name another session addresses this one by. */
185
+ label: string;
186
+ }
@@ -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.
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { ExtensionAPI, ExtensionContext, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
25
+ import { type BusAnswer, requestOverBus } from "./bus-request.ts";
25
26
  import { messageOf } from "./errors.ts";
26
27
  import { watchingOf } from "./session-roster.ts";
27
28
  import { SESSION_TOOL } from "./tool-names.ts";
@@ -54,14 +55,30 @@ export type JoinResult = { ok: true; label: string; watching: Watching } | { ok:
54
55
  /** EventBus channel on which a caller asks `session-bus` to join the team for it. */
55
56
  export const TEAM_JOIN_REQUEST = "my-pi-agent/team-join/request";
56
57
 
58
+ /**
59
+ * What a join asks for.
60
+ *
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 — 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.
66
+ */
67
+ export interface TeamJoinInput {
68
+ label?: string;
69
+ wake?: boolean;
70
+ listen?: boolean;
71
+ name?: boolean;
72
+ }
73
+
57
74
  /** What a caller sends on {@link TEAM_JOIN_REQUEST}. */
58
75
  export interface TeamJoinRequest {
59
76
  /** The caller's context; the bus starts and names through it. */
60
77
  ctx: ExtensionContext;
61
78
  /** 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;
79
+ input: TeamJoinInput;
80
+ /** Claimed once, with what the bus answered; the caller settles on that claim. */
81
+ respond(result: BusAnswer<JoinResult>): void;
65
82
  }
66
83
 
67
84
  /** What a send did. */
@@ -96,49 +113,49 @@ function nestedMessage(result: { content?: Array<{ type: string; text?: string }
96
113
  /**
97
114
  * How an extension joins the team, whatever kind of context it is holding.
98
115
  *
99
- * Two extensions ask — `board` when the page opens or a card is worked, `team` for
116
+ * Two extensions ask — `board` when the page opens or a card is claimed, `team` for
100
117
  * `/team join` — and both hold a command or a tool context, so the request is the one
101
118
  * transport that works from either.
102
119
  */
103
120
  export interface TeamJoin {
104
- /** Join the team, naming this session when `label` is given. Never throws. */
105
- join(ctx: ExtensionContext, label?: string): Promise<JoinResult>;
121
+ /** Join the team as `input.label` when given. Never throws. */
122
+ join(ctx: ExtensionContext, input?: TeamJoinInput): Promise<JoinResult>;
106
123
  }
107
124
 
108
125
  /** The production join, bound to one extension's `pi`. */
109
126
  export function createTeamJoin(pi: Pick<ExtensionAPI, "events" | "getAllTools">): TeamJoin {
110
127
  return {
111
- join: (ctx, label) => join(pi, ctx, label === undefined ? {} : { label }),
128
+ join: (ctx, input) => join(pi, ctx, input ?? {}),
112
129
  };
113
130
  }
114
131
 
132
+ /** What a caller is told when there is no bus to join: one sentence on both paths. */
133
+ const NO_BUS = "the session bus is not loaded";
134
+
115
135
  /**
116
136
  * Join the team: on the bus, named when a label is given, and listening for mail.
117
137
  *
118
138
  * One call for all three, because that is one act — see `join` in
119
139
  * `extensions/session-bus` — and one transport for both kinds of caller: the request
120
- * travels on `pi.events` and the bus answers it.
140
+ * travels on `pi.events` and the bus answers it. Pi's session name is not one of the
141
+ * three: that is `input.name`, which only a caller meaning to rename asks for.
121
142
  *
122
143
  * With no bus loaded there is no listener, so the answer is the sentence the caller
123
144
  * 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.
145
+ * checked here, and the shared handshake refuses the same way if a bus is loaded but
146
+ * listening for nothing.
125
147
  */
126
148
  export async function join(
127
149
  pi: Pick<ExtensionAPI, "events" | "getAllTools">,
128
150
  ctx: ExtensionContext,
129
- input: { label?: string; wake?: boolean; listen?: boolean } = {},
151
+ input: TeamJoinInput = {},
130
152
  ): 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);
153
+ if (!pi.getAllTools().some((tool) => tool.name === SESSION_TOOL)) return { ok: false, error: NO_BUS };
154
+ return requestOverBus<JoinResult, TeamJoinRequest>(pi, {
155
+ channel: TEAM_JOIN_REQUEST,
156
+ payload: (respond) => ({ ctx, input, respond }),
157
+ unclaimed: () => ({ ok: false, error: NO_BUS }),
158
+ failed: (error) => ({ ok: false, error: messageOf(error) }),
142
159
  });
143
160
  }
144
161
 
@@ -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
 
@@ -160,9 +175,12 @@ hold themed strings in state unless `invalidate()` rebuilds them. A widget is
160
175
  derived from state, never authoritative.
161
176
 
162
177
  Pi renders the whole tree every frame, so a component that derives its lines from
163
- another one memoizes them with `RenderCache` (keyed on width and the source
164
- lines' identity) and drops the memo in `invalidate()`. Re-deriving every line per
165
- frame is what makes a long transcript feel slow.
178
+ another one memoizes them with `RenderCache` — `lines()` keyed on width and the
179
+ source lines' identity, or `keyed()` for a component with no inner component (a
180
+ header's view, a footer's snapshot), keyed on the parts it read: primitives by
181
+ value, an object such as the theme by identity — and drops the memo in
182
+ `invalidate()`. Re-deriving every line per frame is what makes a long transcript
183
+ feel slow.
166
184
 
167
185
  ## Focus
168
186
 
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.