@erzhtor/opencode-tmux-notify 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +40 -10
  2. package/package.json +12 -3
  3. package/tmux-notify.ts +96 -32
package/README.md CHANGED
@@ -23,12 +23,17 @@ Or add to `opencode.json`:
23
23
  ```ts
24
24
  type Options {
25
25
  working?: string | { spinner: string; spinMs?: number }
26
- done?: string | { icon: string; clearMs?: number }
26
+ waiting?: string | { icon: string }
27
+ error?: string | { icon: string; clearMs?: number | "never" }
28
+ done?: string | { icon: string; clearMs?: number | "never" }
27
29
  }
28
30
  ```
29
31
 
30
- - **`working`** — string for static icon, or `{ spinner, spinMs }` for animation. Default: braille spinner at 80ms.
31
- - **`done`** — string for icon, or `{ icon, clearMs }` to control the auto-clear. Default: `✔`, 5000ms.
32
+ - **`working`** — string for static icon, or `{ spinner, spinMs }` for animation. Default: heavy braille spinner at 80ms.
33
+ - **`waiting`** — icon when agent is blocked waiting for your input (e.g. asking a question). Default: `⚠`. Always persists until the agent resumes.
34
+ - **`error`** — icon when agent encounters an error. Default: `✘`, persists by default.
35
+ - **`done`** — icon when agent finishes. Default: `✔` with 5000ms auto-clear.
36
+ - **`clearMs: "never"`** — keep the icon until the next state (for `error` and `done` only). Omitting `clearMs` defaults to `"never"` for `error`, and `5000` for `done`.
32
37
 
33
38
  ### Examples
34
39
 
@@ -38,29 +43,51 @@ Use defaults:
38
43
  { "plugin": ["@erzhtor/opencode-tmux-notify"] }
39
44
  ```
40
45
 
41
- Static icon:
46
+ Static icons + persist done forever:
42
47
 
43
48
  ```jsonc
44
49
  ["@erzhtor/opencode-tmux-notify", { "working": "⏳", "done": "✅" }]
45
50
  ```
46
51
 
47
- Custom spinner + timing:
52
+ Custom spinner + flash error:
48
53
 
49
54
  ```jsonc
50
55
  ["@erzhtor/opencode-tmux-notify", {
51
56
  "working": { "spinner": "◐◓◑◒", "spinMs": 120 },
52
- "done": { "icon": "✓", "clearMs": 10000 }
57
+ "done": { "icon": "✓", "clearMs": 10000 },
58
+ "error": { "icon": "⚠", "clearMs": 5000 }
59
+ }]
60
+ ```
61
+
62
+ Disable waiting/error:
63
+
64
+ ```jsonc
65
+ ["@erzhtor/opencode-tmux-notify", {
66
+ "waiting": null,
67
+ "error": null
53
68
  }]
54
69
  ```
55
70
 
56
71
  ### Spinner presets
57
72
 
58
- braille *(default)* — dots walking around:
73
+ braille — dots walking around:
59
74
 
60
75
  ```jsonc
61
76
  ["@erzhtor/opencode-tmux-notify", { "working": { "spinner": "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" } }]
62
77
  ```
63
78
 
79
+ heavy braille *(default)* — denser 8-dot pattern:
80
+
81
+ ```jsonc
82
+ ["@erzhtor/opencode-tmux-notify", { "working": { "spinner": "⣷⣯⣟⡿⢿⣻⣽⣾" } }]
83
+ ```
84
+
85
+ arcs — crescent rotations:
86
+
87
+ ```jsonc
88
+ ["@erzhtor/opencode-tmux-notify", { "working": { "spinner": "◜◠◝◞◡◟" } }]
89
+ ```
90
+
64
91
  half-circles — rotating circle fills:
65
92
 
66
93
  ```jsonc
@@ -113,12 +140,15 @@ classic ascii — universal:
113
140
 
114
141
  | State | Icon | Trigger |
115
142
  | ------- | ---- | -------------------------------- |
116
- | Working | | Agent starts generating a response |
117
- | Done | | Session becomes idle |
118
- | Clean | | 5 seconds after done, or on exit |
143
+ | Working | | Agent starts generating a response |
144
+ | Waiting | | Agent needs your input (question, permission) |
145
+ | Error | | Agent encounters an error |
146
+ | Done | ✔ | Session becomes idle |
147
+ | Clean | — | After `clearMs` timeout, or on exit |
119
148
 
120
149
  - **Multi-instance safe**: Uses `$TMUX_PANE` with `-t` targeting — each OpenCode instance only modifies its own window
121
150
  - **No-op outside tmux**: Silently returns if `$TMUX_PANE` is not set
122
151
  - **No shell injection**: Uses `execFileSync` (no shell interpolation)
123
152
  - **Exit cleanup**: Strips the icon when OpenCode exits
124
153
  - **Debounced**: Only sets the working icon once per agent turn
154
+ - **State priority**: `waiting` > `error` > `done` — a blocked agent isn't truly idle
package/package.json CHANGED
@@ -1,11 +1,20 @@
1
1
  {
2
2
  "name": "@erzhtor/opencode-tmux-notify",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "OpenCode plugin that shows agent activity status in tmux window names",
5
5
  "type": "module",
6
6
  "main": "tmux-notify.ts",
7
- "files": ["tmux-notify.ts"],
8
- "keywords": ["opencode", "opencode-plugin", "tmux", "notify", "notification", "status"],
7
+ "files": [
8
+ "tmux-notify.ts"
9
+ ],
10
+ "keywords": [
11
+ "opencode",
12
+ "opencode-plugin",
13
+ "tmux",
14
+ "notify",
15
+ "notification",
16
+ "status"
17
+ ],
9
18
  "license": "MIT",
10
19
  "repository": {
11
20
  "type": "git",
package/tmux-notify.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { execFileSync } from "child_process";
2
2
 
3
3
  // Spinner presets for the `working.spinner` option:
4
- // braille (default): "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
4
+ // braille: "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
5
+ // heavy braille (default): "⣷⣯⣟⡿⢿⣻⣽⣾"
6
+ // arcs: "◜◠◝◞◡◟"
5
7
  // half-circles: "◐◓◑◒"
6
8
  // quadrant squares: "◰◳◲◱"
7
9
  // quadrant circles: "◴◷◶◵"
@@ -10,11 +12,13 @@ import { execFileSync } from "child_process";
10
12
  // block pulse: "▁▂▃▄▅▆▇█▇▆▅▄▃▂"
11
13
  // arrows: "←↖↑↗→↘↓↙"
12
14
  // classic ascii: "|/-\\"
13
- const DEFAULT_SPINNER = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"; // ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
15
+ const DEFAULT_SPINNER = "\u28f7\u28ef\u28df\u28bf\u28bf\u28fb\u28fd\u28fe"; // ⣷⣯⣟⡿⢿⣻⣽⣾
14
16
 
15
17
  type Options = {
16
18
  working?: string | { spinner: string; spinMs?: number };
17
- done?: string | { icon: string; clearMs?: number };
19
+ waiting?: string | { icon: string };
20
+ error?: string | { icon: string; clearMs?: number | "never" };
21
+ done?: string | { icon: string; clearMs?: number | "never" };
18
22
  };
19
23
 
20
24
  type Resolved = {
@@ -22,38 +26,59 @@ type Resolved = {
22
26
  spinnerMs: number;
23
27
  useSpinner: boolean;
24
28
  iconWorking: string;
29
+ iconWaiting: string;
30
+ iconError: string;
31
+ errorClearMs: number | "never";
25
32
  iconDone: string;
26
- clearMs: number;
33
+ doneClearMs: number | "never";
27
34
  };
28
35
 
29
- export const TmuxNotifyPlugin = async (
30
- _input: any,
31
- options?: Options,
32
- ) => {
36
+ type State = "clean" | "working" | "waiting" | "error" | "done";
37
+
38
+ export const TmuxNotifyPlugin = async (_input: any, options?: Options) => {
33
39
  if (!process.env.TMUX_PANE) return {};
34
40
 
35
41
  const w = options?.working;
36
42
  const useSpinner = w == null || typeof w === "object";
37
43
 
38
44
  const cfg: Resolved = {
39
- spinner: useSpinner ? (typeof w === "object" ? w.spinner : DEFAULT_SPINNER) : DEFAULT_SPINNER,
45
+ spinner:
46
+ useSpinner
47
+ ? typeof w === "object" && w.spinner ? w.spinner : DEFAULT_SPINNER
48
+ : DEFAULT_SPINNER,
40
49
  spinnerMs: typeof w === "object" ? (w.spinMs ?? 80) : 80,
41
50
  useSpinner,
42
51
  iconWorking: typeof w === "string" ? w : "",
52
+
53
+ iconWaiting:
54
+ options?.waiting == null ? "\u26A0"
55
+ : typeof options.waiting === "string" ? options.waiting
56
+ : options.waiting.icon,
57
+
58
+ iconError:
59
+ options?.error == null ? "\u2718"
60
+ : typeof options.error === "string" ? options.error
61
+ : options.error.icon,
62
+ errorClearMs:
63
+ options?.error == null ? "never"
64
+ : typeof options.error === "string" ? "never"
65
+ : options.error.clearMs ?? "never",
66
+
43
67
  iconDone:
44
- options?.done == null ? "\u2714" // ✔
68
+ options?.done == null ? "\u2714"
45
69
  : typeof options.done === "object" ? options.done.icon
46
70
  : options.done,
47
- clearMs:
71
+ doneClearMs:
48
72
  options?.done == null ? 5000
49
- : typeof options.done === "object" ? (options.done.clearMs ?? 5000)
50
- : 5000,
73
+ : typeof options.done === "object" ? (options.done.clearMs ?? "never")
74
+ : "never",
51
75
  };
52
76
 
53
77
  const pane = process.env.TMUX_PANE;
54
- let working = false;
55
- let clearTimer: any = null;
56
- let spinnerId: any = null;
78
+
79
+ let state: State = "clean";
80
+ let clearTimer: ReturnType<typeof setTimeout> | null = null;
81
+ let spinnerId: ReturnType<typeof setInterval> | null = null;
57
82
  let spinnerIdx = 0;
58
83
  let baseName: string | null = null;
59
84
 
@@ -89,19 +114,20 @@ export const TmuxNotifyPlugin = async (
89
114
  }
90
115
  };
91
116
 
92
- process.on("exit", () => {
117
+ const clearClearTimer = () => {
118
+ if (clearTimer) {
119
+ clearTimeout(clearTimer);
120
+ clearTimer = null;
121
+ }
122
+ };
123
+
124
+ const transition = (next: State) => {
93
125
  stopSpinner();
94
- set();
95
- });
126
+ clearClearTimer();
127
+ state = next;
96
128
 
97
- return {
98
- event: async ({ event }: { event: any }) => {
99
- if (event.type === "message.part.updated" && !working) {
100
- working = true;
101
- if (clearTimer) {
102
- clearTimeout(clearTimer);
103
- clearTimer = null;
104
- }
129
+ switch (next) {
130
+ case "working":
105
131
  if (cfg.useSpinner) {
106
132
  spinnerIdx = 0;
107
133
  spinnerId = setInterval(() => {
@@ -112,12 +138,50 @@ export const TmuxNotifyPlugin = async (
112
138
  } else {
113
139
  set(cfg.iconWorking);
114
140
  }
115
- }
116
- if (event.type === "session.idle") {
117
- working = false;
118
- stopSpinner();
141
+ break;
142
+ case "waiting":
143
+ set(cfg.iconWaiting);
144
+ break;
145
+ case "error":
146
+ set(cfg.iconError);
147
+ if (typeof cfg.errorClearMs === "number") {
148
+ clearTimer = setTimeout(() => transition("clean"), cfg.errorClearMs);
149
+ }
150
+ break;
151
+ case "done":
119
152
  set(cfg.iconDone);
120
- clearTimer = setTimeout(() => set(), cfg.clearMs);
153
+ if (typeof cfg.doneClearMs === "number") {
154
+ clearTimer = setTimeout(() => transition("clean"), cfg.doneClearMs);
155
+ }
156
+ break;
157
+ case "clean":
158
+ set();
159
+ break;
160
+ }
161
+ };
162
+
163
+ process.on("exit", () => {
164
+ stopSpinner();
165
+ set();
166
+ });
167
+
168
+ return {
169
+ event: async ({ event }: { event: any }) => {
170
+ if (event.type === "session.status") {
171
+ const status = event.properties?.status?.type;
172
+ if (status === "busy" && state !== "working") {
173
+ transition("working");
174
+ } else if (
175
+ status === "idle" &&
176
+ state !== "waiting" &&
177
+ state !== "error"
178
+ ) {
179
+ transition("done");
180
+ }
181
+ } else if (event.type === "question.asked") {
182
+ transition("waiting");
183
+ } else if (event.type === "session.error") {
184
+ transition("error");
121
185
  }
122
186
  },
123
187
  };