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 +74 -0
- package/package.json +38 -0
- package/sounds/complete.wav +0 -0
- package/src/config.ts +46 -0
- package/src/format.ts +48 -0
- package/src/index.ts +248 -0
- package/src/sound.ts +64 -0
- package/src/zellij.ts +51 -0
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
|
+

|
|
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
|
+

|
|
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
|
+
}
|