pi-zellij 0.4.0 → 0.4.6

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
@@ -13,6 +13,15 @@
13
13
  - Added `/zz` and `/zzh` to open a new pane from a zoxide match or direct directory path and start Pi there.
14
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.
15
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
+ - Added opt-in `paneHighlight` settings so Pi can tint the current zellij pane when an agent turn completes, with optional working-state colors.
17
+
18
+ ### Changed
19
+
20
+ - 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
+ - `/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
+ - 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.
23
+ - Pane focus polling no longer writes transient zellij query timeout warnings into the Pi editor; refocus-based clearing is skipped if focus state cannot be queried reliably.
24
+ - Done-state pane tint is now only applied while the Pi pane is unfocused, so the editor is reset immediately instead of staying green while typing in the active pane.
16
25
 
17
26
  ### Removed
18
27
 
package/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # pi-zellij
2
2
 
3
- Pi package with zellij-powered terminal integrations for [Pi](https://pi.dev).
3
+ Pi package with [zellij](https://zellij.dev)-powered terminal integrations for [Pi](https://pi.dev). Make your workflow agent driven.
4
+
5
+ ## Workflow
6
+
7
+ ![pi-zellij workflow](assets/workflow.gif)
4
8
 
5
9
  ## Why
6
10
 
7
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.
8
12
 
9
- It includes split and tab commands, generic tool launchers, settings-driven floating app shortcuts, zoxide jumps, review workflows, and split-based task handoff.
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.
10
14
 
11
15
  ## Usage
12
16
 
@@ -34,6 +38,13 @@ If pi is already running, use:
34
38
  - pane, tab, and floating commands must be run from inside an active zellij session
35
39
  - `zoxide` is required for the zoxide commands
36
40
 
41
+ ### Recommended zellij version
42
+
43
+ | zellij version | status | notes |
44
+ | --- | --- | --- |
45
+ | `0.44.0+` | recommended | `pi-zellij` can show created pane/tab IDs in success notifications, and `/zt` can launch its initial command directly via `zellij action new-tab -- <command>` |
46
+ | older versions | supported with fallback | commands still work, but zellij may not expose created IDs and `/zt` may fall back to the older typed-input startup path |
47
+
37
48
  ## Feature overview
38
49
 
39
50
  ### Pane and tab workflows
@@ -49,6 +60,8 @@ If pi is already running, use:
49
60
 
50
61
  - `pi-zellij.commands` in `settings.json`
51
62
  - registers floating app shortcuts such as `/zh` for `hx` or `/zg` for `lazygit`
63
+ - `pi-zellij.paneHighlight` in `settings.json`
64
+ - optionally tints the current zellij pane when Pi finishes a turn and is waiting for input
52
65
 
53
66
  ### Review and handoff workflows
54
67
 
@@ -66,6 +79,7 @@ If pi is already running, use:
66
79
  Extensions:
67
80
  - `zv-split`
68
81
  - `zv-open`
82
+ - `zv-highlight`
69
83
  - `zv-zoxide`
70
84
  - `zv-review`
71
85
  - `zv-continue`
@@ -184,6 +198,53 @@ Configured command names cannot reuse built-in Pi commands such as `/settings`,
184
198
 
185
199
  If the same command exists in both global and project settings, the project setting wins. After changing settings, run `/reload` in Pi.
186
200
 
201
+ ### Pane highlight on completion
202
+
203
+ You can optionally tint the current zellij pane when Pi finishes a turn and is waiting for input.
204
+
205
+ Supported locations:
206
+ - `~/.pi/agent/settings.json` for global settings
207
+ - `.pi/settings.json` for project-local settings
208
+
209
+ During the rename from `pi-zv` to `pi-zellij`, legacy `pi-zv.paneHighlight` is still accepted for compatibility. If both keys exist, `pi-zellij.paneHighlight` wins.
210
+
211
+ Minimal form:
212
+
213
+ ```json
214
+ {
215
+ "pi-zellij": {
216
+ "paneHighlight": true
217
+ }
218
+ }
219
+ ```
220
+
221
+ That enables a default done-state background tint. The feature is zellij-only and does nothing outside an active zellij session.
222
+
223
+ Object form:
224
+
225
+ ```json
226
+ {
227
+ "pi-zellij": {
228
+ "paneHighlight": {
229
+ "enabled": true,
230
+ "doneBg": "#17352a",
231
+ "doneFg": "#e7fff0",
232
+ "workingBg": "#2f2415"
233
+ }
234
+ }
235
+ }
236
+ ```
237
+
238
+ Supported keys:
239
+ - `enabled`
240
+ - set to `false` to disable the feature
241
+ - `doneBg`, `doneFg`
242
+ - pane colors to apply after `agent_end` when the pane is not currently focused
243
+ - `workingBg`, `workingFg`
244
+ - optional pane colors to apply while Pi is working; if omitted, `pi-zellij` resets the pane to its default colors when the next input is submitted or when the pane is focused again after being elsewhere
245
+
246
+ When enabled, `pi-zellij` resets the pane color on session start, session switch, the next submitted input, pane refocus when zellij focus state is available, and session shutdown so completed-turn highlights do not linger across sessions. If the pane is already focused when a turn completes, the done-state tint is skipped so the Pi editor does not stay tinted while you type. Aborted runs do not apply the done-state tint. After changing these settings, run `/reload` in Pi.
247
+
187
248
  ### Zoxide jump commands
188
249
 
189
250
  - `/zz <query>`
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { existsSync, mkdirSync } from "node:fs";
4
4
 
@@ -1,9 +1,10 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import zvSplitExtension from "./zv-split.ts";
3
3
  import zvZoxideExtension from "./zv-zoxide.ts";
4
4
  import zvReviewExtension from "./zv-review.ts";
5
5
  import zvContinueExtension from "./zv-continue.ts";
6
6
  import zvOpenExtension from "./zv-open.ts";
7
+ import zvHighlightExtension from "./zv-highlight.ts";
7
8
 
8
9
  export default function piZellijExtensionBundle(pi: ExtensionAPI) {
9
10
  zvSplitExtension(pi);
@@ -11,4 +12,5 @@ export default function piZellijExtensionBundle(pi: ExtensionAPI) {
11
12
  zvReviewExtension(pi);
12
13
  zvContinueExtension(pi);
13
14
  zvOpenExtension(pi);
15
+ zvHighlightExtension(pi);
14
16
  }
@@ -1,10 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { SessionManager, type ExtensionAPI, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
4
+ import { SessionManager, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  buildPiCommand,
7
+ formatPaneSuccessMessage,
7
8
  openCommandInNewSplit,
9
+ type PaneOpenResult,
8
10
  type SplitDirection,
9
11
  } from "./zv-core.ts";
10
12
  import {
@@ -388,7 +390,7 @@ async function openContinueSplit(
388
390
  ctx: ExtensionCommandContext,
389
391
  direction: SplitDirection,
390
392
  request: ContinueRequest,
391
- ): Promise<{ ok: true } | { ok: false; error: string }> {
393
+ ): Promise<PaneOpenResult> {
392
394
  const handoffTarget = await resolveHandoffTarget(pi, ctx, request);
393
395
  if (!handoffTarget.ok) {
394
396
  return handoffTarget;
@@ -422,7 +424,7 @@ function registerContinueCommand(
422
424
 
423
425
  const result = await openContinueSplit(pi, ctx, direction, parsed.request);
424
426
  if (result.ok) {
425
- ctx.ui.notify(successMessage, "info");
427
+ ctx.ui.notify(formatPaneSuccessMessage(successMessage, result.paneId), "info");
426
428
  } else {
427
429
  ctx.ui.notify(`continuation split failed: ${result.error}`, "error");
428
430
  }
@@ -1,8 +1,10 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  const ZELLIJ_TIMEOUT_MS = 5000;
4
4
 
5
5
  export type SplitDirection = "right" | "down";
6
+ export type PaneOpenResult = { ok: true; paneId?: string } | { ok: false; error: string };
7
+ export type TabOpenResult = { ok: true; tabId?: string } | { ok: false; error: string };
6
8
 
7
9
  interface ZellijExecResult {
8
10
  ok: boolean;
@@ -11,10 +13,49 @@ interface ZellijExecResult {
11
13
  error?: string;
12
14
  }
13
15
 
14
- function isInsideZellijSession(): boolean {
16
+ export function isInsideZellijSession(): boolean {
15
17
  return Boolean(process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME || process.env.ZELLIJ_PANE_ID);
16
18
  }
17
19
 
20
+ function getLastNonEmptyLine(value: string): string | undefined {
21
+ const lines = value
22
+ .split(/\r?\n/)
23
+ .map((line) => line.trim())
24
+ .filter((line) => line.length > 0);
25
+ return lines.length > 0 ? lines[lines.length - 1] : undefined;
26
+ }
27
+
28
+ function normalizePaneId(value: string): string | undefined {
29
+ const trimmed = value.trim();
30
+ if (!trimmed) {
31
+ return undefined;
32
+ }
33
+ if (/^(?:terminal|plugin)_\d+$/.test(trimmed)) {
34
+ return trimmed;
35
+ }
36
+ if (/^\d+$/.test(trimmed)) {
37
+ return `terminal_${trimmed}`;
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function getCreatedPaneId(stdout: string): string | undefined {
43
+ return normalizePaneId(getLastNonEmptyLine(stdout) ?? "");
44
+ }
45
+
46
+ function getCreatedTabId(stdout: string): string | undefined {
47
+ const candidate = getLastNonEmptyLine(stdout);
48
+ return candidate && /^\d+$/.test(candidate) ? candidate : undefined;
49
+ }
50
+
51
+ export function formatPaneSuccessMessage(message: string, paneId?: string): string {
52
+ return paneId ? `${message} (${paneId})` : message;
53
+ }
54
+
55
+ export function formatTabSuccessMessage(message: string, tabId?: string): string {
56
+ return tabId ? `${message} (tab ${tabId})` : message;
57
+ }
58
+
18
59
  export function shellEscape(value: string): string {
19
60
  return `'${value.replace(/'/g, `'\\''`)}'`;
20
61
  }
@@ -60,11 +101,88 @@ async function execZellij(pi: ExtensionAPI, args: string[]): Promise<ZellijExecR
60
101
  };
61
102
  }
62
103
 
104
+ function getCurrentPaneTargetArgs(): string[] {
105
+ return process.env.ZELLIJ_PANE_ID ? ["-p", process.env.ZELLIJ_PANE_ID] : [];
106
+ }
107
+
108
+ export async function resetCurrentPaneColor(pi: ExtensionAPI): Promise<{ ok: true } | { ok: false; error: string }> {
109
+ if (!isInsideZellijSession()) {
110
+ return { ok: false, error: "This command must be run from inside an active zellij session" };
111
+ }
112
+
113
+ const result = await execZellij(pi, ["action", "set-pane-color", ...getCurrentPaneTargetArgs(), "--reset"]);
114
+ if (!result.ok) {
115
+ return { ok: false, error: result.error || "Failed to reset zellij pane color" };
116
+ }
117
+
118
+ return { ok: true };
119
+ }
120
+
121
+ export async function setCurrentPaneColor(
122
+ pi: ExtensionAPI,
123
+ options: { bg?: string; fg?: string },
124
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
125
+ if (!isInsideZellijSession()) {
126
+ return { ok: false, error: "This command must be run from inside an active zellij session" };
127
+ }
128
+
129
+ const paneTargetArgs = getCurrentPaneTargetArgs();
130
+ const args = ["action", "set-pane-color", ...paneTargetArgs];
131
+ if (options.bg) {
132
+ args.push("--bg", options.bg);
133
+ }
134
+ if (options.fg) {
135
+ args.push("--fg", options.fg);
136
+ }
137
+ if (args.length === 2 + paneTargetArgs.length) {
138
+ return resetCurrentPaneColor(pi);
139
+ }
140
+
141
+ const result = await execZellij(pi, args);
142
+ if (!result.ok) {
143
+ return { ok: false, error: result.error || "Failed to set zellij pane color" };
144
+ }
145
+
146
+ return { ok: true };
147
+ }
148
+
149
+ function buildNewTabArgs(cwd: string, options?: { name?: string }): string[] {
150
+ const args = ["action", "new-tab", "--cwd", cwd];
151
+ if (options?.name) {
152
+ args.push("--name", options.name);
153
+ }
154
+ return args;
155
+ }
156
+
157
+ async function openCommandInNewTabLegacy(
158
+ pi: ExtensionAPI,
159
+ cwd: string,
160
+ command: string,
161
+ options?: { name?: string },
162
+ ): Promise<TabOpenResult> {
163
+ const newTabResult = await execZellij(pi, buildNewTabArgs(cwd, options));
164
+ if (!newTabResult.ok) {
165
+ return { ok: false, error: newTabResult.error || "Failed to open a new zellij tab" };
166
+ }
167
+
168
+ const writeCommandResult = await execZellij(pi, ["action", "write-chars", command]);
169
+ if (!writeCommandResult.ok) {
170
+ return { ok: false, error: writeCommandResult.error || "Failed to write command to new zellij tab" };
171
+ }
172
+
173
+ const submitCommandResult = await execZellij(pi, ["action", "write", "10"]);
174
+ if (!submitCommandResult.ok) {
175
+ return { ok: false, error: submitCommandResult.error || "Failed to start command in new zellij tab" };
176
+ }
177
+
178
+ return { ok: true, tabId: getCreatedTabId(newTabResult.stdout) };
179
+ }
180
+
63
181
  export async function openCommandInNewSplit(
64
182
  pi: ExtensionAPI,
65
183
  direction: SplitDirection,
66
184
  command: string,
67
- ): Promise<{ ok: true } | { ok: false; error: string }> {
185
+ ): Promise<PaneOpenResult> {
68
186
  if (!isInsideZellijSession()) {
69
187
  return { ok: false, error: "This command must be run from inside an active zellij session" };
70
188
  }
@@ -74,14 +192,14 @@ export async function openCommandInNewSplit(
74
192
  return { ok: false, error: result.error || "Failed to open a new zellij pane" };
75
193
  }
76
194
 
77
- return { ok: true };
195
+ return { ok: true, paneId: getCreatedPaneId(result.stdout) };
78
196
  }
79
197
 
80
198
  export async function openCommandInFloatingPane(
81
199
  pi: ExtensionAPI,
82
200
  command: string,
83
201
  options?: { name?: string; width?: string; height?: string; x?: string; y?: string },
84
- ): Promise<{ ok: true } | { ok: false; error: string }> {
202
+ ): Promise<PaneOpenResult> {
85
203
  if (!isInsideZellijSession()) {
86
204
  return { ok: false, error: "This command must be run from inside an active zellij session" };
87
205
  }
@@ -99,7 +217,7 @@ export async function openCommandInFloatingPane(
99
217
  return { ok: false, error: result.error || "Failed to open a new floating zellij pane" };
100
218
  }
101
219
 
102
- return { ok: true };
220
+ return { ok: true, paneId: getCreatedPaneId(result.stdout) };
103
221
  }
104
222
 
105
223
  export async function openCommandInNewTab(
@@ -107,30 +225,15 @@ export async function openCommandInNewTab(
107
225
  cwd: string,
108
226
  command: string,
109
227
  options?: { name?: string },
110
- ): Promise<{ ok: true } | { ok: false; error: string }> {
228
+ ): Promise<TabOpenResult> {
111
229
  if (!isInsideZellijSession()) {
112
230
  return { ok: false, error: "This command must be run from inside an active zellij session" };
113
231
  }
114
232
 
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" };
233
+ const newTabResult = await execZellij(pi, [...buildNewTabArgs(cwd, options), "--", "sh", "-lc", command]);
234
+ if (newTabResult.ok) {
235
+ return { ok: true, tabId: getCreatedTabId(newTabResult.stdout) };
128
236
  }
129
237
 
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 };
238
+ return openCommandInNewTabLegacy(pi, cwd, command, options);
136
239
  }
@@ -0,0 +1,523 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { isInsideZellijSession, resetCurrentPaneColor, setCurrentPaneColor } from "./zv-core.ts";
6
+
7
+ const DEFAULT_DONE_BG = "#17352a";
8
+ const GLOBAL_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
9
+ const SETTINGS_SECTION_NAMES = ["pi-zellij", "pi-zv"] as const;
10
+ const FOCUS_POLL_INTERVAL_MS = 1000;
11
+ const FOCUS_QUERY_TIMEOUT_MS = 2000;
12
+ const MAX_FOCUS_QUERY_FAILURES = 3;
13
+
14
+ interface PaneHighlightConfigInput {
15
+ enabled?: boolean;
16
+ doneBg?: string;
17
+ doneFg?: string;
18
+ workingBg?: string;
19
+ workingFg?: string;
20
+ }
21
+
22
+ interface PaneHighlightConfig {
23
+ enabled: boolean;
24
+ doneBg: string;
25
+ doneFg?: string;
26
+ workingBg?: string;
27
+ workingFg?: string;
28
+ }
29
+
30
+ interface MessageLike {
31
+ role?: unknown;
32
+ stopReason?: unknown;
33
+ content?: unknown;
34
+ }
35
+
36
+ type JsonRecord = Record<string, unknown>;
37
+
38
+ function readJsonFile(path: string): Record<string, unknown> | undefined {
39
+ if (!existsSync(path)) {
40
+ return undefined;
41
+ }
42
+
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
45
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
46
+ console.warn(`[pi-zellij] Ignoring non-object settings file: ${path}`);
47
+ return undefined;
48
+ }
49
+ return parsed as Record<string, unknown>;
50
+ } catch (error) {
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ console.warn(`[pi-zellij] Failed to read settings from ${path}: ${message}`);
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ function readPaneHighlightSetting(settingsPath: string): unknown {
58
+ const settings = readJsonFile(settingsPath);
59
+ for (const sectionName of SETTINGS_SECTION_NAMES) {
60
+ const section = settings?.[sectionName];
61
+ if (!section) {
62
+ continue;
63
+ }
64
+ if (typeof section !== "object" || Array.isArray(section)) {
65
+ console.warn(`[pi-zellij] Ignoring invalid \"${sectionName}\" settings in ${settingsPath}`);
66
+ continue;
67
+ }
68
+ const paneHighlight = (section as { paneHighlight?: unknown }).paneHighlight;
69
+ if (paneHighlight === undefined) {
70
+ continue;
71
+ }
72
+ return paneHighlight;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ function normalizeOptionalString(value: unknown): string | undefined {
78
+ if (typeof value !== "string") {
79
+ return undefined;
80
+ }
81
+ const trimmed = value.trim();
82
+ return trimmed.length > 0 ? trimmed : undefined;
83
+ }
84
+
85
+ function normalizePaneHighlightConfig(value: unknown, settingsPath: string): Partial<PaneHighlightConfig> | undefined {
86
+ if (value === undefined) {
87
+ return undefined;
88
+ }
89
+ if (typeof value === "boolean") {
90
+ return { enabled: value };
91
+ }
92
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
93
+ console.warn(`[pi-zellij] Ignoring invalid paneHighlight settings in ${settingsPath}`);
94
+ return undefined;
95
+ }
96
+
97
+ const config = value as PaneHighlightConfigInput;
98
+ return {
99
+ enabled: config.enabled !== false,
100
+ doneBg: normalizeOptionalString(config.doneBg),
101
+ doneFg: normalizeOptionalString(config.doneFg),
102
+ workingBg: normalizeOptionalString(config.workingBg),
103
+ workingFg: normalizeOptionalString(config.workingFg),
104
+ };
105
+ }
106
+
107
+ function loadPaneHighlightConfig(cwd: string): PaneHighlightConfig {
108
+ let merged: Partial<PaneHighlightConfig> = { enabled: false };
109
+
110
+ for (const settingsPath of [GLOBAL_SETTINGS_PATH, join(cwd, ".pi", "settings.json")]) {
111
+ const normalized = normalizePaneHighlightConfig(readPaneHighlightSetting(settingsPath), settingsPath);
112
+ if (!normalized) {
113
+ continue;
114
+ }
115
+ merged = { ...merged, ...normalized };
116
+ }
117
+
118
+ return {
119
+ enabled: merged.enabled === true,
120
+ doneBg: merged.doneBg ?? DEFAULT_DONE_BG,
121
+ doneFg: merged.doneFg,
122
+ workingBg: merged.workingBg,
123
+ workingFg: merged.workingFg,
124
+ };
125
+ }
126
+
127
+ function hasWorkingColors(config: PaneHighlightConfig): boolean {
128
+ return Boolean(config.workingBg || config.workingFg);
129
+ }
130
+
131
+ function isRecord(value: unknown): value is JsonRecord {
132
+ return typeof value === "object" && value !== null && !Array.isArray(value);
133
+ }
134
+
135
+ function normalizePaneId(value: unknown): string | undefined {
136
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
137
+ return `terminal_${value}`;
138
+ }
139
+ if (typeof value !== "string") {
140
+ return undefined;
141
+ }
142
+
143
+ const trimmed = value.trim();
144
+ if (!trimmed) {
145
+ return undefined;
146
+ }
147
+ if (/^(?:terminal|plugin)_\d+$/.test(trimmed)) {
148
+ return trimmed;
149
+ }
150
+ if (/^\d+$/.test(trimmed)) {
151
+ return `terminal_${trimmed}`;
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ function readBoolean(value: unknown): boolean | undefined {
157
+ if (typeof value === "boolean") {
158
+ return value;
159
+ }
160
+ if (typeof value === "string") {
161
+ if (value === "true") return true;
162
+ if (value === "false") return false;
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ function normalizeTabId(value: unknown): number | undefined {
168
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
169
+ return value;
170
+ }
171
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) {
172
+ return Number(value.trim());
173
+ }
174
+ return undefined;
175
+ }
176
+
177
+ function getPaneEntries(parsed: unknown): JsonRecord[] {
178
+ if (Array.isArray(parsed)) {
179
+ return parsed.filter(isRecord);
180
+ }
181
+ if (!isRecord(parsed)) {
182
+ return [];
183
+ }
184
+
185
+ for (const key of ["panes", "items", "data", "results"]) {
186
+ const candidate = parsed[key];
187
+ if (Array.isArray(candidate)) {
188
+ return candidate.filter(isRecord);
189
+ }
190
+ }
191
+
192
+ return [parsed];
193
+ }
194
+
195
+ function getPaneIdFromEntry(entry: JsonRecord): string | undefined {
196
+ for (const key of ["pane_id", "paneId", "id", "terminal_id", "terminalId"]) {
197
+ const normalized = normalizePaneId(entry[key]);
198
+ if (normalized) {
199
+ return normalized;
200
+ }
201
+ }
202
+
203
+ const nestedPane = entry.pane;
204
+ if (isRecord(nestedPane)) {
205
+ return getPaneIdFromEntry(nestedPane);
206
+ }
207
+
208
+ return undefined;
209
+ }
210
+
211
+ function getTabIdFromEntry(entry: JsonRecord): number | undefined {
212
+ for (const key of ["tab_id", "tabId"]) {
213
+ const normalized = normalizeTabId(entry[key]);
214
+ if (normalized !== undefined) {
215
+ return normalized;
216
+ }
217
+ }
218
+
219
+ const nestedTab = entry.tab;
220
+ if (isRecord(nestedTab)) {
221
+ return getTabIdFromEntry(nestedTab);
222
+ }
223
+
224
+ const nestedPane = entry.pane;
225
+ if (isRecord(nestedPane)) {
226
+ return getTabIdFromEntry(nestedPane);
227
+ }
228
+
229
+ return undefined;
230
+ }
231
+
232
+ function getFocusedStateFromEntry(entry: JsonRecord): boolean | undefined {
233
+ for (const key of ["is_focused", "focused", "isFocused", "active", "is_active", "isActive"]) {
234
+ const normalized = readBoolean(entry[key]);
235
+ if (normalized !== undefined) {
236
+ return normalized;
237
+ }
238
+ }
239
+
240
+ const nestedState = entry.state;
241
+ if (isRecord(nestedState)) {
242
+ return getFocusedStateFromEntry(nestedState);
243
+ }
244
+
245
+ const nestedPane = entry.pane;
246
+ if (isRecord(nestedPane)) {
247
+ return getFocusedStateFromEntry(nestedPane);
248
+ }
249
+
250
+ return undefined;
251
+ }
252
+
253
+ function hasVisibleAssistantContent(message: MessageLike): boolean {
254
+ if (typeof message.content === "string") {
255
+ return message.content.trim().length > 0;
256
+ }
257
+ if (!Array.isArray(message.content)) {
258
+ return false;
259
+ }
260
+ return message.content.length > 0;
261
+ }
262
+
263
+ function shouldHighlightAfterAgentEnd(messages: readonly unknown[]): boolean {
264
+ const assistantMessages = messages.filter((message): message is MessageLike => {
265
+ return isRecord(message) && message.role === "assistant";
266
+ });
267
+ if (assistantMessages.length === 0) {
268
+ return false;
269
+ }
270
+
271
+ const lastAssistantMessage = assistantMessages[assistantMessages.length - 1]!;
272
+ if (lastAssistantMessage.stopReason === "aborted") {
273
+ return false;
274
+ }
275
+ return hasVisibleAssistantContent(lastAssistantMessage);
276
+ }
277
+
278
+ export default function zvHighlightExtension(pi: ExtensionAPI) {
279
+ let config = loadPaneHighlightConfig(process.cwd());
280
+ let lastActionError: string | undefined;
281
+ let focusPollTimer: ReturnType<typeof setInterval> | undefined;
282
+ let focusPollInFlight = false;
283
+ let shouldClearOnRefocus = false;
284
+ let sawPaneBlurSinceDone = false;
285
+ let consecutiveFocusQueryFailures = 0;
286
+
287
+ function warnActionError(message: string): void {
288
+ if (lastActionError === message) {
289
+ return;
290
+ }
291
+ lastActionError = message;
292
+ console.warn(`[pi-zellij] ${message}`);
293
+ }
294
+
295
+ function clearActionError(): void {
296
+ lastActionError = undefined;
297
+ }
298
+
299
+ function stopFocusPolling(): void {
300
+ if (focusPollTimer) {
301
+ clearInterval(focusPollTimer);
302
+ focusPollTimer = undefined;
303
+ }
304
+ focusPollInFlight = false;
305
+ shouldClearOnRefocus = false;
306
+ sawPaneBlurSinceDone = false;
307
+ consecutiveFocusQueryFailures = 0;
308
+ }
309
+
310
+ async function getActiveTabId(): Promise<number | undefined> {
311
+ const result = await pi.exec("zellij", ["action", "current-tab-info"], {
312
+ timeout: FOCUS_QUERY_TIMEOUT_MS,
313
+ });
314
+ if (result.killed || result.code !== 0) {
315
+ return undefined;
316
+ }
317
+
318
+ const match = /^\s*id:\s*(\d+)\s*$/m.exec(result.stdout);
319
+ return match ? Number(match[1]) : undefined;
320
+ }
321
+
322
+ async function getCurrentPaneFocusedState(): Promise<boolean | undefined> {
323
+ if (!isInsideZellijSession()) {
324
+ return undefined;
325
+ }
326
+
327
+ const currentPaneId = normalizePaneId(process.env.ZELLIJ_PANE_ID);
328
+ if (!currentPaneId) {
329
+ return undefined;
330
+ }
331
+
332
+ const result = await pi.exec("zellij", ["action", "list-panes", "--json", "--state"], {
333
+ timeout: FOCUS_QUERY_TIMEOUT_MS,
334
+ });
335
+ if (result.killed || result.code !== 0) {
336
+ return undefined;
337
+ }
338
+
339
+ try {
340
+ const parsed = JSON.parse(result.stdout) as unknown;
341
+ for (const entry of getPaneEntries(parsed)) {
342
+ if (getPaneIdFromEntry(entry) !== currentPaneId) {
343
+ continue;
344
+ }
345
+ const focused = getFocusedStateFromEntry(entry);
346
+ if (focused === undefined) {
347
+ return undefined;
348
+ }
349
+ if (!focused) {
350
+ clearActionError();
351
+ return false;
352
+ }
353
+
354
+ const paneTabId = getTabIdFromEntry(entry);
355
+ const activeTabId = await getActiveTabId();
356
+ clearActionError();
357
+ if (paneTabId === undefined || activeTabId === undefined) {
358
+ return true;
359
+ }
360
+ return paneTabId === activeTabId;
361
+ }
362
+ return undefined;
363
+ } catch {
364
+ return undefined;
365
+ }
366
+ }
367
+
368
+ async function refreshConfig(cwd: string): Promise<void> {
369
+ const wasEnabled = config.enabled;
370
+ config = loadPaneHighlightConfig(cwd);
371
+ if ((!wasEnabled && !config.enabled) || !isInsideZellijSession()) {
372
+ stopFocusPolling();
373
+ return;
374
+ }
375
+
376
+ stopFocusPolling();
377
+ const result = await resetCurrentPaneColor(pi);
378
+ if (!result.ok) {
379
+ warnActionError(`pane highlight reset failed: ${result.error}`);
380
+ return;
381
+ }
382
+ clearActionError();
383
+ }
384
+
385
+ async function resetPaneIfEnabled(): Promise<void> {
386
+ stopFocusPolling();
387
+ if (!config.enabled || !isInsideZellijSession()) {
388
+ return;
389
+ }
390
+
391
+ const result = await resetCurrentPaneColor(pi);
392
+ if (!result.ok) {
393
+ warnActionError(`pane highlight reset failed: ${result.error}`);
394
+ return;
395
+ }
396
+ clearActionError();
397
+ }
398
+
399
+ async function applyWorkingState(): Promise<void> {
400
+ stopFocusPolling();
401
+ if (!config.enabled || !isInsideZellijSession()) {
402
+ return;
403
+ }
404
+ if (!hasWorkingColors(config)) {
405
+ await resetPaneIfEnabled();
406
+ return;
407
+ }
408
+
409
+ const result = await setCurrentPaneColor(pi, {
410
+ bg: config.workingBg,
411
+ fg: config.workingFg,
412
+ });
413
+ if (!result.ok) {
414
+ warnActionError(`pane highlight update failed: ${result.error}`);
415
+ return;
416
+ }
417
+ clearActionError();
418
+ }
419
+
420
+ async function pollFocusForReset(): Promise<void> {
421
+ if (!shouldClearOnRefocus || focusPollInFlight) {
422
+ return;
423
+ }
424
+
425
+ focusPollInFlight = true;
426
+ try {
427
+ const focused = await getCurrentPaneFocusedState();
428
+ if (focused === undefined) {
429
+ consecutiveFocusQueryFailures += 1;
430
+ if (consecutiveFocusQueryFailures >= MAX_FOCUS_QUERY_FAILURES) {
431
+ stopFocusPolling();
432
+ }
433
+ return;
434
+ }
435
+ consecutiveFocusQueryFailures = 0;
436
+ if (!sawPaneBlurSinceDone) {
437
+ if (!focused) {
438
+ sawPaneBlurSinceDone = true;
439
+ }
440
+ return;
441
+ }
442
+ if (!focused) {
443
+ return;
444
+ }
445
+ await resetPaneIfEnabled();
446
+ } finally {
447
+ focusPollInFlight = false;
448
+ }
449
+ }
450
+
451
+ function armFocusBasedReset(): void {
452
+ stopFocusPolling();
453
+ if (!config.enabled || !isInsideZellijSession() || !process.env.ZELLIJ_PANE_ID) {
454
+ return;
455
+ }
456
+
457
+ shouldClearOnRefocus = true;
458
+ sawPaneBlurSinceDone = true;
459
+ focusPollTimer = setInterval(() => {
460
+ void pollFocusForReset();
461
+ }, FOCUS_POLL_INTERVAL_MS);
462
+ }
463
+
464
+ async function applyDoneState(messages: readonly unknown[]): Promise<void> {
465
+ if (!config.enabled || !isInsideZellijSession()) {
466
+ return;
467
+ }
468
+ if (!shouldHighlightAfterAgentEnd(messages)) {
469
+ await resetPaneIfEnabled();
470
+ return;
471
+ }
472
+
473
+ const focused = await getCurrentPaneFocusedState();
474
+ if (focused !== false) {
475
+ await resetPaneIfEnabled();
476
+ return;
477
+ }
478
+
479
+ const result = await setCurrentPaneColor(pi, {
480
+ bg: config.doneBg,
481
+ fg: config.doneFg,
482
+ });
483
+ if (!result.ok) {
484
+ warnActionError(`pane highlight update failed: ${result.error}`);
485
+ return;
486
+ }
487
+ clearActionError();
488
+ armFocusBasedReset();
489
+ }
490
+
491
+ pi.on("session_start", async (_event, ctx) => {
492
+ await refreshConfig(ctx.cwd);
493
+ });
494
+
495
+ pi.on("session_switch", async (_event, ctx) => {
496
+ await refreshConfig(ctx.cwd);
497
+ });
498
+
499
+ pi.on("session_fork", async (_event, ctx) => {
500
+ await refreshConfig(ctx.cwd);
501
+ });
502
+
503
+ pi.on("session_tree", async (_event, ctx) => {
504
+ await refreshConfig(ctx.cwd);
505
+ });
506
+
507
+ pi.on("input", async () => {
508
+ await resetPaneIfEnabled();
509
+ return { action: "continue" };
510
+ });
511
+
512
+ pi.on("agent_start", async () => {
513
+ await applyWorkingState();
514
+ });
515
+
516
+ pi.on("agent_end", async (event) => {
517
+ await applyDoneState(event.messages);
518
+ });
519
+
520
+ pi.on("session_shutdown", async () => {
521
+ await resetPaneIfEnabled();
522
+ });
523
+ }
@@ -1,11 +1,13 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext } 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";
5
5
  import {
6
6
  buildShellCommand,
7
+ formatPaneSuccessMessage,
7
8
  openCommandInFloatingPane as openCommandInFloatingZellijPane,
8
9
  openCommandInNewSplit,
10
+ type PaneOpenResult,
9
11
  type SplitDirection,
10
12
  } from "./zv-core.ts";
11
13
 
@@ -73,7 +75,7 @@ async function openToolInSplit(
73
75
  ctx: ExtensionCommandContext,
74
76
  direction: SplitDirection,
75
77
  args: string,
76
- ): Promise<{ ok: true } | { ok: false; error: string }> {
78
+ ): Promise<PaneOpenResult> {
77
79
  return openCommandInNewSplit(pi, direction, buildShellCommand(ctx.cwd, args.trim()));
78
80
  }
79
81
 
@@ -82,7 +84,7 @@ async function openToolInFloatingPane(
82
84
  ctx: ExtensionCommandContext,
83
85
  command: string,
84
86
  name?: string,
85
- ): Promise<{ ok: true } | { ok: false; error: string }> {
87
+ ): Promise<PaneOpenResult> {
86
88
  return openCommandInFloatingZellijPane(pi, buildShellCommand(ctx.cwd, command.trim()), {
87
89
  name,
88
90
  ...DEFAULT_FLOATING_PANE_OPTIONS,
@@ -107,7 +109,7 @@ function registerOpenCommand(
107
109
 
108
110
  const result = await openToolInSplit(pi, ctx, direction, command);
109
111
  if (result.ok) {
110
- ctx.ui.notify(successMessage, "info");
112
+ ctx.ui.notify(formatPaneSuccessMessage(successMessage, result.paneId), "info");
111
113
  } else {
112
114
  ctx.ui.notify(`tool split failed: ${result.error}`, "error");
113
115
  }
@@ -257,7 +259,7 @@ function registerConfiguredFloatingCommand(
257
259
  const command = trimmedArgs.length > 0 ? `${config.run} ${trimmedArgs}` : config.run;
258
260
  const result = await openToolInFloatingPane(pi, ctx, command, commandName);
259
261
  if (result.ok) {
260
- ctx.ui.notify(`Opened /${commandName} in a floating pane`, "info");
262
+ ctx.ui.notify(formatPaneSuccessMessage(`Opened /${commandName} in a floating pane`, result.paneId), "info");
261
263
  } else {
262
264
  ctx.ui.notify(`floating pane failed: ${result.error}`, "error");
263
265
  }
@@ -1,5 +1,11 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
- import { buildPiCommand, openCommandInNewSplit, type SplitDirection } from "./zv-core.ts";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ buildPiCommand,
4
+ formatPaneSuccessMessage,
5
+ openCommandInNewSplit,
6
+ type PaneOpenResult,
7
+ type SplitDirection,
8
+ } from "./zv-core.ts";
3
9
 
4
10
  type ReviewMode = "general" | "bugs" | "refactor" | "tests" | "diff";
5
11
 
@@ -89,7 +95,7 @@ async function openReviewSplit(
89
95
  ctx: ExtensionCommandContext,
90
96
  direction: SplitDirection,
91
97
  request: ReviewRequest,
92
- ): Promise<{ ok: true } | { ok: false; error: string }> {
98
+ ): Promise<PaneOpenResult> {
93
99
  return openCommandInNewSplit(pi, direction, buildPiCommand(ctx.cwd, { prompt: buildReviewPrompt(request) }));
94
100
  }
95
101
 
@@ -111,7 +117,7 @@ function registerReviewCommand(
111
117
 
112
118
  const result = await openReviewSplit(pi, ctx, direction, parsed.request);
113
119
  if (result.ok) {
114
- ctx.ui.notify(successMessage, "info");
120
+ ctx.ui.notify(formatPaneSuccessMessage(successMessage, result.paneId), "info");
115
121
  } else {
116
122
  ctx.ui.notify(`review pane failed: ${result.error}`, "error");
117
123
  }
@@ -1,12 +1,21 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
- import { buildPiCommand, openCommandInNewSplit, openCommandInNewTab, type SplitDirection } from "./zv-core.ts";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ buildPiCommand,
4
+ formatPaneSuccessMessage,
5
+ formatTabSuccessMessage,
6
+ openCommandInNewSplit,
7
+ openCommandInNewTab,
8
+ type PaneOpenResult,
9
+ type SplitDirection,
10
+ type TabOpenResult,
11
+ } from "./zv-core.ts";
3
12
 
4
13
  async function openPiInSplit(
5
14
  pi: ExtensionAPI,
6
15
  ctx: ExtensionCommandContext,
7
16
  direction: SplitDirection,
8
17
  args: string,
9
- ): Promise<{ ok: true } | { ok: false; error: string }> {
18
+ ): Promise<PaneOpenResult> {
10
19
  return openCommandInNewSplit(
11
20
  pi,
12
21
  direction,
@@ -18,7 +27,7 @@ async function openPiInTab(
18
27
  pi: ExtensionAPI,
19
28
  ctx: ExtensionCommandContext,
20
29
  args: string,
21
- ): Promise<{ ok: true } | { ok: false; error: string }> {
30
+ ): Promise<TabOpenResult> {
22
31
  return openCommandInNewTab(pi, ctx.cwd, buildPiCommand(ctx.cwd, { prompt: args.trim().length > 0 ? args : undefined }));
23
32
  }
24
33
 
@@ -34,7 +43,7 @@ function registerSplitCommand(
34
43
  handler: async (args, ctx) => {
35
44
  const result = await openPiInSplit(pi, ctx, direction, args);
36
45
  if (result.ok) {
37
- ctx.ui.notify(successMessage, "info");
46
+ ctx.ui.notify(formatPaneSuccessMessage(successMessage, result.paneId), "info");
38
47
  } else {
39
48
  ctx.ui.notify(`zellij split failed: ${result.error}`, "error");
40
49
  }
@@ -53,7 +62,7 @@ function registerTabCommand(
53
62
  handler: async (args, ctx) => {
54
63
  const result = await openPiInTab(pi, ctx, args);
55
64
  if (result.ok) {
56
- ctx.ui.notify(successMessage, "info");
65
+ ctx.ui.notify(formatTabSuccessMessage(successMessage, result.tabId), "info");
57
66
  } else {
58
67
  ctx.ui.notify(`zellij tab failed: ${result.error}`, "error");
59
68
  }
@@ -1,9 +1,15 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { existsSync, statSync } from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { buildPiCommand, openCommandInNewSplit, type SplitDirection } from "./zv-core.ts";
6
+ import {
7
+ buildPiCommand,
8
+ formatPaneSuccessMessage,
9
+ openCommandInNewSplit,
10
+ type PaneOpenResult,
11
+ type SplitDirection,
12
+ } from "./zv-core.ts";
7
13
 
8
14
  const ZOXIDE_TIMEOUT_MS = 5000;
9
15
  const MAX_COMPLETIONS = 10;
@@ -90,7 +96,7 @@ async function openPiInZoxideSplit(
90
96
  query: string,
91
97
  direction: SplitDirection,
92
98
  commandName: string,
93
- ): Promise<{ ok: true } | { ok: false; error: string }> {
99
+ ): Promise<PaneOpenResult> {
94
100
  const targetResult = await resolveZoxideTarget(pi, query, ctx.cwd, commandName);
95
101
  if (!targetResult.ok) {
96
102
  return targetResult;
@@ -121,7 +127,7 @@ function registerZoxideCommand(
121
127
 
122
128
  const result = await openPiInZoxideSplit(pi, ctx, query, direction, name);
123
129
  if (result.ok) {
124
- ctx.ui.notify(successMessage, "info");
130
+ ctx.ui.notify(formatPaneSuccessMessage(successMessage, result.paneId), "info");
125
131
  } else {
126
132
  ctx.ui.notify(`zoxide failed: ${result.error}`, "error");
127
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zellij",
3
- "version": "0.4.0",
3
+ "version": "0.4.6",
4
4
  "description": "Pi package with zellij-powered terminal integrations",
5
5
  "author": "Javi Molina",
6
6
  "license": "MIT",
@@ -46,9 +46,9 @@
46
46
  ]
47
47
  },
48
48
  "peerDependencies": {
49
- "@mariozechner/pi-coding-agent": "*"
49
+ "@earendil-works/pi-coding-agent": "*"
50
50
  },
51
51
  "devDependencies": {
52
- "@mariozechner/pi-coding-agent": "*"
52
+ "@earendil-works/pi-coding-agent": "*"
53
53
  }
54
54
  }