@gavin-hu/my-pi-agent 0.15.1 → 0.17.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 (189) hide show
  1. package/README.md +22 -13
  2. package/README.zh-CN.md +19 -11
  3. package/extensions/bytefly/README.md +255 -200
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notice/component.ts +6 -4
  17. package/extensions/bytefly/notice/config.ts +16 -2
  18. package/extensions/bytefly/panel/commands.ts +46 -25
  19. package/extensions/bytefly/panel/format.ts +52 -22
  20. package/extensions/bytefly/panel/join.ts +36 -15
  21. package/extensions/bytefly/panel/settings.ts +72 -392
  22. package/extensions/bytefly/panel/skills.ts +51 -0
  23. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  24. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -58
  25. package/extensions/bytefly/panel/tui/frame.ts +11 -3
  26. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  27. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  28. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  29. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  30. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  31. package/extensions/bytefly/panel/tui.ts +223 -333
  32. package/extensions/bytefly/panel/types.ts +26 -8
  33. package/extensions/bytefly/picker/discovery.ts +32 -27
  34. package/extensions/bytefly/picker/resources.ts +22 -10
  35. package/extensions/bytefly/picker/runtime.ts +62 -34
  36. package/extensions/bytefly/picker/toggle.ts +16 -10
  37. package/extensions/bytefly/picker/types.ts +31 -13
  38. package/extensions/bytefly/update/format.ts +66 -8
  39. package/extensions/bytefly/update/patch.ts +66 -0
  40. package/extensions/bytefly/update/register.ts +4 -11
  41. package/extensions/bytefly/update/release.ts +65 -0
  42. package/extensions/bytefly/update/state.ts +14 -0
  43. package/extensions/bytefly/update/suppress.ts +47 -29
  44. package/extensions/bytefly/update/types.ts +15 -5
  45. package/extensions/bytefly/working/register.ts +2 -8
  46. package/extensions/check/tools.ts +2 -11
  47. package/extensions/check/tui.ts +2 -0
  48. package/extensions/context/README.md +172 -0
  49. package/extensions/context/collect.ts +53 -0
  50. package/extensions/context/commands.ts +100 -0
  51. package/extensions/context/format.ts +84 -0
  52. package/extensions/context/index.ts +16 -0
  53. package/extensions/context/report.ts +332 -0
  54. package/extensions/context/tui.ts +284 -0
  55. package/extensions/context/types.ts +124 -0
  56. package/extensions/file-browse/README.md +67 -19
  57. package/extensions/file-browse/client.ts +16 -463
  58. package/extensions/file-browse/config.ts +8 -0
  59. package/extensions/file-browse/html.ts +9 -7
  60. package/extensions/file-browse/index.ts +17 -2
  61. package/extensions/file-browse/live-client.ts +13 -13
  62. package/extensions/file-browse/live.ts +44 -10
  63. package/extensions/file-browse/markdown.ts +3 -10
  64. package/extensions/file-browse/palette.ts +224 -0
  65. package/extensions/file-browse/raw-html.ts +3 -10
  66. package/extensions/file-browse/router.ts +2 -2
  67. package/extensions/file-browse/server.ts +13 -2
  68. package/extensions/file-browse/styles.ts +8 -3
  69. package/extensions/file-browse/tree.ts +381 -0
  70. package/extensions/file-open/README.md +1 -2
  71. package/extensions/{document → file-read}/README.md +76 -57
  72. package/extensions/{document → file-read}/config.ts +12 -9
  73. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  74. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  75. package/extensions/{document → file-read}/extract/file.ts +9 -9
  76. package/extensions/{document → file-read}/extract/odt.ts +10 -8
  77. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  78. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  79. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  80. package/extensions/{document → file-read}/formats.ts +11 -11
  81. package/extensions/file-read/index.ts +17 -0
  82. package/extensions/{document → file-read}/paging.ts +15 -13
  83. package/extensions/{document → file-read}/render.ts +17 -13
  84. package/extensions/{document → file-read}/schema.ts +7 -7
  85. package/extensions/{document → file-read}/tool.ts +36 -28
  86. package/extensions/goal/tools.ts +16 -25
  87. package/extensions/job/commands.ts +32 -46
  88. package/extensions/job/index.ts +20 -45
  89. package/extensions/job/runtime.ts +6 -8
  90. package/extensions/job/tools.ts +58 -103
  91. package/extensions/memory/commands.ts +30 -44
  92. package/extensions/memory/tools.ts +10 -20
  93. package/extensions/memory/tui.ts +5 -1
  94. package/extensions/plan/README.md +88 -33
  95. package/extensions/plan/commands.ts +19 -4
  96. package/extensions/plan/config.ts +27 -0
  97. package/extensions/plan/plan-index.ts +3 -1
  98. package/extensions/plan/plans.ts +31 -13
  99. package/extensions/plan/review.ts +34 -14
  100. package/extensions/plan/runtime.ts +3 -1
  101. package/extensions/plan/steps.ts +43 -14
  102. package/extensions/plan/tools/cancel.ts +79 -0
  103. package/extensions/plan/tools/enter.ts +16 -17
  104. package/extensions/plan/tools/exit.ts +60 -50
  105. package/extensions/plan/tools/steps.ts +13 -26
  106. package/extensions/plan/tools/write.ts +30 -38
  107. package/extensions/plan/tools.ts +5 -2
  108. package/extensions/plan/tui.ts +9 -7
  109. package/extensions/plan/types.ts +8 -0
  110. package/extensions/plan/web-chrome.ts +101 -0
  111. package/extensions/plan/web-client.ts +98 -311
  112. package/extensions/plan/web-markdown.ts +222 -0
  113. package/extensions/plan/web-page.ts +28 -2
  114. package/extensions/plan/web-router.ts +2 -2
  115. package/extensions/plan/web.ts +21 -12
  116. package/extensions/question/tools.ts +5 -4
  117. package/extensions/rewind/checklist.ts +4 -0
  118. package/extensions/rewind/review-tui.ts +3 -1
  119. package/extensions/rewind/rewind-tui.ts +4 -0
  120. package/extensions/subagent/README.md +25 -12
  121. package/extensions/subagent/background.ts +1 -7
  122. package/extensions/subagent/format.ts +72 -25
  123. package/extensions/subagent/index.ts +21 -47
  124. package/extensions/subagent/orchestrate.ts +18 -41
  125. package/extensions/subagent/render.ts +29 -42
  126. package/extensions/subagent/stream.ts +24 -18
  127. package/extensions/subagent/types.ts +7 -0
  128. package/extensions/todo/tools.ts +15 -20
  129. package/extensions/todo/tui.ts +5 -1
  130. package/extensions/watch/README.md +5 -5
  131. package/extensions/watch/commands.ts +33 -47
  132. package/extensions/watch/index.ts +10 -35
  133. package/extensions/watch/runtime.ts +5 -7
  134. package/extensions/watch/tools.ts +38 -77
  135. package/extensions/web-access/README.md +3 -3
  136. package/extensions/web-access/search/search.ts +9 -18
  137. package/extensions/wechat/README.md +40 -18
  138. package/extensions/wechat/bridge.ts +38 -40
  139. package/extensions/wechat/client.ts +1 -4
  140. package/extensions/wechat/commands.ts +1 -1
  141. package/extensions/wechat/interaction.ts +25 -4
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +7 -5
  144. package/extensions/wechat/prompt.ts +19 -4
  145. package/extensions/wechat/qrcode.ts +9 -9
  146. package/extensions/wechat/slash.ts +14 -0
  147. package/extensions/wechat/status-screen.ts +2 -2
  148. package/extensions/wechat/timer.ts +9 -30
  149. package/extensions/wechat/tools.ts +7 -18
  150. package/extensions/worktree/README.md +48 -12
  151. package/extensions/worktree/commands.ts +109 -65
  152. package/extensions/worktree/enter.ts +1 -1
  153. package/extensions/worktree/exit.ts +11 -1
  154. package/extensions/worktree/lifecycle.ts +9 -1
  155. package/extensions/worktree/screen.ts +263 -0
  156. package/extensions/worktree/status.ts +84 -38
  157. package/lib/README.md +15 -11
  158. package/lib/config.ts +12 -0
  159. package/lib/escape.ts +15 -2
  160. package/lib/format.ts +6 -2
  161. package/lib/interaction.ts +18 -1
  162. package/lib/pdf.ts +2 -2
  163. package/lib/report.ts +75 -2
  164. package/lib/requirements.ts +2 -2
  165. package/lib/screen-session.ts +85 -0
  166. package/lib/timer.ts +61 -0
  167. package/lib/tool-result.ts +54 -4
  168. package/lib/tui/README.md +19 -11
  169. package/lib/tui/chrome.ts +27 -6
  170. package/lib/tui/columns.ts +33 -8
  171. package/lib/tui/dock-screen.ts +6 -2
  172. package/lib/tui/gauge.ts +9 -5
  173. package/lib/tui/rows.ts +15 -6
  174. package/lib/tui/rules.ts +4 -8
  175. package/lib/tui/tool.ts +55 -2
  176. package/lib/usage.ts +26 -0
  177. package/lib/web/README.md +131 -0
  178. package/lib/web/base.ts +19 -2
  179. package/lib/web/components.ts +10 -6
  180. package/lib/web/layout.ts +2 -0
  181. package/lib/web/markup.ts +2 -1
  182. package/lib/web/runtime.ts +103 -25
  183. package/lib/web/tokens.ts +23 -7
  184. package/package.json +10 -4
  185. package/skills/changelog/SKILL.md +148 -0
  186. package/skills/next/SKILL.md +184 -0
  187. package/extensions/document/index.ts +0 -17
  188. /package/extensions/{document → file-read}/deadline.ts +0 -0
  189. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -20,6 +20,31 @@ How the `list_worktrees` call and its result look in the transcript — the call
20
20
 
21
21
  <img src="../../assets/tools/list_worktrees.png" alt="The list_worktrees tool's call and result, drawn by the generic fallback" width="100%">
22
22
 
23
+ ## The `/worktree` screen
24
+
25
+ <img src="../../assets/worktree.png" alt="The /worktree screen: a Worktrees title rule with a `4 worktrees · 1 dirty · 1 ahead` summary, the current dark-mode row carrying its ahead count, a dirty row, a merged row, and a stale-locked row, the focused row's path and base with the Not isolated gap under it, and the key hints" width="100%">
26
+
27
+ `/worktree` on its own opens a list screen: one row per managed worktree with its
28
+ state (current, dirty, ahead, behind, merged, locked, last used), the active
29
+ worktree's base and isolation gaps under the focused row, and a detail pane for
30
+ the selected one. Outside a terminal — print, RPC, JSON — the same facts print as
31
+ text instead, which is also what the model's `list_worktrees` reads.
32
+
33
+ | Key | |
34
+ |---|---|
35
+ | `j` / `k`, arrows | move |
36
+ | `g` / `G` | first / last row |
37
+ | Enter, `l` | open the selected worktree's detail |
38
+ | `e` | enter the selected worktree |
39
+ | `x` | exit the active worktree |
40
+ | `p` | prune eligible worktrees in place |
41
+ | Esc, `q` | close |
42
+
43
+ `e` and `x` close the screen before they act, because an exit asks through the
44
+ normal prompt and a prompt cannot open inside a mounted screen. `p` never asks,
45
+ so it prunes without closing and reports the result in the footer. In plan mode
46
+ the screen still opens and every verb refuses with a notice.
47
+
23
48
  ## What it does
24
49
 
25
50
  - `enter_worktree` creates `<repo>/.pi/worktrees/<name>` on branch
@@ -50,18 +75,21 @@ How the `list_worktrees` call and its result look in the transcript — the call
50
75
 
51
76
  | | |
52
77
  |---|---|
53
- | `/worktree` | status + list managed worktrees |
78
+ | `/worktree` | the list screen: managed worktrees, the active one, and its gaps |
54
79
  | `/worktree enter [name]` | enter a worktree (a name may be a PR/MR reference) |
55
- | `/worktree exit [--keep\|--remove]` | exit a worktree |
80
+ | `/worktree exit` | exit a worktree |
56
81
  | `/worktree prune` | remove clean, unused, old managed worktrees |
57
82
  | `--worktree <name>` | enter at session start |
58
83
 
59
- `--remove` forces removal without prompting and may discard uncommitted
60
- changes; a branch that still holds work is kept. Declining a confirmation
61
- (entering an outside path, exiting a worktree with work) is reported as normal
62
- feedback, not an error.
84
+ Exit asks whether to keep or remove, and `worktree.onExit` answers without
85
+ asking: a clean worktree this session created is removed, one holding work or
86
+ entered by path asks, and only a worktree with no work loses its branch. There
87
+ are no flags to disagree with either. Declining a confirmation (entering an
88
+ outside path, exiting a worktree with work) is reported as normal feedback, not
89
+ an error. An argument a subcommand does not take prints the usage line instead of
90
+ being ignored.
63
91
 
64
- `/worktree status` (and `list_worktrees`) reports the active worktree, any
92
+ `list_worktrees` (and a headless `/worktree`) reports the active worktree, any
65
93
  override that is not re-rooted, what isolation does not cover, and the managed
66
94
  worktrees:
67
95
 
@@ -261,7 +289,7 @@ use, falling back to directory mtime for a worktree with no record.
261
289
  | Registration | The default export calls `registerWorktree`: root-tool overrides, the `--worktree` flag, lifecycle listeners, model tools, and the `/worktree` command. Nothing long-lived starts at load. |
262
290
  | Tools | `enter_worktree`, `exit_worktree`, `prune_worktrees`, `list_worktrees` via `registerTool`. All `executionMode: "sequential"`. Annotations: `enter_worktree` destructive + open-world; `exit_worktree`/`prune_worktrees` destructive; `list_worktrees` read-only. No `outputSchema`; structured state is returned in tool-result `details` — `exit_worktree` carries `{ state, removed, handoff }`, where `handoff` is `{ branch, base, ahead, merged }`. |
263
291
  | Built-in overrides | `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` are re-registered under the same names with `defaultActive: false`; each spreads the built-in definition and replaces only `execute`. `skipOverrides` is applied at load time. `powershell` is deliberately not overridden: it cannot be re-rooted without a guard, so the `tool_call` handler refuses it (see `guard.blockPowershell`). |
264
- | Commands | `/worktree` via `registerCommand`, with argument completions for the subcommands and exit flags. |
292
+ | Commands | `/worktree` via `registerCommand`, with argument completions for `enter`, `exit`, and `prune`. The bare command mounts `screen.ts` in a terminal and prints the status text otherwise; `enter`/`exit`/`prune` refuse while plan mode is read-only. |
265
293
  | Flags | `--worktree` via `registerFlag`. |
266
294
  | Events | `session_start` (bind inherited env, restore or refuse recorded state, read the flag), `session_shutdown` (release the lock, clear status), `before_agent_start` (set the prompt cwd and worktree section), `tool_call` (guard file tools and `bash`, refuse `powershell`, and count a command it lets through unverified). |
267
295
  | State | The binding is a custom session entry (`worktree`) written with `pi.appendEntry` and rebuilt from `ctx.sessionManager.getBranch()` on `session_start`. `.pi/worktrees/index.json` is a disposable managed-record cache, reconciled against `git worktree list`. Child processes inherit `PI_WORKTREE_*` env. |
@@ -291,6 +319,11 @@ use, falling back to directory mtime for a worktree with no record.
291
319
  provenance and usage in a disposable index. A *Binding* is this session's
292
320
  active root, a session entry that drives `getRoot()` and is replayable on the
293
321
  branch.
322
+ - **One snapshot, two surfaces.** `worktreeSnapshot` reads the active binding, the
323
+ gaps, and every managed worktree once; `formatWorktreeStatus` renders it as text
324
+ and `screen.ts` renders the same rows as the list. A tool result and the screen
325
+ that disagree about a worktree would be a second source of truth, so there is
326
+ one read and two renderings.
294
327
  - **Mutable root in one place.** Pi loads one extension factory per runtime, so
295
328
  `runtime.ts` owns the single active worktree, the config cache, and the
296
329
  process-environment hook; lifecycle, tools, and commands stay stateless
@@ -327,12 +360,13 @@ use, falling back to directory mtime for a worktree with no record.
327
360
  | `enter.ts` / `exit.ts` / `prune.ts` | The lifecycle phases |
328
361
  | `handoff.ts` | The `Handoff:` block an exit leaves: the branch's lead, whether the base contains it, and the merge or PR command. |
329
362
  | `tools.ts` / `commands.ts` | Model tools / slash commands |
363
+ | `screen.ts` | The `/worktree` list screen: rows, keys, detail pane, and verbs |
330
364
  | `root-tools.ts` | Load-time built-in overrides bound to the active root |
331
365
  | `git.ts` | Git plumbing (shared runner + read helpers) |
332
366
  | `guard.ts` | Path, shell, and git-redirect isolation checks, plus the `powershell` refusal |
333
367
  | `gaps.ts` | The isolation gaps a session carries, and how they read (pure) |
334
368
  | `include.ts` | `.worktreeinclude` matcher and copy |
335
- | `config.ts` / `state.ts` / `status.ts` | Config, session entry shape, status text |
369
+ | `config.ts` / `state.ts` / `status.ts` | Config, session entry shape, status text and the shared snapshot |
336
370
  | `registry.ts` | Disposable managed-worktree index + derived status |
337
371
 
338
372
  ## Testing
@@ -344,6 +378,8 @@ bun run test:watch
344
378
  ```
345
379
 
346
380
  The suite covers the pure logic (`include.ts` gitignore matcher, `guard.ts` path
347
- and command checks, `parsePrReference`) and drives the extension end-to-end
348
- against real temporary git repositories through a mocked `pi` API: enter/exit,
349
- isolation guards, borrowed worktrees, prune, and inactive-override reporting.
381
+ and command checks, `parsePrReference`), drives the `/worktree` screen against a
382
+ snapshot (`ui.test.ts`: rows, marks, verbs, and width bounds), and drives the
383
+ extension end-to-end against real temporary git repositories through a mocked
384
+ `pi` API: enter/exit, isolation guards, borrowed worktrees, prune, and
385
+ inactive-override reporting.
@@ -1,22 +1,33 @@
1
1
  /**
2
2
  * Slash commands for the worktree extension.
3
3
  *
4
- * One `/worktree` command keeps the surface small and discoverable:
5
- * /worktree status + list managed worktrees
6
- * /worktree enter [name] create or enter a worktree (name may be a PR/MR ref)
7
- * /worktree exit [--keep|--remove]
4
+ * One `/worktree` command keeps the surface small: the bare command is the
5
+ * worktree screen (the status text when the session is not a terminal), and three
6
+ * verbs do the lifecycle.
7
+ *
8
+ * /worktree the worktree screen
9
+ * /worktree enter [name] create or re-enter a worktree (name may be a PR/MR ref)
10
+ * /worktree exit leave; worktree.onExit and the prompt decide the fate
8
11
  * /worktree prune remove clean, unused, old managed worktrees
12
+ *
13
+ * There are no flags: the exit prompt already offers keep or remove and the
14
+ * config sets the default, so a flag could only disagree with one of the two.
9
15
  */
10
16
 
11
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { messageOf } from "../../lib/errors.ts";
19
+ import { isPlanModeEnabled } from "../../lib/plan-state.ts";
20
+ import { withRailsSuppressed } from "../../lib/rails.ts";
12
21
  import {
13
22
  enterWorktree,
14
23
  exitWorktree,
24
+ formatWorktreeStatus,
15
25
  isWorktreeCancelled,
16
26
  pruneWorktrees,
17
27
  worktreeLabel,
18
- worktreeStatus,
28
+ worktreeSnapshot,
19
29
  } from "./lifecycle.ts";
30
+ import { type WorktreeAction, WorktreeListComponent } from "./screen.ts";
20
31
 
21
32
  interface Completion {
22
33
  value: string;
@@ -25,83 +36,116 @@ interface Completion {
25
36
  }
26
37
 
27
38
  const SUBCOMMANDS: Completion[] = [
28
- { value: "status", label: "status", description: "Show the current worktree and list managed worktrees" },
29
39
  { value: "enter", label: "enter", description: "Create or enter a worktree: enter [name]" },
30
- { value: "exit", label: "exit", description: "Return to the main checkout: exit [--keep|--remove]" },
40
+ { value: "exit", label: "exit", description: "Return to the main checkout" },
31
41
  { value: "prune", label: "prune", description: "Remove clean, unused managed worktrees older than pruneAfterDays" },
32
42
  ];
33
43
 
34
- const EXIT_FLAGS: Completion[] = [
35
- { value: "--keep", label: "--keep", description: "Keep the worktree on exit" },
36
- {
37
- value: "--remove",
38
- label: "--remove",
39
- description: "Force removal without prompting (may discard uncommitted changes)",
40
- },
41
- ];
44
+ const USAGE = "Usage: /worktree [enter [name]|exit|prune]";
45
+
46
+ /** Refuse a lifecycle change while plan mode is read-only, as `/check` does. */
47
+ function refuseInPlanMode(ctx: ExtensionContext): boolean {
48
+ if (!isPlanModeEnabled(ctx.sessionManager.getBranch())) return false;
49
+ ctx.ui.notify("Plan mode is read-only. Approve the plan before changing worktrees.", "warning");
50
+ return true;
51
+ }
52
+
53
+ async function runEnter(pi: ExtensionAPI, ctx: ExtensionContext, name: string | undefined): Promise<void> {
54
+ if (refuseInPlanMode(ctx)) return;
55
+ try {
56
+ const { state } = await enterWorktree(pi, ctx, { name });
57
+ ctx.ui.notify(`Entered worktree ${worktreeLabel(state)}\n${state.path}`, "info");
58
+ } catch (error) {
59
+ if (isWorktreeCancelled(error)) {
60
+ ctx.ui.notify(messageOf(error), "info");
61
+ return;
62
+ }
63
+ ctx.ui.notify(`Enter failed: ${messageOf(error)}`, "error");
64
+ }
65
+ }
66
+
67
+ async function runExit(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
68
+ if (refuseInPlanMode(ctx)) return;
69
+ try {
70
+ const result = await exitWorktree(pi, ctx, {});
71
+ ctx.ui.notify(result.output, "info");
72
+ } catch (error) {
73
+ if (isWorktreeCancelled(error)) {
74
+ ctx.ui.notify(messageOf(error), "info");
75
+ return;
76
+ }
77
+ ctx.ui.notify(`Exit failed: ${messageOf(error)}`, "error");
78
+ }
79
+ }
42
80
 
43
- const USAGE = "Usage: /worktree [status|enter [name]|exit [--keep|--remove]|prune]";
81
+ async function runPrune(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
82
+ if (refuseInPlanMode(ctx)) return;
83
+ ctx.ui.notify(await pruneWorktrees(pi, ctx), "info");
84
+ }
85
+
86
+ /** The bare command: the list screen in a terminal, the status text elsewhere. */
87
+ async function showWorktrees(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
88
+ const snapshot = await worktreeSnapshot(pi, ctx);
89
+ if (ctx.mode !== "tui") {
90
+ ctx.ui.notify(formatWorktreeStatus(snapshot), "info");
91
+ return;
92
+ }
93
+ // The screen closes before an enter or exit runs: both change the session
94
+ // binding, and `exitWorktree` asks through `ctx.ui.select`, which cannot nest
95
+ // inside a mounted screen. Prune runs in place, so its verb stays on the screen.
96
+ const action = await withRailsSuppressed(pi, () =>
97
+ ctx.ui.custom<WorktreeAction | undefined>((tui, theme, _keybindings, done) => {
98
+ return new WorktreeListComponent({
99
+ snapshot,
100
+ theme,
101
+ requestRender: () => tui.requestRender(),
102
+ onDone: (next) => done(next),
103
+ reload: () => worktreeSnapshot(pi, ctx),
104
+ prune: () => pruneWorktrees(pi, ctx),
105
+ readOnly: isPlanModeEnabled(ctx.sessionManager.getBranch()),
106
+ viewportRowsSource: () => tui.terminal?.rows,
107
+ });
108
+ }),
109
+ );
110
+ if (!action) return;
111
+ if (action.kind === "exit") await runExit(pi, ctx);
112
+ else await runEnter(pi, ctx, action.name);
113
+ }
44
114
 
45
115
  export function registerCommands(pi: ExtensionAPI): void {
46
116
  pi.registerCommand("worktree", {
47
- description: "Manage git worktrees: /worktree [status|enter|exit|prune]",
117
+ description: "Manage git worktrees: the worktree screen, or enter, exit, and prune",
48
118
  getArgumentCompletions: (prefix) => {
49
- const [action = "", argument, ...rest] = prefix.trimStart().split(/\s+/);
50
- if (rest.length > 0) return null;
119
+ const text = prefix.trimStart();
120
+ if (text.includes(" ")) return null;
121
+ const items = SUBCOMMANDS.filter((item) => item.value.startsWith(text));
122
+ return items.length > 0 ? items.map((item) => ({ ...item, value: `${item.value} ` })) : null;
123
+ },
124
+ handler: async (args, ctx) => {
125
+ const [sub = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
51
126
 
52
- if (argument === undefined) {
53
- const items = SUBCOMMANDS.filter((item) => item.value.startsWith(action));
54
- return items.length > 0 ? items.map((item) => ({ ...item, value: `${item.value} ` })) : null;
127
+ if (sub === "") {
128
+ await showWorktrees(pi, ctx);
129
+ return;
55
130
  }
56
-
57
- if (action === "exit") {
58
- const flags = EXIT_FLAGS.filter((item) => item.value.startsWith(argument));
59
- return flags.length > 0 ? flags : null;
131
+ // Only `enter` takes an argument. Anything else is a typo, and a typo that
132
+ // is silently ignored is how a command does something the user did not ask for.
133
+ const allowed = sub === "enter" ? 1 : 0;
134
+ if (rest.length > allowed) {
135
+ ctx.ui.notify(`Unexpected argument "${rest[allowed]}".\n${USAGE}`, "error");
136
+ return;
60
137
  }
61
138
 
62
- return null;
63
- },
64
- handler: async (args, ctx) => {
65
- const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
66
-
67
139
  switch (sub) {
68
- case "status":
69
- ctx.ui.notify(await worktreeStatus(pi, ctx), "info");
140
+ case "enter":
141
+ await runEnter(pi, ctx, rest[0]);
70
142
  return;
71
-
72
- case "enter": {
73
- try {
74
- const { state } = await enterWorktree(pi, ctx, { name: rest[0] });
75
- ctx.ui.notify(`Entered worktree ${worktreeLabel(state)}\n${state.path}`, "info");
76
- } catch (error) {
77
- if (isWorktreeCancelled(error)) {
78
- ctx.ui.notify(error.message, "info");
79
- return;
80
- }
81
- ctx.ui.notify(`Enter failed: ${error instanceof Error ? error.message : String(error)}`, "error");
82
- }
83
- return;
84
- }
85
-
86
- case "exit": {
87
- const remove = rest.includes("--remove") ? true : rest.includes("--keep") ? false : undefined;
88
- try {
89
- const result = await exitWorktree(pi, ctx, { remove });
90
- ctx.ui.notify(result.output, "info");
91
- } catch (error) {
92
- if (isWorktreeCancelled(error)) {
93
- ctx.ui.notify(error.message, "info");
94
- return;
95
- }
96
- ctx.ui.notify(`Exit failed: ${error instanceof Error ? error.message : String(error)}`, "error");
97
- }
143
+ case "exit":
144
+ await runExit(pi, ctx);
98
145
  return;
99
- }
100
-
101
146
  case "prune":
102
- ctx.ui.notify(await pruneWorktrees(pi, ctx), "info");
147
+ await runPrune(pi, ctx);
103
148
  return;
104
-
105
149
  default:
106
150
  ctx.ui.notify(`Unknown worktree subcommand "${sub}".\n${USAGE}`, "error");
107
151
  }
@@ -110,7 +110,7 @@ export async function enterWorktree(
110
110
  }
111
111
  if (current) {
112
112
  throw new Error(
113
- `Already working in a worktree (${worktreeLabel(current)}). Call exit_worktree before entering another.`,
113
+ `Already working in a worktree (${worktreeLabel(current)}). Run /worktree exit, or call exit_worktree, before entering another.`,
114
114
  );
115
115
  }
116
116
 
@@ -16,6 +16,7 @@ import {
16
16
  lockWorktree,
17
17
  refExists,
18
18
  unlockWorktree,
19
+ worktreePath,
19
20
  worktreeRemove,
20
21
  } from "./git.ts";
21
22
  import { handoffLines, type Handoff } from "./handoff.ts";
@@ -163,7 +164,16 @@ export async function exitWorktree(
163
164
  }
164
165
  } else {
165
166
  lines.push(`Kept worktree ${state.path}`);
166
- if (state.branch) lines.push(`Re-enter with enter_worktree (path: ${state.path})`);
167
+ if (state.branch) {
168
+ // Name entry only reaches a managed worktree; a path the user pointed at is
169
+ // re-entered through the tool.
170
+ const label = worktreeLabel(state);
171
+ lines.push(
172
+ worktreePath(state.repoRoot, config, label) === state.path
173
+ ? `Re-enter with /worktree enter ${label}`
174
+ : `Re-enter with enter_worktree (path: ${state.path}); /worktree enter only manages ${config.dir}`,
175
+ );
176
+ }
167
177
  touchRecord(state.repoRoot, config, state.path);
168
178
  }
169
179
 
@@ -11,4 +11,12 @@ export { enterWorktree, type EnterOptions } from "./enter.ts";
11
11
  export { exitWorktree, type ExitOptions } from "./exit.ts";
12
12
  export { pruneWorktrees } from "./prune.ts";
13
13
  export { findInactiveOverrides } from "./root-tools.ts";
14
- export { refusalMessage, stateSummary, worktreeFacts, worktreeLabel, worktreeStatus } from "./status.ts";
14
+ export {
15
+ formatWorktreeStatus,
16
+ refusalMessage,
17
+ stateSummary,
18
+ worktreeFacts,
19
+ worktreeLabel,
20
+ worktreeSnapshot,
21
+ worktreeStatus,
22
+ } from "./status.ts";
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The `/worktree` list screen: one row per managed worktree, the active one
3
+ * marked, with a detail pane and three verbs.
4
+ *
5
+ * The frame, navigation, y/N confirmation, and pane come from the shared
6
+ * `DockListScreen`, so this module owns only the worktree vocabulary. Rows come
7
+ * from the same `worktreeSnapshot` the text status renders, so the screen and the
8
+ * `list_worktrees` tool cannot disagree about a worktree.
9
+ *
10
+ * Every verb but `p` closes the screen and hands the action back to the command:
11
+ * entering and exiting change the session binding and `exitWorktree` asks through
12
+ * `ctx.ui.select`, which cannot nest inside a mount. `p` never prompts, so it
13
+ * runs in place and refreshes the rows.
14
+ */
15
+
16
+ import { basename } from "node:path";
17
+ import type { Theme } from "@earendil-works/pi-coding-agent";
18
+ import { messageOf } from "../../lib/errors.ts";
19
+ import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
20
+ import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
21
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
22
+ import { statusRow } from "../../lib/tui/rows.ts";
23
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
24
+ import { gapLines } from "./gaps.ts";
25
+ import { handoffLines } from "./handoff.ts";
26
+ import type { CheckoutStatus } from "./registry.ts";
27
+ import { relativeDay, statusMarks, type WorktreeSnapshot, worktreeLabel } from "./status.ts";
28
+
29
+ /** What a verb asks the command to do once the screen is gone. */
30
+ export type WorktreeAction = { kind: "enter"; name: string } | { kind: "exit" };
31
+
32
+ /** One row: a managed worktree, or the active one when this session does not manage it. */
33
+ export interface WorktreeRow {
34
+ /** Canonical path, and the row's stable id. */
35
+ path: string;
36
+ /** Basename of the path: the name `enter` takes back. */
37
+ name: string;
38
+ branch: string | undefined;
39
+ /** Whether this row is the session's active worktree. */
40
+ current: boolean;
41
+ /** What git says about it; absent for a worktree entered by path. */
42
+ status: CheckoutStatus | undefined;
43
+ }
44
+
45
+ /** The rows for a snapshot, in git's order, with the active worktree always present. */
46
+ export function worktreeRows(snapshot: WorktreeSnapshot): WorktreeRow[] {
47
+ const rows: WorktreeRow[] = snapshot.managed.map((item) => ({
48
+ path: item.entry.path,
49
+ name: basename(item.entry.path),
50
+ branch: item.entry.branch,
51
+ current: snapshot.active?.path === item.entry.path,
52
+ status: item.status,
53
+ }));
54
+ const active = snapshot.active;
55
+ // A worktree entered by path is not in the managed list, but a reader looking
56
+ // for "where am I" must still see it.
57
+ if (active && !rows.some((row) => row.path === active.path)) {
58
+ rows.unshift({
59
+ path: active.path,
60
+ name: worktreeLabel(active),
61
+ branch: active.branch,
62
+ current: true,
63
+ status: undefined,
64
+ });
65
+ }
66
+ return rows;
67
+ }
68
+
69
+ /** The glyph and its role: a missing checkout is the error, dirty work the warning. */
70
+ function rowGlyph(status: CheckoutStatus | undefined): { char: string; role: TuiRole } {
71
+ if (!status) return { char: GLYPHS.worktree, role: "info" };
72
+ if (status.state === "missing") return { char: GLYPHS.cross, role: "bad" };
73
+ if (status.state === "dirty") return { char: GLYPHS.warn, role: "warn" };
74
+ if (status.merged) return { char: GLYPHS.check, role: "ok" };
75
+ return { char: GLYPHS.branch, role: "info" };
76
+ }
77
+
78
+ /** The row's right-hand text: its marks, or `clean` when it has none. */
79
+ function rowMarks(row: WorktreeRow): string {
80
+ const marks = row.status ? statusMarks(row.status, row.current) : ["current"];
81
+ if (marks.length === 0) marks.push("clean");
82
+ return marks.join(" ");
83
+ }
84
+
85
+ export interface WorktreeScreenOptions {
86
+ /** The rows to open with; the command has already read them. */
87
+ snapshot: WorktreeSnapshot;
88
+ theme: Theme;
89
+ requestRender(): void;
90
+ /** Close the screen; `undefined` when the user only closed it. */
91
+ onDone(action: WorktreeAction | undefined): void;
92
+ /** Read the rows again after a verb changed them. */
93
+ reload(): Promise<WorktreeSnapshot>;
94
+ /** Run a prune in place and return its report. */
95
+ prune(): Promise<string>;
96
+ /** Plan mode: the screen still shows the list, but no verb may change it. */
97
+ readOnly: boolean;
98
+ viewportRowsSource?: ViewportRowsSource;
99
+ }
100
+
101
+ /** The `/worktree` screen. */
102
+ export class WorktreeListComponent extends DockListScreen<WorktreeRow> {
103
+ private view: WorktreeSnapshot;
104
+ private pruning = false;
105
+
106
+ constructor(private readonly deps: WorktreeScreenOptions) {
107
+ super({
108
+ theme: deps.theme,
109
+ title: "Worktrees",
110
+ detailTitle: "Worktree",
111
+ emptyList: "No managed worktrees. /worktree enter <name> creates one.",
112
+ emptyDetail: "Nothing recorded for this worktree.",
113
+ requestRender: deps.requestRender,
114
+ onClose: () => deps.onDone(undefined),
115
+ viewportRowsSource: deps.viewportRowsSource,
116
+ });
117
+ this.view = deps.snapshot;
118
+ }
119
+
120
+ protected rows(): readonly WorktreeRow[] {
121
+ return worktreeRows(this.view);
122
+ }
123
+
124
+ protected idOf(row: WorktreeRow): string {
125
+ return row.path;
126
+ }
127
+
128
+ protected rowLine(row: WorktreeRow, width: number, selected: boolean): string {
129
+ const glyph = rowGlyph(row.status);
130
+ return statusRow(this.theme, width, {
131
+ selected,
132
+ glyph: glyph.char,
133
+ glyphRole: glyph.role,
134
+ label: row.name,
135
+ labelRole: selected ? "accent" : "label",
136
+ trailing: rowMarks(row),
137
+ trailingRole: row.current ? "accent" : "faint",
138
+ });
139
+ }
140
+
141
+ protected detailBlock(row: WorktreeRow): string[] {
142
+ const lines = [`${row.path} ${row.branch ?? "(detached)"}`];
143
+ const active = this.view.active;
144
+ // The gaps are the same rules `/worktree` status and `doctor` report: a gap
145
+ // the user cannot see is a gap they cannot weigh.
146
+ if (row.current && active) {
147
+ lines.push(`base: ${active.baseRef} (${active.baseRefMode}) main: ${active.repoRoot}`);
148
+ if (this.view.gaps.length > 0) lines.push("Not isolated:", ...gapLines(this.view.gaps));
149
+ }
150
+ return lines;
151
+ }
152
+
153
+ protected summaryCells(rows: readonly WorktreeRow[]): string[] {
154
+ const cells = [paint(this.theme, "meta", `${rows.length} worktree${rows.length === 1 ? "" : "s"}`)];
155
+ const dirty = rows.filter((row) => row.status?.state === "dirty").length;
156
+ if (dirty > 0) cells.push(paint(this.theme, "warn", `${dirty} dirty`));
157
+ const ahead = rows.filter((row) => (row.status?.ahead ?? 0) > 0).length;
158
+ if (ahead > 0) cells.push(paint(this.theme, "accent", `${ahead} ahead`));
159
+ const inactive = this.view.inactiveOverrides;
160
+ if (inactive.length > 0) cells.push(paint(this.theme, "warn", `not isolated: ${inactive.join(", ")}`));
161
+ return cells;
162
+ }
163
+
164
+ protected listHints(): ScreenHint[] {
165
+ return [
166
+ "Esc close",
167
+ "↑/↓ or j/k select",
168
+ { text: "Enter or l detail", color: "accent" },
169
+ { text: "e enter", color: "accent" },
170
+ { text: "x exit", color: "warning" },
171
+ { text: "p prune", color: "warning" },
172
+ ];
173
+ }
174
+
175
+ protected detailHints(): ScreenHint[] {
176
+ return ["Esc back", "↑/↓ scroll", "g/G ends"];
177
+ }
178
+
179
+ protected detailFor(row: WorktreeRow): DetailContents {
180
+ const lines = [`path ${row.path}`, `branch ${row.branch ?? "(detached)"}`];
181
+ const status = row.status;
182
+ if (!status) {
183
+ lines.push("state entered by path; this session does not manage it");
184
+ } else {
185
+ if (status.head) lines.push(`head ${status.head.slice(0, 7)}`);
186
+ const bits = [status.state === "dirty" ? `dirty (${status.changed} changed)` : "clean"];
187
+ if (status.ahead > 0) bits.push(`${status.ahead} ahead`);
188
+ if (status.behind > 0) bits.push(`${status.behind} behind`);
189
+ if (status.merged) bits.push("merged");
190
+ lines.push(`state ${bits.join(", ")}`);
191
+ if (status.locked) {
192
+ lines.push(`lock ${status.staleLock ? "stale (owner is gone)" : "held by a live session"}`);
193
+ }
194
+ if (status.lastUsedAt) lines.push(`used ${relativeDay(status.lastUsedAt)}`);
195
+ // The handoff is read, not performed: a merge lands in the main checkout.
196
+ if (status.ahead > 0 && row.branch) {
197
+ lines.push(
198
+ "",
199
+ ...handoffLines({
200
+ branch: row.branch,
201
+ base: this.view.baseBranch ?? null,
202
+ ahead: status.ahead,
203
+ merged: status.merged,
204
+ }),
205
+ );
206
+ }
207
+ }
208
+ return { title: row.name, lines, subtitle: row.branch ?? row.path };
209
+ }
210
+
211
+ protected handleVerb(key: string, row: WorktreeRow | undefined): void {
212
+ if (key !== "e" && key !== "x" && key !== "p") return;
213
+ if (this.deps.readOnly) {
214
+ this.notify("Plan mode is read-only. Approve the plan before changing worktrees.");
215
+ return;
216
+ }
217
+ if (key === "e") {
218
+ if (!row) {
219
+ this.notify("No worktree to enter.");
220
+ return;
221
+ }
222
+ if (row.current) {
223
+ this.notify(`${row.name} is already the current worktree.`);
224
+ return;
225
+ }
226
+ if (this.view.active) {
227
+ this.notify(`Still in ${worktreeLabel(this.view.active)}; press x to exit first.`);
228
+ return;
229
+ }
230
+ this.deps.onDone({ kind: "enter", name: row.name });
231
+ return;
232
+ }
233
+ if (key === "x") {
234
+ if (!this.view.active) {
235
+ this.notify("Not in a worktree.");
236
+ return;
237
+ }
238
+ this.deps.onDone({ kind: "exit" });
239
+ return;
240
+ }
241
+ if (this.pruning) {
242
+ this.notify("A prune is already running.");
243
+ return;
244
+ }
245
+ this.pruning = true;
246
+ this.notify("Pruning…");
247
+ void this.pruneInPlace();
248
+ }
249
+
250
+ /** Prune is the one verb that never prompts, so it runs without closing the screen. */
251
+ private async pruneInPlace(): Promise<void> {
252
+ try {
253
+ const report = await this.deps.prune();
254
+ this.view = await this.deps.reload();
255
+ this.resort();
256
+ this.notify(report.split("\n")[0] ?? "Pruned.");
257
+ } catch (error) {
258
+ this.notify(`Prune failed: ${messageOf(error)}`);
259
+ } finally {
260
+ this.pruning = false;
261
+ }
262
+ }
263
+ }