pi-zellij 0.3.1

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.
@@ -0,0 +1,449 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { SessionManager, type ExtensionAPI, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
5
+ import {
6
+ buildPiCommand,
7
+ openCommandInNewSplit,
8
+ type SplitDirection,
9
+ } from "./zv-core.ts";
10
+ import {
11
+ ensureCreatedBranchWorktree,
12
+ getGitRepoInfo,
13
+ type GitRepoInfo,
14
+ } from "./git-core.ts";
15
+
16
+ const MAX_TEXT_LENGTH = 280;
17
+ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "templates");
18
+
19
+ const promptTemplateCache = new Map<string, string>();
20
+
21
+ type ContinueRequest =
22
+ | { mode: "handoff"; note?: string }
23
+ | { mode: "worktree-create"; branch: string; fromRef?: string; note?: string };
24
+
25
+ interface HandoffContext {
26
+ sourceCwd: string;
27
+ sourceSessionName?: string;
28
+ sourceSessionFile?: string;
29
+ currentTask?: string;
30
+ repo?: GitRepoInfo;
31
+ modifiedFiles: string[];
32
+ newFiles: string[];
33
+ otherStatusLines: string[];
34
+ note?: string;
35
+ fromRef?: string;
36
+ targetBranch?: string;
37
+ targetWorktreePath?: string;
38
+ }
39
+
40
+ interface HandoffTarget {
41
+ cwd: string;
42
+ sessionFile: string;
43
+ prompt: string;
44
+ }
45
+
46
+ interface MessageLike {
47
+ role?: string;
48
+ content?: unknown;
49
+ }
50
+
51
+ interface SessionMessageEntryLike {
52
+ type?: string;
53
+ message?: MessageLike;
54
+ }
55
+
56
+ function truncateText(text: string, maxLength: number = MAX_TEXT_LENGTH): string {
57
+ const trimmed = text.trim();
58
+ if (trimmed.length <= maxLength) return trimmed;
59
+ return `${trimmed.slice(0, maxLength - 3)}...`;
60
+ }
61
+
62
+ function normalizeWhitespace(text: string): string {
63
+ return text.replace(/\s+/g, " ").trim();
64
+ }
65
+
66
+ function getContinueUsage(commandName: string): string {
67
+ return `Usage: /${commandName} [note] | /${commandName} -c <branch> [--from <ref>] [note]`;
68
+ }
69
+
70
+ function parseContinueArgs(args: string): { ok: true; request: ContinueRequest } | { ok: false; error: string } {
71
+ const trimmed = args.trim();
72
+ if (!trimmed) {
73
+ return { ok: true, request: { mode: "handoff" } };
74
+ }
75
+
76
+ const tokens = trimmed.split(/\s+/).filter((token) => token.length > 0);
77
+ const [first, ...rest] = tokens;
78
+ if (first === "-t" || first === "--tree" || first === "--worktree") {
79
+ return { ok: false, error: "Existing-branch worktree mode is disabled for now; use -c <branch> to create a new worktree branch" };
80
+ }
81
+ if (first === "-c" || first === "--create") {
82
+ if (rest.length < 1) {
83
+ return { ok: false, error: "Create-worktree mode requires a branch name" };
84
+ }
85
+ const [branch, ...remaining] = rest;
86
+ let fromRef: string | undefined;
87
+ const noteParts: string[] = [];
88
+ for (let index = 0; index < remaining.length; index += 1) {
89
+ const token = remaining[index];
90
+ if (token === "--from" || token === "-f") {
91
+ const next = remaining[index + 1];
92
+ if (!next) {
93
+ return { ok: false, error: "--from requires a git ref" };
94
+ }
95
+ fromRef = next;
96
+ index += 1;
97
+ continue;
98
+ }
99
+ noteParts.push(token);
100
+ }
101
+ const note = noteParts.join(" ").trim() || undefined;
102
+ return { ok: true, request: { mode: "worktree-create", branch, fromRef, note } };
103
+ }
104
+ if (trimmed.startsWith("-")) {
105
+ return { ok: false, error: `Unknown flag: ${first}` };
106
+ }
107
+
108
+ return { ok: true, request: { mode: "handoff", note: trimmed } };
109
+ }
110
+
111
+ function isSessionMessageEntry(entry: unknown): entry is SessionMessageEntryLike {
112
+ return typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "message";
113
+ }
114
+
115
+ function getMessageText(message: MessageLike | undefined): string | undefined {
116
+ if (!message) return undefined;
117
+ if (typeof message.content === "string") {
118
+ const text = normalizeWhitespace(message.content);
119
+ return text.length > 0 ? truncateText(text) : undefined;
120
+ }
121
+ if (!Array.isArray(message.content)) return undefined;
122
+ const text = message.content
123
+ .filter((part): part is { type: "text"; text: string } => {
124
+ return typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string";
125
+ })
126
+ .map((part) => normalizeWhitespace(part.text))
127
+ .filter((part) => part.length > 0)
128
+ .join(" ")
129
+ .trim();
130
+ return text.length > 0 ? truncateText(text) : undefined;
131
+ }
132
+
133
+ function isControlMessageText(text: string): boolean {
134
+ const trimmed = text.trim();
135
+ if (!trimmed) return true;
136
+ if (trimmed.startsWith("/") || trimmed.startsWith(":")) return true;
137
+ if (/^run (?:\/|:)reload/i.test(trimmed)) return true;
138
+ return false;
139
+ }
140
+
141
+ function isSyntheticHandoffText(text: string): boolean {
142
+ const trimmed = text.trim();
143
+ return trimmed.startsWith("Handoff context from another Pi pane:") || trimmed.startsWith("Continue the current task from this new pane.") || trimmed.startsWith("Continue the current task in this git worktree for branch ");
144
+ }
145
+
146
+ function isLowSignalTaskText(text: string): boolean {
147
+ const normalized = text.trim().toLowerCase();
148
+ if (!normalized) return true;
149
+ if ([
150
+ "yes",
151
+ "ok",
152
+ "okay",
153
+ "yep",
154
+ "yeah",
155
+ "sure",
156
+ "nice",
157
+ "cool",
158
+ "great",
159
+ "go ahead",
160
+ "do it",
161
+ "makes sense",
162
+ "ok makes sense",
163
+ "okay makes sense",
164
+ ].includes(normalized)) {
165
+ return true;
166
+ }
167
+ return normalized.split(/\s+/).length === 1 && normalized.length <= 4;
168
+ }
169
+
170
+ function findRecentTaskText(entries: readonly unknown[]): string | undefined {
171
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
172
+ const entry = entries[index];
173
+ if (!isSessionMessageEntry(entry) || entry.message?.role !== "user") continue;
174
+ const text = getMessageText(entry.message);
175
+ if (!text || isControlMessageText(text) || isSyntheticHandoffText(text) || isLowSignalTaskText(text)) continue;
176
+ return text;
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ function shouldIgnoreStatusPath(file: string): boolean {
182
+ return file === ".agents" || file.startsWith(".agents/") || file === ".pi" || file.startsWith(".pi/") || file === "node_modules" || file.startsWith("node_modules/");
183
+ }
184
+
185
+ function summarizeGitStatusLines(statusLines: readonly string[]): { modifiedFiles: string[]; newFiles: string[]; otherStatusLines: string[] } {
186
+ const modifiedFiles: string[] = [];
187
+ const newFiles: string[] = [];
188
+ const otherStatusLines: string[] = [];
189
+
190
+ for (const line of statusLines) {
191
+ const code = line.slice(0, 2);
192
+ const file = line.slice(3).trim();
193
+ if (!file || shouldIgnoreStatusPath(file)) {
194
+ continue;
195
+ }
196
+ if (code === "??") {
197
+ newFiles.push(file);
198
+ continue;
199
+ }
200
+ if (code.includes("M") || code.includes("A") || code.includes("D") || code.includes("R") || code.includes("C")) {
201
+ modifiedFiles.push(file);
202
+ continue;
203
+ }
204
+ otherStatusLines.push(line);
205
+ }
206
+
207
+ return { modifiedFiles, newFiles, otherStatusLines };
208
+ }
209
+
210
+ function buildHandoffSummary(context: HandoffContext, includeLineageHint: boolean): string {
211
+ const lines = ["Handoff context from another Pi pane:"];
212
+ lines.push(`- Source cwd: ${context.sourceCwd}`);
213
+ if (context.sourceSessionName) lines.push(`- Session name: ${context.sourceSessionName}`);
214
+ if (context.repo?.branch) lines.push(`- Source branch: ${context.repo.branch}`);
215
+ if (context.targetBranch) lines.push(`- Target branch: ${context.targetBranch}`);
216
+ if (context.fromRef) lines.push(`- Base ref: ${context.fromRef}`);
217
+ if (context.targetWorktreePath) lines.push(`- Target worktree: ${context.targetWorktreePath}`);
218
+ if (context.currentTask) lines.push(`- Current task: ${context.currentTask}`);
219
+ if (context.note) lines.push(`- Focus note: ${context.note}`);
220
+ if (context.modifiedFiles.length > 0) {
221
+ lines.push("- Modified files:");
222
+ for (const file of context.modifiedFiles) {
223
+ lines.push(` ${file}`);
224
+ }
225
+ }
226
+ if (context.newFiles.length > 0) {
227
+ lines.push("- New files:");
228
+ for (const file of context.newFiles) {
229
+ lines.push(` ${file}`);
230
+ }
231
+ }
232
+ if (context.otherStatusLines.length > 0) {
233
+ lines.push("- Other git status:");
234
+ for (const line of context.otherStatusLines) {
235
+ lines.push(` ${line}`);
236
+ }
237
+ }
238
+ if (includeLineageHint) {
239
+ lines.push("- This session was forked from the current conversation path so prior context is already available here.");
240
+ }
241
+ return lines.join("\n");
242
+ }
243
+
244
+ function readPromptTemplate(name: string): string {
245
+ const cached = promptTemplateCache.get(name);
246
+ if (cached) return cached;
247
+ const template = readFileSync(join(TEMPLATE_DIR, name), "utf8");
248
+ promptTemplateCache.set(name, template);
249
+ return template;
250
+ }
251
+
252
+ function renderPromptTemplate(name: string, replacements: Record<string, string>): string {
253
+ let template = readPromptTemplate(name);
254
+ for (const [key, value] of Object.entries(replacements)) {
255
+ template = template.replaceAll(`{{${key}}}`, value);
256
+ }
257
+ return template.trim();
258
+ }
259
+
260
+ function buildFocusNoteSentence(note?: string): string {
261
+ return note ? ` Focus on: ${note}.` : "";
262
+ }
263
+
264
+ function buildSameCheckoutPrompt(note: string | undefined, inheritedHistory: boolean): string {
265
+ if (inheritedHistory) {
266
+ return renderPromptTemplate("handoff-same-checkout.md", {
267
+ FOCUS_NOTE_SENTENCE: buildFocusNoteSentence(note),
268
+ });
269
+ }
270
+ if (note) {
271
+ return `Continue the current task from this new pane. Focus on: ${note}. Use the handoff summary already present in this session. Start with the highest-priority next step.`;
272
+ }
273
+ return "Continue the current task from this new pane. Use the handoff summary already present in this session. Start with the highest-priority next step.";
274
+ }
275
+
276
+ function buildWorktreePrompt(branch: string, note?: string): string {
277
+ return renderPromptTemplate("handoff-worktree.md", {
278
+ TARGET_BRANCH: branch,
279
+ FOCUS_NOTE_SENTENCE: buildFocusNoteSentence(note),
280
+ });
281
+ }
282
+
283
+ function buildWorktreeBootstrapPrompt(branch: string, note?: string): string {
284
+ return buildWorktreePrompt(branch, note);
285
+ }
286
+
287
+ function appendUserMessage(sessionManager: SessionManager, text: string): void {
288
+ sessionManager.appendMessage({
289
+ role: "user",
290
+ content: text,
291
+ timestamp: Date.now(),
292
+ });
293
+ }
294
+
295
+ function createForkedSameCheckoutSession(ctx: ExtensionCommandContext, summary: string): string | undefined {
296
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
297
+ const leafId = ctx.sessionManager.getLeafId();
298
+ if (!currentSessionFile || !leafId) return undefined;
299
+
300
+ const currentSession = SessionManager.open(currentSessionFile, ctx.sessionManager.getSessionDir());
301
+ const branchedSessionFile = currentSession.createBranchedSession(leafId);
302
+ if (!branchedSessionFile) return undefined;
303
+
304
+ const branchedSession = SessionManager.open(branchedSessionFile, ctx.sessionManager.getSessionDir());
305
+ appendUserMessage(branchedSession, summary);
306
+ return branchedSessionFile;
307
+ }
308
+
309
+ function createSummaryOnlySession(cwd: string, summary: string): string | undefined {
310
+ const sessionManager = SessionManager.create(cwd);
311
+ appendUserMessage(sessionManager, summary);
312
+ return sessionManager.getSessionFile();
313
+ }
314
+
315
+ async function buildHandoffContext(pi: ExtensionAPI, ctx: ExtensionCommandContext, note?: string): Promise<HandoffContext> {
316
+ const branchEntries = ctx.sessionManager.getBranch();
317
+ const repo = await getGitRepoInfo(pi, ctx.cwd);
318
+ const statusSummary = summarizeGitStatusLines(repo?.statusLines ?? []);
319
+ return {
320
+ sourceCwd: ctx.cwd,
321
+ sourceSessionName: ctx.sessionManager.getSessionName(),
322
+ sourceSessionFile: ctx.sessionManager.getSessionFile(),
323
+ currentTask: ctx.sessionManager.getSessionName() || findRecentTaskText(branchEntries),
324
+ repo,
325
+ modifiedFiles: statusSummary.modifiedFiles,
326
+ newFiles: statusSummary.newFiles,
327
+ otherStatusLines: statusSummary.otherStatusLines,
328
+ note,
329
+ };
330
+ }
331
+
332
+ async function resolveHandoffTarget(
333
+ pi: ExtensionAPI,
334
+ ctx: ExtensionCommandContext,
335
+ request: ContinueRequest,
336
+ ): Promise<{ ok: true; target: HandoffTarget } | { ok: false; error: string }> {
337
+ if (request.mode === "handoff") {
338
+ const context = await buildHandoffContext(pi, ctx, request.note);
339
+ const expectInheritedHistory = Boolean(context.sourceSessionFile && ctx.sessionManager.getLeafId());
340
+ const summary = buildHandoffSummary(context, expectInheritedHistory);
341
+ const forkedSessionFile = createForkedSameCheckoutSession(ctx, summary);
342
+ const sessionFile = forkedSessionFile || createSummaryOnlySession(ctx.cwd, summary);
343
+ if (!sessionFile) {
344
+ return { ok: false, error: "Failed to create a handoff session" };
345
+ }
346
+ return {
347
+ ok: true,
348
+ target: {
349
+ cwd: ctx.cwd,
350
+ sessionFile,
351
+ prompt: buildSameCheckoutPrompt(request.note, Boolean(forkedSessionFile)),
352
+ },
353
+ };
354
+ }
355
+
356
+ const repo = await getGitRepoInfo(pi, ctx.cwd);
357
+ if (!repo) {
358
+ return { ok: false, error: "Not inside a git repository" };
359
+ }
360
+
361
+ const worktreeResult = await ensureCreatedBranchWorktree(pi, repo.repoRoot, request.branch, request.fromRef);
362
+ if (!worktreeResult.ok) {
363
+ return worktreeResult;
364
+ }
365
+
366
+ const context = await buildHandoffContext(pi, ctx, request.note);
367
+ context.targetBranch = request.branch;
368
+ context.fromRef = request.fromRef;
369
+ context.targetWorktreePath = worktreeResult.path;
370
+ const summary = buildHandoffSummary(context, false);
371
+ const sessionFile = createSummaryOnlySession(worktreeResult.path, summary);
372
+ if (!sessionFile) {
373
+ return { ok: false, error: "Failed to create a worktree handoff session" };
374
+ }
375
+
376
+ return {
377
+ ok: true,
378
+ target: {
379
+ cwd: worktreeResult.path,
380
+ sessionFile,
381
+ prompt: buildWorktreeBootstrapPrompt(request.branch, request.note),
382
+ },
383
+ };
384
+ }
385
+
386
+ async function openContinueSplit(
387
+ pi: ExtensionAPI,
388
+ ctx: ExtensionCommandContext,
389
+ direction: SplitDirection,
390
+ request: ContinueRequest,
391
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
392
+ const handoffTarget = await resolveHandoffTarget(pi, ctx, request);
393
+ if (!handoffTarget.ok) {
394
+ return handoffTarget;
395
+ }
396
+
397
+ return openCommandInNewSplit(
398
+ pi,
399
+ direction,
400
+ buildPiCommand(handoffTarget.target.cwd, {
401
+ sessionFile: handoffTarget.target.sessionFile,
402
+ prompt: handoffTarget.target.prompt,
403
+ }),
404
+ );
405
+ }
406
+
407
+ function registerContinueCommand(
408
+ pi: ExtensionAPI,
409
+ name: string,
410
+ direction: SplitDirection,
411
+ description: string,
412
+ successMessage: string,
413
+ ): void {
414
+ pi.registerCommand(name, {
415
+ description,
416
+ handler: async (args, ctx) => {
417
+ const parsed = parseContinueArgs(args);
418
+ if (!parsed.ok) {
419
+ ctx.ui.notify(`${parsed.error}. ${getContinueUsage(name)}`, "warning");
420
+ return;
421
+ }
422
+
423
+ const result = await openContinueSplit(pi, ctx, direction, parsed.request);
424
+ if (result.ok) {
425
+ ctx.ui.notify(successMessage, "info");
426
+ } else {
427
+ ctx.ui.notify(`continuation split failed: ${result.error}`, "error");
428
+ }
429
+ },
430
+ });
431
+ }
432
+
433
+ export default function zvContinueExtension(pi: ExtensionAPI) {
434
+ registerContinueCommand(
435
+ pi,
436
+ "zcv",
437
+ "right",
438
+ "Open a new right split and continue the current task, optionally in a git worktree",
439
+ "Opened a continuation split to the right",
440
+ );
441
+
442
+ registerContinueCommand(
443
+ pi,
444
+ "zch",
445
+ "down",
446
+ "Open a new lower split and continue the current task, optionally in a git worktree",
447
+ "Opened a continuation split below",
448
+ );
449
+ }
@@ -0,0 +1,136 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ const ZELLIJ_TIMEOUT_MS = 5000;
4
+
5
+ export type SplitDirection = "right" | "down";
6
+
7
+ interface ZellijExecResult {
8
+ ok: boolean;
9
+ stdout: string;
10
+ stderr: string;
11
+ error?: string;
12
+ }
13
+
14
+ function isInsideZellijSession(): boolean {
15
+ return Boolean(process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME || process.env.ZELLIJ_PANE_ID);
16
+ }
17
+
18
+ export function shellEscape(value: string): string {
19
+ return `'${value.replace(/'/g, `'\\''`)}'`;
20
+ }
21
+
22
+ export function buildPiCommand(cwd: string, options?: { sessionFile?: string; prompt?: string }): string {
23
+ const commandParts = ["cd", shellEscape(cwd), "&&", "exec", "pi"];
24
+ if (options?.sessionFile) {
25
+ commandParts.push("--session", shellEscape(options.sessionFile));
26
+ }
27
+ const prompt = options?.prompt?.trim();
28
+ if (prompt) {
29
+ commandParts.push(shellEscape(prompt));
30
+ }
31
+ return commandParts.join(" ");
32
+ }
33
+
34
+ export function buildShellCommand(cwd: string, command: string): string {
35
+ return ["cd", shellEscape(cwd), "&&", "exec", "sh", "-lc", shellEscape(command)].join(" ");
36
+ }
37
+
38
+ async function execZellij(pi: ExtensionAPI, args: string[]): Promise<ZellijExecResult> {
39
+ const result = await pi.exec("zellij", args, { timeout: ZELLIJ_TIMEOUT_MS });
40
+ if (result.killed) {
41
+ return {
42
+ ok: false,
43
+ stdout: result.stdout,
44
+ stderr: result.stderr,
45
+ error: "zellij command timed out",
46
+ };
47
+ }
48
+ if (result.code !== 0) {
49
+ return {
50
+ ok: false,
51
+ stdout: result.stdout,
52
+ stderr: result.stderr,
53
+ error: result.stderr.trim() || result.stdout.trim() || `zellij exited with code ${result.code}`,
54
+ };
55
+ }
56
+ return {
57
+ ok: true,
58
+ stdout: result.stdout,
59
+ stderr: result.stderr,
60
+ };
61
+ }
62
+
63
+ export async function openCommandInNewSplit(
64
+ pi: ExtensionAPI,
65
+ direction: SplitDirection,
66
+ command: string,
67
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
68
+ if (!isInsideZellijSession()) {
69
+ return { ok: false, error: "This command must be run from inside an active zellij session" };
70
+ }
71
+
72
+ const result = await execZellij(pi, ["run", "--direction", direction, "--", "sh", "-lc", command]);
73
+ if (!result.ok) {
74
+ return { ok: false, error: result.error || "Failed to open a new zellij pane" };
75
+ }
76
+
77
+ return { ok: true };
78
+ }
79
+
80
+ export async function openCommandInFloatingPane(
81
+ pi: ExtensionAPI,
82
+ command: string,
83
+ options?: { name?: string; width?: string; height?: string; x?: string; y?: string },
84
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
85
+ if (!isInsideZellijSession()) {
86
+ return { ok: false, error: "This command must be run from inside an active zellij session" };
87
+ }
88
+
89
+ const args = ["run", "--floating"];
90
+ if (options?.name) args.push("--name", options.name);
91
+ if (options?.width) args.push("--width", options.width);
92
+ if (options?.height) args.push("--height", options.height);
93
+ if (options?.x) args.push("-x", options.x);
94
+ if (options?.y) args.push("-y", options.y);
95
+ args.push("--", "sh", "-lc", command);
96
+
97
+ const result = await execZellij(pi, args);
98
+ if (!result.ok) {
99
+ return { ok: false, error: result.error || "Failed to open a new floating zellij pane" };
100
+ }
101
+
102
+ return { ok: true };
103
+ }
104
+
105
+ export async function openCommandInNewTab(
106
+ pi: ExtensionAPI,
107
+ cwd: string,
108
+ command: string,
109
+ options?: { name?: string },
110
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
111
+ if (!isInsideZellijSession()) {
112
+ return { ok: false, error: "This command must be run from inside an active zellij session" };
113
+ }
114
+
115
+ const newTabArgs = ["action", "new-tab", "--cwd", cwd];
116
+ if (options?.name) {
117
+ newTabArgs.push("--name", options.name);
118
+ }
119
+
120
+ const newTabResult = await execZellij(pi, newTabArgs);
121
+ if (!newTabResult.ok) {
122
+ return { ok: false, error: newTabResult.error || "Failed to open a new zellij tab" };
123
+ }
124
+
125
+ const writeCommandResult = await execZellij(pi, ["action", "write-chars", command]);
126
+ if (!writeCommandResult.ok) {
127
+ return { ok: false, error: writeCommandResult.error || "Failed to write command to new zellij tab" };
128
+ }
129
+
130
+ const submitCommandResult = await execZellij(pi, ["action", "write", "10"]);
131
+ if (!submitCommandResult.ok) {
132
+ return { ok: false, error: submitCommandResult.error || "Failed to start command in new zellij tab" };
133
+ }
134
+
135
+ return { ok: true };
136
+ }