@gavin-hu/my-pi-agent 0.16.0 → 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 (135) hide show
  1. package/README.md +14 -8
  2. package/README.zh-CN.md +14 -9
  3. package/extensions/bytefly/README.md +174 -101
  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/panel/commands.ts +46 -25
  17. package/extensions/bytefly/panel/format.ts +49 -14
  18. package/extensions/bytefly/panel/join.ts +35 -9
  19. package/extensions/bytefly/panel/settings.ts +10 -27
  20. package/extensions/bytefly/panel/skills.ts +51 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  22. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  23. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  24. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  25. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  26. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  27. package/extensions/bytefly/panel/tui.ts +223 -331
  28. package/extensions/bytefly/panel/types.ts +26 -8
  29. package/extensions/bytefly/picker/discovery.ts +32 -27
  30. package/extensions/bytefly/picker/resources.ts +16 -8
  31. package/extensions/bytefly/picker/runtime.ts +62 -34
  32. package/extensions/bytefly/picker/toggle.ts +16 -10
  33. package/extensions/bytefly/picker/types.ts +31 -13
  34. package/extensions/bytefly/update/format.ts +66 -8
  35. package/extensions/bytefly/update/patch.ts +66 -0
  36. package/extensions/bytefly/update/register.ts +4 -11
  37. package/extensions/bytefly/update/release.ts +65 -0
  38. package/extensions/bytefly/update/state.ts +14 -0
  39. package/extensions/bytefly/update/suppress.ts +47 -29
  40. package/extensions/bytefly/update/types.ts +15 -5
  41. package/extensions/check/tui.ts +2 -0
  42. package/extensions/context/README.md +21 -11
  43. package/extensions/context/format.ts +10 -8
  44. package/extensions/context/report.ts +21 -8
  45. package/extensions/context/tui.ts +26 -10
  46. package/extensions/context/types.ts +5 -3
  47. package/extensions/file-browse/README.md +29 -1
  48. package/extensions/file-browse/client.ts +16 -463
  49. package/extensions/file-browse/html.ts +7 -5
  50. package/extensions/file-browse/palette.ts +224 -0
  51. package/extensions/file-browse/router.ts +2 -2
  52. package/extensions/file-browse/styles.ts +8 -3
  53. package/extensions/file-browse/tree.ts +381 -0
  54. package/extensions/file-read/README.md +4 -1
  55. package/extensions/file-read/extract/odt.ts +7 -5
  56. package/extensions/goal/tools.ts +6 -11
  57. package/extensions/job/index.ts +20 -45
  58. package/extensions/job/tools.ts +51 -77
  59. package/extensions/memory/tools.ts +4 -6
  60. package/extensions/memory/tui.ts +5 -1
  61. package/extensions/plan/README.md +88 -33
  62. package/extensions/plan/commands.ts +19 -4
  63. package/extensions/plan/config.ts +27 -0
  64. package/extensions/plan/plan-index.ts +3 -1
  65. package/extensions/plan/plans.ts +31 -13
  66. package/extensions/plan/review.ts +34 -14
  67. package/extensions/plan/runtime.ts +3 -1
  68. package/extensions/plan/steps.ts +43 -14
  69. package/extensions/plan/tools/cancel.ts +79 -0
  70. package/extensions/plan/tools/enter.ts +10 -10
  71. package/extensions/plan/tools/exit.ts +54 -43
  72. package/extensions/plan/tools/steps.ts +7 -19
  73. package/extensions/plan/tools/write.ts +22 -29
  74. package/extensions/plan/tools.ts +5 -2
  75. package/extensions/plan/tui.ts +9 -7
  76. package/extensions/plan/types.ts +8 -0
  77. package/extensions/plan/web-chrome.ts +101 -0
  78. package/extensions/plan/web-client.ts +98 -311
  79. package/extensions/plan/web-markdown.ts +222 -0
  80. package/extensions/plan/web-page.ts +28 -2
  81. package/extensions/plan/web-router.ts +2 -2
  82. package/extensions/rewind/checklist.ts +4 -0
  83. package/extensions/rewind/review-tui.ts +3 -1
  84. package/extensions/rewind/rewind-tui.ts +4 -0
  85. package/extensions/subagent/README.md +25 -12
  86. package/extensions/subagent/format.ts +72 -25
  87. package/extensions/subagent/index.ts +21 -47
  88. package/extensions/subagent/orchestrate.ts +18 -41
  89. package/extensions/subagent/render.ts +29 -42
  90. package/extensions/subagent/stream.ts +24 -18
  91. package/extensions/subagent/types.ts +7 -0
  92. package/extensions/todo/tools.ts +5 -6
  93. package/extensions/todo/tui.ts +5 -1
  94. package/extensions/watch/index.ts +10 -35
  95. package/extensions/watch/tools.ts +31 -59
  96. package/extensions/wechat/README.md +40 -18
  97. package/extensions/wechat/bridge.ts +38 -40
  98. package/extensions/wechat/client.ts +1 -4
  99. package/extensions/wechat/commands.ts +1 -1
  100. package/extensions/wechat/interaction.ts +25 -4
  101. package/extensions/wechat/login-screen.ts +2 -3
  102. package/extensions/wechat/notices.ts +7 -5
  103. package/extensions/wechat/prompt.ts +19 -4
  104. package/extensions/wechat/qrcode.ts +9 -9
  105. package/extensions/wechat/slash.ts +14 -0
  106. package/extensions/wechat/status-screen.ts +2 -2
  107. package/extensions/wechat/tools.ts +3 -11
  108. package/extensions/worktree/README.md +48 -12
  109. package/extensions/worktree/commands.ts +109 -65
  110. package/extensions/worktree/enter.ts +1 -1
  111. package/extensions/worktree/exit.ts +11 -1
  112. package/extensions/worktree/lifecycle.ts +9 -1
  113. package/extensions/worktree/screen.ts +263 -0
  114. package/extensions/worktree/status.ts +84 -38
  115. package/lib/README.md +7 -6
  116. package/lib/config.ts +12 -0
  117. package/lib/format.ts +6 -2
  118. package/lib/interaction.ts +18 -1
  119. package/lib/report.ts +75 -2
  120. package/lib/tool-result.ts +54 -4
  121. package/lib/tui/README.md +16 -8
  122. package/lib/tui/chrome.ts +27 -6
  123. package/lib/tui/dock-screen.ts +6 -2
  124. package/lib/tui/gauge.ts +9 -5
  125. package/lib/usage.ts +26 -0
  126. package/lib/web/README.md +131 -0
  127. package/lib/web/base.ts +19 -2
  128. package/lib/web/components.ts +10 -6
  129. package/lib/web/layout.ts +2 -0
  130. package/lib/web/markup.ts +2 -1
  131. package/lib/web/runtime.ts +103 -25
  132. package/lib/web/tokens.ts +23 -7
  133. package/package.json +3 -1
  134. package/skills/changelog/SKILL.md +57 -45
  135. package/skills/next/SKILL.md +184 -0
@@ -2,20 +2,20 @@
2
2
  * Rendering a QR string as terminal lines.
3
3
  *
4
4
  * Uses the `qrcode` package only for the module matrix, then draws Unicode
5
- * half-blocks so two matrix rows share one terminal line. No ANSI is emitted;
6
- * the login screen decides colours, and the width is the matrix width plus a
7
- * quiet zone.
5
+ * half-blocks so two matrix rows share one terminal line. The package loads on
6
+ * the first QR, not when the extension registers: a session that never logs in
7
+ * pays nothing for it. No ANSI is emitted; the login screen decides colours, and
8
+ * the width is the matrix width plus a quiet zone.
8
9
  */
9
10
 
10
- import QRCode from "qrcode";
11
-
12
11
  interface QrMatrix {
13
12
  size: number;
14
13
  get(row: number, col: number): boolean;
15
14
  }
16
15
 
17
- /** Build the module matrix for `text`. */
18
- export function qrMatrix(text: string): QrMatrix {
16
+ /** Build the module matrix for `text`, loading `qrcode` on first use. */
17
+ export async function qrMatrix(text: string): Promise<QrMatrix> {
18
+ const { default: QRCode } = await import("qrcode");
19
19
  const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
20
20
  return qr.modules as unknown as QrMatrix;
21
21
  }
@@ -25,9 +25,9 @@ export function qrMatrix(text: string): QrMatrix {
25
25
  * columns wide (half-block characters are single width), so a caller can test
26
26
  * `qrLines(text)[0].length` against the terminal width.
27
27
  */
28
- export function qrLines(text: string, options: { quiet?: number } = {}): string[] {
28
+ export async function qrLines(text: string, options: { quiet?: number } = {}): Promise<string[]> {
29
29
  const quiet = Math.max(0, options.quiet ?? 2);
30
- const matrix = qrMatrix(text);
30
+ const matrix = await qrMatrix(text);
31
31
  const size = matrix.size;
32
32
  const isDark = (row: number, col: number): boolean =>
33
33
  row >= 0 && col >= 0 && row < size && col < size && matrix.get(row, col);
@@ -21,6 +21,8 @@ export interface SlashPeerState {
21
21
  status: "idle" | "queued" | "running";
22
22
  queued: number;
23
23
  runningMs?: number;
24
+ /** The title of a dialog waiting on the sender, when one is. */
25
+ prompt?: string;
24
26
  }
25
27
 
26
28
  /** The session controls an action may invoke; every one acts on the sender. */
@@ -152,6 +154,18 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
152
154
  kind: "action",
153
155
  run: async (ctx) => {
154
156
  const state = ctx.session.status();
157
+ // A waiting dialog is the whole status while it waits: the turn is blocked on
158
+ // it, so "working, 12s" would hide the one thing the peer can act on.
159
+ if (state.prompt !== undefined) {
160
+ await ctx.reply(
161
+ localized(
162
+ ctx.language,
163
+ `正在等你回答「${state.prompt}」,回复「取消」可跳过。`,
164
+ `Waiting for your answer: “${state.prompt}” — reply “cancel” to skip.`,
165
+ ),
166
+ );
167
+ return;
168
+ }
155
169
  if (state.status !== "running") {
156
170
  await ctx.reply(
157
171
  state.queued > 0
@@ -12,7 +12,7 @@
12
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
13
  import type { Component } from "@earendil-works/pi-tui";
14
14
  import { formatDuration, sanitizeLine } from "../../lib/format.ts";
15
- import { screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
15
+ import { screenFooter, screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
16
16
  import { clip } from "../../lib/tui/columns.ts";
17
17
  import { formatRange, IdCursor, isCloseKey, navIntent } from "../../lib/tui/cursor.ts";
18
18
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
@@ -441,7 +441,7 @@ export class StatusScreenComponent implements Component {
441
441
  ...statusBody(view, theme, target, selection),
442
442
  ];
443
443
  if (this.flash) lines.push("", clip(` ${paint(theme, "meta", this.flash)}`, target));
444
- lines.push("", screenHint(theme, target, this.hints(view)));
444
+ lines.push(...screenFooter(theme, target, screenHint(theme, target, this.hints(view))));
445
445
  return lines;
446
446
  }
447
447
  }
@@ -11,6 +11,7 @@ import type { JsonValue } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { Type, type Static } from "typebox";
13
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
+ import { failedResult, textResult } from "../../lib/tool-result.ts";
14
15
  import { callLine, errorLine, rejectionLine } from "../../lib/tui/tool.ts";
15
16
  import { paint } from "../../lib/tui/tokens.ts";
16
17
  import type { Bridge } from "./bridge.ts";
@@ -39,17 +40,8 @@ function sendResult(sent: boolean, error?: string) {
39
40
  const details = error === undefined ? { sent } : { sent, error };
40
41
  const structuredContent: Record<string, JsonValue> = { sent };
41
42
  if (error !== undefined) structuredContent.error = error;
42
- return {
43
- content: [
44
- {
45
- type: "text" as const,
46
- text: sent ? "Sent to the WeChat owner." : `WeChat send failed: ${error ?? "unknown error"}`,
47
- },
48
- ],
49
- details,
50
- structuredContent,
51
- ...(sent ? {} : { isError: true as const }),
52
- };
43
+ const text = sent ? "Sent to the WeChat owner." : `WeChat send failed: ${error ?? "unknown error"}`;
44
+ return sent ? textResult(text, details, structuredContent) : failedResult(text, details, structuredContent);
53
45
  }
54
46
 
55
47
  export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
@@ -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";