@gavin-hu/my-pi-agent 0.16.0 → 0.18.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.
- package/README.md +29 -15
- package/README.zh-CN.md +27 -15
- package/extensions/bytefly/README.md +195 -115
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +29 -39
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +89 -22
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +74 -459
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +26 -152
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +27 -6
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +10 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +35 -52
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/README.md +3 -3
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +333 -84
- package/extensions/plan/commands.ts +73 -7
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +184 -58
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +95 -95
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +123 -107
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +17 -12
- package/extensions/plan/tools/exit.ts +148 -59
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +29 -31
- package/extensions/plan/tools.ts +6 -3
- package/extensions/plan/tui.ts +60 -15
- package/extensions/plan/types.ts +45 -3
- package/extensions/plan/web-chrome.ts +102 -0
- package/extensions/plan/web-client.ts +177 -446
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +131 -19
- package/extensions/plan/web-router.ts +148 -32
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +26 -13
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +25 -44
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +45 -36
- package/extensions/wechat/bridge.ts +46 -48
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +3 -3
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +54 -18
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +9 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +53 -23
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -40
- package/lib/README.md +12 -7
- package/lib/claims/overlap.ts +43 -0
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +28 -1
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +117 -25
- package/lib/requirements.ts +1 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +30 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +91 -19
- package/lib/tui/gauge.ts +9 -5
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +5 -2
- package/skills/changelog/SKILL.md +57 -45
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +209 -0
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -27,6 +27,8 @@ export interface ReplyPipelineDeps {
|
|
|
27
27
|
export interface ReplyPipeline {
|
|
28
28
|
/** Add streamed text; parts go out as boundaries are crossed. */
|
|
29
29
|
push(delta: string): void;
|
|
30
|
+
/** Send the current buffer now, in order; used before a dialog or file. */
|
|
31
|
+
flush(): Promise<void>;
|
|
30
32
|
/** Send whatever is left and resolve once every part is out. */
|
|
31
33
|
finish(): Promise<void>;
|
|
32
34
|
/** Drop the unsent remainder (an aborted turn) and cancel the timer. */
|
|
@@ -106,6 +108,13 @@ export function createReplyPipeline(deps: ReplyPipelineDeps): ReplyPipeline {
|
|
|
106
108
|
enqueue(part);
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
async function flushBuffer(): Promise<void> {
|
|
112
|
+
cancelQuiet();
|
|
113
|
+
enqueue(buffer);
|
|
114
|
+
buffer = "";
|
|
115
|
+
await chain;
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
return {
|
|
110
119
|
push(delta: string): void {
|
|
111
120
|
if (!delta) return;
|
|
@@ -125,11 +134,10 @@ export function createReplyPipeline(deps: ReplyPipelineDeps): ReplyPipeline {
|
|
|
125
134
|
scheduleQuiet();
|
|
126
135
|
},
|
|
127
136
|
|
|
137
|
+
flush: flushBuffer,
|
|
138
|
+
|
|
128
139
|
async finish(): Promise<void> {
|
|
129
|
-
|
|
130
|
-
enqueue(buffer);
|
|
131
|
-
buffer = "";
|
|
132
|
-
await chain;
|
|
140
|
+
await flushBuffer();
|
|
133
141
|
},
|
|
134
142
|
|
|
135
143
|
drop(): void {
|
|
@@ -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(
|
|
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
|
-
|
|
43
|
-
|
|
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` |
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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`)
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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:
|
|
117
|
+
description: "Manage git worktrees: the worktree screen, or enter, exit, and prune",
|
|
48
118
|
getArgumentCompletions: (prefix) => {
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
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 (
|
|
53
|
-
|
|
54
|
-
return
|
|
127
|
+
if (sub === "") {
|
|
128
|
+
await showWorktrees(pi, ctx);
|
|
129
|
+
return;
|
|
55
130
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 "
|
|
69
|
-
|
|
140
|
+
case "enter":
|
|
141
|
+
await runEnter(pi, ctx, rest[0]);
|
|
70
142
|
return;
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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)}).
|
|
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)
|
|
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
|
|
|
@@ -355,6 +355,11 @@ export async function inspectWork(pi: ExtensionAPI, dir: string, base?: string):
|
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
export interface ManagedWorktree {
|
|
358
|
+
/**
|
|
359
|
+
* Canonical path, resolved through symlinks by `listManagedWorktrees`. Every
|
|
360
|
+
* caller takes that as given: re-resolving it here would repeat a sync ancestor
|
|
361
|
+
* walk the listing already paid for, once per worktree per status read.
|
|
362
|
+
*/
|
|
358
363
|
path: string;
|
|
359
364
|
branch?: string;
|
|
360
365
|
head?: string;
|
|
@@ -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 {
|
|
14
|
+
export {
|
|
15
|
+
formatWorktreeStatus,
|
|
16
|
+
refusalMessage,
|
|
17
|
+
stateSummary,
|
|
18
|
+
worktreeFacts,
|
|
19
|
+
worktreeLabel,
|
|
20
|
+
worktreeSnapshot,
|
|
21
|
+
worktreeStatus,
|
|
22
|
+
} from "./status.ts";
|
|
@@ -145,7 +145,9 @@ export function reconcileRegistry(
|
|
|
145
145
|
options: { persist?: boolean } = {},
|
|
146
146
|
): { registry: WorktreeRegistry; changed: boolean } {
|
|
147
147
|
const registry = loadRegistry(repoRoot, config);
|
|
148
|
-
|
|
148
|
+
// Resolve each entry once: the live set and the synthesized records both need it.
|
|
149
|
+
const resolved = managed.map((entry) => ({ entry, path: realPathOfNearest(entry.path) }));
|
|
150
|
+
const live = new Set(resolved.map((row) => row.path));
|
|
149
151
|
const byPath = new Map(registry.worktrees.map((record) => [record.path, record] as const));
|
|
150
152
|
|
|
151
153
|
let changed = false;
|
|
@@ -157,8 +159,7 @@ export function reconcileRegistry(
|
|
|
157
159
|
changed = true;
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
|
-
for (const entry of
|
|
161
|
-
const path = realPathOfNearest(entry.path);
|
|
162
|
+
for (const { entry, path } of resolved) {
|
|
162
163
|
if (byPath.has(path)) continue;
|
|
163
164
|
next.push({
|
|
164
165
|
path,
|
|
@@ -183,13 +184,18 @@ export interface StatusOptions {
|
|
|
183
184
|
record?: WorktreeRecord;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* Derive one checkout's status from its git listing and registry record.
|
|
189
|
+
*
|
|
190
|
+
* `entry.path` is taken as canonical — the listing and `reconcileRegistry` are the
|
|
191
|
+
* two places a path is resolved — so this does not walk the ancestors again.
|
|
192
|
+
*/
|
|
187
193
|
export async function statusOf(
|
|
188
194
|
pi: ExtensionAPI,
|
|
189
195
|
entry: ManagedWorktree,
|
|
190
196
|
options: StatusOptions = {},
|
|
191
197
|
): Promise<CheckoutStatus> {
|
|
192
|
-
const path =
|
|
198
|
+
const path = entry.path;
|
|
193
199
|
const locked = entry.locked !== undefined;
|
|
194
200
|
const staleLock = locked && entry.locked ? isStaleLock(entry.locked) : false;
|
|
195
201
|
const base: CheckoutStatus = {
|