@mortz.prk/opencode-auto-mode 1.0.2 → 1.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/gui-state.ts CHANGED
@@ -1,160 +1,269 @@
1
- import type { AutoModeDecision, AutoModeStatus } from "./rpc.js";
1
+ import { MAX_SESSION_ID_CHARS, type AutoModeAction, type AutoModeStatus } from "./rpc.js";
2
2
 
3
- /**
4
- * The routed session as status polling sees it. The RPC client reads the app's
5
- * own server, so a session on another server has no status to show here, and
6
- * the selection key must carry the server: two servers can use the same
7
- * session id.
8
- */
9
3
  export interface StatusTarget {
10
- /** `${server id}\n${session id}`: unique across servers. */
4
+ /** Server, session and location fingerprint. */
11
5
  key: string;
12
- /** The session id on its own server, the id the status RPC takes. */
13
6
  sessionID: string;
14
- /** Whether the session runs on the app's own server, the one the client reads. */
15
7
  ownServer: boolean;
8
+ location: { directory: string; workspaceID?: string };
16
9
  }
17
10
 
18
- /** The session identity the status client needs; the desktop's mounted session satisfies it. */
19
11
  export interface RoutedSession {
20
- /** `${server id}\n${session id}`: unique across servers. */
21
12
  readonly key: string;
22
- /** The session's id on its server. */
23
13
  readonly id: string;
24
- /** The session's server. */
25
14
  readonly server: { readonly builtin: boolean };
15
+ readonly location: { readonly directory: string; readonly workspaceID?: string } | undefined;
16
+ }
17
+
18
+ /** Shared location fingerprint for routed sessions and server targets. */
19
+ export function statusLocationKey(serverKey: string, location: { directory: string; workspaceID?: string }): string {
20
+ return JSON.stringify([serverKey, location.directory, location.workspaceID]);
26
21
  }
27
22
 
28
- /** The status target of a routed session, or undefined while none is routed. */
23
+ /** Unknown locations must not accidentally query the server's default location. */
29
24
  export function statusTarget(session: RoutedSession | undefined): StatusTarget | undefined {
30
- return session === undefined
31
- ? undefined
32
- : { key: session.key, sessionID: session.id, ownServer: session.server.builtin };
25
+ if (session?.location === undefined || !session.id || session.id.length > MAX_SESSION_ID_CHARS || !session.location.directory) return undefined;
26
+ return {
27
+ key: statusLocationKey(session.key, session.location),
28
+ sessionID: session.id,
29
+ ownServer: session.server.builtin,
30
+ location: { ...session.location },
31
+ };
33
32
  }
34
33
 
35
- /**
36
- * Guards the desktop app's status snapshot against slow responses. A status
37
- * request can outlive the session it was made for: a poll for session A can
38
- * settle after the user routed B, and two polls for one session can settle out
39
- * of order. The guard numbers requests, invalidates every request made before
40
- * a selection change, and advances a watermark as responses are accepted, so
41
- * only the newest response for the selection that is still routed reaches the
42
- * panel and the pill. Issuing a request never invalidates an earlier response:
43
- * a poll that takes longer than the polling interval still describes the
44
- * session it settles for.
45
- */
46
- export interface StatusGuard {
47
- /** Follow a selection key; true when the snapshot belongs to an earlier selection. */
48
- select(key: string | undefined): boolean;
49
- /** Number a request; undefined when the selection is no longer routed. */
50
- begin(key: string): number | undefined;
51
- /** Whether a response is newer than every response already accepted for this selection. */
52
- accept(token: number): boolean;
34
+ export type StatusSource = (target: StatusTarget, signal: AbortSignal) => Promise<AutoModeStatus | undefined>;
35
+ export interface StatusLoader {
36
+ (target: StatusTarget | undefined): Promise<void>;
37
+ invalidate(): void;
38
+ dispose(): void;
53
39
  }
54
40
 
55
- export function createStatusGuard(): StatusGuard {
56
- let selected: string | undefined;
57
- let issued = 0;
58
- // Highest token issued before the current selection: every token at or below
59
- // it belongs to a request for an earlier selection, including one the user
60
- // routed away from and back to.
61
- let invalidated = 0;
62
- // Highest token whose response was accepted for the current selection.
63
- let accepted = 0;
41
+ const UNAVAILABLE: AutoModeStatus = {
42
+ enabled: false, mode: "invalid", configError: "status unavailable", errorKind: "status",
43
+ reviewTimeoutMs: 0, reviewing: [], counts: { allow: 0, ask: 0, deny: 0 },
44
+ };
64
45
 
46
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
47
+
48
+ interface AbortableRequest<T> { abort(): void; done: Promise<T> }
49
+
50
+ /**
51
+ * One deferred, abortable request: timeout, caller abort and disposal reject
52
+ * alike, and the timer is always cleared. Deferral keeps synchronous transport
53
+ * errors on that same cleanup path.
54
+ */
55
+ function startAbortableRequest<T>(
56
+ timeoutMs: number,
57
+ abortMessage: string,
58
+ work: (signal: AbortSignal) => Promise<T>,
59
+ ): AbortableRequest<T> {
60
+ const controller = new AbortController();
61
+ const aborted = new Promise<never>((_, reject) => {
62
+ controller.signal.addEventListener("abort", () => reject(new Error(abortMessage)), { once: true });
63
+ });
64
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
65
+ const request = Promise.resolve().then(() => {
66
+ if (controller.signal.aborted) throw new Error(abortMessage);
67
+ return work(controller.signal);
68
+ });
65
69
  return {
66
- select(key) {
67
- if (key === selected) return false;
68
- selected = key;
69
- // Responses to requests for the previous selection are now stale.
70
- invalidated = issued;
71
- accepted = 0;
72
- return true;
73
- },
74
- begin(key) {
75
- if (key !== selected) return undefined;
76
- return ++issued;
77
- },
78
- accept(token) {
79
- // A response is accepted unless a newer one for this selection already
80
- // was; accepting it advances the watermark past every older request.
81
- if (token <= invalidated || token <= accepted) return false;
82
- accepted = token;
83
- return true;
84
- },
70
+ abort: () => controller.abort(),
71
+ done: Promise.race([request, aborted]).finally(() => clearTimeout(timer)),
85
72
  };
86
73
  }
87
74
 
88
- /** Reads the status of one session, as the RPC client does. */
89
- export type StatusSource = (sessionID: string) => Promise<AutoModeStatus | undefined>;
90
-
91
75
  /**
92
- * Builds the refresh the desktop app polls with. Switching selections discards
93
- * the previous snapshot before the next request starts, so a failed request
94
- * for the new session cannot leave the old session's decisions on screen. A
95
- * failed request for the current session keeps its last snapshot, so a server
96
- * restart does not flash an empty panel.
97
- *
98
- * A session on another server is never requested: the client reads the app's
99
- * own server, and the same session id there is a different session.
76
+ * One abortable request at a time. Selection changes, timeout and disposal
77
+ * cancel it; tokens prevent late replies even if a transport ignores abort.
78
+ * Failed requests immediately replace stale successful status with unavailable.
100
79
  */
101
80
  export function createStatusLoader(
102
81
  load: StatusSource,
103
- apply: (next: AutoModeStatus | undefined) => void,
104
- ): (target: StatusTarget | undefined) => Promise<void> {
105
- const guard = createStatusGuard();
106
-
107
- return async (target) => {
108
- if (guard.select(target?.key)) apply(undefined);
109
- if (target === undefined || !target.ownServer) return;
110
- const token = guard.begin(target.key);
111
- if (token === undefined) return;
112
- try {
113
- const next = await load(target.sessionID);
114
- if (guard.accept(token)) apply(next);
115
- } catch {
116
- // The server may be restarting; the next poll refreshes this session.
117
- }
82
+ apply: (next: AutoModeStatus | undefined, target?: StatusTarget) => void,
83
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
84
+ ): StatusLoader {
85
+ let selected: string | undefined;
86
+ let pending: { request: AbortableRequest<AutoModeStatus | undefined>; done: Promise<void> } | undefined;
87
+ let generation = 0;
88
+ let disposed = false;
89
+ const cancelPending = () => {
90
+ pending?.request.abort();
91
+ pending = undefined;
118
92
  };
93
+
94
+ const refresh: StatusLoader = Object.assign((target: StatusTarget | undefined): Promise<void> => {
95
+ if (disposed) return Promise.resolve();
96
+ if (selected !== target?.key) {
97
+ selected = target?.key;
98
+ generation++;
99
+ cancelPending();
100
+ apply(undefined);
101
+ }
102
+ if (target === undefined || !target.ownServer) return Promise.resolve();
103
+ if (pending !== undefined) return pending.done;
104
+ const token = generation;
105
+ const request = startAbortableRequest(timeoutMs, "status request aborted", (signal) => load(target, signal));
106
+ const done = request.done.then(
107
+ (next) => { if (!disposed && token === generation) apply(next ?? { ...UNAVAILABLE }, target); },
108
+ () => { if (!disposed && token === generation) apply({ ...UNAVAILABLE }, target); },
109
+ ).finally(() => {
110
+ if (pending?.request === request) pending = undefined;
111
+ });
112
+ pending = { request, done };
113
+ return done;
114
+ }, {
115
+ invalidate() {
116
+ generation++;
117
+ cancelPending();
118
+ },
119
+ dispose() {
120
+ disposed = true;
121
+ generation++;
122
+ cancelPending();
123
+ },
124
+ });
125
+ return refresh;
119
126
  }
120
127
 
121
- export function latest(status: AutoModeStatus | undefined): AutoModeDecision | undefined {
122
- return status?.recent.at(-1);
128
+ export type ModeSource = (target: StatusTarget, mode: AutoModeAction, signal: AbortSignal) => Promise<AutoModeStatus>;
129
+
130
+ /** Coalesce repeated clicks per captured session/location; never toggle from stale state. */
131
+ export function createModeRunner(
132
+ change: ModeSource,
133
+ apply: (target: StatusTarget, status: AutoModeStatus) => void,
134
+ failure: () => void,
135
+ changed: () => void = () => {},
136
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
137
+ ) {
138
+ const pending = new Map<string, { request: AbortableRequest<AutoModeStatus>; done: Promise<void> }>();
139
+ let disposed = false;
140
+ const limit = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_REQUEST_TIMEOUT_MS;
141
+ return {
142
+ pending: (target: StatusTarget) => pending.has(target.key),
143
+ run(target: StatusTarget | undefined, mode: AutoModeAction | undefined): Promise<void> {
144
+ if (disposed || target === undefined || !target.ownServer || mode === undefined) return Promise.resolve();
145
+ const existing = pending.get(target.key);
146
+ if (existing) return existing.done;
147
+ const request = startAbortableRequest(limit, "mode change aborted", (signal) => change(target, mode, signal));
148
+ const done = request.done.then((next) => {
149
+ if (!disposed && next !== undefined) apply(target, next);
150
+ }, () => { if (!disposed) failure(); }).finally(() => {
151
+ if (pending.get(target.key)?.request === request) pending.delete(target.key);
152
+ if (!disposed) changed();
153
+ });
154
+ pending.set(target.key, { request, done });
155
+ changed();
156
+ return done;
157
+ },
158
+ dispose() {
159
+ disposed = true;
160
+ for (const entry of pending.values()) entry.request.abort();
161
+ pending.clear();
162
+ },
163
+ };
123
164
  }
124
165
 
125
- /**
126
- * What the decisions panel lists for a snapshot. A defined snapshot with
127
- * nothing reviewing and no decisions yet is still the normal new-session case,
128
- * so it shows the empty state rather than a blank panel.
129
- */
130
- export function panelView(status: AutoModeStatus | undefined): "empty" | "entries" {
131
- return status === undefined || (status.reviewing.length === 0 && status.recent.length === 0)
132
- ? "empty"
133
- : "entries";
166
+ /** Invalid/unavailable status cannot enable automatic approval. */
167
+ export function pillAction(status: AutoModeStatus | undefined): AutoModeAction | undefined {
168
+ switch (status?.mode) {
169
+ case "on": return status.enabled ? "off" : "on";
170
+ case "off": return "on";
171
+ case "paused": return "resume";
172
+ default: return undefined;
173
+ }
134
174
  }
135
175
 
136
176
  export function pillLabel(status: AutoModeStatus | undefined): string | undefined {
137
177
  if (status === undefined) return undefined;
138
- if (status.reviewing.length > 0) return "auto-mode reviewing…";
139
- const last = latest(status);
140
- if (last === undefined) return undefined;
141
- if (last.errorKind !== undefined) return `auto-mode ${last.decision}: ${last.errorKind}`;
142
- return `auto-mode ${last.decision}`;
178
+ switch (status.mode) {
179
+ case "invalid":
180
+ if (status.errorKind === "status" || (status.errorKind === undefined && status.configError === "status unavailable")) return "status unavailable";
181
+ return status.errorKind === "config" ? "Invalid config — review disabled" : "Review unavailable";
182
+ case "paused": return "paused (breaker)";
183
+ case "off": return "off";
184
+ case "on": return status.enabled ? "auto" : "off";
185
+ }
143
186
  }
144
187
 
145
- /**
146
- * Whether the pill's notice for the latest decision has expired. A review in
147
- * progress is always shown; a finished decision stays visible for `noticeMs`
148
- * after it was recorded.
149
- *
150
- * `now` must come from a clock the caller reads reactively and advances on
151
- * every polling tick, including failed ones: the status loader keeps its
152
- * previous snapshot when a request fails without applying it, so a notice
153
- * expired against wall-clock time read only on snapshot changes would never
154
- * disappear while the server is unreachable.
155
- */
156
- export function noticeExpired(status: AutoModeStatus | undefined, now: number, noticeMs: number): boolean {
157
- if (status === undefined || status.reviewing.length > 0) return false;
158
- const last = latest(status);
159
- return last !== undefined && now - last.at > noticeMs;
188
+ /** Invalid configuration must show its bounded error, not a generic on/off pill. */
189
+ export function pillDescription(status: AutoModeStatus | undefined): string | undefined {
190
+ if (status?.mode !== "invalid" || status.configError === undefined || status.configError === "status unavailable") return undefined;
191
+ return `${pillLabel(status)}: ${status.configError}`;
192
+ }
193
+
194
+ export function pillTone(status: AutoModeStatus | undefined): "success" | "warning" | "danger" | undefined {
195
+ if (status === undefined) return undefined;
196
+ if (status.mode === "invalid") return "danger";
197
+ if (status.mode === "paused") return "warning";
198
+ return status.mode === "on" && status.enabled ? "success" : undefined;
199
+ }
200
+
201
+ export interface DecisionEvent {
202
+ location: { directory: string; workspaceID?: string };
203
+ data: unknown;
204
+ }
205
+ export interface ModeNotice {
206
+ message: string;
207
+ kind: "deny" | "breaker";
208
+ }
209
+
210
+ /** No replay/history, raw reasons, saved-approval nags, or unbounded dedup state. */
211
+ export function createDecisionNotifier(
212
+ target: () => StatusTarget | undefined,
213
+ show: (notice: ModeNotice) => void,
214
+ now: () => number = Date.now,
215
+ ): (event: DecisionEvent) => void {
216
+ const seen = new Set<string>();
217
+ let windowStart = 0;
218
+ let shown = 0;
219
+ return (event) => {
220
+ const current = target();
221
+ if (current === undefined || !current.ownServer || event.location.directory !== current.location.directory
222
+ || event.location.workspaceID !== current.location.workspaceID) return;
223
+ if (event.data === null || typeof event.data !== "object") return;
224
+ const data = event.data as Record<string, unknown>;
225
+ if (data.sessionID !== current.sessionID || typeof data.reviewID !== "string" || data.reviewID.length > 256) return;
226
+ const kind = data.stage === "breaker" ? "breaker" : data.decision === "deny" ? "deny" : undefined;
227
+ if (kind === undefined) return;
228
+ const key = JSON.stringify([current.key, data.reviewID, kind]);
229
+ if (seen.has(key)) return;
230
+ seen.add(key);
231
+ if (seen.size > 128) seen.delete(seen.values().next().value!);
232
+ const at = now();
233
+ if (at - windowStart >= 5_000) { windowStart = at; shown = 0; }
234
+ // Cap bursts of deny toasts, but never suppress a new breaker trip.
235
+ if (kind === "deny" && shown >= 4) return;
236
+ shown++;
237
+ show({ kind, message: kind === "breaker" ? "Auto mode paused (breaker)" : "Auto mode denied" });
238
+ };
239
+ }
240
+
241
+ /** One live subscription, rebound when the host replaces its client. */
242
+ export function createDecisionSubscription(
243
+ receive: (event: DecisionEvent) => void,
244
+ ): {
245
+ update(client: object, subscribe: (receive: (event: DecisionEvent) => void, signal: AbortSignal) => () => void): void;
246
+ dispose(): void;
247
+ } {
248
+ let current: object | undefined;
249
+ let controller: AbortController | undefined;
250
+ let stop: (() => void) | undefined;
251
+ let disposed = false;
252
+ const disconnect = () => { controller?.abort(); stop?.(); controller = undefined; stop = undefined; };
253
+ return {
254
+ update(client, subscribe) {
255
+ if (disposed || client === current) return;
256
+ disconnect();
257
+ current = client;
258
+ const next = new AbortController();
259
+ controller = next;
260
+ try {
261
+ stop = subscribe((event) => { if (!next.signal.aborted) receive(event); }, next.signal);
262
+ } catch {
263
+ disconnect();
264
+ current = undefined; // Retry at the next poll if the server is unavailable.
265
+ }
266
+ },
267
+ dispose() { disposed = true; disconnect(); current = undefined; },
268
+ };
160
269
  }
package/src/gui.tsx CHANGED
@@ -1,127 +1,76 @@
1
1
  /** @jsxImportSource solid-js */
2
- import { createSignal, For, onCleanup } from "solid-js";
3
- import { Command, ComposerPill, Panel, Plugin, createKeyed, type PanelTab } from "@opencode/plugin/gui";
2
+ import { createSignal, onCleanup } from "solid-js";
3
+ import { ComposerPill, Plugin, createKeyed } from "@opencode/plugin/gui";
4
4
  import { AutoMode, type AutoModeStatus } from "./rpc.js";
5
- import { createStatusLoader, latest, noticeExpired, panelView, pillLabel, statusTarget } from "./gui-state.js";
5
+ import { createDecisionNotifier, createDecisionSubscription, createModeRunner, createStatusLoader, pillAction, pillDescription, pillLabel, pillTone, statusTarget } from "./gui-state.js";
6
6
 
7
- const POLL_MS = 3_000;
8
- const NOTICE_MS = 30_000;
9
-
10
- const TONE: Record<string, "success" | "warning" | "danger" | undefined> = {
11
- allow: "success",
12
- ask: "warning",
13
- deny: "danger",
14
- };
7
+ const POLL_MS = 1_000;
15
8
 
16
9
  export default Plugin.define({
17
- i18n: {
18
- en: {
19
- "command.open": "Open auto-mode decisions",
20
- "panel.title": "Auto-mode",
21
- "panel.empty": "No decisions in this session yet.",
22
- "panel.disabled": "Auto-mode has no valid configuration; automatic review is disabled. Host permissions still apply.",
23
- "panel.reviewing": "Reviewing…",
24
- "pill.title": "Auto-mode decisions",
25
- },
26
- },
27
10
  setup(ctx) {
28
- const [status, setStatus] = createSignal<AutoModeStatus | undefined>(undefined);
11
+ const [status, setStatus] = createSignal<{ key: string; value: AutoModeStatus } | undefined>(undefined);
12
+ const [revision, setRevision] = createSignal(0);
13
+ // The desktop client reads its own server only. Never query that server for
14
+ // a routed session belonging to a different server or an unknown location.
15
+ const target = () => statusTarget(ctx.sessions.current());
29
16
  const refresh = createStatusLoader(
30
- (sessionID) => ctx.client.rpc(AutoMode).status({ sessionID }) as Promise<AutoModeStatus | undefined>,
31
- setStatus,
17
+ (selected, signal) => ctx.client.rpc(AutoMode).status({ sessionID: selected.sessionID }, {
18
+ location: selected.location, signal,
19
+ }) as Promise<AutoModeStatus | undefined>,
20
+ (value, selected) => {
21
+ if (value === undefined) { setStatus(undefined); return; }
22
+ if (selected !== undefined && target()?.key === selected.key) setStatus({ key: selected.key, value });
23
+ },
32
24
  );
33
- // `ctx.client` reads the app's own server, so a session on another server
34
- // has no status here; `session.key` keeps the same session id on two
35
- // servers apart.
36
- const target = () => statusTarget(ctx.sessions.current());
37
- // The pill's notice expiry needs a clock of its own: the loader keeps the
38
- // previous snapshot when a poll fails without applying it, so a clock read
39
- // only when the snapshot changes would leave an expired decision on screen
40
- // while the server is unreachable. Advance it on every tick regardless.
41
- const [now, setNow] = createSignal(Date.now());
25
+ const runner = createModeRunner(
26
+ (selected, mode, signal) => {
27
+ refresh.invalidate(); // An older poll must not overwrite a successful mutation.
28
+ return ctx.client.rpc(AutoMode).setMode({ sessionID: selected.sessionID, mode }, {
29
+ location: selected.location, signal,
30
+ }) as Promise<AutoModeStatus>;
31
+ },
32
+ (selected, value) => {
33
+ if (target()?.key === selected.key) setStatus({ key: selected.key, value });
34
+ },
35
+ () => ctx.toast.show({ title: "Auto-mode", description: "Could not change auto mode. Try again.", variant: "error", duration: 5_000 }),
36
+ () => setRevision((value) => value + 1),
37
+ );
38
+ const receive = createDecisionNotifier(target, (notice) => {
39
+ ctx.toast.show({ title: "Auto-mode", description: notice.message,
40
+ variant: notice.kind === "deny" ? "error" : "default", duration: 5_000 });
41
+ });
42
+ const subscription = createDecisionSubscription(receive);
42
43
  const poll = () => {
43
- setNow(Date.now());
44
- void refresh(target());
44
+ const client = ctx.client;
45
+ subscription.update(client, (handler, signal) => client.rpc(AutoMode).events.on("decision", handler, { signal }));
46
+ const selected = target();
47
+ if (selected === undefined || !runner.pending(selected)) void refresh(selected);
45
48
  };
46
-
47
49
  createKeyed(() => target()?.key, poll, { otherwise: poll });
48
50
  const timer = setInterval(poll, POLL_MS);
49
- onCleanup(() => clearInterval(timer));
50
-
51
- const tab: PanelTab = {
52
- id: "decisions",
53
- get title() { return ctx.t("panel.title"); },
54
- closable: "compact",
55
- fallback: true,
56
- };
57
-
58
- ctx.add(Panel, {
59
- id: "decisions",
60
- region: "side",
61
- mobile: { get title() { return ctx.t("panel.title"); }, order: 60, kind: "menu", icon: "shield" },
62
- list: (input) => (input.open.includes("decisions") ? [tab] : []),
63
- render: () => (
64
- <div style={{ padding: "8px 12px", "font-size": "12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
65
- {status()?.enabled === false
66
- ? <div>{ctx.t("panel.disabled")}</div>
67
- : null}
68
- {panelView(status()) === "empty"
69
- ? <div>{ctx.t("panel.empty")}</div>
70
- : (
71
- <>
72
- <For each={[...(status()?.reviewing ?? [])]}>
73
- {(entry) => <div>{ctx.t("panel.reviewing")} {entry.reviewID}</div>}
74
- </For>
75
- <For each={[...(status()?.recent ?? [])].reverse()}>
76
- {(entry) => (
77
- <div style={{ display: "flex", "flex-direction": "column", gap: "2px" }}>
78
- <span style={{ "font-weight": 600 }}>
79
- {entry.decision}{entry.errorKind === undefined ? "" : ` (${entry.errorKind})`} · {entry.action}
80
- </span>
81
- <span>{entry.reason}</span>
82
- <span style={{ opacity: 0.6 }}>
83
- {entry.stage} · {Math.round(entry.durationMs)}ms · {new Date(entry.at).toLocaleTimeString()}
84
- </span>
85
- </div>
86
- )}
87
- </For>
88
- </>
89
- )}
90
- </div>
91
- ),
92
- });
93
-
94
- ctx.add(Command, () => ({
95
- id: "open",
96
- title: ctx.t("command.open"),
97
- group: "tools",
98
- run: () => {
99
- const session = ctx.sessions.current();
100
- if (session) ctx.layout.toggle(`${ctx.id}:decisions`, session);
101
- },
102
- }));
51
+ onCleanup(() => { clearInterval(timer); runner.dispose(); refresh.dispose(); subscription.dispose(); });
103
52
 
104
53
  ctx.add(ComposerPill, {
105
54
  id: "status",
106
55
  order: 20,
107
- show: () => {
108
- const snapshot = status();
109
- const label = pillLabel(snapshot);
110
- if (label === undefined) return undefined;
111
- // `now()` is tracked, so the pill disappears on the tick that passes
112
- // the notice window even if no snapshot arrives after it.
113
- if (noticeExpired(snapshot, now(), NOTICE_MS)) return undefined;
114
- const reviewing = (snapshot?.reviewing.length ?? 0) > 0;
56
+ show: (input) => {
57
+ revision();
58
+ const selected = statusTarget(input.session);
59
+ const stored = status();
60
+ const snapshot = selected?.ownServer && stored?.key === selected.key ? stored.value : undefined;
61
+ const action = pillAction(snapshot);
62
+ const pending = selected !== undefined && runner.pending(selected);
63
+ const label = pending ? "changing auto mode…" : pillLabel(snapshot) ?? "status unavailable";
64
+ const description = pillDescription(snapshot);
115
65
  return {
116
- label,
117
- tone: reviewing ? "warning" : TONE[latest(snapshot)?.decision ?? "allow"],
118
- title: ctx.t("pill.title"),
119
- rows: (snapshot?.recent ?? []).slice(-5).reverse().map((entry) => ({
120
- id: entry.reviewID,
121
- label: `${entry.decision} · ${entry.action}`,
122
- tone: TONE[entry.decision],
123
- detail: entry.reason,
124
- })),
66
+ label, tone: pillTone(snapshot),
67
+ title: pending ? "Auto-mode: change pending"
68
+ : description !== undefined ? `Auto-mode: ${description}`
69
+ : action === undefined ? "Auto-mode unavailable" : `Auto-mode: ${action}`,
70
+ // Capture this pill's session/location. Never consult the active route
71
+ // inside a click handler: cached composers and session switches differ.
72
+ run: !pending && action !== undefined && selected?.ownServer
73
+ ? () => { void runner.run(selected, action); } : undefined,
125
74
  };
126
75
  },
127
76
  });