@mortz.prk/opencode-auto-mode 0.0.0-stage → 0.1.2

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/src/tui.tsx ADDED
@@ -0,0 +1,322 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createMemo, createSignal, Show, type JSX } from "solid-js";
3
+ import { Plugin } from "@opencode/plugin/tui";
4
+ import type { Context } from "@opencode/plugin/tui/context";
5
+ import type { ResolvedTheme } from "@opencode/theme/tui";
6
+ import { readDecisions } from "./state.js";
7
+
8
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+ const MAX_RESOURCE = 48;
10
+ const POLL_MS = 1_000;
11
+ const STALE_MS = 90_000;
12
+
13
+ type Reply = "once" | "reject";
14
+
15
+ type Pending = {
16
+ requestID: string;
17
+ sessionID: string;
18
+ action: string;
19
+ resource: string;
20
+ at: number;
21
+ escalated: boolean;
22
+ };
23
+
24
+ function str(value: unknown, fallback: string): string {
25
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
26
+ }
27
+
28
+ function truncate(value: string, max: number): string {
29
+ return value.length > max ? value.slice(0, max - 1) + "…" : value;
30
+ }
31
+
32
+ // `ctx.data.session.list()` is unreliable across betas: on 0.0.0-beta-17823
33
+ // the host's session store memo never invalidates, so iterating sessions to
34
+ // find pending permissions sees an empty list forever. Instead the badge is
35
+ // fed by permission events plus the server plugin's decision file: tier-3
36
+ // reviews run inside the evaluate hook BEFORE a permission request exists,
37
+ // so "reviewing" records (synthetic IDs) create spinner entries, and the
38
+ // permission.asked event that follows an escalation adopts the matching
39
+ // entry so its spinner time carries over.
40
+ function Badge(props: {
41
+ pending: () => Pending | undefined;
42
+ answer: (reply: "once" | "reject") => Promise<void>;
43
+ allowKey: string;
44
+ denyKey: string;
45
+ keymap: Pick<Context["keymap"], "layer">;
46
+ theme: () => ResolvedTheme;
47
+ }): JSX.Element {
48
+ const theme = props.theme();
49
+
50
+ props.keymap.layer(() => ({
51
+ mode: "base",
52
+ commands: [
53
+ {
54
+ id: "auto-mode.allow",
55
+ title: "Allow pending permission",
56
+ group: "Auto-mode",
57
+ palette: true,
58
+ bind: props.allowKey,
59
+ // Only real, escalated requests can be answered — during review no
60
+ // server-side permission request exists yet.
61
+ enabled: () => props.pending()?.escalated === true,
62
+ run: () => {
63
+ void props.answer("once");
64
+ },
65
+ },
66
+ {
67
+ id: "auto-mode.deny",
68
+ title: "Deny pending permission",
69
+ group: "Auto-mode",
70
+ palette: true,
71
+ bind: props.denyKey,
72
+ enabled: () => props.pending()?.escalated === true,
73
+ run: () => {
74
+ void props.answer("reject");
75
+ },
76
+ },
77
+ ],
78
+ }));
79
+
80
+ const thinking = createMemo(() => {
81
+ const p = props.pending();
82
+ return p !== undefined && !p.escalated;
83
+ });
84
+ const label = createMemo(() => (thinking() ? "reviewing" : "needs your approval"));
85
+
86
+ // The glyph comes from opentui's intrinsic <spinner>, not our own timer:
87
+ // npm installs can split solid-js into two instances (the plugin's exact
88
+ // pin vs @opentui/solid's peer pin), which freezes timer-driven signal
89
+ // renders. The spinner element animates on its own renderable, so the
90
+ // badge works regardless of how dependencies resolve.
91
+ return (
92
+ <box flexDirection="row" gap={1}>
93
+ <spinner frames={SPINNER_FRAMES} interval={120} color={theme.text.feedback.warning.base} />
94
+ <text fg={theme.text.feedback.warning.base}>auto-mode {label()}</text>
95
+ </box>
96
+ );
97
+ }
98
+
99
+ export default Plugin.define({
100
+ id: "opencode-auto-mode",
101
+ setup: async (ctx) => {
102
+ const options = ctx.options as Record<string, any>;
103
+ const keybinds = (options.keybinds ?? {}) as Record<string, unknown>;
104
+ const allowKey = str(keybinds.allow, "ctrl+alt+a");
105
+ const denyKey = str(keybinds.deny, "ctrl+alt+d");
106
+
107
+ // Own pending list, fed by events. Keeping it locally (instead of reading
108
+ // the host's session store) makes the badge independent of host store
109
+ // behavior across betas.
110
+ const [pending, setPending] = createSignal<Pending[]>([]);
111
+ const firstSeen = new Map<string, number>();
112
+
113
+ const addPending = (data: {
114
+ id: string;
115
+ sessionID: string;
116
+ action: string;
117
+ resources: readonly string[];
118
+ }) => {
119
+ if (pending().some((p) => p.requestID === data.id)) return;
120
+ // An asked event means the server plugin's review already finished with
121
+ // ASK. Adopt a matching in-review entry so its spinner duration carries
122
+ // over instead of restarting the badge from scratch.
123
+ const inReview = pending().find(
124
+ (p) => !p.escalated && p.sessionID === data.sessionID && p.action === data.action,
125
+ );
126
+ if (inReview) {
127
+ firstSeen.delete(inReview.requestID);
128
+ setPending((prev) =>
129
+ prev.map((p) =>
130
+ p === inReview ? { ...p, requestID: data.id, escalated: true } : p,
131
+ ),
132
+ );
133
+ firstSeen.set(data.id, inReview.at);
134
+ return;
135
+ }
136
+ const at = firstSeen.get(data.id) ?? Date.now();
137
+ firstSeen.set(data.id, at);
138
+ setPending((prev) => [
139
+ ...prev,
140
+ {
141
+ requestID: data.id,
142
+ sessionID: data.sessionID,
143
+ action: data.action,
144
+ resource: truncate(data.resources.join(" ") || data.action, MAX_RESOURCE),
145
+ at,
146
+ escalated: true,
147
+ },
148
+ ]);
149
+ };
150
+
151
+ const removePending = (requestID: string) => {
152
+ firstSeen.delete(requestID);
153
+ setPending((prev) => prev.filter((p) => p.requestID !== requestID));
154
+ };
155
+
156
+ // Primary channel: the host data bus. Some host versions forward only a
157
+ // subset of event types, so the raw server stream below is the fallback;
158
+ // handlers dedupe by request ID.
159
+ const unsubs = [
160
+ ctx.data.on("permission.asked", (event) => addPending(event.data)),
161
+ ctx.data.on("permission.replied", (event) => removePending(event.data.requestID)),
162
+ ];
163
+
164
+ // Fallback channel: the raw server event stream, with reconnection.
165
+ const abort = new AbortController();
166
+ const runStream = async () => {
167
+ while (!abort.signal.aborted) {
168
+ try {
169
+ for await (const event of ctx.client.event.subscribe({ signal: abort.signal })) {
170
+ if (event.type === "permission.asked") addPending(event.data);
171
+ else if (event.type === "permission.replied") removePending(event.data.requestID);
172
+ }
173
+ } catch {
174
+ // fall through to reconnect
175
+ }
176
+ if (abort.signal.aborted) return;
177
+ await new Promise((resolve) => setTimeout(resolve, 2000));
178
+ }
179
+ };
180
+ void runStream();
181
+
182
+ // Reconcile with the server plugin's decision file:
183
+ // - "reviewing" records create spinner entries (they precede any event,
184
+ // since the evaluate hook runs before the request is published)
185
+ // - "ask" escalates the entry, "allow"/"deny" resolves it
186
+ // - entries that outlived every record and the review window are pruned
187
+ const poll = setInterval(() => {
188
+ snapshotView();
189
+ const decisions = readDecisions();
190
+ const now = Date.now();
191
+ let next = pending();
192
+ let dirty = false;
193
+
194
+ for (const [reviewID, rec] of Object.entries(decisions)) {
195
+ if (rec.decision !== "reviewing") continue;
196
+ if (next.some((p) => p.requestID === reviewID)) continue;
197
+ if (now - rec.at >= STALE_MS) continue;
198
+ firstSeen.set(reviewID, rec.at);
199
+ next = [
200
+ ...next,
201
+ {
202
+ requestID: reviewID,
203
+ sessionID: rec.sessionID,
204
+ action: rec.action ?? "",
205
+ resource: truncate(
206
+ (rec.resources ?? []).join(" ") || rec.action || "",
207
+ MAX_RESOURCE,
208
+ ),
209
+ at: rec.at,
210
+ escalated: false,
211
+ },
212
+ ];
213
+ dirty = true;
214
+ }
215
+
216
+ const reconciled: Pending[] = [];
217
+ for (const p of next) {
218
+ const record = decisions[p.requestID];
219
+ if (record?.decision === "allow" || record?.decision === "deny") {
220
+ firstSeen.delete(p.requestID);
221
+ dirty = true;
222
+ continue;
223
+ }
224
+ if (record?.decision === "ask" && !p.escalated) {
225
+ reconciled.push({ ...p, escalated: true });
226
+ dirty = true;
227
+ continue;
228
+ }
229
+ if (!record && now - p.at >= STALE_MS) {
230
+ firstSeen.delete(p.requestID);
231
+ dirty = true;
232
+ continue;
233
+ }
234
+ reconciled.push(p);
235
+ }
236
+ if (dirty) setPending(reconciled);
237
+ }, POLL_MS);
238
+
239
+ // View scoping: decision records live in a machine-global file (and on
240
+ // shared servers permission events are global too), so without filtering
241
+ // every open TUI would badge other TUIs' reviews. Router + tabs state is
242
+ // client-local — a foreign session is never the current route nor an open
243
+ // tab here — which discriminates both separate-server and shared-server
244
+ // setups. Snapshotted on the existing poll tick (1s granularity is fine
245
+ // for a status badge) instead of relying on host reactivity.
246
+ const [visibleIDs, setVisibleIDs] = createSignal<ReadonlySet<string>>(new Set());
247
+ const snapshotView = () => {
248
+ const ids = new Set<string>();
249
+ try {
250
+ if (ctx.ui.tabs.enabled()) {
251
+ for (const tab of ctx.ui.tabs.list()) ids.add(tab.sessionID);
252
+ }
253
+ } catch {
254
+ // tabs unavailable — route only
255
+ }
256
+ try {
257
+ const route = ctx.ui.router.current();
258
+ if (route.type === "session") ids.add(route.sessionID);
259
+ } catch {
260
+ // router unavailable — tabs only
261
+ }
262
+ const key = [...ids].sort().join(",");
263
+ const prev = [...visibleIDs()].sort().join(",");
264
+ if (key !== prev) setVisibleIDs(ids);
265
+ };
266
+ snapshotView();
267
+
268
+ const mostRecent = createMemo(() => {
269
+ const visible = visibleIDs();
270
+ const requests = pending().filter((p) => visible.has(p.sessionID));
271
+ return requests.length > 0 ? requests[requests.length - 1] : undefined;
272
+ });
273
+
274
+ const answer = async (reply: "once" | "reject") => {
275
+ const pendingRequest = mostRecent();
276
+ if (!pendingRequest) return;
277
+ try {
278
+ await ctx.client.permission.reply({
279
+ sessionID: pendingRequest.sessionID,
280
+ requestID: pendingRequest.requestID,
281
+ decision: reply,
282
+ });
283
+ } catch (err) {
284
+ const message = err instanceof Error ? err.message : String(err);
285
+ ctx.ui.toast.show({
286
+ variant: "error",
287
+ title: "Auto-mode",
288
+ message: `Failed to reply: ${message}`,
289
+ duration: 4000,
290
+ });
291
+ }
292
+ };
293
+
294
+ // Top-right overlay, above the permission dialog (which covers the bottom
295
+ // bar). Only visible while this TUI has a session with a pending review:
296
+ // spinner while reviewing, ⚠ when it needs the user.
297
+ const disposeBadge = ctx.ui.slot({
298
+ append: "app",
299
+ render: () => (
300
+ <Show when={mostRecent() !== undefined}>
301
+ <box position="absolute" top={0} right={0} zIndex={10_000}>
302
+ <Badge
303
+ pending={mostRecent}
304
+ answer={answer}
305
+ allowKey={allowKey}
306
+ denyKey={denyKey}
307
+ keymap={ctx.keymap}
308
+ theme={() => ctx.theme}
309
+ />
310
+ </box>
311
+ </Show>
312
+ ),
313
+ });
314
+
315
+ return () => {
316
+ for (const unsub of unsubs) unsub();
317
+ abort.abort();
318
+ clearInterval(poll);
319
+ disposeBadge();
320
+ };
321
+ },
322
+ });