@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
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { homedir } from "node:os";
13
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
14
14
  import { SUBAGENT_FINISHED_CHANNEL } from "../../../lib/events.ts";
15
15
  import { budgetBody, budgetNotice, budgetState } from "./budget.ts";
16
16
  import { createSnapshotReader, type SnapshotReader } from "./collect/snapshot.ts";
@@ -32,6 +32,30 @@ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
32
32
  const delegation = createDelegationLedger();
33
33
  pi.events.on(SUBAGENT_FINISHED_CHANNEL, (event) => delegation.note(event));
34
34
 
35
+ // The label a peer knows this session by, once the follower below reports one; `null` is
36
+ // no record, and the bar keeps Pi's own session name.
37
+ let rosterLabel: string | null = null;
38
+ /** Whether this session's label is being followed: a tree change re-installs, it is not new. */
39
+ let followed = false;
40
+ const followLabel = (ctx: ExtensionContext): void => {
41
+ if (followed) return;
42
+ followed = true;
43
+ // Loaded where it is used: the follower walks the bus's layout on disk and subscribes to
44
+ // its label, and the registration graph is a budget that may only fall.
45
+ void import("./collect/label.ts")
46
+ .then(({ followRosterLabel }) =>
47
+ followRosterLabel({
48
+ pi,
49
+ ctx,
50
+ agentDir: getAgentDir(),
51
+ sink: (label) => {
52
+ rosterLabel = label;
53
+ },
54
+ }),
55
+ )
56
+ .catch(() => {});
57
+ };
58
+
35
59
  /** The installed reader and the budget it was priced with, while a footer is up. */
36
60
  let budget: { limit: number; read: SnapshotReader } | undefined;
37
61
  /** One warning per session: the notice stays in scrollback, and a re-install must not repeat it. */
@@ -41,8 +65,11 @@ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
41
65
 
42
66
  const install = (ctx: ExtensionContext): void => {
43
67
  if (ctx.mode !== "tui") return;
68
+ // Not awaited: a footer is installed synchronously, and the bar draws Pi's name until the
69
+ // label lands — which is what a session with no record shows anyway.
70
+ followLabel(ctx);
44
71
  const config = loadByteflyConfig(ctx.cwd);
45
- const read = createSnapshotReader(config, delegation);
72
+ const read = createSnapshotReader(config, delegation, () => rosterLabel);
46
73
  budget = { limit: config.budget, read };
47
74
 
48
75
  ctx.ui.setFooter((tui, _theme, footerData) =>
@@ -57,9 +84,11 @@ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
57
84
  };
58
85
 
59
86
  pi.on("session_start", (_event, ctx) => {
60
- // A new session in this process starts from zero: the ledger is not branch state,
61
- // and a budget that was already reported is unreported again.
87
+ // A new session in this process starts from zero: the ledger is not branch state, the
88
+ // label belongs to the session, and a budget that was already reported is unreported again.
62
89
  delegation.reset();
90
+ followed = false;
91
+ rosterLabel = null;
63
92
  warned = false;
64
93
  warnedUnpriced = false;
65
94
  install(ctx);
@@ -9,14 +9,25 @@
9
9
 
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
11
  import type { Component } from "@earendil-works/pi-tui";
12
+ import { RenderCache } from "../../../../lib/tui/render-cache.ts";
13
+ import { snapshotKey } from "./key.ts";
12
14
  import { renderLine } from "./layout.ts";
13
15
  import { buildLines } from "./lines.ts";
14
16
  import type { FooterData, StatusSnapshot, TuiLike } from "../types.ts";
15
17
 
16
- /** A two-line, width-adaptive status bar. */
18
+ /**
19
+ * A two-line, width-adaptive status bar.
20
+ *
21
+ * The bar is derived from a snapshot the reader rebuilds every frame, so it memoizes on a
22
+ * key rather than on the snapshot's identity: the width, the theme object (its strings are
23
+ * what the memo holds), and `snapshotKey`'s walk over every field the bar could draw. A
24
+ * frame where nothing moved costs the key and a comparison; a frame that moved pays the key
25
+ * as well, which is why the key is measured against the build it saves.
26
+ */
17
27
  class StatusBar implements Component {
18
28
  /** Unsubscribes the branch watcher; set by `createFooter`. */
19
29
  dispose?: () => void;
30
+ private readonly cache = new RenderCache();
20
31
 
21
32
  constructor(
22
33
  private readonly read: () => StatusSnapshot,
@@ -25,15 +36,18 @@ class StatusBar implements Component {
25
36
  ) {}
26
37
 
27
38
  invalidate(): void {
28
- // The bar recomputes from a fresh snapshot each render, so there is
29
- // nothing cached to clear. Theme-dependent strings are built in render.
39
+ // Pi calls this on a theme change; the key holds the theme's identity too, so this
40
+ // is what drops themed strings a rebuild would otherwise keep.
41
+ this.cache.invalidate();
30
42
  }
31
43
 
32
44
  render(width: number): string[] {
33
45
  const snapshot = this.read();
34
46
  const theme = this.getTheme();
35
47
  const target = Math.max(1, width);
36
- return buildLines(snapshot, theme, this.home).map((line) => renderLine(line, target, theme));
48
+ return this.cache.keyed([target, theme, snapshotKey(snapshot)], () =>
49
+ buildLines(snapshot, theme, this.home).map((line) => renderLine(line, target, theme)),
50
+ );
37
51
  }
38
52
  }
39
53
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A key for one status snapshot: everything the bar draws, and nothing about identity.
3
+ *
4
+ * The footer repaints on every frame, and its snapshot is a fresh object each time — the
5
+ * reader rebuilds it from the session and the host, and prices only what moved — so a memo
6
+ * cannot key on the snapshot itself. It keys on this string instead, and this string is
7
+ * derived from the snapshot's own shape: object keys sorted, a `Map` walked as sorted
8
+ * entries, a list by its items, a primitive by its value.
9
+ *
10
+ * That derivation is the point. A hand-written list of the fields a bar reads is exactly
11
+ * the kind of list that goes stale silently — a field added to `StatusSnapshot` and
12
+ * forgotten here is a bar that keeps showing the old value until something else moves —
13
+ * whereas a walk over the object cannot miss a field that exists.
14
+ *
15
+ * Lengths prefix strings and collection sizes, so no two different snapshots can produce
16
+ * one key by running values together.
17
+ */
18
+
19
+ import type { StatusSnapshot } from "../types.ts";
20
+
21
+ /** The key for one snapshot: equal strings mean a frame would draw the same bar. */
22
+ export function snapshotKey(snapshot: StatusSnapshot): string {
23
+ return keyPart(snapshot);
24
+ }
25
+
26
+ /** One value's part of the key, tagged by kind so `1` and `"1"` cannot collide. */
27
+ function keyPart(value: unknown): string {
28
+ if (value === undefined) return "~";
29
+ if (value === null) return "n";
30
+ if (typeof value === "number") return String(value);
31
+ if (typeof value === "boolean") return value ? "t" : "f";
32
+ if (typeof value === "string") return `${value.length}:${value}`;
33
+ if (value instanceof Map) {
34
+ // Sorted, because a Map's insertion order is not a fact about what the bar draws.
35
+ const entries = [...value.entries()].map(([key, entry]) => `${keyPart(String(key))}=${keyPart(entry)}`);
36
+ entries.sort();
37
+ return `m${value.size}{${entries.join(",")}}`;
38
+ }
39
+ if (Array.isArray(value)) return `a${value.length}[${value.map(keyPart).join(",")}]`;
40
+ if (typeof value === "object") {
41
+ const record = value as Record<string, unknown>;
42
+ const keys = Object.keys(record).sort();
43
+ return `o${keys.length}{${keys.map((key) => `${key}:${keyPart(record[key])}`).join(",")}}`;
44
+ }
45
+ return String(value);
46
+ }
@@ -125,16 +125,26 @@ export function renderLine(spec: LineSpec, width: number, theme: Theme): string
125
125
  const target = Math.max(1, width);
126
126
  const states = makeStates(spec);
127
127
 
128
+ // Each pass keeps the line it measured, so a decision never costs a second assembly and
129
+ // a line that already fits never reaches `truncateToWidth` — which walks the string
130
+ // character by character (14 µs on a footer line, against 0.2 µs for the width scan) and
131
+ // is the whole reason this function was measurable per frame.
132
+ let line = assemble(states, target);
133
+ let measured = visibleWidth(line);
128
134
  let guard = 0;
129
- while (visibleWidth(assemble(states, target)) > target && guard++ < states.length * 8 + 16) {
135
+ while (measured > target && guard++ < states.length * 8 + 16) {
130
136
  if (!reduceOnce(states)) break;
137
+ line = assemble(states, target);
138
+ measured = visibleWidth(line);
131
139
  }
132
140
  // Only once every segment is at its floor do we shrink separators, so the
133
141
  // truncation ellipsis never replaces a separator's content and adjacent
134
142
  // segments always keep at least one column of padding between them.
135
- while (visibleWidth(assemble(states, target)) > target) {
143
+ while (measured > target) {
136
144
  if (!collapseSeparator(states)) break;
145
+ line = assemble(states, target);
146
+ measured = visibleWidth(line);
137
147
  }
138
148
 
139
- return truncateToWidth(assemble(states, target), target, paint(theme, "faint", "…"));
149
+ return measured > target ? truncateToWidth(line, target, paint(theme, "faint", "…")) : line;
140
150
  }
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Turn a `StatusSnapshot` into the two footer lines.
3
3
  *
4
- * Line 1 — state: left `pwd` and the session name, right the alert, the mode
5
- * chips, then `⎇ branch · ⑂ worktree · ⊞ 5043 · ◉ 4909`. Line 2 — meters: left
6
- * the context gauge and usage, right the model and thinking level.
4
+ * Line 1 — state: left `pwd` and the session name, right the alert, the remaining
5
+ * status chips, then `⎇ branch · ⑂ worktree · ◉ 4780`. Line 2 — meters: left the
6
+ * mode, the context gauge, and the usage; right the model and thinking level.
7
7
  *
8
8
  * The split is one job per row. The state chips change while a session runs, so
9
9
  * they ride the line with slack; the meters are the dense row, and a fact that
10
- * moves onto the top row is one the bottom row stops dropping to fit.
10
+ * moves onto the top row is one the bottom row stops dropping to fit. The mode
11
+ * leads the meters because it qualifies every number after it.
11
12
  */
12
13
 
13
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
@@ -19,6 +20,7 @@ import {
19
20
  costSegment,
20
21
  delegationSegment,
21
22
  modelSegment,
23
+ modeSegment,
22
24
  modesSegment,
23
25
  portsSegment,
24
26
  pwdSegment,
@@ -46,14 +48,15 @@ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string
46
48
  modesSegment(snapshot, theme),
47
49
  branchSegment(snapshot, theme),
48
50
  worktreeSegment(snapshot, theme),
49
- // Last, so the two ports form one run behind the location chips. A page starting
50
- // or stopping then changes the end of the line, and `⎇ branch · ⑂ worktree` stays
51
- // the adjacent pair a reader scans for where the work lands.
51
+ // Last, so the page port rides behind the location chips. A page starting or
52
+ // stopping then changes the end of the line, and `⎇ branch · ⑂ worktree` stays the
53
+ // adjacent pair a reader scans for where the work lands.
52
54
  portsSegment(snapshot, theme),
53
55
  ]),
54
56
  };
55
57
 
56
58
  const line2Left = defined([
59
+ modeSegment(snapshot, theme),
57
60
  contextSegment(snapshot, theme),
58
61
  windowSegment(snapshot, theme),
59
62
  costSegment(snapshot, theme),
@@ -24,7 +24,7 @@ import {
24
24
  thinkingColor,
25
25
  truncateLabel,
26
26
  } from "./format.ts";
27
- import { compactStatus, dropStatusDetail } from "./status.ts";
27
+ import { compactStatus } from "./status.ts";
28
28
  import type { Segment, StatusSnapshot } from "../types.ts";
29
29
 
30
30
  /** Tertiary text: paths and de-emphasized meters. */
@@ -55,7 +55,11 @@ export function pwdSegment(snapshot: StatusSnapshot, theme: Theme, home: string
55
55
  }
56
56
 
57
57
  export function sessionSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
58
- const name = snapshot.sessionName ? sanitizeLine(snapshot.sessionName) : "";
58
+ // The roster's label first: Pi's session name is what the terminal calls this session, and
59
+ // the label is what a card, a handoff, and `/team` use. No record means no team, so the bar
60
+ // keeps the name the user knows.
61
+ const raw = snapshot.rosterLabel ?? snapshot.sessionName;
62
+ const name = raw ? sanitizeLine(raw) : "";
59
63
  if (!name) return null;
60
64
  const short = truncateLabel(name, CONFIG.sessionLabelMax);
61
65
  // Brackets and a brighter token keep the session name from reading as a
@@ -101,41 +105,23 @@ export function worktreeSegment(snapshot: StatusSnapshot, theme: Theme): Segment
101
105
  }
102
106
 
103
107
  /**
104
- * The pages this session is serving, as one cluster.
108
+ * The session's page port, while anything is served on it.
105
109
  *
106
- * The board and file-browse chips say one thing — a page is up on this port — so they
107
- * share a segment, a weight, and a floor. Read separately they were a mode chip and a
108
- * port chip: `▤ 5043` rode the mode weight and outlived `◉ 4909` by four columns, which
109
- * made two facts of the same kind look like two kinds of fact. Shared, they also drop
110
- * together, and the surviving form is the pair of glyphs, so a bar too narrow for a port
111
- * still says a page is up.
110
+ * One server serves every page of a session, so there is one number to draw: the chip
111
+ * the `serve` extension publishes, with the glyph alone as the form that survives a bar
112
+ * too narrow for a port.
112
113
  */
113
114
  export function portsSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
114
- const open = [
115
- { key: CONFIG.boardStatusKey, icon: CONFIG.icons.board },
116
- { key: CONFIG.browseStatusKey, icon: CONFIG.icons.browse },
117
- ]
118
- .filter((entry) => snapshot.statuses.has(entry.key))
119
- .map((entry) => ({
120
- icon: paint(theme, "ok", entry.icon),
121
- port: chipLabel(snapshot.statuses.get(entry.key) ?? ""),
122
- }));
123
- if (open.length === 0) return null;
124
- const joined = (separator: string): string =>
125
- open
126
- .map((entry) => (entry.port ? `${entry.icon} ${paint(theme, "accent", entry.port)}` : entry.icon))
127
- .join(separator);
128
- // Widest first: the item separator, then a single space, then the glyphs alone. A
129
- // one-page session has two forms; two pages sharing a padded bar have three.
130
- const forms = [
131
- ...new Set([joined(faint(theme, CONFIG.separators.item)), joined(" "), open.map((entry) => entry.icon).join(" ")]),
132
- ];
115
+ const chip = snapshot.statuses.get(CONFIG.httpStatusKey);
116
+ if (chip === undefined) return null;
117
+ const icon = paint(theme, "ok", CONFIG.icons.http);
118
+ const port = chipLabel(chip);
133
119
  return {
134
120
  id: "ports",
135
121
  weight: 3,
136
122
  droppable: true,
137
123
  separator: faint(theme, CONFIG.separators.item),
138
- forms,
124
+ forms: [...new Set([port ? `${icon} ${paint(theme, "accent", port)}` : icon, icon])],
139
125
  };
140
126
  }
141
127
 
@@ -169,21 +155,36 @@ export function alertSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
169
155
  };
170
156
  }
171
157
 
158
+ /**
159
+ * The mode: `≡ plan` while planning, `▶ auto` otherwise.
160
+ *
161
+ * Plan owns the flag, so the presence of its status key is what the bar reads; the
162
+ * mode's word and colour are the bar's own, so neither the plan file name nor a
163
+ * truncated chip can reach it. Planning wears `warn`, the same role the composer's
164
+ * border takes while it is on. It leads the resource line, before the gauge, so the
165
+ * mode is read before how much room is left. A session without `plan` still reads
166
+ * `auto`: that is the mode it is in.
167
+ */
168
+ export function modeSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
169
+ if (snapshot.statuses.has(CONFIG.planStatusKey)) {
170
+ const plan = paint(theme, "warn", `${CONFIG.icons.planMode} ${CONFIG.labels.planMode}`);
171
+ return { id: "mode", weight: 1, droppable: false, separator: "", forms: [plan, compactStatus(plan)] };
172
+ }
173
+ const auto = paint(theme, "accent", `${CONFIG.icons.autoMode} ${CONFIG.labels.autoMode}`);
174
+ return { id: "mode", weight: 1, droppable: false, separator: "", forms: [auto] };
175
+ }
176
+
172
177
  export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
173
178
  const others = [...snapshot.statuses.entries()]
174
179
  .filter(
175
180
  ([key]) =>
181
+ key !== CONFIG.planStatusKey &&
176
182
  key !== CONFIG.worktreeStatusKey &&
177
- key !== CONFIG.boardStatusKey &&
178
- key !== CONFIG.browseStatusKey &&
183
+ key !== CONFIG.httpStatusKey &&
179
184
  !isAlert(key),
180
185
  )
181
186
  .sort(([a], [b]) => a.localeCompare(b))
182
- .map(([key, value]) => {
183
- const status = sanitize(value);
184
- // The plan chip trails the plan file name; the slot shows the mode only.
185
- return key === CONFIG.planStatusKey ? dropStatusDetail(status) : status;
186
- })
187
+ .map(([, value]) => sanitize(value))
187
188
  .filter(Boolean);
188
189
  if (others.length === 0) return null;
189
190
  const full = others.join(faint(theme, CONFIG.separators.item));
@@ -193,7 +194,7 @@ export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
193
194
  const icons = others.map((status) => compactStatus(status)).join(" ");
194
195
  return {
195
196
  id: "statuses",
196
- // Weight 2, ahead of the branch: a mode is stated again by the session, while
197
+ // Weight 2, ahead of the branch: a status is stated again by the session, while
197
198
  // `⎇` is the only thing on the bar that says where the work will land.
198
199
  weight: 2,
199
200
  droppable: true,
@@ -212,7 +213,9 @@ export function contextSegment(snapshot: StatusSnapshot, theme: Theme): Segment
212
213
  const forms = widths.map((blocks) =>
213
214
  blocks > 0 ? `${gaugeBar(theme, { percent, blocks, role, emptyRole: "faint" })} ${percentText}` : percentText,
214
215
  );
215
- return { id: "context", weight: 1, droppable: false, separator: "", forms };
216
+ // The mode always leads this segment, so the separator prints on every frame;
217
+ // the gauge's offset is the mode's width, not whether one is present.
218
+ return { id: "context", weight: 1, droppable: false, separator: faint(theme, CONFIG.separators.item), forms };
216
219
  }
217
220
 
218
221
  export function windowSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
@@ -29,14 +29,3 @@ export function compactStatus(status: string): string {
29
29
  const text = tokens.length === 2 && /^\d+$/.test(tokens[1]) ? `${icon}${tokens[1]}` : icon;
30
30
  return `${prefix}${text}${suffix}`;
31
31
  }
32
-
33
- /**
34
- * Drop a status's trailing ` · detail` while keeping its SGR styling.
35
- *
36
- * The `plan-mode` chip carries the plan file name as detail (`≡ plan · name`);
37
- * the bar shows only the mode. Matching `[^ESC]*` removes the visible suffix
38
- * without consuming the closing reset, so the chip's color stays balanced.
39
- */
40
- export function dropStatusDetail(status: string): string {
41
- return status.replace(/ · [^\x1b]*/, "");
42
- }
@@ -84,6 +84,13 @@ export interface StatusSnapshot {
84
84
  cwd: string;
85
85
  /** Session name set by the user, `null` when unnamed. */
86
86
  sessionName: string | null;
87
+ /**
88
+ * The name peers address this session by, when the bus has recorded one.
89
+ *
90
+ * `null` means no record — bytefly runs without the bus and the bar keeps Pi's own
91
+ * session name, which is all a session that never joined has.
92
+ */
93
+ rosterLabel: string | null;
87
94
  /** Branch name, `null` when not in a repository, `"detached"` on detached HEAD. */
88
95
  branch: string | null;
89
96
  /** Extension status texts (from `ctx.ui.setStatus`), including `worktree`. */
@@ -16,6 +16,7 @@ import { screenHint } from "../../../lib/tui/chrome.ts";
16
16
  import { keyHintText } from "../../../lib/tui/glyphs.ts";
17
17
  import { BODY_INDENT } from "../../../lib/tui/indent.ts";
18
18
  import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
19
+ import { RenderCache } from "../../../lib/tui/render-cache.ts";
19
20
  import { paint } from "../../../lib/tui/tokens.ts";
20
21
  import { HEADER_CONFIG, type HeaderHeight, type HelpEntry } from "./config.ts";
21
22
  import { identityCells } from "./identity.ts";
@@ -69,11 +70,7 @@ function helpParts(entry: HelpEntry): { key: string; description: string } {
69
70
 
70
71
  /** The themed header component. */
71
72
  export class ByteflyHeader implements Component {
72
- private cachedWidth: number | null = null;
73
- private cachedTheme: Theme | undefined;
74
- private cachedView = "";
75
- private cachedKeys = "";
76
- private cachedLines: string[] = [];
73
+ private readonly cache = new RenderCache();
77
74
 
78
75
  constructor(
79
76
  private readonly getTheme: () => Theme,
@@ -82,7 +79,7 @@ export class ByteflyHeader implements Component {
82
79
  ) {}
83
80
 
84
81
  invalidate(): void {
85
- this.cachedWidth = null;
82
+ this.cache.invalidate();
86
83
  }
87
84
 
88
85
  /**
@@ -97,20 +94,7 @@ export class ByteflyHeader implements Component {
97
94
  const view = this.view();
98
95
  const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}:${view.rows ?? ""}`;
99
96
  const keys = this.keySignature();
100
- if (
101
- this.cachedWidth === w &&
102
- this.cachedTheme === theme &&
103
- this.cachedView === viewKey &&
104
- this.cachedKeys === keys
105
- ) {
106
- return this.cachedLines;
107
- }
108
- this.cachedLines = this.build(w, theme, view);
109
- this.cachedWidth = w;
110
- this.cachedTheme = theme;
111
- this.cachedView = viewKey;
112
- this.cachedKeys = keys;
113
- return this.cachedLines;
97
+ return this.cache.keyed([w, theme, viewKey, keys], () => this.build(w, theme, view));
114
98
  }
115
99
 
116
100
  /** The chord of every help binding, so a rebind repaints the help rows. */
@@ -93,7 +93,7 @@ export const HEADER_CONFIG: HeaderConfig = {
93
93
  { binding: "app.thinking.toggle", description: "to expand thinking" },
94
94
  { binding: "app.model.select", description: "to select model" },
95
95
  { binding: "app.model.cycleForward", description: "to cycle models" },
96
- { binding: "app.thinking.cycle", description: "to cycle thinking" },
96
+ { raw: "shift+tab", description: "to toggle plan" },
97
97
  { binding: "app.editor.external", description: "for external editor" },
98
98
  ],
99
99
  [
@@ -1,14 +1,15 @@
1
1
  /**
2
- * bytefly — the package's terminal chrome and diagnostics, composed from eleven
2
+ * bytefly — the package's terminal chrome and diagnostics, composed from twelve
3
3
  * feature folders: `header/` (the themed startup mark and version), `footer/`
4
4
  * (the two-line, width-adaptive status bar with its price calculation),
5
5
  * `message/` (the user-message nameplate), `notice/` (the shared transcript
6
6
  * notice renderer), `block/` (the unified tool-block frame), `separator/` (the
7
- * transcript turn rule), `working/` (the live run line on Pi's working row),
8
- * `panel/` (the unified `/bytefly` screen and its
7
+ * transcript turn rule), `working/` (the live run line on the composer's top
8
+ * border), `panel/` (the unified `/bytefly` screen and its
9
9
  * `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
10
- * probe runtime), `notify/` (OS notifications for unattended runs), and
11
- * `update/` (Pi's package list, the host suppression, and the header marker).
10
+ * probe runtime), `notify/` (OS notifications for unattended runs), `mode/`
11
+ * (the editor's Shift+Tab plan-mode toggle and its border), and `update/` (Pi's
12
+ * package list, the host suppression, and the header marker).
12
13
  *
13
14
  * The header, footer, turn rule, and working line are installed
14
15
  * only in interactive (`tui`) sessions, and are always on; the user-message
@@ -22,7 +23,7 @@
22
23
  * Load with: pi --extension ./extensions/bytefly
23
24
  */
24
25
 
25
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
27
  import { isExtensionEnabled } from "../../lib/env.ts";
27
28
  import { registerToolBlocks } from "./block/register.ts";
28
29
  import { registerChrome } from "./chrome.ts";
@@ -87,4 +88,29 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
87
88
  // the one repaint hook rather than installing a header of its own.
88
89
  registerPiRelease(pi, piUpdates, { ...deps.release, onUpdate: () => header.refresh() });
89
90
  registerPanel(pi, { doctor, update, discover: deps.extensions?.discover });
91
+
92
+ // Shift+Tab is a chord Pi reserves for the thinking cycle, so the mode key is
93
+ // an editor rather than a shortcut. It loads on the first TUI session and only
94
+ // when `plan` is loaded, so a session without it keeps Pi's key and pays for
95
+ // nothing; the mode module owns that availability check.
96
+ let detachModeKey: (() => void) | undefined;
97
+ let modeEpoch = 0;
98
+ const installModeKey = async (ctx: ExtensionContext): Promise<void> => {
99
+ const epoch = ++modeEpoch;
100
+ detachModeKey?.();
101
+ detachModeKey = undefined;
102
+ if (ctx.mode !== "tui") return;
103
+ const { attachModeKey } = await import("./mode/editor.ts");
104
+ // A shutdown or a newer session may land while the module loads; the stale
105
+ // reply must not install an editor nothing will release.
106
+ if (epoch !== modeEpoch) return;
107
+ detachModeKey = attachModeKey(pi, ctx);
108
+ };
109
+ pi.on("session_start", (_event, ctx) => installModeKey(ctx));
110
+ pi.on("session_tree", (_event, ctx) => installModeKey(ctx));
111
+ pi.on("session_shutdown", () => {
112
+ modeEpoch++;
113
+ detachModeKey?.();
114
+ detachModeKey = undefined;
115
+ });
90
116
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The Shift+Tab chord at the main editor: the plan-mode toggle and the border
3
+ * that says which mode the composer is in, with the live working line riding it.
4
+ *
5
+ * Pi reserves `app.thinking.cycle` on Shift+Tab, so an extension shortcut for the
6
+ * key is refused. The editor is the narrower place to own it: an overlay or a
7
+ * picker has focus instead, so a dialog keeps its own Shift+Tab. Attaching also
8
+ * unsets the built-in cycle binding, because a key that acts and a key the help
9
+ * still claims are two different answers to the same press.
10
+ *
11
+ * Replacing Pi's editor means opting back into what that editor did:
12
+ * `embedWorkingStatus` is what makes the host hand the working indicator over for
13
+ * the top border, as Pi's own editor asks for. Without it the indicator falls back
14
+ * to a row above the composer, and the border would carry the mode colour alone.
15
+ *
16
+ * The mode is plan's to own; this module only asks over the shared channel and
17
+ * reads the branch for the border. Attach once per session and release with the
18
+ * returned cleanup, which reverts only the editor and binding it still owns.
19
+ * Without `plan` there is nothing to toggle, so attach does nothing and Pi keeps
20
+ * its thinking-cycle chord.
21
+ */
22
+
23
+ import {
24
+ CustomEditor,
25
+ type ExtensionAPI,
26
+ type ExtensionContext,
27
+ type KeybindingsManager as AppKeybindingsManager,
28
+ type Theme,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import { type EditorTheme, getKeybindings, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui";
31
+ import { isPlanExtensionLoaded, requestPlanToggle } from "../../../lib/plan-entry.ts";
32
+ import { isPlanModeEnabled } from "../../../lib/plan-state.ts";
33
+ import { isShiftTab } from "../../../lib/tui/cursor.ts";
34
+ import { paint } from "../../../lib/tui/tokens.ts";
35
+
36
+ /** What the editor reads on every input and frame. */
37
+ interface ModeEditorOptions {
38
+ /** Ask the mode's owner to flip it. */
39
+ onToggle: () => void;
40
+ /** The branch is the truth, whoever toggled it. */
41
+ isPlanning: () => boolean;
42
+ /** The role theme, read each frame so a theme change repaints. */
43
+ theme: () => Theme;
44
+ }
45
+
46
+ /** The composer with Shift+Tab bound to plan mode and its border to the mode's own colour. */
47
+ export class ModeEditor extends CustomEditor {
48
+ constructor(
49
+ tui: TUI,
50
+ theme: EditorTheme,
51
+ keybindings: KeybindingsManager,
52
+ private readonly options: ModeEditorOptions,
53
+ ) {
54
+ // Pi's manager carries the resolved keybindings config on top of the TUI one;
55
+ // the editor only calls `matches()`, which both provide. The option is the same
56
+ // one Pi's own editor sets, so the working line lands on this border instead of
57
+ // in a row of its own above the composer.
58
+ super(tui, theme, keybindings as unknown as AppKeybindingsManager, { embedWorkingStatus: true });
59
+ }
60
+
61
+ override handleInput(data: string): void {
62
+ if (isShiftTab(data)) {
63
+ this.options.onToggle();
64
+ return;
65
+ }
66
+ super.handleInput(data);
67
+ }
68
+
69
+ override render(width: number): string[] {
70
+ // The two roles the footer's mode segment draws, so the bar and the composer agree.
71
+ const role = this.options.isPlanning() ? "warn" : "accent";
72
+ this.borderColor = (text) => paint(this.options.theme(), role, text);
73
+ return super.render(width);
74
+ }
75
+ }
76
+
77
+ /** The binding the editor takes over; Pi's default chord for the thinking cycle. */
78
+ const THINKING_CYCLE = "app.thinking.cycle";
79
+
80
+ /**
81
+ * Install the mode editor and take Shift+Tab from the thinking cycle, or return
82
+ * `undefined` when the `plan` extension is not loaded.
83
+ *
84
+ * Returns an idempotent cleanup. It clears the editor only while ours is the
85
+ * installed factory, and restores the binding only while the empty override it
86
+ * wrote is still there, so a later extension's editor or binding is never undone.
87
+ */
88
+ export function attachModeKey(
89
+ pi: Pick<ExtensionAPI, "events" | "getAllTools">,
90
+ ctx: ExtensionContext,
91
+ ): (() => void) | undefined {
92
+ if (!isPlanExtensionLoaded(pi)) return undefined;
93
+ const factory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
94
+ new ModeEditor(tui, theme, keybindings, {
95
+ onToggle: () => {
96
+ void requestPlanToggle(pi, ctx);
97
+ },
98
+ isPlanning: () => isPlanModeEnabled(ctx.sessionManager.getBranch()),
99
+ theme: () => ctx.ui.theme,
100
+ });
101
+ const bindings = getKeybindings();
102
+ const previous = bindings.getUserBindings()[THINKING_CYCLE];
103
+ ctx.ui.setEditorComponent(factory);
104
+ bindings.setUserBindings({ ...bindings.getUserBindings(), [THINKING_CYCLE]: [] });
105
+
106
+ return () => {
107
+ if (ctx.ui.getEditorComponent() === factory) ctx.ui.setEditorComponent(undefined);
108
+ const current = bindings.getUserBindings()[THINKING_CYCLE];
109
+ if (!Array.isArray(current) || current.length > 0) return;
110
+ const restored = { ...bindings.getUserBindings() };
111
+ if (previous === undefined) delete restored[THINKING_CYCLE];
112
+ else restored[THINKING_CYCLE] = previous;
113
+ bindings.setUserBindings(restored);
114
+ };
115
+ }