opencode-status-tab 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/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # opencode-zellij-indicator
2
+
3
+ **Know which of your OpenCode agents needs you — without switching tabs.**
4
+
5
+ When you run several [OpenCode](https://opencode.ai) sessions across
6
+ [Zellij](https://zellij.dev) tabs, they all look identical. You can't see which
7
+ one is still grinding, which is silently waiting for you to approve something,
8
+ and which finished five minutes ago. So you keep clicking through them.
9
+
10
+ ## The four states
11
+
12
+ | Icon | When | What it means for you |
13
+ |------|------|-----------------------|
14
+ | ⏳ | working | OpenCode is busy — ignore it for now |
15
+ | ❓ | needs you | blocked on a permission prompt or a question — go unblock it |
16
+ | 🔔 | done, unseen | it finished while you were away — go check the result |
17
+ | ✅ | done, seen | finished, and you've already looked |
18
+
19
+ ## Example
20
+
21
+ ![OpenCode status icons on each Zellij tab](docs/tab-states.png)
22
+
23
+ ## Naming
24
+
25
+ OpenCode gives each session an auto-generated title, and the plugin uses that as the Zellij tab name. To change it, run OpenCode's built-in `/rename` slash command.
26
+
27
+ ## Install
28
+
29
+ **1. Install Zellij and OpenCode.**
30
+ Requires Zellij ≥ 0.44.0
31
+
32
+ **2. Enable the plugin.**
33
+ Add the following to your `opencode.json`
34
+ ```json
35
+ {
36
+ "plugin": ["opencode-zellij-indicator"]
37
+ }
38
+ ```
39
+ Outside Zellij the plugin does nothing (it exits immediately), so it's safe to leave enabled everywhere at no cost.
40
+
41
+ **3. Run OpenCode inside Zellij.**
42
+ ```sh
43
+ zellij # opens the Zellij workspace
44
+ opencode # run this inside Zellij
45
+ ```
46
+
47
+ That single tab now shows OpenCode's status. To feel the point of the plugin,
48
+ open more tabs and run OpenCode in each — press `Ctrl t` then `n` for a new
49
+ tab (`Ctrl t` then the arrow keys to switch between them).
50
+
51
+ ### Agent Setup
52
+ Prefer to let an agent do it? Copy this into your prompt:
53
+ ```
54
+ Fetch https://raw.githubusercontent.com/aidan-gallagher/opencode-zellij-indicator/master/docs/agent-setup.md and follow the setup steps.
55
+ ```
56
+
57
+ ## Configuration
58
+
59
+ Set these as [environment variables](https://askubuntu.com/questions/730/how-do-i-set-environment-variables) before launching OpenCode.
60
+
61
+ ### Stopwatch
62
+
63
+ `OPENCODE_ZELLIJ_STOPWATCH=1`
64
+ Show how long a session has been running. After a minute, the elapsed minutes appear next to the icon:
65
+
66
+ ![Stopwatch on a running tab](docs/stopwatch.png)
67
+
68
+ ### Sound
69
+
70
+ `OPENCODE_ZELLIJ_SOUND=1`
71
+ Play a sound when OpenCode finishes in a background tab.
72
+
73
+ `OPENCODE_ZELLIJ_SOUND_CMD="pw-play ~/alert.wav"`
74
+ Override the default sound with your own command.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "opencode-status-tab",
3
+ "version": "0.4.0",
4
+ "description": "Show each opencode session's status (running / needs-you / done-unseen / done-seen) and title on its Zellij tab",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "sounds",
10
+ "README.md"
11
+ ],
12
+ "scripts": {
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "keywords": [
16
+ "opencode",
17
+ "opencode-plugin",
18
+ "zellij",
19
+ "terminal",
20
+ "tab",
21
+ "status"
22
+ ],
23
+ "author": "Aidan Gallagher",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/aidan-gallagher/opencode-zellij-indicator.git"
28
+ },
29
+ "homepage": "https://github.com/aidan-gallagher/opencode-zellij-indicator#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/aidan-gallagher/opencode-zellij-indicator/issues"
32
+ },
33
+ "devDependencies": {
34
+ "@opencode-ai/plugin": "1.4.8",
35
+ "@types/node": "^22",
36
+ "typescript": "^5"
37
+ }
38
+ }
Binary file
package/src/config.ts ADDED
@@ -0,0 +1,46 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Environment-derived configuration + shared logging.
3
+ //
4
+ // Everything here is read once at module load from env vars, has no runtime
5
+ // state, and depends on nothing else in the plugin.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type Phase = "running" | "permission" | "done"
9
+
10
+ const env = (key: string, def: string) => {
11
+ const v = process.env[key]
12
+ return v && v.length > 0 ? v : def
13
+ }
14
+
15
+ // The four icons. Defaults: hourglass = busy, question = needs you, bell =
16
+ // finished and wants your eyes, tick = you've since reviewed it. Override any of
17
+ // them with env vars for other glyphs.
18
+ export const ICON_RUNNING = env("OPENCODE_ZELLIJ_ICON_RUNNING", "⏳")
19
+ export const ICON_PERMISSION = env("OPENCODE_ZELLIJ_ICON_PERMISSION", "❓")
20
+ export const ICON_UNSEEN = env("OPENCODE_ZELLIJ_ICON_ATTENTION", "🔔")
21
+ export const ICON_SEEN = env("OPENCODE_ZELLIJ_ICON_SEEN", "✅")
22
+ export const ALL_ICONS = [
23
+ ICON_RUNNING,
24
+ ICON_PERMISSION,
25
+ ICON_UNSEEN,
26
+ ICON_SEEN,
27
+ ].filter((i) => i.length)
28
+
29
+ export const STOPWATCH_ENABLED = process.env.OPENCODE_ZELLIJ_STOPWATCH === "1"
30
+
31
+ const DEFAULT_POLL_MS = 1500
32
+ const pollParsed = Number.parseInt(env("OPENCODE_ZELLIJ_POLL_MS", String(DEFAULT_POLL_MS)), 10)
33
+ export const POLL_MS = Number.isFinite(pollParsed) ? pollParsed : DEFAULT_POLL_MS
34
+
35
+ // Tools that block waiting for the user (opencode's interactive question / the
36
+ // plan-mode "switch to build agent?" prompt). While one of these runs, the
37
+ // session is really waiting on you, so show the permission icon rather than the
38
+ // running one.
39
+ export const ASK_TOOLS = new Set(["question", "plan_exit"])
40
+
41
+ // Debug logging (set OPENCODE_ZELLIJ_DEBUG=1). Goes to opencode's server log,
42
+ // not the TUI. Invaluable for diagnosing "why isn't my tab renaming?".
43
+ const DEBUG = process.env.OPENCODE_ZELLIJ_DEBUG === "1"
44
+ export const log = (msg: string) => {
45
+ if (DEBUG) console.error(`[zellij-indicator] ${msg}`)
46
+ }
package/src/format.ts ADDED
@@ -0,0 +1,48 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Pure presentation helpers: turning state into the strings we render on the
3
+ // tab. No runtime state, no side effects, no `$`.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ import {
7
+ ALL_ICONS,
8
+ ICON_PERMISSION,
9
+ ICON_RUNNING,
10
+ ICON_SEEN,
11
+ ICON_UNSEEN,
12
+ STOPWATCH_ENABLED,
13
+ type Phase,
14
+ } from "./config"
15
+
16
+ // Strip any trailing status icon(s) so we recover the clean base tab name.
17
+ export function stripIcons(s: string): string {
18
+ let out = s.trimEnd()
19
+ let changed = true
20
+ while (changed) {
21
+ changed = false
22
+ for (const ic of ALL_ICONS) {
23
+ if (out.endsWith(ic)) {
24
+ out = out.slice(0, -ic.length).trimEnd()
25
+ changed = true
26
+ }
27
+ }
28
+ }
29
+ return out
30
+ }
31
+
32
+ export function iconFor(phase: Phase, seen: boolean): string {
33
+ if (phase === "running") return ICON_RUNNING
34
+ if (phase === "permission") return ICON_PERMISSION
35
+ return seen ? ICON_SEEN : ICON_UNSEEN
36
+ }
37
+
38
+ // Returns compact stopwatch string once >= 1 min, or undefined if not yet / not
39
+ // applicable (feature disabled, not running, or no start time).
40
+ export function formatStopwatch(runStartedAt: number | undefined, phase: Phase): string | undefined {
41
+ if (!STOPWATCH_ENABLED || !runStartedAt || phase !== "running") return undefined
42
+ const mins = Math.floor((Date.now() - runStartedAt) / 60_000)
43
+ if (mins < 1) return undefined
44
+ if (mins < 60) return `${mins}`
45
+ const h = Math.floor(mins / 60)
46
+ const m = mins % 60
47
+ return m === 0 ? `${h}h` : `${h}h${m}`
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,248 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { ASK_TOOLS, POLL_MS, STOPWATCH_ENABLED, log, type Phase } from "./config"
3
+ import { formatStopwatch, iconFor, stripIcons } from "./format"
4
+ import { playSound } from "./sound"
5
+ import { isFocused, renameTab, resolvePane } from "./zellij"
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // opencode-zellij-indicator
9
+ //
10
+ // A pure opencode plugin that shows each opencode session's state on its Zellij
11
+ // tab. No fork, no WASM, no status-bar replacement — it just shells out to the
12
+ // `zellij` CLI (`rename-tab-by-id`) and no-ops when not running inside Zellij.
13
+ //
14
+ // Four states, each a configurable icon:
15
+ // 1. running — opencode is working
16
+ // 2. permission — waiting for you to approve/deny (always stands out)
17
+ // 3. done, unseen — finished and you haven't looked yet
18
+ // 4. done, seen — finished and you've since focused that tab
19
+ //
20
+ // The tab label becomes "<opencode session title> <icon>". Config, presentation
21
+ // helpers, the zellij CLI wrappers and the completion sound live in sibling modules; this
22
+ // file owns the runtime state machine and event wiring.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export const ZellijStatus: Plugin = async ({ $ }) => {
26
+ const paneIdRaw = process.env.ZELLIJ_PANE_ID
27
+ const paneId = paneIdRaw ? Number.parseInt(paneIdRaw, 10) : NaN
28
+ if (!process.env.ZELLIJ_SESSION_NAME || Number.isNaN(paneId)) {
29
+ log(
30
+ `not inside Zellij (ZELLIJ_SESSION_NAME=${process.env.ZELLIJ_SESSION_NAME}, ZELLIJ_PANE_ID=${paneIdRaw}) — disabled`,
31
+ )
32
+ return {} // not inside Zellij — do nothing
33
+ }
34
+ log(`init: pane=${paneId} session=${process.env.ZELLIJ_SESSION_NAME}`)
35
+
36
+ let phase: Phase = "done"
37
+ let seen = true
38
+ let title: string | undefined
39
+ let baseName: string | undefined
40
+ let tabId: number | undefined
41
+ const subagents = new Set<string>()
42
+ // Permission prompts currently awaiting an answer (by permission id). opencode
43
+ // asks permission *before* firing tool.execute.before, so we track the pending
44
+ // prompts to stop that running-transition from clobbering the permission icon.
45
+ const pendingPerms = new Set<string>()
46
+ let lastName: string | undefined
47
+ let pollTimer: ReturnType<typeof setInterval> | undefined
48
+ let runStartedAt: number | undefined
49
+ let stopwatchTimer: ReturnType<typeof setTimeout> | undefined
50
+
51
+ function endStopwatch() {
52
+ if (stopwatchTimer) {
53
+ clearTimeout(stopwatchTimer)
54
+ stopwatchTimer = undefined
55
+ }
56
+ }
57
+
58
+ function scheduleStopwatch() {
59
+ if (!STOPWATCH_ENABLED || stopwatchTimer) return
60
+ // Fire on the next minute boundary from runStartedAt.
61
+ const elapsed = Date.now() - (runStartedAt ?? Date.now())
62
+ const msUntilNextMinute = 60_000 - (elapsed % 60_000)
63
+ stopwatchTimer = setTimeout(async () => {
64
+ stopwatchTimer = undefined
65
+ if (phase !== "running") return
66
+ await render()
67
+ scheduleStopwatch() // reschedule for the following minute
68
+ }, msUntilNextMinute)
69
+ stopwatchTimer.unref?.()
70
+ }
71
+
72
+ async function refreshTab() {
73
+ const resolved = await resolvePane($, paneId)
74
+ if (!resolved) return
75
+ tabId = resolved.tabId
76
+ if (baseName === undefined) {
77
+ baseName = stripIcons(resolved.tabName)
78
+ log(`resolved tab: id=${tabId} baseName=${JSON.stringify(baseName)}`)
79
+ }
80
+ }
81
+
82
+ async function render() {
83
+ await refreshTab()
84
+ if (tabId === undefined) return
85
+ const label = (title && title.trim()) || (baseName && baseName.trim()) || ""
86
+ const stopwatch = formatStopwatch(runStartedAt, phase)
87
+ const icon = iconFor(phase, seen)
88
+ const name = label
89
+ ? stopwatch ? `${label} ${icon} (⏱ ${stopwatch})` : `${label} ${icon}`
90
+ : stopwatch ? `${icon} (⏱ ${stopwatch})` : icon
91
+ if (name === lastName) return
92
+ lastName = name
93
+ log(`rename tab ${tabId} -> ${JSON.stringify(name)} (phase=${phase} seen=${seen} stopwatch=${stopwatch})`)
94
+ await renameTab($, tabId, name)
95
+ }
96
+
97
+ function stopPoll() {
98
+ if (pollTimer) {
99
+ clearInterval(pollTimer)
100
+ pollTimer = undefined
101
+ }
102
+ }
103
+
104
+ function startPoll() {
105
+ if (pollTimer) return
106
+ pollTimer = setInterval(async () => {
107
+ if (phase !== "done" || seen) return stopPoll()
108
+ if (await isFocused($, paneId)) {
109
+ log("tab focused while done+unseen -> seen")
110
+ seen = true
111
+ await render()
112
+ stopPoll()
113
+ }
114
+ }, POLL_MS)
115
+ pollTimer.unref?.()
116
+ }
117
+
118
+ async function setRunning() {
119
+ if (phase !== "running") {
120
+ runStartedAt = Date.now()
121
+ scheduleStopwatch()
122
+ }
123
+ phase = "running"
124
+ stopPoll()
125
+ await render()
126
+ }
127
+
128
+ // "done" covers finished turns and errors.
129
+ async function setDone() {
130
+ const wasDone = phase === "done"
131
+ phase = "done"
132
+ runStartedAt = undefined
133
+ endStopwatch()
134
+ seen = await isFocused($, paneId) // if you're already looking, it's immediately "seen"
135
+ await render()
136
+ if (!seen) {
137
+ startPoll()
138
+ // Finished while you were away — alert once, on the transition only (so
139
+ // a following session.error can't play the sound twice for the same finished turn).
140
+ if (!wasDone) void playSound($)
141
+ }
142
+ }
143
+
144
+ // Waiting on a permission prompt — always stands out, regardless of focus.
145
+ async function setPermission() {
146
+ phase = "permission"
147
+ runStartedAt = undefined
148
+ endStopwatch()
149
+ seen = false
150
+ stopPoll()
151
+ await render()
152
+ }
153
+
154
+ return {
155
+ event: async ({ event }) => {
156
+ // Permission events are handled here rather than via the `permission.ask`
157
+ // hook (which doesn't fire in this opencode) and are read loosely because
158
+ // the SDK types lag the runtime: the real events are `permission.asked`
159
+ // (props: id, sessionID) and `permission.replied` (props: requestID, reply)
160
+ // — not the `permission.updated` / `permissionID` the .d.ts declares.
161
+ const type = event.type as string
162
+ const props = (event as { properties?: Record<string, unknown> }).properties ?? {}
163
+ if (type === "permission.asked") {
164
+ const sessionID = props.sessionID as string | undefined
165
+ if (!sessionID || !subagents.has(sessionID)) {
166
+ pendingPerms.add(String(props.id))
167
+ await setPermission()
168
+ }
169
+ return
170
+ }
171
+ if (type === "permission.replied") {
172
+ pendingPerms.delete(String(props.requestID))
173
+ // Prompt answered — resume "running"; a follow-up session.idle/error
174
+ // will settle it to done if the turn is actually finished.
175
+ if (pendingPerms.size === 0 && phase === "permission") await setRunning()
176
+ return
177
+ }
178
+
179
+ switch (event.type) {
180
+ case "session.created":
181
+ case "session.updated": {
182
+ const info = event.properties.info
183
+ if (info.parentID) {
184
+ subagents.add(info.id)
185
+ } else if (info.title && info.title !== title) {
186
+ title = info.title
187
+ await render()
188
+ }
189
+ break
190
+ }
191
+ case "session.idle": {
192
+ if (subagents.has(event.properties.sessionID)) break // subagent only
193
+ await setDone()
194
+ break
195
+ }
196
+ case "session.error": {
197
+ await setDone()
198
+ break
199
+ }
200
+ case "session.deleted": {
201
+ const info = event.properties.info
202
+ if (info.parentID) {
203
+ subagents.delete(info.id)
204
+ break
205
+ }
206
+ stopPoll()
207
+ if (tabId !== undefined && baseName !== undefined) {
208
+ lastName = undefined
209
+ log(`session deleted -> restore base name ${JSON.stringify(baseName)}`)
210
+ await renameTab($, tabId, baseName)
211
+ }
212
+ break
213
+ }
214
+ }
215
+ },
216
+
217
+ "chat.message": async () => {
218
+ await setRunning()
219
+ },
220
+
221
+ "tool.execute.before": async (input) => {
222
+ log(`tool.execute.before tool=${JSON.stringify(input.tool)}`)
223
+ // Interactive prompts (question / plan_exit) are waiting on the user.
224
+ if (ASK_TOOLS.has(input.tool)) await setPermission()
225
+ // Safety net: if a permission prompt is already open for this tool, don't
226
+ // clobber the ❓ icon with "running". (Normally this hook fires just
227
+ // before `permission.asked`, so pendingPerms is still empty here and the
228
+ // asked event repaints ❓ a beat later — but guard against either order.)
229
+ else if (pendingPerms.size > 0) log("tool.execute.before while permission pending — not clobbering")
230
+ else await setRunning()
231
+ },
232
+
233
+ // Once an interactive prompt is answered, we're processing again.
234
+ "tool.execute.after": async (input) => {
235
+ if (ASK_TOOLS.has(input.tool)) await setRunning()
236
+ },
237
+
238
+ // Belt-and-braces for opencode versions that fire this hook (the current
239
+ // one drives permission state from the `permission.asked` event instead).
240
+ "permission.ask": async () => {
241
+ await setPermission()
242
+ },
243
+ }
244
+ }
245
+
246
+ // Export both named and default — opencode discovers named plugin exports, and a
247
+ // default export is belt-and-braces for loader compatibility across versions.
248
+ export default ZellijStatus
package/src/sound.ts ADDED
@@ -0,0 +1,64 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Sound notification when a turn finishes while you're NOT looking at its tab
3
+ // (the 🔔 "done, unseen" state). Opt-in, off by default.
4
+ // OPENCODE_ZELLIJ_SOUND=1 enable
5
+ // OPENCODE_ZELLIJ_SOUND_CMD="..." run this command instead of the built-in
6
+ // player (e.g. "pw-play ~/alert.wav")
7
+ // When SOUND_CMD is unset we play the bundled WAV via the first audio player we
8
+ // find. If no player exists we silently do nothing — audio is never guaranteed.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import { fileURLToPath } from "node:url"
12
+ import { log } from "./config"
13
+ import type { Shell } from "./zellij"
14
+
15
+ const SOUND_ENABLED = process.env.OPENCODE_ZELLIJ_SOUND === "1"
16
+ const SOUND_CMD = (() => {
17
+ const v = process.env.OPENCODE_ZELLIJ_SOUND_CMD
18
+ return v && v.length > 0 ? v : undefined
19
+ })()
20
+ const BUNDLED_SOUND = fileURLToPath(new URL("../sounds/complete.wav", import.meta.url))
21
+ // Ordered by preference; the first one present on the system wins. aplay/paplay
22
+ // handle WAV; ffplay is the catch-all. macOS uses afplay.
23
+ const SOUND_PLAYERS: Array<[string, string[]]> =
24
+ process.platform === "darwin"
25
+ ? [["afplay", [BUNDLED_SOUND]]]
26
+ : [
27
+ ["paplay", [BUNDLED_SOUND]],
28
+ ["pw-play", [BUNDLED_SOUND]],
29
+ ["aplay", ["-q", BUNDLED_SOUND]],
30
+ ["ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", BUNDLED_SOUND]],
31
+ ]
32
+
33
+ let lastPlayedAt = 0
34
+
35
+ // Fire-and-forget notification sound. Fully swallows errors and never blocks
36
+ // the event loop, so a missing player or audio glitch can never break the
37
+ // plugin. A short time-guard stops accidental duplicate plays.
38
+ export async function playSound($: Shell) {
39
+ if (!SOUND_ENABLED) return
40
+ const now = Date.now()
41
+ if (now - lastPlayedAt < 2000) return
42
+ lastPlayedAt = now
43
+ try {
44
+ if (SOUND_CMD) {
45
+ log(`sound via SOUND_CMD: ${SOUND_CMD}`)
46
+ await $`sh -c ${SOUND_CMD}`.quiet().nothrow()
47
+ return
48
+ }
49
+ for (const [cmd, args] of SOUND_PLAYERS) {
50
+ try {
51
+ const res = await $`${cmd} ${args}`.quiet().nothrow()
52
+ if (res.exitCode === 0) {
53
+ log(`sound via ${cmd}`)
54
+ return
55
+ }
56
+ } catch {
57
+ // try the next player
58
+ }
59
+ }
60
+ log("sound: no working audio player found")
61
+ } catch (e) {
62
+ log(`sound failed: ${e instanceof Error ? e.message : "unknown"}`)
63
+ }
64
+ }
package/src/zellij.ts ADDED
@@ -0,0 +1,51 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Thin wrappers around the `zellij` CLI. Each takes the Bun shell (`$`) that
3
+ // opencode hands the plugin, so this module holds no state of its own.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ import type { Plugin } from "@opencode-ai/plugin"
7
+ import { log } from "./config"
8
+
9
+ // The Bun shell type, derived from the plugin input so we don't depend on a
10
+ // deep/private import path from the SDK.
11
+ export type Shell = Parameters<Plugin>[0]["$"]
12
+
13
+ export function renameTab($: Shell, id: number, name: string) {
14
+ return $`zellij action rename-tab-by-id ${id} ${name}`.quiet().nothrow()
15
+ }
16
+
17
+ // Locate our own pane among all panes and return its tab id + raw tab name.
18
+ // Returns undefined if we can't find it (or the CLI call fails).
19
+ export async function resolvePane(
20
+ $: Shell,
21
+ paneId: number,
22
+ ): Promise<{ tabId: number; tabName: string } | undefined> {
23
+ try {
24
+ const out = await $`zellij action list-panes --json --all`.quiet().nothrow().text()
25
+ const panes = JSON.parse(out) as Array<{
26
+ id: number
27
+ is_plugin: boolean
28
+ tab_id: number
29
+ tab_name?: string
30
+ }>
31
+ const mine = panes.find((p) => !p.is_plugin && p.id === paneId)
32
+ if (!mine) {
33
+ log(`could not find own pane (id=${paneId}) among ${panes.length} panes`)
34
+ return undefined
35
+ }
36
+ return { tabId: mine.tab_id, tabName: String(mine.tab_name ?? "") }
37
+ } catch (e) {
38
+ log(`list-panes failed: ${e instanceof Error ? e.message : "unknown"}`)
39
+ return undefined
40
+ }
41
+ }
42
+
43
+ // Whether the client is currently focused on our pane's tab.
44
+ export async function isFocused($: Shell, paneId: number): Promise<boolean> {
45
+ try {
46
+ const out = await $`zellij action list-clients`.quiet().nothrow().text()
47
+ return new RegExp(`\\bterminal_${paneId}\\b`).test(out)
48
+ } catch {
49
+ return false
50
+ }
51
+ }