@opencode-cockpit/trust 0.0.0 → 0.8.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.
Files changed (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli/preview.js +251 -0
  3. package/dist/core/adapt/seen.js +24 -0
  4. package/dist/core/adapt/v1.js +109 -0
  5. package/dist/core/adapt/v2.js +109 -0
  6. package/dist/core/config.js +93 -0
  7. package/dist/core/danger.js +366 -0
  8. package/dist/core/engine.js +245 -0
  9. package/dist/core/family.js +512 -0
  10. package/dist/core/history.js +169 -0
  11. package/dist/core/index.js +16 -0
  12. package/dist/core/keys.js +149 -0
  13. package/dist/core/ledger.js +202 -0
  14. package/dist/core/paths.js +38 -0
  15. package/dist/core/policy.js +128 -0
  16. package/dist/core/rules.js +152 -0
  17. package/dist/core/sample.js +311 -0
  18. package/dist/core/shell.js +253 -0
  19. package/dist/core/signature.js +38 -0
  20. package/dist/core/view/actions.js +321 -0
  21. package/dist/core/view/activity.js +557 -0
  22. package/dist/core/view/explorer.js +970 -0
  23. package/dist/core/view/model.js +193 -0
  24. package/dist/core/view/parts.js +290 -0
  25. package/dist/core/view/rows.js +165 -0
  26. package/dist/core/view/sidebar.js +170 -0
  27. package/dist/tui/index.js +920 -0
  28. package/dist/tui/journal.js +80 -0
  29. package/dist/tui/render.js +85 -0
  30. package/dist/tui/source.js +112 -0
  31. package/dist/tui/view/dialog.js +43 -0
  32. package/dist/tui/view/rows.js +69 -0
  33. package/package.json +49 -3
  34. package/tui.js +6 -0
  35. package/types/cli/preview.d.ts +22 -0
  36. package/types/core/adapt/seen.d.ts +46 -0
  37. package/types/core/adapt/v1.d.ts +18 -0
  38. package/types/core/adapt/v2.d.ts +20 -0
  39. package/types/core/config.d.ts +57 -0
  40. package/types/core/danger.d.ts +50 -0
  41. package/types/core/engine.d.ts +117 -0
  42. package/types/core/family.d.ts +97 -0
  43. package/types/core/history.d.ts +81 -0
  44. package/types/core/index.d.ts +16 -0
  45. package/types/core/keys.d.ts +68 -0
  46. package/types/core/ledger.d.ts +158 -0
  47. package/types/core/paths.d.ts +24 -0
  48. package/types/core/policy.d.ts +52 -0
  49. package/types/core/rules.d.ts +55 -0
  50. package/types/core/sample.d.ts +22 -0
  51. package/types/core/shell.d.ts +40 -0
  52. package/types/core/signature.d.ts +18 -0
  53. package/types/core/view/actions.d.ts +76 -0
  54. package/types/core/view/activity.d.ts +101 -0
  55. package/types/core/view/explorer.d.ts +154 -0
  56. package/types/core/view/model.d.ts +106 -0
  57. package/types/core/view/parts.d.ts +76 -0
  58. package/types/core/view/rows.d.ts +60 -0
  59. package/types/core/view/sidebar.d.ts +62 -0
  60. package/types/tui/index.d.ts +15 -0
  61. package/types/tui/journal.d.ts +19 -0
  62. package/types/tui/render.d.ts +14 -0
  63. package/types/tui/source.d.ts +35 -0
  64. package/types/tui/view/dialog.d.ts +24 -0
  65. package/types/tui/view/rows.d.ts +19 -0
@@ -0,0 +1,920 @@
1
+ import { createComponent as _$createComponent } from "@opentui/solid";
2
+ /** @jsxImportSource @opentui/solid */
3
+
4
+ /**
5
+ * Trust's interface half — the only half it has. OpenCode's `permission.ask` server hook is declared
6
+ * and never called (docs/opencode/permissions.md), so a plugin answers a request the way OpenCode's
7
+ * own auto mode does: from the interface, on `permission.asked`, with a reply of "once".
8
+ *
9
+ * Everything that decides is in `core/`; this file wires it to the host: events in, a reply out, the
10
+ * ledger file read and appended, and the two surfaces — the sidebar block and the ledger dialog.
11
+ */
12
+
13
+ import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature";
14
+ import { bindingLookup, dualTui } from "@opencode-cockpit/client/host";
15
+ import { sidebarOrder } from "@opencode-cockpit/client/sidebar";
16
+ import { createSignal } from "solid-js";
17
+ import { commandOf } from "../core/adapt/seen.js";
18
+ import { loadTrustConfig, resolveSettings } from "../core/config.js";
19
+ import { createEngine } from "../core/engine.js";
20
+ import { trustPaths } from "../core/paths.js";
21
+ import { rulesFrom } from "../core/rules.js";
22
+ import { configSnippet, revoke, widen, widenScope } from "../core/view/actions.js";
23
+ import { activityRows, targetOf } from "../core/view/activity.js";
24
+ import { ALWAYS_KEY, explorerRows, nodeTarget, reveal } from "../core/view/explorer.js";
25
+ import { sidebarRows, tally } from "../core/view/sidebar.js";
26
+ import { createJournal } from "./journal.js";
27
+ import { createSource } from "./source.js";
28
+ import { Dialog } from "./view/dialog.js";
29
+ import { Rows } from "./view/rows.js";
30
+ const TRUST_PACKAGE = "@opencode-cockpit/trust";
31
+
32
+ /** `<leader>p`, for permissions: free on both OpenCodes (1.18.32's and 2.0.18's defaults) and in Cockpit. */
33
+ const DEFAULT_KEYS = {
34
+ "cockpit.trust.ledger": "<leader>p"
35
+ };
36
+ /** How often other windows' events are read from the ledger, and pending requests checked. */
37
+ const SYNC_MS = 3_000;
38
+ /** How long a bash request waits for its call's command line before it is decided without one. */
39
+ const PARK_MS = 1_000;
40
+ /** Calls remembered for their command line: far more than can be waiting at once. */
41
+ const CALLS_MAX = 500;
42
+ /** The host's dialog: as wide as xlarge allows (Shell's console measured it). */
43
+ const DIALOG_COLUMNS = 116;
44
+
45
+ /** Trust's interface half as a factory, so the `opencode-cockpit` bundle can include it. */
46
+ export function createTrustTui({
47
+ source = TRUST_PACKAGE
48
+ } = {}) {
49
+ return async (api, rawOptions) => {
50
+ const log = api.log.child("trust");
51
+ const claim = claimFeature(api.renderer, "trust", source);
52
+ if (!claim.active) {
53
+ log.warn("configured twice", {
54
+ owner: claim.owner,
55
+ skipped: source
56
+ });
57
+ api.ui.toast({
58
+ variant: "warning",
59
+ title: "Trust",
60
+ message: duplicateFeatureMessage("Trust", claim.owner, source)
61
+ });
62
+ return;
63
+ }
64
+ api.lifecycle.onDispose(() => claim.release());
65
+ const directory = api.state.path.directory;
66
+ const config = await loadTrustConfig(directory, rawOptions);
67
+ const settings = resolveSettings(config);
68
+ if (!settings.enabled) {
69
+ log.info("off by config", {
70
+ directory
71
+ });
72
+ return;
73
+ }
74
+ const keys = bindingLookup({
75
+ ...DEFAULT_KEYS,
76
+ ...config.keybinds
77
+ });
78
+ const paths = trustPaths(directory);
79
+ const journal = createJournal(paths);
80
+ const engine = createEngine({
81
+ ...settings,
82
+ keep: 20
83
+ });
84
+
85
+ /** OpenCode's config as its `config.get` returned it; undefined until read, and Trust stays out. */
86
+ let opencodeConfig;
87
+ let rulesReady = false;
88
+ /** Something you should know about. The sidebar says it whatever else it has to say. */
89
+ let trouble;
90
+ const calls = new Map();
91
+ const agents = new Map();
92
+
93
+ // --- painting ----------------------------------------------------------------------------------
94
+
95
+ const [sidebarLines, setLines] = createSignal([]);
96
+ const [dialogRows, setDialogRows] = createSignal([]);
97
+ let block;
98
+ let drawnAt = 0;
99
+ let said = "";
100
+ const sidebarWidth = () => {
101
+ /** The container the host gave the block, as Subagents measures it: the block's own width follows its rows. */
102
+ const parent = block?.parent?.width ?? 0;
103
+ const own = block?.width ?? 0;
104
+ const measured = parent >= 12 ? Math.min(parent, own >= 12 ? own : parent) : own;
105
+ return measured >= 12 ? measured : Math.max(20, Math.min(40, Math.floor(api.renderer.width / 4) - 2));
106
+ };
107
+
108
+ /**
109
+ * The dialog: the activity `/trust` opens on, and the ledger behind `l`. Each keeps its own cursor
110
+ * by key, so a rule revoked or a family folded moves the rows about, not the cursor.
111
+ */
112
+ const dialog = {
113
+ open: false,
114
+ view: "activity",
115
+ /** `?`: every key, in the body's place. */
116
+ keys: false,
117
+ notice: undefined,
118
+ activity: undefined,
119
+ node: undefined,
120
+ /** Families opened, and those whose tail is listed too; every family starts folded. */
121
+ opened: new Set(),
122
+ full: new Set(),
123
+ filter: "",
124
+ /** `/` pressed: the text typed so far, until enter or esc. */
125
+ typing: undefined,
126
+ /** `tab` into the card: the focused button. */
127
+ button: undefined
128
+ };
129
+ /** What was drawn last: actions and clicks act on what is on screen. */
130
+ let shown = {};
131
+ const reading = () => ({
132
+ state: engine.state,
133
+ settings,
134
+ now: Date.now(),
135
+ history: engine.history
136
+ });
137
+ const project = directory.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
138
+
139
+ /**
140
+ * Off by default (core/config.ts): the sidebar is crowded, and Trust answers the same without it.
141
+ * The palette flips it for the session; config decides where it starts. Not remembered across
142
+ * restarts — remembered UI state makes a command look dead (docs/opencode/gotchas.md).
143
+ */
144
+ let inSidebar = settings.sidebar;
145
+ const paint = () => {
146
+ drawnAt = sidebarWidth();
147
+ /** Hidden, the block says nothing — except trouble: a failure always speaks. */
148
+ const next = !inSidebar && !trouble ? [] : sidebarRows({
149
+ width: drawnAt,
150
+ recent: engine.recent(),
151
+ count: engine.count(),
152
+ pending: engine.pending(),
153
+ state: engine.state,
154
+ limit: settings.sidebarRows,
155
+ shown: inSidebar,
156
+ ...(inSidebar ? {
157
+ project: tally(engine.state, settings, Date.now())
158
+ } : {}),
159
+ ...(trouble ? {
160
+ trouble
161
+ } : {})
162
+ });
163
+ /** Only when they changed: new rows rebuild every line of the block. */
164
+ const text = JSON.stringify(next);
165
+ if (text !== said) {
166
+ said = text;
167
+ setLines(next);
168
+ }
169
+ if (dialog.open) {
170
+ const height = api.renderer.height;
171
+ const size = {
172
+ width: Math.max(40, Math.min(DIALOG_COLUMNS, api.renderer.width - 2)),
173
+ height: Math.max(11, height - Math.floor(height / 4) * 2),
174
+ project,
175
+ ...reading(),
176
+ ...(dialog.notice ? {
177
+ notice: dialog.notice
178
+ } : {}),
179
+ ...(dialog.keys ? {
180
+ keys: true
181
+ } : {})
182
+ };
183
+ if (dialog.view === "activity") {
184
+ const view = activityRows({
185
+ ...size,
186
+ ...(dialog.activity !== undefined ? {
187
+ selected: dialog.activity
188
+ } : {})
189
+ });
190
+ dialog.activity = view.item?.key;
191
+ shown = {
192
+ activity: view
193
+ };
194
+ setDialogRows(view.rows);
195
+ } else {
196
+ const view = explorerRows({
197
+ ...size,
198
+ open: dialog.opened,
199
+ full: dialog.full,
200
+ filter: dialog.filter,
201
+ ...(dialog.node !== undefined ? {
202
+ selected: dialog.node
203
+ } : {}),
204
+ ...(dialog.button !== undefined ? {
205
+ focus: {
206
+ button: dialog.button
207
+ }
208
+ } : {}),
209
+ ...(dialog.typing !== undefined ? {
210
+ typing: dialog.typing
211
+ } : {})
212
+ });
213
+ dialog.node = view.node?.key;
214
+ if (dialog.button !== undefined && view.buttons.length > 0) dialog.button = Math.min(dialog.button, view.buttons.length - 1);else dialog.button = undefined;
215
+ shown = {
216
+ ledger: view
217
+ };
218
+ setDialogRows(view.rows);
219
+ }
220
+ }
221
+ api.renderer.requestRender();
222
+ };
223
+ /** Several changes in one turn are one paint (Shell's painter). */
224
+ let scheduled = false;
225
+ const draw = () => {
226
+ if (scheduled) return;
227
+ scheduled = true;
228
+ setTimeout(() => {
229
+ scheduled = false;
230
+ try {
231
+ paint();
232
+ } catch (error) {
233
+ log.error("paint failed", {
234
+ error
235
+ });
236
+ }
237
+ }, 0);
238
+ };
239
+
240
+ // --- the ledger file ---------------------------------------------------------------------------
241
+
242
+ /** Everything new in the file — this window's events and every other's — into the state. */
243
+ const sync = () => journal.read().then(({
244
+ events,
245
+ reset
246
+ }) => {
247
+ if (events.length === 0 && !reset) return;
248
+ engine.load(events, {
249
+ reset
250
+ });
251
+ draw();
252
+ }).catch(error => {
253
+ log.error("ledger unreadable", {
254
+ file: paths.events,
255
+ error
256
+ });
257
+ trouble = "ledger unreadable — see cockpit.log";
258
+ draw();
259
+ });
260
+ const write = events => {
261
+ if (events.length === 0) return;
262
+ journal.append(events).then(() => {
263
+ if (trouble?.startsWith("ledger not saved")) trouble = undefined;
264
+ return sync();
265
+ }).catch(error => {
266
+ log.error("ledger not saved", {
267
+ file: paths.events,
268
+ error
269
+ });
270
+ trouble = `ledger not saved: ${error.code ?? "error"}`;
271
+ draw();
272
+ });
273
+ };
274
+
275
+ // --- requests ----------------------------------------------------------------------------------
276
+
277
+ const loadRules = async () => {
278
+ try {
279
+ opencodeConfig = await feed.config();
280
+ rulesReady = true;
281
+ if (trouble?.startsWith("OpenCode's config")) trouble = undefined;
282
+ log.debug("rules", {
283
+ rules: rulesFrom(opencodeConfig).length
284
+ });
285
+ } catch (error) {
286
+ /** Without the rules there is no knowing what you asked to be asked about: Trust stays out. */
287
+ rulesReady = false;
288
+ log.error("config unreadable", {
289
+ error
290
+ });
291
+ trouble = "OpenCode's config unreadable — not answering";
292
+ }
293
+ draw();
294
+ };
295
+
296
+ /**
297
+ * Bash requests whose call has not said its command line yet, by call id. Measured on 1.18.32: the
298
+ * call's `running` update — the one carrying `command` — arrived *after* `permission.asked` for
299
+ * three requests in four (the first call of a turn was the exception). So a request without its
300
+ * line waits for it, briefly; one that never gets it is decided without, which means asked.
301
+ */
302
+ const parked = new Map();
303
+ const lineOf = request => request.call ? calls.get(request.call) ?? feed.call(request) : undefined;
304
+ const asked = (request, at) => {
305
+ /**
306
+ * Decided as soon as the request can be read, never after a file read: OpenCode's `--auto`
307
+ * answers in 15–22ms, and a decision that waited on the disk could answer a request already gone.
308
+ */
309
+ if (!rulesReady) return;
310
+ const call = lineOf(request);
311
+ if (request.permission === "bash" && call?.line === undefined && request.call && !parked.has(request.call)) {
312
+ const key = request.call;
313
+ parked.set(key, {
314
+ request,
315
+ at,
316
+ timer: setTimeout(() => unpark(key), PARK_MS)
317
+ });
318
+ return;
319
+ }
320
+ decideNow(request, at, call);
321
+ };
322
+
323
+ /** The call's line arrived, a reply came first, or the wait ran out: decide with what is known. */
324
+ const unpark = key => {
325
+ const waiting = parked.get(key);
326
+ if (!waiting) return;
327
+ parked.delete(key);
328
+ clearTimeout(waiting.timer);
329
+ if (!lineOf(waiting.request)) log.debug("no command line", {
330
+ request: waiting.request.id,
331
+ call: key
332
+ });
333
+ decideNow(waiting.request, waiting.at, lineOf(waiting.request));
334
+ };
335
+ const decideNow = (request, at, call) => {
336
+ const agent = agents.get(request.sessionID) ?? feed.agent(request.sessionID, request.messageID) ?? "unknown";
337
+ const {
338
+ judgement,
339
+ event
340
+ } = engine.ask({
341
+ request,
342
+ context: {
343
+ ...call,
344
+ root: directory
345
+ },
346
+ agent,
347
+ rules: rulesFrom(opencodeConfig, agent),
348
+ at
349
+ });
350
+ log.debug("asked", {
351
+ request: request.id,
352
+ permission: request.permission,
353
+ agent,
354
+ why: judgement.why
355
+ });
356
+ write([event]);
357
+ draw();
358
+ if (!judgement.answer) return;
359
+ feed.approve(request).then(() => {
360
+ const auto = engine.answered(request.id, Date.now());
361
+ if (auto) write([auto]);
362
+ log.info("auto", {
363
+ request: request.id,
364
+ session: request.sessionID,
365
+ call: request.call,
366
+ permission: request.permission,
367
+ agent,
368
+ subjects: judgement.items.map(item => item.subject),
369
+ why: judgement.why,
370
+ ms: Date.now() - at
371
+ });
372
+ if (trouble?.startsWith("an answer failed")) trouble = undefined;
373
+ draw();
374
+ }).catch(error => {
375
+ /** The prompt is still there and yours: your answer to it counts as any other. */
376
+ engine.failed(request.id);
377
+ log.warn("auto reply failed", {
378
+ request: request.id,
379
+ error
380
+ });
381
+ trouble = `an answer failed: ${error instanceof Error ? error.message : String(error)}`;
382
+ draw();
383
+ });
384
+ };
385
+ const feed = createSource(api, log, seen => {
386
+ const at = Date.now();
387
+ for (const each of seen) {
388
+ switch (each.type) {
389
+ case "call":
390
+ {
391
+ calls.set(each.call, commandOf(each.input));
392
+ if (calls.size > CALLS_MAX) calls.delete(calls.keys().next().value);
393
+ if (parked.has(each.call) && calls.get(each.call)?.line !== undefined) unpark(each.call);
394
+ break;
395
+ }
396
+ case "agent":
397
+ agents.set(each.sessionID, each.agent);
398
+ break;
399
+ case "config":
400
+ void loadRules();
401
+ break;
402
+ case "asked":
403
+ asked(each.request, at);
404
+ break;
405
+ case "replied":
406
+ {
407
+ /** Answered while it waited for its line: decided first, so the answer counts against it. */
408
+ for (const [key, waiting] of parked) if (waiting.request.id === each.requestID) unpark(key);
409
+ const {
410
+ events,
411
+ credit
412
+ } = engine.replied({
413
+ requestID: each.requestID,
414
+ reply: each.reply,
415
+ at
416
+ });
417
+ if (credit.kind === "ignored") log.debug("reply not counted", {
418
+ request: each.requestID,
419
+ why: credit.why
420
+ });
421
+ write(events);
422
+ draw();
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ });
428
+
429
+ /** Requests already waiting when Trust started, and any the events never told us were answered. */
430
+ const reconcile = async adopt => {
431
+ const listed = await feed.pending().catch(error => {
432
+ log.debug("pending list failed", {
433
+ error
434
+ });
435
+ return undefined;
436
+ });
437
+ if (!listed) return;
438
+ const ids = new Set();
439
+ for (const each of listed) {
440
+ if (each.type !== "asked") continue;
441
+ ids.add(each.request.id);
442
+ /** Its asking was not seen, so "now" stands in for it: a person answers later still. */
443
+ if (adopt) asked(each.request, Date.now());
444
+ }
445
+ engine.reconcile(ids, Date.now());
446
+ draw();
447
+ };
448
+ const boot = async () => {
449
+ await sync();
450
+ await loadRules();
451
+ await reconcile(true);
452
+ log.info("ready", {
453
+ ledger: paths.events,
454
+ threshold: settings.threshold,
455
+ dangerExtra: settings.dangerExtra,
456
+ expireDays: settings.expireDays,
457
+ rules: rulesReady
458
+ });
459
+ };
460
+ void boot();
461
+ let ticking = setInterval(() => {
462
+ void sync();
463
+ if (engine.pending().length > 0) void reconcile(false);
464
+ /** The sidebar was laid out, or resized, since the rows were drawn. */
465
+ if (sidebarWidth() !== drawnAt) draw();
466
+ }, SYNC_MS);
467
+
468
+ // --- the ledger dialog -------------------------------------------------------------------------
469
+
470
+ const notice = (text, tone = "muted") => {
471
+ dialog.notice = {
472
+ text,
473
+ tone
474
+ };
475
+ draw();
476
+ };
477
+
478
+ /** What `x`, `w` and `c` act on: the cursor's item or node, as last drawn. */
479
+ const selected = () => {
480
+ if (dialog.view === "activity") {
481
+ const view = shown.activity;
482
+ if (!view?.item) return undefined;
483
+ return {
484
+ target: targetOf(view.item, view.model),
485
+ families: view.model.families
486
+ };
487
+ }
488
+ const view = shown.ledger;
489
+ if (!view?.node) return undefined;
490
+ return {
491
+ target: nodeTarget(view.node),
492
+ families: view.model.families
493
+ };
494
+ };
495
+
496
+ /** What `w` and `x` decided, appended and said. Nothing is ever rewritten in the ledger. */
497
+ const act = (name, outcome) => {
498
+ write(outcome.events);
499
+ if (outcome.events.length > 0) log.info(name, {
500
+ events: outcome.events.map(event => event.type === "revoked" ? {
501
+ type: event.type,
502
+ agent: event.agent,
503
+ subject: event.subject
504
+ } : event.type === "widened" || event.type === "unwidened" ? {
505
+ type: event.type,
506
+ agent: event.agent,
507
+ family: event.family
508
+ } : {
509
+ type: event.type
510
+ })
511
+ });
512
+ notice(outcome.notice.text, outcome.notice.tone);
513
+ };
514
+ const revokeSelected = () => {
515
+ const now = selected();
516
+ if (now) act("revoked", revoke(now.target, reading(), Date.now()));
517
+ };
518
+ const widenSelected = () => {
519
+ const now = selected();
520
+ if (now) act("widen", widen(widenScope(now.target, now.families), now.families, Date.now()));
521
+ };
522
+ const copy = () => {
523
+ const now = selected();
524
+ if (!now) return;
525
+ const snippet = configSnippet(now.target);
526
+ const ok = api.renderer.copyToClipboardOSC52?.(snippet.text) ?? false;
527
+ notice(ok ? `Copied ${snippet.text} — paste it into opencode.json${snippet.note ? `; ${snippet.note}` : ""}.` : `This terminal refused the clipboard. The rule: ${snippet.text}`, ok ? "success" : "warning");
528
+ };
529
+ const togglePause = () => {
530
+ const paused = !engine.state.paused;
531
+ write([{
532
+ v: 1,
533
+ at: Date.now(),
534
+ type: paused ? "paused" : "resumed"
535
+ }]);
536
+ log.info(paused ? "paused" : "resumed", {
537
+ directory
538
+ });
539
+ if (dialog.open) notice(paused ? "Paused in this project: Trust keeps counting, and answers nothing." : "Resumed.");else api.ui.toast({
540
+ variant: "info",
541
+ title: "Trust",
542
+ message: paused ? "Paused in this project." : "Answering again in this project."
543
+ });
544
+ };
545
+
546
+ /** Every key but `?` acts on the screen: from the key list it goes back to the screen first. */
547
+ const listed = run => () => {
548
+ dialog.keys = false;
549
+ run();
550
+ draw();
551
+ };
552
+ const move = by => {
553
+ dialog.notice = undefined;
554
+ if (dialog.view === "activity") {
555
+ const items = shown.activity?.model.items ?? [];
556
+ const at = Math.max(0, items.findIndex(item => item.key === dialog.activity));
557
+ dialog.activity = items[Math.max(0, Math.min(items.length - 1, at + by))]?.key;
558
+ } else {
559
+ const nodes = shown.ledger?.model.nodes ?? [];
560
+ const at = Math.max(0, nodes.findIndex(node => node.key === dialog.node));
561
+ dialog.node = nodes[Math.max(0, Math.min(nodes.length - 1, at + by))]?.key;
562
+ dialog.button = undefined;
563
+ }
564
+ draw();
565
+ };
566
+
567
+ /** Into the ledger, on `key` when given; the activity keeps its cursor for the way back. */
568
+ const toLedger = key => {
569
+ dialog.view = "ledger";
570
+ dialog.button = undefined;
571
+ dialog.notice = undefined;
572
+ if (key !== undefined) dialog.node = key;
573
+ draw();
574
+ };
575
+
576
+ /** `enter` on the activity: why — the card of the rule that answered, or that is close, in the ledger. */
577
+ const why = () => {
578
+ const view = shown.activity;
579
+ const item = view?.item;
580
+ if (!view || !item) return toLedger();
581
+ if (item.kind === "always") return toLedger(ALWAYS_KEY);
582
+ const subject = item.kind === "answer" ? {
583
+ permission: item.answer.permission,
584
+ subject: item.answer.items[0]?.subject ?? ""
585
+ } : {
586
+ permission: item.command.permission,
587
+ subject: item.command.subject
588
+ };
589
+ toLedger(reveal({
590
+ open: dialog.opened,
591
+ full: dialog.full
592
+ }, view.model.families, subject));
593
+ };
594
+ const node = () => shown.ledger?.node;
595
+
596
+ /**
597
+ * `space`/`enter` on a heading opens or folds it; on `+ N more` it lists the rest; on a command in a
598
+ * family it folds the family, the cursor going to its heading. `→` only opens, `←` only folds.
599
+ */
600
+ const fold = (way = "toggle") => {
601
+ const at = node();
602
+ if (!at || at.kind === "always") return;
603
+ dialog.notice = undefined;
604
+ const key = at.family.key;
605
+ if (at.kind === "more") {
606
+ if (way !== "close") dialog.full.add(key);else {
607
+ dialog.opened.delete(key);
608
+ dialog.node = `f:${key}`;
609
+ }
610
+ } else if (at.kind === "command") {
611
+ if (!at.nested || way === "open") return;
612
+ dialog.opened.delete(key);
613
+ dialog.full.delete(key);
614
+ dialog.node = `f:${key}`;
615
+ } else if (dialog.opened.has(key) && way !== "open") {
616
+ dialog.opened.delete(key);
617
+ dialog.full.delete(key);
618
+ } else if (!dialog.opened.has(key) && way !== "close") dialog.opened.add(key);
619
+ draw();
620
+ };
621
+
622
+ /** The card's buttons: `tab` in and out, `←`/`→` between them, `enter` presses the focused one. */
623
+ const buttons = () => shown.ledger?.buttons ?? [];
624
+ const press = action => {
625
+ if (action === "revoke") revokeSelected();else if (action === "widen") widenSelected();else if (action === "copy") copy();else if (action === "ledger") toLedger();
626
+ };
627
+ const enter = () => {
628
+ if (dialog.view === "activity") return why();
629
+ if (dialog.button !== undefined) {
630
+ const button = buttons()[dialog.button];
631
+ if (button && !button.off) press(button.action);
632
+ return;
633
+ }
634
+ const at = node();
635
+ if (at?.kind === "command" || at?.kind === "always") {
636
+ if (buttons().length > 0) dialog.button = 0;
637
+ return draw();
638
+ }
639
+ fold();
640
+ };
641
+ const sideways = by => {
642
+ if (dialog.view !== "ledger") return;
643
+ if (dialog.button !== undefined) {
644
+ const count = buttons().length;
645
+ dialog.button = count > 0 ? (dialog.button + by + count) % count : undefined;
646
+ return draw();
647
+ }
648
+ fold(by > 0 ? "open" : "close");
649
+ };
650
+ const tab = () => {
651
+ if (dialog.view !== "ledger") return;
652
+ dialog.button = dialog.button === undefined && buttons().length > 0 ? 0 : undefined;
653
+ draw();
654
+ };
655
+ const filter = () => {
656
+ if (dialog.view === "activity") dialog.view = "ledger";
657
+ dialog.typing = dialog.filter;
658
+ dialog.button = undefined;
659
+ draw();
660
+ };
661
+
662
+ /** A click: a button presses, a row takes the cursor. */
663
+ const click = (x, y) => {
664
+ const hits = (dialog.view === "activity" ? shown.activity?.hits : shown.ledger?.hits) ?? [];
665
+ const on = hits.find(hit => hit.y === y && (hit.x0 === undefined || x >= hit.x0) && (hit.x1 === undefined || x < hit.x1));
666
+ if (!on) return;
667
+ dialog.keys = false;
668
+ if (on.kind === "button") {
669
+ dialog.notice = undefined;
670
+ return press(on.action);
671
+ }
672
+ dialog.notice = undefined;
673
+ if (dialog.view === "activity") dialog.activity = on.key;else {
674
+ dialog.node = on.key;
675
+ dialog.button = undefined;
676
+ }
677
+ draw();
678
+ };
679
+ const dialogLayer = () => ({
680
+ priority: 100,
681
+ commands: [{
682
+ name: "cockpit.trust.down",
683
+ title: "Next",
684
+ run: listed(() => move(1))
685
+ }, {
686
+ name: "cockpit.trust.up",
687
+ title: "Previous",
688
+ run: listed(() => move(-1))
689
+ }, {
690
+ name: "cockpit.trust.enter",
691
+ title: "Why, open, or press",
692
+ run: listed(() => enter())
693
+ }, {
694
+ name: "cockpit.trust.fold",
695
+ title: "Open or fold a family",
696
+ run: listed(() => fold())
697
+ }, {
698
+ name: "cockpit.trust.right",
699
+ title: "Open a family, or the next button",
700
+ run: listed(() => sideways(1))
701
+ }, {
702
+ name: "cockpit.trust.left",
703
+ title: "Fold a family, or the previous button",
704
+ run: listed(() => sideways(-1))
705
+ }, {
706
+ name: "cockpit.trust.l",
707
+ title: "Open the ledger, or a family in it",
708
+ run: listed(() => dialog.view === "activity" ? toLedger() : sideways(1))
709
+ }, {
710
+ name: "cockpit.trust.tab",
711
+ title: "Into the card and back",
712
+ run: listed(() => tab())
713
+ }, {
714
+ name: "cockpit.trust.filter",
715
+ title: "Filter the ledger",
716
+ run: listed(() => filter())
717
+ }, {
718
+ name: "cockpit.trust.keys",
719
+ title: "Show or hide every key",
720
+ run: () => {
721
+ dialog.keys = !dialog.keys;
722
+ dialog.notice = undefined;
723
+ draw();
724
+ }
725
+ }, {
726
+ name: "cockpit.trust.revoke",
727
+ title: "Revoke, or forget a count",
728
+ run: listed(() => revokeSelected())
729
+ }, {
730
+ name: "cockpit.trust.widen",
731
+ title: "Trust the whole family, or undo it",
732
+ run: listed(() => widenSelected())
733
+ }, {
734
+ name: "cockpit.trust.copy",
735
+ title: "Copy as config",
736
+ run: listed(() => copy())
737
+ }, {
738
+ name: "cockpit.trust.togglePause",
739
+ title: "Pause or resume",
740
+ run: listed(() => togglePause())
741
+ }, {
742
+ name: "cockpit.trust.close",
743
+ title: "Close",
744
+ run: () => api.ui.dialog.clear()
745
+ }],
746
+ bindings: [{
747
+ key: "j,down",
748
+ cmd: "cockpit.trust.down"
749
+ }, {
750
+ key: "k,up",
751
+ cmd: "cockpit.trust.up"
752
+ }, {
753
+ key: "return",
754
+ cmd: "cockpit.trust.enter"
755
+ }, {
756
+ key: "space",
757
+ cmd: "cockpit.trust.fold"
758
+ }, {
759
+ key: "right",
760
+ cmd: "cockpit.trust.right"
761
+ }, {
762
+ key: "h,left",
763
+ cmd: "cockpit.trust.left"
764
+ }, {
765
+ key: "l",
766
+ cmd: "cockpit.trust.l"
767
+ }, {
768
+ key: "tab",
769
+ cmd: "cockpit.trust.tab"
770
+ }, {
771
+ key: "/",
772
+ cmd: "cockpit.trust.filter"
773
+ }, {
774
+ key: "?,shift+/",
775
+ cmd: "cockpit.trust.keys"
776
+ }, {
777
+ key: "x",
778
+ cmd: "cockpit.trust.revoke"
779
+ }, {
780
+ key: "w",
781
+ cmd: "cockpit.trust.widen"
782
+ }, {
783
+ key: "c",
784
+ cmd: "cockpit.trust.copy"
785
+ }, {
786
+ key: "p",
787
+ cmd: "cockpit.trust.togglePause"
788
+ }, {
789
+ key: "q",
790
+ cmd: "cockpit.trust.close"
791
+ }]
792
+ });
793
+
794
+ /**
795
+ * Ahead of the keymap, because the host's dialog takes `esc` before any layer hears it (Shell's
796
+ * search does the same): the filter being typed gets every key; and `esc` steps back one thing at
797
+ * a time — the key list, the card's buttons, the filter, the ledger — before the host closes the
798
+ * dialog from the activity, as it always has.
799
+ */
800
+ api.lifecycle.onDispose(api.keymap.intercept(ctx => {
801
+ if (!dialog.open) return;
802
+ const event = ctx.event;
803
+ if (dialog.typing !== undefined) {
804
+ ctx.consume({
805
+ preventDefault: true,
806
+ stopPropagation: true
807
+ });
808
+ if (event.name === "escape") dialog.typing = undefined;else if (event.name === "return" || event.name === "enter") {
809
+ dialog.filter = dialog.typing.trim();
810
+ dialog.typing = undefined;
811
+ dialog.node = undefined;
812
+ } else if (event.name === "backspace") dialog.typing = dialog.typing.slice(0, -1);else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ") dialog.typing += event.sequence;
813
+ return draw();
814
+ }
815
+ if (event.name !== "escape") return;
816
+ if (dialog.keys) dialog.keys = false;else if (dialog.view === "activity") return;else if (dialog.button !== undefined) dialog.button = undefined;else if (dialog.filter !== "") {
817
+ dialog.filter = "";
818
+ dialog.node = undefined;
819
+ } else dialog.view = "activity";
820
+ ctx.consume({
821
+ preventDefault: true,
822
+ stopPropagation: true
823
+ });
824
+ dialog.notice = undefined;
825
+ draw();
826
+ }, {
827
+ priority: 10_000
828
+ }));
829
+ const openLedger = () => {
830
+ /** Config may have changed since: what the dialog says about "ask" rules should be today's. */
831
+ void loadRules();
832
+ Object.assign(dialog, {
833
+ open: true,
834
+ view: "activity",
835
+ keys: false,
836
+ notice: undefined,
837
+ activity: undefined,
838
+ typing: undefined,
839
+ button: undefined
840
+ });
841
+ paint();
842
+ api.ui.dialog.replace(() => _$createComponent(Dialog, {
843
+ api: api,
844
+ rows: dialogRows,
845
+ keys: dialogLayer,
846
+ onScroll: by => listed(() => move(by))(),
847
+ onClick: (x, y) => click(x, y)
848
+ }), () => {
849
+ dialog.open = false;
850
+ dialog.typing = undefined;
851
+ });
852
+ api.ui.dialog.setSize("xlarge");
853
+ log.debug("trust: open", {
854
+ items: shown.activity?.model.items.length ?? 0
855
+ });
856
+ };
857
+ api.keymap.registerLayer({
858
+ commands: [{
859
+ name: "cockpit.trust.ledger",
860
+ title: "Show what Trust answers for you",
861
+ category: "Cockpit · Trust",
862
+ namespace: "palette",
863
+ slashName: "trust",
864
+ run: () => openLedger()
865
+ }, {
866
+ name: "cockpit.trust.sidebar",
867
+ title: "Show or hide Trust in the sidebar",
868
+ desc: "for this session",
869
+ category: "Cockpit · Trust",
870
+ namespace: "palette",
871
+ run: () => {
872
+ inSidebar = !inSidebar;
873
+ log.debug("sidebar", {
874
+ shown: inSidebar
875
+ });
876
+ paint();
877
+ /** Said as well as drawn: on the home screen there is no sidebar to show it in. */
878
+ api.ui.toast({
879
+ variant: "info",
880
+ title: "Trust",
881
+ message: inSidebar ? "Shown in the sidebar." : "Hidden from the sidebar."
882
+ });
883
+ }
884
+ }, {
885
+ name: "cockpit.trust.pause",
886
+ title: "Pause or resume Trust in this project",
887
+ category: "Cockpit · Trust",
888
+ namespace: "palette",
889
+ run: () => togglePause()
890
+ }],
891
+ bindings: keys.gather("cockpit", Object.keys(DEFAULT_KEYS))
892
+ });
893
+ api.slots.register({
894
+ /** Between Subagents (150) and the shells (170) by default; lower draws first. */
895
+ order: sidebarOrder("trust", 160, config.sidebarOrder, {
896
+ directory
897
+ }),
898
+ slots: {
899
+ sidebar_content: () => _$createComponent(Rows, {
900
+ api: api,
901
+ rows: sidebarLines,
902
+ onReady: box => {
903
+ block = box;
904
+ draw();
905
+ }
906
+ })
907
+ }
908
+ });
909
+ api.lifecycle.onDispose(() => {
910
+ for (const waiting of parked.values()) clearTimeout(waiting.timer);
911
+ parked.clear();
912
+ clearInterval(ticking);
913
+ ticking = undefined;
914
+ feed.dispose();
915
+ });
916
+ };
917
+ }
918
+
919
+ /** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */
920
+ export default dualTui("opencode-cockpit.trust", createTrustTui());