pi-zellij 0.3.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -5,11 +5,6 @@
5
5
  ### Added
6
6
 
7
7
  - Initial `pi-zellij` release with zellij-powered pane workflows for Pi.
8
-
9
- ### Changed
10
-
11
- - Clarified README notification wording to match the current `zv-notify` behavior: `Waiting`, `Task Complete`, and `Error`.
12
- - Added `zv-notify` for desktop notifications using `osascript` on macOS or `notify-send` on Linux.
13
8
  - Added `/zv`, `/zj`, and `/zt` to open a new zellij pane or tab and start a fresh Pi session in the same working directory.
14
9
  - Added `/zo` and `/zoh` to open a new pane and run any shell command there.
15
10
  - Added configurable floating app commands via `pi-zellij.commands` in Pi `settings.json`, including shorthand entries such as `"zh": "hx"` and `"zg": "lazygit"`, plus object entries with `acceptArgs` support.
@@ -18,3 +13,7 @@
18
13
  - Added `/zz` and `/zzh` to open a new pane from a zoxide match or direct directory path and start Pi there.
19
14
  - 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.
20
15
  - 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
+ ### Removed
18
+
19
+ - 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
@@ -4,9 +4,9 @@ Pi package with zellij-powered terminal integrations for [Pi](https://pi.dev).
4
4
 
5
5
  ## Why
6
6
 
7
- [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, plus optional desktop notifications.
7
+ [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.
8
8
 
9
- It includes split and tab commands, generic tool launchers, settings-driven floating app shortcuts, zoxide jumps, review workflows, split-based task handoff, and automatic run notifications.
9
+ It includes split and tab commands, generic tool launchers, settings-driven floating app shortcuts, zoxide jumps, review workflows, and split-based task handoff.
10
10
 
11
11
  ## Usage
12
12
 
@@ -33,9 +33,6 @@ If pi is already running, use:
33
33
  - `zellij` must be installed
34
34
  - pane, tab, and floating commands must be run from inside an active zellij session
35
35
  - `zoxide` is required for the zoxide commands
36
- - notifications use:
37
- - `osascript` on macOS
38
- - `notify-send` on Linux
39
36
 
40
37
  ## Feature overview
41
38
 
@@ -64,15 +61,9 @@ If pi is already running, use:
64
61
  - `/skill:code-review`
65
62
  - loads the bundled structured review skill for files, directories, diffs, and PRs
66
63
 
67
- ### Notifications
68
-
69
- - automatic via `zv-notify`
70
- - sends desktop notifications for Pi run states such as `Waiting`, `Task Complete`, and `Error`
71
-
72
64
  ## Bundled extensions and resources
73
65
 
74
66
  Extensions:
75
- - `zv-notify`
76
67
  - `zv-split`
77
68
  - `zv-open`
78
69
  - `zv-zoxide`
@@ -159,7 +150,9 @@ Examples:
159
150
  /zg
160
151
  ```
161
152
 
162
- For commands that should accept extra arguments, use the object form:
153
+ For commands that should accept extra arguments, use the object form.
154
+
155
+ Helix and lazygit example:
163
156
 
164
157
  ```json
165
158
  {
@@ -169,16 +162,22 @@ For commands that should accept extra arguments, use the object form:
169
162
  "run": "hx",
170
163
  "acceptArgs": true,
171
164
  "description": "Open Helix in a floating pane"
165
+ },
166
+ "zg": {
167
+ "run": "lazygit",
168
+ "description": "Open lazygit in a floating pane"
172
169
  }
173
170
  }
174
171
  }
175
172
  }
176
173
  ```
177
174
 
178
- Then you can pass arguments through to the configured command:
175
+ Then you can use:
179
176
 
180
177
  ```text
178
+ /zh
181
179
  /zh src/auth.ts
180
+ /zg
182
181
  ```
183
182
 
184
183
  Configured command names cannot reuse built-in Pi commands such as `/settings`, `/model`, or `/reload`, and they also cannot replace pi-zellij's own slash commands such as `/zv`, `/zj`, `/zt`, `/zz`, or `/zcv`.
@@ -273,24 +272,3 @@ Examples:
273
272
 
274
273
  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`.
275
274
 
276
- ## Notifications
277
-
278
- The bundled `zv-notify` extension summarizes each run and sends a desktop notification when supported by the host system.
279
-
280
- Current notification types:
281
- - `Waiting`
282
- - `Task Complete`
283
- - `Error`
284
-
285
- You can control notification noise with one setting:
286
- - `PI_ZV_NOTIFY_LEVEL=all` - `Waiting`, `Task Complete`, and `Error`
287
- - `PI_ZV_NOTIFY_LEVEL=medium` - `Task Complete` and `Error`
288
- - `PI_ZV_NOTIFY_LEVEL=low` - `Error` only
289
- - `PI_ZV_NOTIFY_LEVEL=disabled` - disable notifications
290
-
291
- ## Environment variables
292
-
293
- - `PI_ZV_NOTIFY_LEVEL` - notification level: `all`, `medium`, `low`, or `disabled` (default: `all`)
294
- - `PI_ZV_NOTIFY_THRESHOLD_MS` - duration threshold before a run is labeled `Task Complete` instead of `Waiting` (default: `15000`)
295
- - `PI_ZV_NOTIFY_DEBOUNCE_MS` - minimum delay between duplicate notifications (default: `3000`)
296
- - `PI_ZV_NOTIFY_TITLE` - notification title override (default: `Pi`)
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import zvNotifyExtension from "./zv-notify.ts";
3
2
  import zvSplitExtension from "./zv-split.ts";
4
3
  import zvZoxideExtension from "./zv-zoxide.ts";
5
4
  import zvReviewExtension from "./zv-review.ts";
@@ -7,7 +6,6 @@ import zvContinueExtension from "./zv-continue.ts";
7
6
  import zvOpenExtension from "./zv-open.ts";
8
7
 
9
8
  export default function piZellijExtensionBundle(pi: ExtensionAPI) {
10
- zvNotifyExtension(pi);
11
9
  zvSplitExtension(pi);
12
10
  zvZoxideExtension(pi);
13
11
  zvReviewExtension(pi);
package/install.mjs CHANGED
@@ -21,7 +21,7 @@ const isRemove = args.includes("--remove") || args.includes("-r");
21
21
  const isHelp = args.includes("--help") || args.includes("-h");
22
22
 
23
23
  function printHelp() {
24
- console.log(`\n${PACKAGE_NAME}\n\nWhy:\n ${PACKAGE_NAME} adds zellij-powered pane workflows and system notifications to pi.\n\nUsage:\n npx ${PACKAGE_NAME} Install or update the package\n npx ${PACKAGE_NAME} --remove Remove the installed package\n npx ${PACKAGE_NAME} --help Show this help\n`);
24
+ console.log(`\n${PACKAGE_NAME}\n\nWhy:\n ${PACKAGE_NAME} adds zellij-powered pane workflows to pi.\n\nUsage:\n npx ${PACKAGE_NAME} Install or update the package\n npx ${PACKAGE_NAME} --remove Remove the installed package\n npx ${PACKAGE_NAME} --help Show this help\n`);
25
25
  }
26
26
 
27
27
  function ensureDir(dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zellij",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Pi package with zellij-powered terminal integrations",
5
5
  "author": "Javi Molina",
6
6
  "license": "MIT",
@@ -10,8 +10,7 @@
10
10
  "pi",
11
11
  "pi-coding-agent",
12
12
  "zellij",
13
- "terminal",
14
- "notifications"
13
+ "terminal"
15
14
  ],
16
15
  "homepage": "https://github.com/javiermolinar/pi-zellij#readme",
17
16
  "repository": {
@@ -1,303 +0,0 @@
1
- import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
2
- import {
3
- isBashToolResult,
4
- isEditToolResult,
5
- isFindToolResult,
6
- isGrepToolResult,
7
- isReadToolResult,
8
- isWriteToolResult,
9
- } from "@mariozechner/pi-coding-agent";
10
- import { basename } from "node:path";
11
-
12
- const DEFAULT_THRESHOLD_MS = 15000;
13
- const DEFAULT_DEBOUNCE_MS = 3000;
14
- const NOTIFY_TIMEOUT_MS = 5000;
15
- const DEFAULT_NOTIFY_LEVEL = "all";
16
-
17
- type NotifyLevel = "all" | "medium" | "low" | "disabled";
18
- type NotifyBackend = "unknown" | "osascript" | "notify-send" | "unavailable";
19
-
20
- interface RunState {
21
- startedAt: number;
22
- readFiles: Set<string>;
23
- changedFiles: Set<string>;
24
- searchCount: number;
25
- bashCount: number;
26
- firstToolError: string | undefined;
27
- }
28
-
29
- interface AssistantMessageLike {
30
- role: "assistant";
31
- stopReason?: string;
32
- errorMessage?: string;
33
- content?: Array<{ type?: string; text?: string }>;
34
- }
35
-
36
- function getNumberFromEnv(name: string, fallback: number): number {
37
- const value = process.env[name];
38
- if (!value) return fallback;
39
- const parsed = Number.parseInt(value, 10);
40
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
41
- }
42
-
43
- function getNotifyLevelFromEnv(): NotifyLevel {
44
- const value = process.env.PI_ZV_NOTIFY_LEVEL?.trim().toLowerCase();
45
- if (value === "all" || value === "medium" || value === "low" || value === "disabled") {
46
- return value;
47
- }
48
- return DEFAULT_NOTIFY_LEVEL;
49
- }
50
-
51
- function pluralize(count: number, singular: string, plural: string = `${singular}s`): string {
52
- return count === 1 ? singular : plural;
53
- }
54
-
55
- function formatDuration(ms: number): string {
56
- const totalSeconds = Math.max(1, Math.round(ms / 1000));
57
- const minutes = Math.floor(totalSeconds / 60);
58
- const seconds = totalSeconds % 60;
59
- if (minutes === 0) return `${seconds}s`;
60
- if (seconds === 0) return `${minutes}m`;
61
- return `${minutes}m ${seconds}s`;
62
- }
63
-
64
- function getPathFromInput(event: ToolResultEvent): string | undefined {
65
- const path = event.input.path;
66
- return typeof path === "string" && path.length > 0 ? path : undefined;
67
- }
68
-
69
- function getFirstText(event: ToolResultEvent): string | undefined {
70
- const textPart = event.content.find((part) => part.type === "text");
71
- if (!textPart || textPart.type !== "text") return undefined;
72
- const text = textPart.text.trim();
73
- return text.length > 0 ? text : undefined;
74
- }
75
-
76
- function summarizeError(event: ToolResultEvent): string {
77
- const path = getPathFromInput(event);
78
- if (path) {
79
- return `${event.toolName} failed for ${basename(path)}`;
80
- }
81
- if (isBashToolResult(event)) {
82
- return "bash command failed";
83
- }
84
- const text = getFirstText(event);
85
- if (!text) {
86
- return `${event.toolName} failed`;
87
- }
88
- return text.length > 120 ? `${text.slice(0, 117)}...` : text;
89
- }
90
-
91
- function summarizeSuccess(state: RunState, durationMs: number, thresholdMs: number): string {
92
- const changedCount = state.changedFiles.size;
93
- if (changedCount === 1) {
94
- const [file] = [...state.changedFiles];
95
- const summary = `Updated ${basename(file)}`;
96
- return durationMs >= thresholdMs ? `${summary} in ${formatDuration(durationMs)}` : summary;
97
- }
98
- if (changedCount > 1) {
99
- const summary = `Updated ${changedCount} ${pluralize(changedCount, "file")}`;
100
- return durationMs >= thresholdMs ? `${summary} in ${formatDuration(durationMs)}` : summary;
101
- }
102
-
103
- const readCount = state.readFiles.size;
104
- if (readCount === 1) {
105
- const [file] = [...state.readFiles];
106
- const summary = `Reviewed ${basename(file)}`;
107
- return durationMs >= thresholdMs ? `${summary} in ${formatDuration(durationMs)}` : summary;
108
- }
109
- if (readCount > 1) {
110
- const summary = `Reviewed ${readCount} ${pluralize(readCount, "file")}`;
111
- return durationMs >= thresholdMs ? `${summary} in ${formatDuration(durationMs)}` : summary;
112
- }
113
-
114
- if (state.searchCount > 0 && state.bashCount > 0) {
115
- const summary = `Ran ${state.searchCount} ${pluralize(state.searchCount, "search")} and ${state.bashCount} ${pluralize(state.bashCount, "shell command")}`;
116
- return durationMs >= thresholdMs ? `${summary} in ${formatDuration(durationMs)}` : summary;
117
- }
118
- if (state.searchCount > 0) {
119
- const summary = state.searchCount === 1 ? "Searched the codebase" : `Ran ${state.searchCount} searches`;
120
- return durationMs >= thresholdMs ? `${summary} in ${formatDuration(durationMs)}` : summary;
121
- }
122
- if (state.bashCount > 0) {
123
- const summary = `Ran ${state.bashCount} ${pluralize(state.bashCount, "shell command")}`;
124
- return durationMs >= thresholdMs ? `${summary} in ${formatDuration(durationMs)}` : summary;
125
- }
126
- return durationMs >= thresholdMs
127
- ? `Finished in ${formatDuration(durationMs)}`
128
- : "Finished and waiting for input";
129
- }
130
-
131
- function isAssistantMessage(message: unknown): message is AssistantMessageLike {
132
- return typeof message === "object" && message !== null && (message as { role?: unknown }).role === "assistant";
133
- }
134
-
135
- function getLastAssistantMessage(messages: readonly unknown[]): AssistantMessageLike | undefined {
136
- for (let index = messages.length - 1; index >= 0; index -= 1) {
137
- const message = messages[index];
138
- if (isAssistantMessage(message)) return message;
139
- }
140
- return undefined;
141
- }
142
-
143
- function summarizeAssistantText(message: AssistantMessageLike): string | undefined {
144
- if (!Array.isArray(message.content)) return undefined;
145
-
146
- const text = message.content
147
- .filter(
148
- (part): part is { type: "text"; text: string } =>
149
- typeof part === "object" &&
150
- part !== null &&
151
- part.type === "text" &&
152
- typeof part.text === "string" &&
153
- part.text.trim().length > 0,
154
- )
155
- .map((part) => part.text.trim())
156
- .join("\n")
157
- .trim();
158
-
159
- if (text.length === 0) return undefined;
160
- return text.length > 120 ? `${text.slice(0, 117)}...` : text;
161
- }
162
-
163
- function summarizeRunError(messages: readonly unknown[], fallbackError?: string): string | undefined {
164
- const assistantMessage = getLastAssistantMessage(messages);
165
- if (!assistantMessage) return fallbackError;
166
- if (assistantMessage.stopReason !== "error" && assistantMessage.stopReason !== "aborted") {
167
- return undefined;
168
- }
169
-
170
- const summary = assistantMessage.errorMessage?.trim() || summarizeAssistantText(assistantMessage) || fallbackError || "Agent run failed";
171
- return summary.length > 120 ? `${summary.slice(0, 117)}...` : summary;
172
- }
173
-
174
- function buildSubtitle(hasRunError: boolean, state: RunState, durationMs: number, thresholdMs: number): string {
175
- if (hasRunError) return "Error";
176
- if (state.changedFiles.size > 0 || durationMs >= thresholdMs) return "Task Complete";
177
- return "Waiting";
178
- }
179
-
180
- function shouldNotify(level: NotifyLevel, subtitle: string): boolean {
181
- if (level === "disabled") return false;
182
- if (level === "all") return true;
183
- if (level === "medium") return subtitle === "Task Complete" || subtitle === "Error";
184
- if (level === "low") return subtitle === "Error";
185
- return true;
186
- }
187
-
188
- function createEmptyRunState(): RunState {
189
- return {
190
- startedAt: Date.now(),
191
- readFiles: new Set<string>(),
192
- changedFiles: new Set<string>(),
193
- searchCount: 0,
194
- bashCount: 0,
195
- firstToolError: undefined,
196
- };
197
- }
198
-
199
- function getDefaultBackend(): NotifyBackend {
200
- if (process.platform === "darwin") return "osascript";
201
- if (process.platform === "linux") return "notify-send";
202
- return "unavailable";
203
- }
204
-
205
- function escapeAppleScriptString(value: string): string {
206
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
207
- }
208
-
209
- export default function zvNotifyExtension(pi: ExtensionAPI) {
210
- const thresholdMs = getNumberFromEnv("PI_ZV_NOTIFY_THRESHOLD_MS", DEFAULT_THRESHOLD_MS);
211
- const debounceMs = getNumberFromEnv("PI_ZV_NOTIFY_DEBOUNCE_MS", DEFAULT_DEBOUNCE_MS);
212
- const notifyLevel = getNotifyLevelFromEnv();
213
- const title = process.env.PI_ZV_NOTIFY_TITLE || "Pi";
214
-
215
- let runState = createEmptyRunState();
216
- let lastNotificationAt = 0;
217
- let lastNotificationKey = "";
218
- let notifyBackend: NotifyBackend = "unknown";
219
-
220
- const sendNotification = async (subtitle: string, body: string): Promise<{ ok: boolean; error?: string }> => {
221
- if (notifyBackend === "unknown") {
222
- notifyBackend = getDefaultBackend();
223
- }
224
- if (notifyBackend === "unavailable") {
225
- return { ok: false, error: "desktop notifications are unavailable on this platform" };
226
- }
227
-
228
- const notificationKey = `${subtitle}\n${body}`;
229
- const now = Date.now();
230
- if (notificationKey === lastNotificationKey && now - lastNotificationAt < debounceMs) {
231
- return { ok: true };
232
- }
233
-
234
- const result =
235
- notifyBackend === "osascript"
236
- ? await pi.exec(
237
- "osascript",
238
- [
239
- "-e",
240
- `display notification ${escapeAppleScriptString(body)} with title ${escapeAppleScriptString(title)} subtitle ${escapeAppleScriptString(subtitle)}`,
241
- ],
242
- { timeout: NOTIFY_TIMEOUT_MS },
243
- )
244
- : await pi.exec("notify-send", [`${title} — ${subtitle}`, body], { timeout: NOTIFY_TIMEOUT_MS });
245
-
246
- if (result.killed) {
247
- return { ok: false, error: "notification command timed out" };
248
- }
249
- if (result.code !== 0) {
250
- const error = result.stderr.trim() || result.stdout.trim() || `notification command exited with code ${result.code}`;
251
- if (error.includes("not found") || error.includes("ENOENT")) {
252
- notifyBackend = "unavailable";
253
- }
254
- return { ok: false, error };
255
- }
256
-
257
- lastNotificationAt = now;
258
- lastNotificationKey = notificationKey;
259
- return { ok: true };
260
- };
261
-
262
- pi.on("agent_start", async () => {
263
- runState = createEmptyRunState();
264
- });
265
-
266
- pi.on("tool_result", async (event) => {
267
- if (event.isError && !runState.firstToolError) {
268
- runState.firstToolError = summarizeError(event);
269
- }
270
-
271
- if (isReadToolResult(event)) {
272
- const path = getPathFromInput(event);
273
- if (path) runState.readFiles.add(path);
274
- return;
275
- }
276
-
277
- if (isEditToolResult(event) || isWriteToolResult(event)) {
278
- const path = getPathFromInput(event);
279
- if (path && !event.isError) runState.changedFiles.add(path);
280
- return;
281
- }
282
-
283
- if (isGrepToolResult(event) || isFindToolResult(event)) {
284
- if (!event.isError) runState.searchCount += 1;
285
- return;
286
- }
287
-
288
- if (isBashToolResult(event) && !event.isError) {
289
- runState.bashCount += 1;
290
- }
291
- });
292
-
293
- pi.on("agent_end", async (event) => {
294
- const durationMs = Date.now() - runState.startedAt;
295
- const runError = summarizeRunError(event.messages, runState.firstToolError);
296
- const subtitle = buildSubtitle(Boolean(runError), runState, durationMs, thresholdMs);
297
- if (!shouldNotify(notifyLevel, subtitle)) {
298
- return;
299
- }
300
- const body = runError || summarizeSuccess(runState, durationMs, thresholdMs);
301
- await sendNotification(subtitle, body);
302
- });
303
- }