pi-zellij 0.4.6 → 0.4.9

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/CHANGELOG.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  ### Added
6
6
 
7
+ - Added optional `pi-zellij.review.skill` settings for `/zrv` and `/zrh` to use an existing loaded skill, with project overrides and errors for unavailable or unreadable skills.
7
8
  - Initial `pi-zellij` release with zellij-powered pane workflows for Pi.
8
9
  - Added `/zv`, `/zj`, and `/zt` to open a new zellij pane or tab and start a fresh Pi session in the same working directory.
9
10
  - Added `/zo` and `/zoh` to open a new pane and run any shell command there.
@@ -14,9 +15,15 @@
14
15
  - Added `zv-review` with `/zrv` and `/zrh`, plus bundled `code-review` skill and `/review` / `/review-diff` prompt templates for focused review workflows, including GitHub pull request review via `gh` when given a PR URL.
15
16
  - Added `zv-continue` with `/zcv` and `/zch` for split-based task handoff in the current checkout or by creating a git worktree branch with `-c <branch>`.
16
17
  - Added opt-in `paneHighlight` settings so Pi can tint the current zellij pane when an agent turn completes, with optional working-state colors.
18
+ - Added an agent-facing `zellij_open_terminal` tool so Pi can open explicitly requested interactive terminal commands in right or lower splits, tabs, and floating panes.
19
+ - Added an agent-facing `zellij_start_pi` tool for fresh Pi sessions in right or lower splits and tabs, with optional initial prompts, model settings, pane titles, and explicit history inheritance through a separate cloned session.
17
20
 
18
21
  ### Changed
19
22
 
23
+ - `/zrv` and `/zrh` now use self-contained review instructions by default, injected only into the new review session. `/review` and `/review-diff` no longer refer to a bundled skill.
24
+ - Explicit history inheritance now rejects branches with no conversation messages before creating a cloned session or opening a pane.
25
+ - Expanded `zellij_open_terminal` prompt guidance so agents recognize requests to start another Pi session and prefer the dedicated `zellij_start_pi` interface when available.
26
+ - Pi launch command construction now supports quoted provider, model, and thinking options and stops option parsing before initial prompts.
20
27
  - When zellij reports created pane or tab IDs, `pi-zellij` now shows them in success notifications for split, floating, zoxide, review, continuation, and tab commands.
21
28
  - `/zt` now uses `zellij action new-tab -- <command>` when available instead of always simulating typed input, while keeping the previous typed-input path as a compatibility fallback.
22
29
  - Pane highlights now clear on the next submitted input or when the pane is focused again after being elsewhere, instead of waiting for the next agent start event. Aborted runs no longer apply the done-state tint.
@@ -25,4 +32,5 @@
25
32
 
26
33
  ### Removed
27
34
 
35
+ - Removed the bundled `code-review` skill from the package and installer so terminal workflows do not register a global review skill.
28
36
  - Removed the bundled `zv-notify` extension so `pi-zellij` does not conflict with separate notification packages or user-specific notification setups.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Pi package with [zellij](https://zellij.dev)-powered terminal integrations for [
10
10
 
11
11
  [Pi](https://pi.dev) works well in the terminal, but pane orchestration is better handled by a terminal multiplexer. `pi-zellij` adds zellij-native split workflows for Pi.
12
12
 
13
- It includes split and tab commands, generic tool launchers, settings-driven floating app shortcuts, opt-in pane highlighting for completed agent turns, zoxide jumps, review workflows, and split-based task handoff.
13
+ It includes split and tab commands, generic tool launchers, agent-facing zellij tools, settings-driven floating app shortcuts, opt-in pane highlighting for completed agent turns, zoxide jumps, review workflows, and split-based task handoff.
14
14
 
15
15
  ## Usage
16
16
 
@@ -70,9 +70,7 @@ If pi is already running, use:
70
70
  - `/review <target>`, `/review-diff [focus-or-pr-url]`
71
71
  - expand bundled review prompts in the current pane
72
72
  - `/zrv`, `/zrh`, plus review flags
73
- - open review-focused Pi sessions in a split
74
- - `/skill:code-review`
75
- - loads the bundled structured review skill for files, directories, diffs, and PRs
73
+ - open review-focused Pi sessions in a split with built-in instructions or a configured skill
76
74
 
77
75
  ## Bundled extensions and resources
78
76
 
@@ -85,7 +83,6 @@ Extensions:
85
83
  - `zv-continue`
86
84
 
87
85
  Other bundled resources:
88
- - `code-review` skill
89
86
  - `/review` prompt template
90
87
  - `/review-diff` prompt template
91
88
 
@@ -132,6 +129,29 @@ Examples:
132
129
 
133
130
  Commands are executed via `sh -lc` in the current project directory.
134
131
 
132
+ ### Agent-opened Pi sessions and terminals
133
+
134
+ `pi-zellij` registers two agent-facing tools:
135
+
136
+ - `zellij_start_pi` starts another interactive Pi session with an optional initial prompt and model settings.
137
+ - `zellij_open_terminal` opens arbitrary interactive terminal commands.
138
+
139
+ Example requests:
140
+
141
+ ```text
142
+ start a fresh Pi agent in a right split and ask it to review the auth flow
143
+ continue this conversation in a lower Pi pane and focus on the failing tests
144
+ start Pi in a new tab using openai/gpt-4o with high thinking
145
+ open k9s in a new tab
146
+ open lazygit in a right split
147
+ open npm run dev below
148
+ open htop in a floating pane
149
+ ```
150
+
151
+ `zellij_start_pi` supports `tab`, `right`, and `down` placements and defaults to `tab`. It starts a fresh session by default. The agent must set `continueSession` explicitly to inherit history; pi-zellij then clones the current active conversation into a separate session instead of opening the same session file in two processes. History inheritance requires a persisted source session with conversation history. The tool accepts `prompt`, `provider`, `model`, `thinking`, and `title` options, and handles the Pi command and shell quoting internally.
152
+
153
+ `zellij_open_terminal` supports `tab`, `right`, `down`, and `floating` placements and also defaults to `tab`. Floating terminals use the same 90% by 90% pane size as configured floating commands. Use it for TUIs, log tails, dev servers, watches, and other terminal views that should remain interactive instead of being captured through the normal shell tool.
154
+
135
155
  ### Configured floating commands
136
156
 
137
157
  You can register your own floating app shortcuts in Pi's main settings file under `pi-zellij.commands`.
@@ -294,7 +314,7 @@ Examples:
294
314
 
295
315
  ### Review helpers
296
316
 
297
- `pi-zellij` also bundles a reusable `code-review` skill plus prompt templates for in-place review:
317
+ `pi-zellij` includes built-in review instructions; no skill installation is required and no review skill is registered globally. It also bundles prompt templates for in-place review:
298
318
 
299
319
  - `/review <target>`
300
320
  - prompt template for reviewing a file, directory, or GitHub pull request URL in the current pane
@@ -333,3 +353,26 @@ Examples:
333
353
 
334
354
  If the target is a GitHub pull request URL, the review workflow switches to PR review and instructs pi to inspect the pull request with `gh pr view` and `gh pr diff`.
335
355
 
356
+ #### Use your own review skill
357
+
358
+ By default, `/zrv` and `/zrh` inject the built-in review instructions into the new session's initial prompt only. To use an existing skill instead, set `pi-zellij.review.skill` in `~/.pi/agent/settings.json` or the trusted project's `.pi/settings.json`:
359
+
360
+ ```json
361
+ {
362
+ "pi-zellij": {
363
+ "review": {
364
+ "skill": "my-code-review"
365
+ }
366
+ }
367
+ }
368
+ ```
369
+
370
+ Use the skill's name without the `/skill:` prefix. It must be loaded in the current Pi session. The commands expand its instructions before opening the pane, preserving its location for relative references and passing the review target and mode. The skill controls the workflow and output format; the request still tells Pi not to edit files unless asked.
371
+
372
+ - Omit the setting to use the built-in instructions.
373
+ - Project settings override global settings. Set `"skill": null` in the project to use the built-in instructions despite a global override.
374
+ - An unavailable or unreadable skill, or invalid review settings, produces an error without opening a pane. There is no silent fallback.
375
+ - Review settings are read on each invocation. After installing or enabling a skill, run `/reload` first.
376
+
377
+ This setting only affects `/zrv` and `/zrh`; `/review` and `/review-diff` remain self-contained prompt templates.
378
+
@@ -1,33 +1,15 @@
1
- ---
2
- name: code-review
3
- description: Structured code review workflow for files, directories, and git diffs. Use when reviewing code for bugs, regressions, maintainability, missing tests, or refactor opportunities.
4
- ---
5
-
6
1
  # Code Review
7
2
 
8
- Use this skill for targeted code review requests, including file reviews, module reviews, and current diff reviews.
9
-
10
3
  ## Review goals
11
4
 
12
5
  - Find the most important issues first.
13
6
  - Prefer concrete, evidence-based findings over broad stylistic commentary.
14
7
  - Call out missing tests or risky edge cases when they matter.
15
- - Do not edit files unless the user explicitly asks for changes.
16
-
17
- ## Review modes
18
-
19
- Infer the mode from the user request:
20
- - **General review** — correctness, readability, maintainability, and missing tests
21
- - **Bug-focused review** — correctness, runtime failures, invalid assumptions, edge cases, regressions
22
- - **Refactor review** — simplification, structure, naming, duplication, cohesion, maintainability
23
- - **Test review** — missing coverage, brittle assertions, edge cases, regression protection
24
- - **Diff review** — review the current git diff or requested change set for regressions and missing validation
25
- - **GitHub PR review** — when given a GitHub pull request URL, inspect it with `gh pr view` and `gh pr diff` before concluding
26
8
 
27
9
  ## Workflow
28
10
 
29
11
  1. Identify the exact review scope.
30
- - If the user named a file, directory, symbol, or diff, stick to that scope.
12
+ - Stick to the requested file, directory, symbol, diff, or pull request.
31
13
  - If the scope is ambiguous, ask one concise clarifying question.
32
14
 
33
15
  2. Inspect the relevant code before concluding.
@@ -44,11 +26,8 @@ Infer the mode from the user request:
44
26
  - If there are no meaningful issues, say so clearly.
45
27
 
46
28
  5. Make findings actionable.
47
- - For each finding, explain:
48
- - what is wrong
49
- - why it matters
50
- - where it occurs
51
- - what change would improve it
29
+ - Explain what is wrong, why it matters, where it occurs, and what change would improve it.
30
+ - Include file paths and line numbers where possible.
52
31
 
53
32
  ## Output format
54
33
 
@@ -61,13 +40,11 @@ Summary
61
40
 
62
41
  Findings
63
42
  1. Severity: <high|medium|low>
64
- - Location: <file or diff area>
43
+ - Location: <file and line or diff area>
65
44
  - Issue: <what is wrong>
66
45
  - Impact: <why it matters>
67
46
  - Fix: <concrete suggestion>
68
47
 
69
- 2. ...
70
-
71
48
  Test gaps
72
49
  - <missing test or "None noted">
73
50
  ```
@@ -3,9 +3,18 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  const ZELLIJ_TIMEOUT_MS = 5000;
4
4
 
5
5
  export type SplitDirection = "right" | "down";
6
+ export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
6
7
  export type PaneOpenResult = { ok: true; paneId?: string } | { ok: false; error: string };
7
8
  export type TabOpenResult = { ok: true; tabId?: string } | { ok: false; error: string };
8
9
 
10
+ export interface PiCommandOptions {
11
+ sessionFile?: string;
12
+ prompt?: string;
13
+ provider?: string;
14
+ model?: string;
15
+ thinking?: PiThinkingLevel;
16
+ }
17
+
9
18
  interface ZellijExecResult {
10
19
  ok: boolean;
11
20
  stdout: string;
@@ -60,14 +69,23 @@ export function shellEscape(value: string): string {
60
69
  return `'${value.replace(/'/g, `'\\''`)}'`;
61
70
  }
62
71
 
63
- export function buildPiCommand(cwd: string, options?: { sessionFile?: string; prompt?: string }): string {
72
+ export function buildPiCommand(cwd: string, options?: PiCommandOptions): string {
64
73
  const commandParts = ["cd", shellEscape(cwd), "&&", "exec", "pi"];
65
74
  if (options?.sessionFile) {
66
75
  commandParts.push("--session", shellEscape(options.sessionFile));
67
76
  }
77
+ if (options?.provider) {
78
+ commandParts.push("--provider", shellEscape(options.provider));
79
+ }
80
+ if (options?.model) {
81
+ commandParts.push("--model", shellEscape(options.model));
82
+ }
83
+ if (options?.thinking) {
84
+ commandParts.push("--thinking", shellEscape(options.thinking));
85
+ }
68
86
  const prompt = options?.prompt?.trim();
69
87
  if (prompt) {
70
- commandParts.push(shellEscape(prompt));
88
+ commandParts.push("--", shellEscape(prompt));
71
89
  }
72
90
  return commandParts.join(" ");
73
91
  }
@@ -182,12 +200,17 @@ export async function openCommandInNewSplit(
182
200
  pi: ExtensionAPI,
183
201
  direction: SplitDirection,
184
202
  command: string,
203
+ options?: { name?: string },
185
204
  ): Promise<PaneOpenResult> {
186
205
  if (!isInsideZellijSession()) {
187
206
  return { ok: false, error: "This command must be run from inside an active zellij session" };
188
207
  }
189
208
 
190
- const result = await execZellij(pi, ["run", "--direction", direction, "--", "sh", "-lc", command]);
209
+ const args = ["run", "--direction", direction];
210
+ if (options?.name) args.push("--name", options.name);
211
+ args.push("--", "sh", "-lc", command);
212
+
213
+ const result = await execZellij(pi, args);
191
214
  if (!result.ok) {
192
215
  return { ok: false, error: result.error || "Failed to open a new zellij pane" };
193
216
  }
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
@@ -7,8 +7,10 @@ import {
7
7
  formatPaneSuccessMessage,
8
8
  openCommandInFloatingPane as openCommandInFloatingZellijPane,
9
9
  openCommandInNewSplit,
10
+ openCommandInNewTab,
10
11
  type PaneOpenResult,
11
12
  type SplitDirection,
13
+ type TabOpenResult,
12
14
  } from "./zv-core.ts";
13
15
 
14
16
  const DEFAULT_FLOATING_PANE_OPTIONS = {
@@ -70,18 +72,59 @@ interface ConfiguredFloatingCommand {
70
72
  description: string;
71
73
  }
72
74
 
75
+ type TerminalPlacement = SplitDirection | "tab" | "floating";
76
+
77
+ type OpenToolContext = Pick<ExtensionContext, "cwd">;
78
+
79
+ interface ZellijOpenTerminalParams {
80
+ command: string;
81
+ placement?: TerminalPlacement;
82
+ title?: string;
83
+ }
84
+
85
+ interface OpenedTerminal {
86
+ ok: true;
87
+ placement: TerminalPlacement;
88
+ command: string;
89
+ paneId?: string;
90
+ tabId?: string;
91
+ }
92
+
93
+ const ZELLIJ_OPEN_TERMINAL_PARAMETERS = {
94
+ type: "object",
95
+ additionalProperties: false,
96
+ required: ["command"],
97
+ properties: {
98
+ command: {
99
+ type: "string",
100
+ description: "Interactive terminal command to run, for example k9s, htop, lazygit, or npm run dev",
101
+ },
102
+ placement: {
103
+ type: "string",
104
+ enum: ["right", "down", "tab", "floating"],
105
+ default: "tab",
106
+ description: "Where to open the command. Use floating for a 90% by 90% floating pane.",
107
+ },
108
+ title: {
109
+ type: "string",
110
+ description: "Optional zellij pane or tab name. Defaults to the command.",
111
+ },
112
+ },
113
+ } as const;
114
+
73
115
  async function openToolInSplit(
74
116
  pi: ExtensionAPI,
75
- ctx: ExtensionCommandContext,
117
+ ctx: OpenToolContext,
76
118
  direction: SplitDirection,
77
119
  args: string,
120
+ name?: string,
78
121
  ): Promise<PaneOpenResult> {
79
- return openCommandInNewSplit(pi, direction, buildShellCommand(ctx.cwd, args.trim()));
122
+ return openCommandInNewSplit(pi, direction, buildShellCommand(ctx.cwd, args.trim()), { name });
80
123
  }
81
124
 
82
125
  async function openToolInFloatingPane(
83
126
  pi: ExtensionAPI,
84
- ctx: ExtensionCommandContext,
127
+ ctx: OpenToolContext,
85
128
  command: string,
86
129
  name?: string,
87
130
  ): Promise<PaneOpenResult> {
@@ -91,6 +134,15 @@ async function openToolInFloatingPane(
91
134
  });
92
135
  }
93
136
 
137
+ async function openToolInTab(
138
+ pi: ExtensionAPI,
139
+ ctx: OpenToolContext,
140
+ command: string,
141
+ name?: string,
142
+ ): Promise<TabOpenResult> {
143
+ return openCommandInNewTab(pi, ctx.cwd, buildShellCommand(ctx.cwd, command.trim()), { name });
144
+ }
145
+
94
146
  function registerOpenCommand(
95
147
  pi: ExtensionAPI,
96
148
  name: string,
@@ -267,6 +319,92 @@ function registerConfiguredFloatingCommand(
267
319
  });
268
320
  }
269
321
 
322
+ function normalizeTerminalPlacement(value: unknown): TerminalPlacement {
323
+ return value === "right" || value === "down" || value === "tab" || value === "floating" ? value : "tab";
324
+ }
325
+
326
+ function getPlacementLabel(placement: TerminalPlacement): string {
327
+ if (placement === "right") {
328
+ return "right split";
329
+ }
330
+ if (placement === "down") {
331
+ return "lower split";
332
+ }
333
+ if (placement === "floating") {
334
+ return "floating pane";
335
+ }
336
+ return "tab";
337
+ }
338
+
339
+ async function openTerminalCommand(
340
+ pi: ExtensionAPI,
341
+ ctx: OpenToolContext,
342
+ params: ZellijOpenTerminalParams,
343
+ ): Promise<OpenedTerminal | { ok: false; error: string }> {
344
+ const command = typeof params.command === "string" ? params.command.trim() : "";
345
+ if (!command) {
346
+ return { ok: false, error: "Specify a command to open" };
347
+ }
348
+
349
+ const placement = normalizeTerminalPlacement(params.placement);
350
+ const title = params.title?.trim() || command;
351
+
352
+ if (placement === "tab") {
353
+ const result = await openToolInTab(pi, ctx, command, title);
354
+ if (!result.ok) {
355
+ return result;
356
+ }
357
+ return { ok: true, placement, command, tabId: result.tabId };
358
+ }
359
+
360
+ const result = placement === "floating"
361
+ ? await openToolInFloatingPane(pi, ctx, command, title)
362
+ : await openToolInSplit(pi, ctx, placement, command, title);
363
+ if (!result.ok) {
364
+ return result;
365
+ }
366
+
367
+ return { ok: true, placement, command, paneId: result.paneId };
368
+ }
369
+
370
+ function registerAgentTerminalTool(pi: ExtensionAPI): void {
371
+ pi.registerTool({
372
+ name: "zellij_open_terminal",
373
+ label: "Open zellij terminal",
374
+ description:
375
+ "Open an interactive terminal command in zellij as a right split, lower split, new tab, or floating pane. Use for user-requested Pi sessions, TUIs, logs, dev servers, watches, or long-running terminal views.",
376
+ promptSnippet:
377
+ "Open an interactive terminal command or another Pi session in zellij when the user asks for it in another pane, split, tab, or floating terminal.",
378
+ promptGuidelines: [
379
+ "Use zellij_open_terminal only when the user explicitly asks to open a command in zellij, another pane, split, tab, or floating terminal.",
380
+ "Use zellij_open_terminal from pi-zellij when the user requests another Pi session in a split or tab, including an initial prompt. Prefer zellij_start_pi when it is available.",
381
+ "Use zellij_open_terminal with placement='tab' when the user says tab, placement='right' for a side pane, placement='down' for a below/lower pane, and placement='floating' for a floating pane.",
382
+ "Use zellij_open_terminal for interactive TUIs like k9s, lazygit, htop, hunk, log tails, dev servers, or watches; do not use bash for these unless the user wants captured output.",
383
+ "Do not open terminals proactively with zellij_open_terminal without a user request.",
384
+ ],
385
+ parameters: ZELLIJ_OPEN_TERMINAL_PARAMETERS as any,
386
+ executionMode: "sequential",
387
+ async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
388
+ const params = rawParams as ZellijOpenTerminalParams;
389
+ const result = await openTerminalCommand(pi, ctx, params);
390
+ if (!result.ok) {
391
+ throw new Error(result.error);
392
+ }
393
+
394
+ return {
395
+ content: [{ type: "text", text: `Opened ${result.command} in a zellij ${getPlacementLabel(result.placement)}.` }],
396
+ details: {
397
+ command: result.command,
398
+ placement: result.placement,
399
+ cwd: ctx.cwd,
400
+ ...(result.paneId ? { paneId: result.paneId } : {}),
401
+ ...(result.tabId ? { tabId: result.tabId } : {}),
402
+ },
403
+ };
404
+ },
405
+ });
406
+ }
407
+
270
408
  export default function zvOpenExtension(pi: ExtensionAPI) {
271
409
  registerOpenCommand(
272
410
  pi,
@@ -292,4 +430,6 @@ export default function zvOpenExtension(pi: ExtensionAPI) {
292
430
  registerConfiguredFloatingCommand(pi, commandName, config);
293
431
  registeredConfiguredNames.add(commandName);
294
432
  }
433
+
434
+ registerAgentTerminalTool(pi);
295
435
  }
@@ -1,4 +1,13 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ CONFIG_DIR_NAME,
3
+ getAgentDir,
4
+ stripFrontmatter,
5
+ type ExtensionAPI,
6
+ type ExtensionCommandContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
2
11
  import {
3
12
  buildPiCommand,
4
13
  formatPaneSuccessMessage,
@@ -7,6 +16,8 @@ import {
7
16
  type SplitDirection,
8
17
  } from "./zv-core.ts";
9
18
 
19
+ const REVIEW_TEMPLATE_PATH = join(dirname(fileURLToPath(import.meta.url)), "templates", "review.md");
20
+
10
21
  type ReviewMode = "general" | "bugs" | "refactor" | "tests" | "diff";
11
22
 
12
23
  interface ReviewRequest {
@@ -61,13 +72,56 @@ function getGitHubPullRequestUrl(value: string | undefined): string | undefined
61
72
  return /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+(?:[/?#].*)?$/.test(trimmed) ? trimmed : undefined;
62
73
  }
63
74
 
64
- function buildReviewPrompt(request: ReviewRequest): string {
65
- const commonInstructions = [
66
- "Use the bundled code-review skill if it is available.",
67
- "Start with a concise summary ordered by severity.",
68
- "Then list concrete findings with suggested fixes and any missing tests.",
69
- "Do not edit files unless asked.",
70
- ].join(" ");
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+
79
+ function readReviewSkillSetting(settingsPath: string): string | null | undefined {
80
+ let settings: unknown;
81
+ try {
82
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ throw new Error(`Failed to read settings from ${settingsPath}: ${message}`);
87
+ }
88
+
89
+ if (!isRecord(settings)) {
90
+ throw new Error(`Expected a settings object in ${settingsPath}`);
91
+ }
92
+ const section = settings["pi-zellij"];
93
+ if (section === undefined) return undefined;
94
+ if (!isRecord(section)) {
95
+ throw new Error(`pi-zellij must be an object in ${settingsPath}`);
96
+ }
97
+ const review = section.review;
98
+ if (review === undefined) return undefined;
99
+ if (!isRecord(review)) {
100
+ throw new Error(`pi-zellij.review must be an object in ${settingsPath}`);
101
+ }
102
+ const skill = review.skill;
103
+ if (skill === undefined || skill === null) return skill;
104
+ if (typeof skill !== "string" || !skill.trim()) {
105
+ throw new Error(`pi-zellij.review.skill must be a non-empty skill name or null in ${settingsPath}`);
106
+ }
107
+ return skill.trim();
108
+ }
109
+
110
+ function loadReviewSkill(ctx: ExtensionCommandContext): string | undefined {
111
+ const settingsPaths = [join(getAgentDir(), "settings.json")];
112
+ if (ctx.isProjectTrusted()) {
113
+ settingsPaths.push(join(ctx.cwd, CONFIG_DIR_NAME, "settings.json"));
114
+ }
115
+
116
+ let skill: string | null | undefined;
117
+ for (const settingsPath of settingsPaths) {
118
+ const value = readReviewSkillSetting(settingsPath);
119
+ if (value !== undefined) skill = value;
120
+ }
121
+ return skill ?? undefined;
122
+ }
123
+
124
+ function buildReviewScope(request: ReviewRequest): string {
71
125
  const pullRequestUrl = getGitHubPullRequestUrl(request.targetOrFocus);
72
126
  const modeInstruction =
73
127
  request.mode === "bugs"
@@ -79,15 +133,43 @@ function buildReviewPrompt(request: ReviewRequest): string {
79
133
  : "Focus on correctness, readability, maintainability, and missing tests.";
80
134
 
81
135
  if (pullRequestUrl) {
82
- return `Review GitHub pull request ${pullRequestUrl}. Use the gh CLI to inspect it, including gh pr view ${pullRequestUrl} and gh pr diff ${pullRequestUrl}. ${modeInstruction} Prioritize the changed code, likely regressions, and missing tests before adding lower-priority notes. ${commonInstructions}`;
136
+ return `Review GitHub pull request ${pullRequestUrl}. Use the gh CLI to inspect it, including gh pr view ${pullRequestUrl} and gh pr diff ${pullRequestUrl}. ${modeInstruction} Prioritize the changed code, likely regressions, and missing tests before adding lower-priority notes.`;
83
137
  }
84
138
 
85
139
  if (request.mode === "diff") {
86
140
  const focus = request.targetOrFocus ? ` Extra focus: ${request.targetOrFocus}.` : "";
87
- return `Review the current git diff in this repository.${focus} Prioritize regressions, correctness issues, risky edge cases, and missing tests. ${commonInstructions}`;
141
+ return `Review the current git diff in this repository.${focus} Prioritize regressions, correctness issues, risky edge cases, and missing tests.`;
88
142
  }
89
143
 
90
- return `Review ${request.targetOrFocus} from the current project. ${modeInstruction} If the target is a directory, review the most relevant files within that scope. ${commonInstructions}`;
144
+ return `Review ${request.targetOrFocus} from the current project. ${modeInstruction} If the target is a directory, review the most relevant files within that scope.`;
145
+ }
146
+
147
+ function buildReviewPrompt(request: ReviewRequest, ctx: ExtensionCommandContext): string {
148
+ const scope = `${buildReviewScope(request)} Do not edit files unless asked.`;
149
+ const skillName = loadReviewSkill(ctx);
150
+ if (!skillName) {
151
+ return `${readFileSync(REVIEW_TEMPLATE_PATH, "utf8").trim()}\n\n${scope}`;
152
+ }
153
+
154
+ const skill = ctx.getSystemPromptOptions().skills?.find((entry) => entry.name === skillName);
155
+ if (!skill) {
156
+ throw new Error(
157
+ `Review skill "${skillName}" is not available in this session. Enable or install it and run /reload, or set pi-zellij.review.skill to null.`,
158
+ );
159
+ }
160
+
161
+ let body: string;
162
+ try {
163
+ body = stripFrontmatter(readFileSync(skill.filePath, "utf8")).trim();
164
+ if (!body) throw new Error("Skill instructions are empty");
165
+ } catch (error) {
166
+ const message = error instanceof Error ? error.message : String(error);
167
+ throw new Error(`Cannot load review skill "${skillName}" from ${skill.filePath}: ${message}`);
168
+ }
169
+
170
+ // Expand before launching, as Pi does for /skill:name. This validates the file
171
+ // and carries even CLI-only skills into the child without rediscovering them.
172
+ return `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>\n\n${scope}`;
91
173
  }
92
174
 
93
175
  async function openReviewSplit(
@@ -96,7 +178,12 @@ async function openReviewSplit(
96
178
  direction: SplitDirection,
97
179
  request: ReviewRequest,
98
180
  ): Promise<PaneOpenResult> {
99
- return openCommandInNewSplit(pi, direction, buildPiCommand(ctx.cwd, { prompt: buildReviewPrompt(request) }));
181
+ try {
182
+ const prompt = buildReviewPrompt(request, ctx);
183
+ return await openCommandInNewSplit(pi, direction, buildPiCommand(ctx.cwd, { prompt }));
184
+ } catch (error) {
185
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
186
+ }
100
187
  }
101
188
 
102
189
  function registerReviewCommand(
@@ -1,34 +1,191 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ buildSessionContext,
3
+ SessionManager,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
2
7
  import {
3
8
  buildPiCommand,
4
9
  formatPaneSuccessMessage,
5
10
  formatTabSuccessMessage,
11
+ isInsideZellijSession,
6
12
  openCommandInNewSplit,
7
13
  openCommandInNewTab,
8
- type PaneOpenResult,
14
+ type PiCommandOptions,
15
+ type PiThinkingLevel,
9
16
  type SplitDirection,
10
- type TabOpenResult,
11
17
  } from "./zv-core.ts";
12
18
 
13
- async function openPiInSplit(
14
- pi: ExtensionAPI,
15
- ctx: ExtensionCommandContext,
16
- direction: SplitDirection,
17
- args: string,
18
- ): Promise<PaneOpenResult> {
19
- return openCommandInNewSplit(
20
- pi,
21
- direction,
22
- buildPiCommand(ctx.cwd, { prompt: args.trim().length > 0 ? args : undefined }),
23
- );
19
+ export type PiPlacement = SplitDirection | "tab";
20
+ type PiLaunchContext = Pick<ExtensionContext, "cwd">;
21
+ type PiContinuationContext = Pick<ExtensionContext, "cwd" | "sessionManager">;
22
+
23
+ export interface ZellijStartPiParams {
24
+ placement?: PiPlacement;
25
+ prompt?: string;
26
+ continueSession?: boolean;
27
+ provider?: string;
28
+ model?: string;
29
+ thinking?: PiThinkingLevel;
30
+ title?: string;
31
+ }
32
+
33
+ interface OpenedPiSession {
34
+ ok: true;
35
+ placement: PiPlacement;
36
+ paneId?: string;
37
+ tabId?: string;
38
+ }
39
+
40
+ const THINKING_LEVELS: readonly PiThinkingLevel[] = [
41
+ "off",
42
+ "minimal",
43
+ "low",
44
+ "medium",
45
+ "high",
46
+ "xhigh",
47
+ "max",
48
+ ];
49
+
50
+ const ZELLIJ_START_PI_PARAMETERS = {
51
+ type: "object",
52
+ additionalProperties: false,
53
+ properties: {
54
+ placement: {
55
+ type: "string",
56
+ enum: ["right", "down", "tab"],
57
+ default: "tab",
58
+ description: "Where to start Pi: a right split, lower split, or new tab. Defaults to tab.",
59
+ },
60
+ prompt: {
61
+ type: "string",
62
+ description: "Optional initial prompt for the new Pi session. The extension handles shell quoting.",
63
+ },
64
+ continueSession: {
65
+ type: "boolean",
66
+ default: false,
67
+ description:
68
+ "Clone the current active conversation into a separate session before starting Pi. Set true only when the user explicitly asks to continue or inherit the current history. Defaults to false for a fresh session.",
69
+ },
70
+ provider: {
71
+ type: "string",
72
+ description: "Optional Pi provider. Requires model; omit both to use Pi's normal session/default selection.",
73
+ },
74
+ model: {
75
+ type: "string",
76
+ description: "Optional Pi model pattern or ID, including provider/model and model:thinking forms.",
77
+ },
78
+ thinking: {
79
+ type: "string",
80
+ enum: THINKING_LEVELS,
81
+ description: "Optional Pi thinking level for the new session.",
82
+ },
83
+ title: {
84
+ type: "string",
85
+ description: "Optional zellij pane or tab name.",
86
+ },
87
+ },
88
+ } as const;
89
+
90
+ function normalizePiPlacement(value: unknown): PiPlacement {
91
+ return value === "right" || value === "down" || value === "tab" ? value : "tab";
92
+ }
93
+
94
+ function normalizeOptionalString(value: unknown): string | undefined {
95
+ if (typeof value !== "string") return undefined;
96
+ return value.trim() || undefined;
97
+ }
98
+
99
+ function normalizeThinkingLevel(value: unknown): PiThinkingLevel | undefined {
100
+ return typeof value === "string" && THINKING_LEVELS.includes(value as PiThinkingLevel)
101
+ ? (value as PiThinkingLevel)
102
+ : undefined;
103
+ }
104
+
105
+ function getPlacementLabel(placement: PiPlacement): string {
106
+ if (placement === "right") return "right split";
107
+ if (placement === "down") return "lower split";
108
+ return "tab";
24
109
  }
25
110
 
26
- async function openPiInTab(
111
+ async function openPiSession(
27
112
  pi: ExtensionAPI,
28
- ctx: ExtensionCommandContext,
29
- args: string,
30
- ): Promise<TabOpenResult> {
31
- return openCommandInNewTab(pi, ctx.cwd, buildPiCommand(ctx.cwd, { prompt: args.trim().length > 0 ? args : undefined }));
113
+ ctx: PiLaunchContext,
114
+ placement: PiPlacement,
115
+ options: PiCommandOptions,
116
+ title?: string,
117
+ ): Promise<OpenedPiSession | { ok: false; error: string }> {
118
+ const command = buildPiCommand(ctx.cwd, options);
119
+ if (placement === "tab") {
120
+ const result = await openCommandInNewTab(pi, ctx.cwd, command, { name: title });
121
+ if (!result.ok) return result;
122
+ return { ok: true, placement, tabId: result.tabId };
123
+ }
124
+
125
+ const result = await openCommandInNewSplit(pi, placement, command, { name: title });
126
+ if (!result.ok) return result;
127
+ return { ok: true, placement, paneId: result.paneId };
128
+ }
129
+
130
+ function createContinuationSession(
131
+ ctx: PiContinuationContext,
132
+ ): { ok: true; sessionFile: string } | { ok: false; error: string } {
133
+ const sourceSessionFile = ctx.sessionManager.getSessionFile();
134
+ if (!sourceSessionFile) {
135
+ return { ok: false, error: "Cannot inherit history from an ephemeral session" };
136
+ }
137
+
138
+ const currentLeaf = ctx.sessionManager.getLeafEntry();
139
+ if (!currentLeaf) {
140
+ return { ok: false, error: "The current session has no conversation history to inherit" };
141
+ }
142
+
143
+ // During tool execution, the active leaf is the assistant message containing
144
+ // this tool call. Exclude it so the child does not inherit a tool call without
145
+ // its result.
146
+ const continuationLeafId =
147
+ currentLeaf.type === "message" && currentLeaf.message.role === "assistant"
148
+ ? currentLeaf.parentId
149
+ : currentLeaf.id;
150
+ if (!continuationLeafId) {
151
+ return { ok: false, error: "The current session has no completed conversation history to inherit" };
152
+ }
153
+
154
+ try {
155
+ const inheritedContext = buildSessionContext(ctx.sessionManager.getEntries(), continuationLeafId);
156
+ if (inheritedContext.messages.length === 0) {
157
+ return { ok: false, error: "The current session has no conversation history to inherit" };
158
+ }
159
+
160
+ const inheritedBranch = ctx.sessionManager.getBranch(continuationLeafId);
161
+ const hasCompletedAssistantMessage = inheritedBranch.some(
162
+ (entry) => entry.type === "message" && entry.message.role === "assistant",
163
+ );
164
+ if (hasCompletedAssistantMessage) {
165
+ const sourceSession = SessionManager.open(sourceSessionFile, ctx.sessionManager.getSessionDir());
166
+ const sessionFile = sourceSession.createBranchedSession(continuationLeafId);
167
+ if (sessionFile) return { ok: true, sessionFile };
168
+ }
169
+
170
+ // A branch with only the triggering user message is not written by
171
+ // createBranchedSession until it receives an assistant response. Fork the
172
+ // file and move its active leaf instead so Pi can open it immediately.
173
+ const continuationSession = SessionManager.forkFrom(
174
+ sourceSessionFile,
175
+ ctx.cwd,
176
+ ctx.sessionManager.getSessionDir(),
177
+ );
178
+ continuationSession.branch(continuationLeafId);
179
+ continuationSession.appendCustomEntry("pi-zellij-continuation");
180
+ const sessionFile = continuationSession.getSessionFile();
181
+ if (!sessionFile) {
182
+ return { ok: false, error: "Failed to create a persistent continuation session" };
183
+ }
184
+ return { ok: true, sessionFile };
185
+ } catch (error) {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ return { ok: false, error: `Failed to clone the current session: ${message}` };
188
+ }
32
189
  }
33
190
 
34
191
  function registerSplitCommand(
@@ -41,7 +198,8 @@ function registerSplitCommand(
41
198
  pi.registerCommand(name, {
42
199
  description,
43
200
  handler: async (args, ctx) => {
44
- const result = await openPiInSplit(pi, ctx, direction, args);
201
+ const prompt = normalizeOptionalString(args);
202
+ const result = await openPiSession(pi, ctx, direction, { prompt });
45
203
  if (result.ok) {
46
204
  ctx.ui.notify(formatPaneSuccessMessage(successMessage, result.paneId), "info");
47
205
  } else {
@@ -60,7 +218,8 @@ function registerTabCommand(
60
218
  pi.registerCommand(name, {
61
219
  description,
62
220
  handler: async (args, ctx) => {
63
- const result = await openPiInTab(pi, ctx, args);
221
+ const prompt = normalizeOptionalString(args);
222
+ const result = await openPiSession(pi, ctx, "tab", { prompt });
64
223
  if (result.ok) {
65
224
  ctx.ui.notify(formatTabSuccessMessage(successMessage, result.tabId), "info");
66
225
  } else {
@@ -70,6 +229,92 @@ function registerTabCommand(
70
229
  });
71
230
  }
72
231
 
232
+ function registerAgentPiTool(pi: ExtensionAPI): void {
233
+ pi.registerTool({
234
+ name: "zellij_start_pi",
235
+ label: "Start Pi in zellij",
236
+ description:
237
+ "Start another interactive Pi session in a zellij right split, lower split, or tab, with an optional initial prompt and model settings. Sessions are fresh by default; current history is inherited only when continueSession is explicitly true.",
238
+ promptSnippet:
239
+ "Start another interactive Pi agent in a zellij split or tab with an optional initial prompt, model settings, or explicitly inherited history.",
240
+ promptGuidelines: [
241
+ "Use zellij_start_pi when the user explicitly requests another Pi session or agent in a zellij split or tab, including a request with an initial prompt.",
242
+ "Use zellij_start_pi with placement='tab' when the user says tab, placement='right' for a side pane, and placement='down' for a below/lower pane.",
243
+ "zellij_start_pi starts a fresh session by default. Set continueSession=true only when the user explicitly asks the new Pi session to continue or inherit the current conversation.",
244
+ "Pass Pi tasks through zellij_start_pi's prompt parameter and model choices through provider, model, and thinking; do not construct or quote a Pi shell command yourself.",
245
+ "Do not start another Pi session proactively with zellij_start_pi without a user request.",
246
+ ],
247
+ parameters: ZELLIJ_START_PI_PARAMETERS as any,
248
+ executionMode: "sequential",
249
+ async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
250
+ const params = rawParams as ZellijStartPiParams;
251
+ if (!isInsideZellijSession()) {
252
+ throw new Error("This tool must be run from inside an active zellij session");
253
+ }
254
+
255
+ const placement = normalizePiPlacement(params.placement);
256
+ const prompt = normalizeOptionalString(params.prompt);
257
+ const provider = normalizeOptionalString(params.provider);
258
+ const model = normalizeOptionalString(params.model);
259
+ const thinking = normalizeThinkingLevel(params.thinking);
260
+ const title = normalizeOptionalString(params.title);
261
+
262
+ if (params.provider !== undefined && !provider) {
263
+ throw new Error("provider must be a non-empty string");
264
+ }
265
+ if (params.model !== undefined && !model) {
266
+ throw new Error("model must be a non-empty string");
267
+ }
268
+ if (provider && !model) {
269
+ throw new Error("provider requires model");
270
+ }
271
+ if (params.thinking !== undefined && !thinking) {
272
+ throw new Error(`Invalid thinking level: ${String(params.thinking)}`);
273
+ }
274
+
275
+ let sessionFile: string | undefined;
276
+ if (params.continueSession === true) {
277
+ const continuation = createContinuationSession(ctx);
278
+ if (!continuation.ok) throw new Error(continuation.error);
279
+ sessionFile = continuation.sessionFile;
280
+ }
281
+
282
+ const result = await openPiSession(
283
+ pi,
284
+ ctx,
285
+ placement,
286
+ { sessionFile, prompt, provider, model, thinking },
287
+ title,
288
+ );
289
+ if (!result.ok) {
290
+ throw new Error(result.error);
291
+ }
292
+
293
+ const historyLabel = sessionFile ? "a Pi session with inherited history" : "a fresh Pi session";
294
+ return {
295
+ content: [
296
+ {
297
+ type: "text",
298
+ text: `Opened ${historyLabel} in a zellij ${getPlacementLabel(result.placement)}.`,
299
+ },
300
+ ],
301
+ details: {
302
+ placement: result.placement,
303
+ cwd: ctx.cwd,
304
+ history: sessionFile ? "inherited" : "fresh",
305
+ ...(sessionFile ? { sessionFile } : {}),
306
+ ...(provider ? { provider } : {}),
307
+ ...(model ? { model } : {}),
308
+ ...(thinking ? { thinking } : {}),
309
+ ...(title ? { title } : {}),
310
+ ...(result.paneId ? { paneId: result.paneId } : {}),
311
+ ...(result.tabId ? { tabId: result.tabId } : {}),
312
+ },
313
+ };
314
+ },
315
+ });
316
+ }
317
+
73
318
  export default function zvSplitExtension(pi: ExtensionAPI) {
74
319
  registerSplitCommand(
75
320
  pi,
@@ -93,4 +338,6 @@ export default function zvSplitExtension(pi: ExtensionAPI) {
93
338
  "Open a new zellij tab and start a fresh pi session",
94
339
  "Opened a new tab",
95
340
  );
341
+
342
+ registerAgentPiTool(pi);
96
343
  }
package/install.mjs CHANGED
@@ -14,7 +14,7 @@ const PACKAGE_DIR = path.join(AGENT_DIR, "packages", PACKAGE_NAME);
14
14
  const PACKAGE_SETTINGS_ENTRY = `./packages/${PACKAGE_NAME}`;
15
15
  const SOURCE_DIR = path.dirname(fileURLToPath(import.meta.url));
16
16
  const FILES_TO_COPY = ["package.json", "README.md", "CHANGELOG.md"];
17
- const DIRECTORIES_TO_COPY = ["extensions", "skills", "prompts"];
17
+ const DIRECTORIES_TO_COPY = ["extensions", "prompts"];
18
18
 
19
19
  const args = process.argv.slice(2);
20
20
  const isRemove = args.includes("--remove") || args.includes("-r");
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "pi-zellij",
3
- "version": "0.4.6",
3
+ "version": "0.4.9",
4
4
  "description": "Pi package with zellij-powered terminal integrations",
5
5
  "author": "Javi Molina",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
+ "scripts": {
9
+ "test": "node --test tests/*.test.mjs"
10
+ },
8
11
  "keywords": [
9
12
  "pi-package",
10
13
  "pi",
@@ -25,7 +28,6 @@
25
28
  },
26
29
  "files": [
27
30
  "extensions/",
28
- "skills/",
29
31
  "prompts/",
30
32
  "install.mjs",
31
33
  "README.md",
@@ -38,9 +40,6 @@
38
40
  "extensions": [
39
41
  "./extensions/index.ts"
40
42
  ],
41
- "skills": [
42
- "./skills"
43
- ],
44
43
  "prompts": [
45
44
  "./prompts"
46
45
  ]
@@ -1,8 +1,6 @@
1
1
  ---
2
2
  description: Review the current git diff for regressions and missing tests
3
3
  ---
4
- Use the bundled `code-review` skill if it is available.
5
-
6
4
  Review the current git diff in this repository. If `${@:1}` is a GitHub pull request URL, inspect that pull request instead using `gh pr view <url>` and `gh pr diff <url>`. ${@:1} Prioritize regressions, correctness issues, risky edge cases, and missing tests.
7
5
 
8
6
  Start with a concise summary ordered by severity. Then list concrete findings with suggested fixes. Do not edit files unless asked.
package/prompts/review.md CHANGED
@@ -1,8 +1,6 @@
1
1
  ---
2
2
  description: Review a file or directory for bugs, maintainability issues, and missing tests
3
3
  ---
4
- Use the bundled `code-review` skill if it is available.
5
-
6
4
  Review `$1` from the current project. If the target is a GitHub pull request URL, inspect it with `gh pr view <url>` and `gh pr diff <url>`. Focus on correctness, readability, maintainability, and missing tests. If the target is a directory, review the most relevant files within that scope.
7
5
 
8
6
  Start with a concise summary ordered by severity. Then list concrete findings with suggested fixes. Do not edit files unless asked.