opencode-lookout 0.0.0-stage → 0.1.3

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,522 @@
1
+ import { Plugin } from "@opencode/plugin/tui";
2
+ import {
3
+ createRoot,
4
+ createSignal,
5
+ createEffect,
6
+ untrack,
7
+ For,
8
+ Show,
9
+ ErrorBoundary,
10
+ } from "solid-js";
11
+ import { Lookout } from "./rpc";
12
+ import { ID } from "./config.mjs";
13
+ import {
14
+ BROWSE_KINDS,
15
+ displayRows,
16
+ statusTone,
17
+ filterRows,
18
+ text,
19
+ } from "./catalog.mjs";
20
+ import type { Row, Snapshot, Preview } from "./types";
21
+
22
+ const titles: Record<string, string> = {
23
+ agent: "Agents",
24
+ skill: "Skills",
25
+ plugin: "Plugins",
26
+ command: "Commands",
27
+ provider: "Providers",
28
+ model: "Models",
29
+ };
30
+
31
+ /** Additive sidebar UI. All inventory and mutations use the selected server location. */
32
+ export default Plugin.define({
33
+ id: ID,
34
+ setup(ctx) {
35
+ const rpc = ctx.client.rpc(Lookout);
36
+ const abort = new AbortController();
37
+ let stopped = false,
38
+ revision = 0,
39
+ timer: ReturnType<typeof setTimeout> | undefined;
40
+ let stopRoot = () => {};
41
+ const [preferences, savePreferences] = ctx.storage.store("sidebar", {
42
+ initial: {
43
+ collapsed: false,
44
+ expanded: ["agent", "skill", "plugin"],
45
+ pins: [] as string[],
46
+ },
47
+ });
48
+ const [snapshot, setSnapshot] = createSignal<Snapshot>({
49
+ rows: [],
50
+ errors: [],
51
+ location: "",
52
+ refreshedAt: "",
53
+ });
54
+ const [loading, setLoading] = createSignal(false);
55
+ const [unavailable, setUnavailable] = createSignal(false);
56
+ const rows = () => displayRows(snapshot().rows);
57
+ const currentLocation = () => {
58
+ const route = ctx.ui.router.current();
59
+ return (
60
+ (route.type === "session"
61
+ ? ctx.data.session.get(route.sessionID)?.location
62
+ : undefined) ??
63
+ ctx.location ??
64
+ ctx.data.location.default()
65
+ );
66
+ };
67
+ const notify = (
68
+ message: string,
69
+ variant: "info" | "error" | "success" = "info",
70
+ ) => {
71
+ if (!stopped) ctx.ui.toast.show({ title: "Lookout", message, variant });
72
+ };
73
+
74
+ /** Ignore responses for old locations and stop all pending requests on unload. */
75
+ async function refresh() {
76
+ const location = currentLocation(),
77
+ request = ++revision;
78
+ setLoading(true);
79
+ if (snapshot().location !== location.directory)
80
+ setSnapshot({
81
+ rows: [],
82
+ errors: [],
83
+ location: location.directory ?? "",
84
+ refreshedAt: "",
85
+ });
86
+ try {
87
+ const result = (await rpc.snapshot(
88
+ {},
89
+ { location, signal: abort.signal },
90
+ )) as Snapshot;
91
+ if (!stopped && request === revision) {
92
+ setSnapshot(result);
93
+ setUnavailable(false);
94
+ }
95
+ } catch {
96
+ if (!stopped && request === revision) {
97
+ setUnavailable(true);
98
+ setSnapshot((previous) => ({
99
+ ...previous,
100
+ errors: [
101
+ "Server extension unavailable. Last results may be stale. Run the external status command.",
102
+ ],
103
+ }));
104
+ }
105
+ } finally {
106
+ if (!stopped && request === revision) setLoading(false);
107
+ }
108
+ }
109
+
110
+ async function pin(row: Row) {
111
+ const key = `${row.kind}:${row.id}`;
112
+ await savePreferences((draft) => {
113
+ draft.pins = draft.pins.includes(key)
114
+ ? draft.pins.filter((p) => p !== key)
115
+ : [...draft.pins, key].slice(-30);
116
+ });
117
+ }
118
+
119
+ /** Preview and confirmation are separate requests; neither silently grants access. */
120
+ async function toggle(row: Row) {
121
+ if (!row.manageable || unavailable() || snapshot().errors.length) return;
122
+ const location = currentLocation();
123
+ try {
124
+ const preview = (await rpc.preview(
125
+ { kind: row.kind, id: row.id, enabled: row.owned },
126
+ { location, signal: abort.signal },
127
+ )) as Preview;
128
+ const accepted = await ctx.ui.dialog.confirm({
129
+ title: row.owned ? "Enable resource?" : "Disable resource?",
130
+ message: preview.summary,
131
+ label: { confirm: "Apply globally", cancel: "Cancel" },
132
+ });
133
+ if (!accepted || stopped) return;
134
+ if (currentLocation().directory !== location.directory) {
135
+ notify("Location changed. Preview again.", "error");
136
+ return;
137
+ }
138
+ await rpc.apply(
139
+ { token: preview.token },
140
+ { location, signal: abort.signal },
141
+ );
142
+ notify(
143
+ "Configuration saved. Refresh to check the effective state.",
144
+ "success",
145
+ );
146
+ } catch (error) {
147
+ // A reload can interrupt the reply after a successful write. Never retry blindly.
148
+ notify(
149
+ `Change was not confirmed: ${text(error instanceof Error ? error.message : "request failed", 180)}. Refresh before retrying.`,
150
+ "error",
151
+ );
152
+ } finally {
153
+ if (!stopped) await refresh();
154
+ }
155
+ }
156
+
157
+ async function detail(row: Row) {
158
+ // Let the previous dialog finish key dispatch and restore focus first.
159
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
160
+ if (stopped) return;
161
+ const choice = await ctx.ui.dialog.select({
162
+ title: `${titles[row.kind]} · ${text(row.id)}`,
163
+ options: [
164
+ {
165
+ title: "Show details",
166
+ value: "details",
167
+ description: `${row.status} · ${row.source}`,
168
+ },
169
+ {
170
+ title: preferences.pins.includes(`${row.kind}:${row.id}`)
171
+ ? "Unpin from sidebar"
172
+ : "Pin to sidebar",
173
+ value: "pin",
174
+ },
175
+ {
176
+ title: row.owned
177
+ ? "Enable (remove our restriction)"
178
+ : "Disable globally",
179
+ value: "toggle",
180
+ description: row.reason,
181
+ disabled:
182
+ !row.manageable || unavailable() || snapshot().errors.length > 0,
183
+ },
184
+ ],
185
+ });
186
+ if (choice === "pin") await pin(row);
187
+ if (choice === "toggle") await toggle(row);
188
+ if (choice === "details")
189
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
190
+ if (choice === "details" && !stopped) {
191
+ const lines = [
192
+ `ID: ${text(row.id)}`,
193
+ `Status: ${row.status}`,
194
+ `Source: ${row.source}`,
195
+ row.description,
196
+ ...row.details,
197
+ "",
198
+ row.reason,
199
+ ].filter(Boolean);
200
+ // A close callback gives the read-only inspector an explicit lifecycle.
201
+ await new Promise<void>((resolve) => {
202
+ ctx.ui.dialog.set({ size: "large" });
203
+ ctx.ui.dialog.show(
204
+ () => (
205
+ <box flexDirection="column" padding={2} gap={1}>
206
+ <text fg={ctx.theme.text.base}>
207
+ <b>{text(row.label)}</b>
208
+ </text>
209
+ <For each={lines}>
210
+ {(line) => <text fg={ctx.theme.text.base}>{line}</text>}
211
+ </For>
212
+ <text fg={ctx.theme.text.muted}>Escape to return</text>
213
+ </box>
214
+ ),
215
+ resolve,
216
+ );
217
+ });
218
+ }
219
+ }
220
+
221
+ /** Native searchable dialogs keep selection, keyboard focus, and styling consistent. */
222
+ async function browse(initialKind = "all") {
223
+ let kind = BROWSE_KINDS.includes(initialKind) ? initialKind : "all",
224
+ state = "all";
225
+ await refresh();
226
+ while (!stopped) {
227
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
228
+ if (stopped) break;
229
+ let action = "";
230
+ const filteredRows = filterRows(rows(), { kind, state }) as Row[];
231
+ const selected = await ctx.ui.dialog.select<Row>({
232
+ title: `Lookout · ${kind === "all" ? "All" : titles[kind]} · ${state}${snapshot().errors.length ? " · stale" : ""}`,
233
+ placeholder: "Search IDs, names, descriptions, models…",
234
+ options: filteredRows.map((row) => ({
235
+ title: text(row.label),
236
+ value: row,
237
+ description: `${row.id} · ${row.status}`,
238
+ category: titles[row.kind],
239
+ })),
240
+ search: (query, options) => {
241
+ const matches = new Set(
242
+ filterRows(filteredRows, { query }).map(
243
+ (r: Row) => `${r.kind}:${r.id}`,
244
+ ),
245
+ );
246
+ return options.filter((o) =>
247
+ matches.has(`${o.value.kind}:${o.value.id}`),
248
+ );
249
+ },
250
+ actions: [
251
+ {
252
+ bind: "ctrl+f",
253
+ title: "Category",
254
+ selection: "none",
255
+ onTrigger: () => {
256
+ action = "category";
257
+ ctx.ui.dialog.clear();
258
+ },
259
+ },
260
+ {
261
+ bind: "ctrl+s",
262
+ title: "State",
263
+ selection: "none",
264
+ onTrigger: () => {
265
+ action = "state";
266
+ ctx.ui.dialog.clear();
267
+ },
268
+ },
269
+ {
270
+ bind: "ctrl+r",
271
+ title: "Refresh",
272
+ selection: "none",
273
+ onTrigger: () => {
274
+ action = "refresh";
275
+ ctx.ui.dialog.clear();
276
+ },
277
+ },
278
+ ],
279
+ });
280
+ if (selected) {
281
+ await detail(selected);
282
+ return;
283
+ } else if (action === "category")
284
+ kind =
285
+ (await ctx.ui.dialog.select({
286
+ title: "Category",
287
+ options: ["all", ...BROWSE_KINDS].map((value) => ({
288
+ title: titles[value] ?? "All categories",
289
+ value,
290
+ })),
291
+ })) ?? kind;
292
+ else if (action === "state")
293
+ state =
294
+ (await ctx.ui.dialog.select({
295
+ title: "Filter by state",
296
+ options: [
297
+ { value: "all", title: "All" },
298
+ { value: "managed", title: "Controllable here" },
299
+ {
300
+ value: "disabled",
301
+ title: "Disabled / restricted by Lookout",
302
+ },
303
+ { value: "local", title: "Local sources" },
304
+ { value: "errors", title: "Failed / unknown / stale" },
305
+ ],
306
+ })) ?? state;
307
+ else if (action === "refresh") await refresh();
308
+ else break;
309
+ }
310
+ }
311
+
312
+ // Event-driven refresh, coalesced to avoid a burst of RPCs during config reload.
313
+ const stopEvents = ctx.data.listen(({ details }) => {
314
+ if (
315
+ ![
316
+ "config.updated",
317
+ "agent.updated",
318
+ "skill.updated",
319
+ "plugin.updated",
320
+ "command.updated",
321
+ "provider.updated",
322
+ "model.updated",
323
+ "mcp.status.changed",
324
+ ].includes(details.type)
325
+ )
326
+ return;
327
+ clearTimeout(timer);
328
+ timer = setTimeout(() => {
329
+ if (!stopped) void refresh();
330
+ }, 250);
331
+ });
332
+ createRoot((dispose) => {
333
+ stopRoot = dispose;
334
+ createEffect(() => {
335
+ // Read the reactive directory even when refresh itself is untracked.
336
+ void currentLocation().directory;
337
+ untrack(() => void refresh());
338
+ });
339
+ });
340
+ ctx.keymap.layer(() => ({
341
+ mode: "global",
342
+ commands: [
343
+ {
344
+ id: "lookout.open",
345
+ title: "Open Lookout",
346
+ group: "Lookout",
347
+ palette: true,
348
+ slash: { name: "lookout", arguments: true },
349
+ run: (input) => browse(input?.trim() || "all"),
350
+ },
351
+ {
352
+ id: "lookout.refresh",
353
+ title: "Refresh Lookout",
354
+ group: "Lookout",
355
+ palette: true,
356
+ run: refresh,
357
+ },
358
+ {
359
+ id: "lookout.help",
360
+ title: "Lookout troubleshooting",
361
+ group: "Lookout",
362
+ palette: true,
363
+ run: async () => {
364
+ await ctx.ui.dialog.alert({
365
+ title: "Troubleshooting",
366
+ message:
367
+ "From your Lookout checkout, outside OpenCode, run:\nnode bin/lookout.mjs status\nUse unload to disable the extension, or load to enable it.\nUnload preserves resource restrictions. See the README for restore and safe mode.",
368
+ });
369
+ },
370
+ },
371
+ ],
372
+ }));
373
+
374
+ function Sidebar() {
375
+ const dot = (row: Row) => {
376
+ const tone = statusTone(
377
+ row,
378
+ unavailable() || snapshot().errors.length > 0,
379
+ );
380
+ return tone === "muted"
381
+ ? ctx.theme.text.muted
382
+ : ctx.theme.text.feedback[tone].base;
383
+ };
384
+ const selectedAgent = () => {
385
+ const route = ctx.ui.router.current();
386
+ return route.type === "session"
387
+ ? ctx.data.session.get(route.sessionID)?.agent
388
+ : undefined;
389
+ };
390
+ const visible = (kind: string) => {
391
+ const categoryRows = rows().filter((row) => row.kind === kind);
392
+ const pinned = categoryRows.filter((row) =>
393
+ preferences.pins.includes(`${kind}:${row.id}`),
394
+ );
395
+ return (
396
+ pinned.length
397
+ ? pinned
398
+ : filterRows(categoryRows)
399
+ .filter(
400
+ (row: Row) =>
401
+ (kind !== "plugin" || row.source !== "builtin") &&
402
+ row.status !== "hidden",
403
+ )
404
+ .sort(
405
+ (a: Row, b: Row) =>
406
+ Number(b.kind === "agent" && b.id === selectedAgent()) -
407
+ Number(a.kind === "agent" && a.id === selectedAgent()),
408
+ )
409
+ ).slice(0, 3) as Row[];
410
+ };
411
+ return (
412
+ <box flexDirection="column" marginTop={1}>
413
+ <text
414
+ fg={ctx.theme.text.base}
415
+ onMouseDown={() =>
416
+ void savePreferences((d) => {
417
+ d.collapsed = !d.collapsed;
418
+ })
419
+ }
420
+ >
421
+ <b>{preferences.collapsed ? "▶" : "▼"} Lookout</b>
422
+ </text>
423
+ <Show when={!preferences.collapsed}>
424
+ <Show when={loading()}>
425
+ <text fg={ctx.theme.text.muted}>Refreshing…</text>
426
+ </Show>
427
+ <Show when={snapshot().errors.length}>
428
+ <text fg={ctx.theme.text.muted}>
429
+ Stale / unavailable — refresh
430
+ </text>
431
+ </Show>
432
+ <For each={BROWSE_KINDS}>
433
+ {(kind) => (
434
+ <box flexDirection="column">
435
+ <text
436
+ fg={ctx.theme.text.base}
437
+ onMouseDown={() =>
438
+ void savePreferences((d) => {
439
+ d.expanded = d.expanded.includes(kind)
440
+ ? d.expanded.filter((k) => k !== kind)
441
+ : [...d.expanded, kind];
442
+ })
443
+ }
444
+ >
445
+ {preferences.expanded.includes(kind) ? "▼" : "▶"}{" "}
446
+ <b>{titles[kind]}</b>{" "}
447
+ <span style={{ fg: ctx.theme.text.muted }}>
448
+ {rows().filter((r) => r.kind === kind).length}
449
+ </span>
450
+ </text>
451
+ <Show when={preferences.expanded.includes(kind)}>
452
+ <For each={visible(kind)}>
453
+ {(row) => (
454
+ <box flexDirection="column">
455
+ <text
456
+ fg={ctx.theme.text.base}
457
+ onMouseDown={() => void detail(row)}
458
+ >
459
+ {" "}
460
+ <span style={{ fg: dot(row) }}>•</span>{" "}
461
+ {row.kind === "agent" && row.id === selectedAgent()
462
+ ? "› "
463
+ : ""}
464
+ {text(row.label, 28)}
465
+ </text>
466
+ <Show when={row.kind === "plugin"}>
467
+ <text
468
+ fg={ctx.theme.text.muted}
469
+ wrapMode="none"
470
+ truncate
471
+ onMouseDown={() => void detail(row)}
472
+ >
473
+ {" "}
474
+ {row.description || "missing description"}
475
+ </text>
476
+ </Show>
477
+ </box>
478
+ )}
479
+ </For>
480
+ <text
481
+ fg={ctx.theme.text.muted}
482
+ onMouseDown={() => void browse(kind)}
483
+ >
484
+ {" "}
485
+ Search / manage…
486
+ </text>
487
+ </Show>
488
+ </box>
489
+ )}
490
+ </For>
491
+ <text fg={ctx.theme.text.muted} onMouseDown={() => void browse()}>
492
+ /lookout
493
+ </text>
494
+ </Show>
495
+ </box>
496
+ );
497
+ }
498
+ const stopSlot = ctx.ui.slot({
499
+ append: "sidebar.content",
500
+ render: () => (
501
+ <ErrorBoundary
502
+ fallback={
503
+ <text fg={ctx.theme.text.muted}>
504
+ Lookout unavailable; use unload.
505
+ </text>
506
+ }
507
+ >
508
+ <Sidebar />
509
+ </ErrorBoundary>
510
+ ),
511
+ });
512
+ return () => {
513
+ stopped = true;
514
+ ++revision;
515
+ abort.abort();
516
+ clearTimeout(timer);
517
+ stopEvents();
518
+ stopRoot();
519
+ stopSlot();
520
+ };
521
+ },
522
+ });
package/src/types.ts ADDED
@@ -0,0 +1,58 @@
1
+ /** Display-only inventory. Do not add raw server responses or credentials here. */
2
+ export interface Row {
3
+ kind: string;
4
+ id: string;
5
+ label: string;
6
+ description: string;
7
+ status: string;
8
+ source: string;
9
+ manageable: boolean;
10
+ owned: boolean;
11
+ reason: string;
12
+ details: string[];
13
+ }
14
+
15
+ /** Snapshot is scoped to the RPC's server location, not the terminal's filesystem. */
16
+ export interface Snapshot {
17
+ rows: Row[];
18
+ errors: string[];
19
+ location: string;
20
+ refreshedAt: string;
21
+ }
22
+
23
+ /** Enable means remove this extension's restriction, never grant new permission. */
24
+ export interface ChangeRequest {
25
+ kind: "skill" | "mcp" | "plugin";
26
+ id: string;
27
+ enabled: boolean;
28
+ }
29
+
30
+ /** One-use confirmation token; expires after 60 seconds and is bound to config bytes. */
31
+ export interface Preview {
32
+ token: string;
33
+ summary: string;
34
+ }
35
+
36
+ /** Minimal public V2 catalog projection consumed by normalization, never serialized wholesale. */
37
+ export interface CatalogEntry {
38
+ id?: string;
39
+ name?: string;
40
+ title?: string;
41
+ description?: string;
42
+ hidden?: boolean;
43
+ mode?: string;
44
+ model?: { providerID: string; id: string; variant?: string };
45
+ path?: string;
46
+ autoinvoke?: boolean;
47
+ source?: { type: string; path?: string };
48
+ state?: { status: string };
49
+ features?: Record<string, boolean | undefined>;
50
+ status?: string | { status: string };
51
+ integrationID?: string;
52
+ activation?: string;
53
+ providerID?: string;
54
+ enabled?: boolean;
55
+ variants?: { id: string }[];
56
+ limit?: { context?: number; output?: number };
57
+ capabilities?: { tools?: boolean };
58
+ }