@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,970 @@
1
+ /**
2
+ * The ledger, behind `l`: every rule Trust holds, as a tree of families, and a card that explains the
3
+ * one selected — always on screen, so there is no details key to find.
4
+ *
5
+ * Trust · opencode-cockpit 11 trusted · 14 learning · 101 seen once ● answering
6
+ * ────────────────────────────────────────┬─────────────────────────────────────────────────────
7
+ * FAMILIES / filter │ head -30
8
+ * ▾ head 1 ✓ 5 ○ │ ✓ Trusted for general · ready, not used yet
9
+ * ▌ head -30 ✓ trusted │
10
+ * head -40 ▰▰▱ 2 of 3 │ Exactly head -30
11
+ * + 3 more │ Still asks head -31 · head -30 > out.txt
12
+ * ▸ git status 1 ✓ 1 ○ │ History ✓ 9h ✓ 9h ✓ 9h → trusted
13
+ * ls -la ✓ trusted 1× │ 3 approvals in a row, all yours
14
+ * git push … ! ▰▰▰▰▰▱▱▱ 5 of 8 │ Family head · 6 commands, 1 trusted
15
+ * ! OpenCode always 2 broad rules │ x Revoke w Trust any head c Copy rule
16
+ * ────────────────────────────────────────┴─────────────────────────────────────────────────────
17
+ * [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [p] Pause [?] Keys [esc] Back
18
+ *
19
+ * **A command is one row.** The list it replaced split a family into what answers and what learns, so
20
+ * `head -30` was in one section while `head` was in the other, and the details repeated. Here a family
21
+ * is one fold, like a folder in an editor, and a command one row in it whatever its agents say; the
22
+ * card lists each agent's standing.
23
+ *
24
+ * **The card is the explanation.** The command whole, on a raised panel; where each agent stands; the
25
+ * exact text and what still asks; the approvals that earned it (history.ts); its family and what `w`
26
+ * would do; and the actions as buttons you can click or reach with `tab`.
27
+ *
28
+ * Below about ninety columns the card moves under the tree, the selection kept in view above it.
29
+ */
30
+
31
+ import { closeHint } from "@opencode-cockpit/client/design";
32
+ import { anyOf, narrower, outside, redirected, showSubject, spelledWords, widenable } from "../family.js";
33
+ import { earned } from "../history.js";
34
+ import { keyOf } from "../ledger.js";
35
+ import { NOT_COVERED, revokeLabel, widenLabel, widenScope } from "./actions.js";
36
+ import { alwaysGroups, commandsOf, countsOf, familiesOf, leadOf } from "./model.js";
37
+ import { agentsText, badge, button, buttonHits, chip, footerRow, headerRow, keyListBody, meter, muted, plain, plural, since, when, wrapRuns } from "./parts.js";
38
+ import { cursorRow, filled, fit, rowText, spread, widthOf } from "./rows.js";
39
+
40
+ /* ─── the tree ───────────────────────────────────────────────────────────────────────────────── */
41
+
42
+ /** Commands an open family lists before folding the rest into `+ N more`. */
43
+ export const TAIL = 3;
44
+ export const familyNodeKey = family => `f:${family.key}`;
45
+ export const commandNodeKey = command => `c:${command.key}`;
46
+ export const ALWAYS_KEY = "o:always";
47
+
48
+ /** A family drawn as its one command: one command, and no widening a heading would have to say. */
49
+ const single = family => family.commands.length === 1 && family.widened.length === 0;
50
+ const commandText = command => `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}`;
51
+ const familyText = family => `${family.permission === "bash" ? "" : `${family.permission} `}${showSubject(family.permission, family.family)}`;
52
+ export function explorerModel(input) {
53
+ const commands = commandsOf(input);
54
+ const families = familiesOf(input, commands);
55
+ const needle = input.filter.trim().toLowerCase();
56
+ const nodes = [];
57
+ for (const family of families) {
58
+ const named = needle !== "" && familyText(family).toLowerCase().includes(needle);
59
+ const listed = needle === "" || named ? family.commands : family.commands.filter(command => commandText(command).toLowerCase().includes(needle));
60
+ if (needle !== "" && listed.length === 0 && !named) continue;
61
+ if (single(family)) {
62
+ const command = family.commands[0];
63
+ nodes.push({
64
+ kind: "command",
65
+ key: commandNodeKey(command),
66
+ command,
67
+ family,
68
+ nested: false
69
+ });
70
+ continue;
71
+ }
72
+ const open = needle !== "" || input.open.has(family.key);
73
+ nodes.push({
74
+ kind: "family",
75
+ key: familyNodeKey(family),
76
+ family,
77
+ open
78
+ });
79
+ if (!open) continue;
80
+ const whole = needle !== "" || input.full.has(family.key) || listed.length <= TAIL + 1;
81
+ const shown = whole ? listed : listed.slice(0, TAIL);
82
+ for (const command of shown) nodes.push({
83
+ kind: "command",
84
+ key: commandNodeKey(command),
85
+ command,
86
+ family,
87
+ nested: true
88
+ });
89
+ if (!whole) nodes.push({
90
+ kind: "more",
91
+ key: `m:${family.key}`,
92
+ family,
93
+ hidden: listed.length - shown.length
94
+ });
95
+ }
96
+ const groups = alwaysGroups(input.state);
97
+ if (groups.length > 0 && (needle === "" || "opencode always".includes(needle) || groups.some(group => group.patterns.join(" ").toLowerCase().includes(needle)))) nodes.push({
98
+ kind: "always",
99
+ key: ALWAYS_KEY,
100
+ groups
101
+ });
102
+ return {
103
+ nodes,
104
+ families,
105
+ commands,
106
+ counts: countsOf(commands)
107
+ };
108
+ }
109
+
110
+ /** What `x`, `w` and `c` act on, for a node. */
111
+ export function nodeTarget(node) {
112
+ if (node.kind === "always") return {
113
+ kind: "always",
114
+ groups: node.groups
115
+ };
116
+ if (node.kind === "command") return {
117
+ kind: "command",
118
+ command: node.command,
119
+ family: node.family
120
+ };
121
+ return {
122
+ kind: "family",
123
+ family: node.family
124
+ };
125
+ }
126
+
127
+ /** The tree state that shows `command` with its family open, its tail too when it is folded there. */
128
+ export function reveal(tree, families, subject) {
129
+ for (const family of families) {
130
+ const at = family.commands.findIndex(command => command.permission === subject.permission && command.subject === subject.subject);
131
+ if (at < 0) continue;
132
+ if (!single(family)) {
133
+ tree.open.add(family.key);
134
+ if (at >= TAIL && family.commands.length > TAIL + 1) tree.full.add(family.key);
135
+ }
136
+ return commandNodeKey(family.commands[at]);
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ /* ─── a tree row ─────────────────────────────────────────────────────────────────────────────── */
142
+
143
+ /** The right-hand column of a tree row: how the command stands, or what a family holds. */
144
+ function statusRuns(node) {
145
+ if (node.kind === "more") return [];
146
+ if (node.kind === "always") return [{
147
+ text: plural(node.groups.reduce((sum, group) => sum + group.patterns.length, 0), "broad rule"),
148
+ tone: "warning"
149
+ }];
150
+ if (node.kind === "family") {
151
+ const trusted = node.family.commands.filter(command => command.phase === "answering").length;
152
+ const learning = node.family.commands.filter(command => command.phase === "learning").length;
153
+ const rest = node.family.commands.length - trusted;
154
+ return [...(trusted > 0 ? [{
155
+ text: `${trusted} ✓`,
156
+ tone: "success"
157
+ }] : []), ...(trusted > 0 && rest > 0 ? [{
158
+ text: " "
159
+ }] : []), ...(rest > 0 ? [{
160
+ text: `${rest} ○`,
161
+ tone: learning > 0 ? "warning" : "muted"
162
+ }] : [])];
163
+ }
164
+ const {
165
+ command
166
+ } = node;
167
+ const {
168
+ stand
169
+ } = leadOf(command);
170
+ const autos = command.standings.reduce((sum, each) => sum + each.entry.autos, 0);
171
+ if (stand.kind === "trusted") return [{
172
+ text: "✓ trusted",
173
+ tone: "success"
174
+ }, ...(autos > 0 ? [muted(` ${autos}×`)] : [])];
175
+ if (stand.kind === "widened") return [{
176
+ text: "✓ widened",
177
+ tone: "success"
178
+ }, ...(autos > 0 ? [muted(` ${autos}×`)] : [])];
179
+ if (stand.expired) return [muted("expired")];
180
+ /** A long meter says its count in the short form, so the command keeps the room. */
181
+ return [...meter(stand.have, stand.need, command.danger !== undefined), muted(stand.need > 4 ? ` ${stand.have}/${stand.need}` : ` ${stand.have} of ${stand.need}`)];
182
+ }
183
+
184
+ /**
185
+ * One row of the tree. `statusWidth` is the status column's, `badges` the cells before it kept for a
186
+ * dangerous command's `!` — so every meter and every `✓` starts in the same column.
187
+ */
188
+ function treeRow(node, width, statusWidth, badges, selected) {
189
+ let left;
190
+ if (node.kind === "family") {
191
+ left = [muted(` ${node.open ? "▾" : "▸"} `), {
192
+ text: familyText(node.family),
193
+ tone: node.family.commands.every(c => c.phase === "once") && node.family.widened.length === 0 ? "muted" : "text",
194
+ bold: true
195
+ }, ...(node.family.widened.length > 0 ? [{
196
+ text: " "
197
+ }, badge("any", "success")] : [])];
198
+ } else if (node.kind === "command") {
199
+ const indent = node.nested ? 5 : 3;
200
+ left = [{
201
+ text: " ".repeat(indent)
202
+ }, {
203
+ text: nestedText(node, width - indent - statusWidth - badges - 3),
204
+ tone: node.command.phase === "once" ? "muted" : "text"
205
+ }];
206
+ } else if (node.kind === "more") left = [muted(` + ${node.hidden} more`)];else left = [{
207
+ text: " ! ",
208
+ tone: "warning"
209
+ }, plain("OpenCode always")];
210
+ const status = statusRuns(node);
211
+ const danger = node.kind === "command" && node.command.danger !== undefined && node.command.phase !== "answering";
212
+ const right = [...(danger ? [badge("!", "error"), {
213
+ text: " "
214
+ }] : badges > 0 ? [{
215
+ text: " ".repeat(badges)
216
+ }] : []), ...status];
217
+ const padded = status.length === 0 ? [] : [...right, {
218
+ text: " ".repeat(Math.max(0, statusWidth - widthOf(rowText(status))))
219
+ }, {
220
+ text: " "
221
+ }];
222
+ const row = padded.length > 0 ? spread(left, padded, width) : fit(left, width);
223
+ return selected ? cursorRow(row, width) : row;
224
+ }
225
+
226
+ /**
227
+ * A command under its family's heading, in `room` columns. Cut from the right, two variants of one
228
+ * family read alike (`git status --sho…` twice), so a command that does not fit gives up the family's
229
+ * words first — the heading above already says them: `… --short -uno`.
230
+ */
231
+ function nestedText(node, room) {
232
+ const text = commandText(node.command);
233
+ if (widthOf(text) <= room || !node.nested) return text;
234
+ const family = familyText(node.family);
235
+ return text.startsWith(`${family} `) ? `…${text.slice(family.length)}` : text;
236
+ }
237
+ const statusWidthOf = (nodes, width) => Math.min(Math.floor(width * 0.45), Math.max(0, ...nodes.map(node => widthOf(rowText(statusRuns(node))))));
238
+
239
+ /** Cells for the `!` of a dangerous command among `nodes`: its badge and a space, or none. */
240
+ const badgesOf = nodes => nodes.some(node => node.kind === "command" && node.command.danger !== undefined && node.command.phase !== "answering") ? 4 : 0;
241
+
242
+ /* ─── the card ───────────────────────────────────────────────────────────────────────────────── */
243
+
244
+ /** `Still asks` and two spaces: the longest label. */
245
+ const LABEL = 12;
246
+
247
+ /** One labelled fact on the card: each line wraps on its own. `keep`: higher stays when room is short. */
248
+
249
+ /** A fact's rows at `width`: each line wrapped, a row of moments cut from its oldest end. */
250
+ function factLines(fact, width) {
251
+ return fact.lines.flatMap((line, at) => at === 0 && fact.newest ? [fit(tail(line, width), width)] : wrapRuns(line, width));
252
+ }
253
+ /** The buttons a node offers: `x`, `w` where a family can widen, `c`. */
254
+ export function buttonsOf(node, families) {
255
+ const target = nodeTarget(node);
256
+ const x = revokeLabel(target);
257
+ const out = [{
258
+ key: "x",
259
+ label: x.label,
260
+ off: x.off,
261
+ action: "revoke"
262
+ }];
263
+ if (node.kind !== "always") {
264
+ const w = widenLabel(widenScope(target, families));
265
+ out.push({
266
+ key: "w",
267
+ label: w.label,
268
+ off: w.off,
269
+ action: "widen"
270
+ });
271
+ }
272
+ out.push({
273
+ key: "c",
274
+ label: "Copy rule",
275
+ off: false,
276
+ action: "copy"
277
+ });
278
+ return out;
279
+ }
280
+
281
+ /** The exact text, and — when a font could draw a word of it as something else — that word in words. */
282
+ function exactly(command) {
283
+ const spelled = spelledWords(command.permission, command.subject);
284
+ return {
285
+ label: "Exactly",
286
+ keep: 3,
287
+ lines: [[plain(commandText(command)), ...(spelled.length > 0 ? [muted(` — ${spelled.map(each => `${each.word} is ${each.said}`).join(", ")}, which some fonts draw as one line`)] : [])]]
288
+ };
289
+ }
290
+
291
+ /** What still asks once a command is trusted exactly: a smaller one, and the same into a file. */
292
+ function stillAsks(command, widened) {
293
+ if (widened) return {
294
+ label: "Still asks",
295
+ keep: 6,
296
+ lines: [[muted(`${NOT_COVERED}.`)]]
297
+ };
298
+ if (command.permission === "edit") return {
299
+ label: "Still asks",
300
+ keep: 6,
301
+ lines: [[muted("any other file")]]
302
+ };
303
+ if (command.permission !== "bash") return {
304
+ label: "Still asks",
305
+ keep: 6,
306
+ lines: [[muted(`any other ${command.permission}`)]]
307
+ };
308
+ const examples = [narrower(command.subject), redirected(command.subject)].filter(each => each !== undefined);
309
+ const runs = [];
310
+ examples.forEach((example, index) => {
311
+ if (index > 0) runs.push(muted(" · "));
312
+ runs.push(plain(example));
313
+ });
314
+ runs.push(muted(examples.length > 0 ? " · any other argument" : "any other argument"));
315
+ return {
316
+ label: "Still asks",
317
+ keep: 6,
318
+ lines: [runs]
319
+ };
320
+ }
321
+
322
+ /**
323
+ * The moments that made a rule what it is, oldest to newest, the newest kept when they do not all fit:
324
+ * `✓ 9h ✓ 9h ✓ 9h → trusted answered 4×`.
325
+ */
326
+ export function historyRuns(marks, need, expireMs, now) {
327
+ const tokens = [];
328
+ let streak = 0;
329
+ let last = 0;
330
+ for (const each of marks) {
331
+ if (each.kind === "rejected") {
332
+ streak = 0;
333
+ tokens.push([{
334
+ text: "✗",
335
+ tone: "error"
336
+ }, muted(` ${since(now - each.at)}`)]);
337
+ continue;
338
+ }
339
+ if (each.kind === "revoked") {
340
+ streak = 0;
341
+ tokens.push([muted(`revoked ${since(now - each.at)}`)]);
342
+ continue;
343
+ }
344
+ if (expireMs > 0 && last > 0 && each.at - last > expireMs) {
345
+ streak = 0;
346
+ tokens.push([muted("expired")]);
347
+ }
348
+ last = Math.max(last, each.at);
349
+ if (each.kind === "auto") {
350
+ tokens.push([muted(`answered ${each.count}×`)]);
351
+ continue;
352
+ }
353
+ streak++;
354
+ tokens.push([{
355
+ text: "✓",
356
+ tone: "success"
357
+ }, muted(` ${since(now - each.at)}`)]);
358
+ if (streak === need) tokens.push([{
359
+ text: "→ trusted",
360
+ tone: "success"
361
+ }]);
362
+ }
363
+ return tokens.flatMap((token, at) => [...(at > 0 ? [{
364
+ text: " "
365
+ }] : []), ...token]);
366
+ }
367
+
368
+ /** The newest end of `runs` in `width` columns, `… ` in front when the oldest were left out. */
369
+ function tail(runs, width) {
370
+ if (widthOf(rowText(runs)) <= width) return [...runs];
371
+ const out = [];
372
+ let used = 2;
373
+ for (let i = runs.length - 1; i >= 0; i--) {
374
+ const run = runs[i];
375
+ const w = widthOf(run.text);
376
+ if (used + w > width) break;
377
+ out.unshift(run);
378
+ used += w;
379
+ }
380
+ while (out[0]?.text.trim() === "") out.shift();
381
+ return [muted("… "), ...out];
382
+ }
383
+
384
+ /** The sentence under the moments: why it stands where it does. */
385
+ function historySaid(standing, marks, reading) {
386
+ const {
387
+ entry,
388
+ stand
389
+ } = standing;
390
+ const {
391
+ settings,
392
+ now
393
+ } = reading;
394
+ const expireMs = settings.expireDays * 86_400_000;
395
+ if (marks.length === 0) return [muted(`approved ${entry.approvals}× in all, first ${when(now - entry.firstAt)} — older than what is kept`)];
396
+ if (stand.kind === "trusted") {
397
+ const got = earned(marks, Number.POSITIVE_INFINITY, expireMs);
398
+ return [muted(`${got.streak} ${got.streak === 1 ? "approval" : "approvals"} in a row, all yours`)];
399
+ }
400
+ if (stand.kind === "widened") return [muted(`answered through ${anyOf(entry.permission, stand.family)}, not by its own count`)];
401
+ if (stand.expired) return [muted(`unused over ${settings.expireDays} days, so it counts again from 0`)];
402
+ const got = earned(marks, stand.need, expireMs);
403
+ const left = stand.need - stand.have;
404
+ if (got.broken === "rejected" && got.brokenAt !== undefined) return [muted(`you rejected it ${when(now - got.brokenAt)}; ${stand.have} in a row since, ${left} more to go`)];
405
+ if (got.broken === "revoked" && got.brokenAt !== undefined) return [muted(`revoked ${when(now - got.brokenAt)}; ${stand.have} in a row since, ${left} more to go`)];
406
+ return [muted(`${stand.have} in a row, all yours · ${left} more and Trust answers it`)];
407
+ }
408
+
409
+ /** One agent's standing, on the card's panel. */
410
+ function standingRuns(standing, danger) {
411
+ const {
412
+ entry,
413
+ stand
414
+ } = standing;
415
+ if (stand.kind === "trusted") return [{
416
+ text: "✓ Trusted",
417
+ tone: "success",
418
+ bold: true
419
+ }, muted(" for "), chip(entry.agent), muted(entry.autos > 0 ? ` · answered ${entry.autos}×` : " · ready, not used yet")];
420
+ if (stand.kind === "widened") return [{
421
+ text: "✓ Answered",
422
+ tone: "success",
423
+ bold: true
424
+ }, muted(" for "), chip(entry.agent), muted(` · through ${anyOf(entry.permission, stand.family)}`)];
425
+ if (stand.expired) return [{
426
+ text: "○ Expired",
427
+ tone: "muted",
428
+ bold: true
429
+ }, muted(" for "), chip(entry.agent), muted(" · counting from 0")];
430
+ return [...meter(stand.have, stand.need, danger), {
431
+ text: ` ${stand.have} of ${stand.need}`,
432
+ tone: "text",
433
+ bold: true
434
+ }, muted(" for "), chip(entry.agent), muted(stand.have <= 1 && entry.autos === 0 ? " · seen once" : ` · ${stand.need - stand.have} more to go`)];
435
+ }
436
+ function familyFact(family, command, reading) {
437
+ if (!family) return undefined;
438
+ const trusted = family.commands.filter(each => each.phase === "answering").length;
439
+ const name = familyText(family);
440
+ const head = [plain(name), muted(` · ${plural(family.commands.length, "command")}, ${trusted} trusted`)];
441
+ const agent = command ? leadOf(command).entry.agent : family.commands[0] ? leadOf(family.commands[0]).entry.agent : undefined;
442
+ const widenedFor = family.widened.filter(each => agent === undefined || each.agent === agent);
443
+ if (widenedFor.length > 0) {
444
+ const at = Math.max(...widenedFor.map(each => each.at));
445
+ return {
446
+ label: "Family",
447
+ keep: 4,
448
+ lines: [head, [plain(anyOf(family.permission, family.family)), muted(` trusted for ${agentsText(widenedFor.map(each => each.agent))} ${when(reading.now - at)} · [w] undoes it`)]]
449
+ };
450
+ }
451
+ const can = widenable(family.permission, family.family);
452
+ if (!can.ok) return {
453
+ label: "Family",
454
+ keep: 4,
455
+ lines: [head, [muted(`never widened: ${can.why}`)]]
456
+ };
457
+ return {
458
+ label: "Family",
459
+ keep: 4,
460
+ lines: [head, [muted("[w] trusts "), plain(anyOf(family.permission, family.family)), muted(` for ${agent ?? "this agent"}${family.commands.length > 1 ? ", not one by one" : ""}`)]]
461
+ };
462
+ }
463
+ function commandCard(command, family, reading) {
464
+ const {
465
+ settings,
466
+ now,
467
+ history
468
+ } = reading;
469
+ const lead = leadOf(command);
470
+ const danger = command.danger !== undefined;
471
+ const expireMs = settings.expireDays * 86_400_000;
472
+ const marks = history.marks.get(keyOf(command.permission, lead.entry.agent, command.subject)) ?? [];
473
+ const need = lead.stand.kind === "counting" ? lead.stand.need : settings.threshold + (danger ? settings.dangerExtra : 0);
474
+ const facts = [exactly(command)];
475
+ if (command.phase === "answering") facts.push(stillAsks(command, lead.stand.kind === "widened"));
476
+ const many = command.standings.length > 1;
477
+ facts.push({
478
+ label: "History",
479
+ keep: 7,
480
+ newest: true,
481
+ lines: [marks.length > 0 ? historyRuns(marks, need, expireMs, now) : [muted("—")], [...(many ? [chip(lead.entry.agent), {
482
+ text: " "
483
+ }] : []), ...historySaid(lead, marks, reading)]]
484
+ });
485
+ if (danger && command.phase !== "answering") {
486
+ const can = family ? widenable(family.permission, family.family) : {
487
+ ok: true
488
+ };
489
+ facts.push({
490
+ label: "Dangerous",
491
+ keep: 5,
492
+ lines: [[muted(`${command.danger} — ${needOf(settings, true)} in a row instead of ${settings.threshold}${command.permission === "bash" && !can.ok && !family ? ", never widened" : ""}`)]]
493
+ });
494
+ }
495
+ const why = outside(command.permission, command.subject);
496
+ if (family && why !== undefined && family.widened.some(each => each.agent === lead.entry.agent)) facts.push({
497
+ label: "Widened",
498
+ keep: 5,
499
+ lines: [[muted("but "), plain(anyOf(command.permission, family.family)), muted(` leaves it out: ${why}`)]]
500
+ });
501
+ const fam = familyFact(family, command, reading);
502
+ if (fam) facts.push(fam);
503
+ if (lead.stand.kind === "trusted" && settings.expireDays > 0) {
504
+ const left = Math.max(0, Math.ceil((lead.entry.lastAt + expireMs - now) / 86_400_000));
505
+ facts.push({
506
+ label: "Expires",
507
+ keep: 1,
508
+ lines: [[muted(left >= settings.expireDays ? `if unused for ${settings.expireDays} days` : `if unused for ${left} more ${left === 1 ? "day" : "days"}`)]]
509
+ });
510
+ }
511
+ return {
512
+ title: [{
513
+ text: commandText(command),
514
+ tone: "text",
515
+ bold: true
516
+ }],
517
+ standing: command.standings.map(each => standingRuns(each, danger)),
518
+ facts,
519
+ buttons: []
520
+ };
521
+ }
522
+ const needOf = (settings, danger) => settings.threshold + (danger ? settings.dangerExtra : 0);
523
+ function familyCard(family, reading) {
524
+ const counts = countsOf(family.commands);
525
+ const standing = family.widened.length > 0 ? family.widened.map(each => [{
526
+ text: "✓ Any",
527
+ tone: "success",
528
+ bold: true
529
+ }, plain(` ${showSubject(family.permission, family.family)} …`), muted(" trusted for "), chip(each.agent), muted(` · you widened it ${when(reading.now - each.at)}`)]) : [[{
530
+ text: `${counts.trusted} trusted`,
531
+ tone: counts.trusted > 0 ? "success" : "muted",
532
+ bold: true
533
+ }, muted(" · "), {
534
+ text: `${counts.learning} learning`,
535
+ tone: counts.learning > 0 ? "warning" : "muted",
536
+ bold: true
537
+ }, muted(` · ${counts.once} seen once`)]];
538
+ const facts = [];
539
+ const list = [];
540
+ family.commands.forEach((command, at) => {
541
+ if (at > 0) list.push(muted(" · "));
542
+ list.push({
543
+ text: showSubject(command.permission, command.subject),
544
+ tone: command.phase === "answering" ? "success" : "text"
545
+ });
546
+ });
547
+ if (list.length > 0) facts.push({
548
+ label: "Commands",
549
+ keep: 7,
550
+ lines: [list]
551
+ });
552
+ const best = family.commands.find(command => command.phase === "learning");
553
+ if (best) {
554
+ const {
555
+ stand,
556
+ entry
557
+ } = leadOf(best);
558
+ if (stand.kind === "counting") facts.push({
559
+ label: "Closest",
560
+ keep: 5,
561
+ lines: [[plain(showSubject(best.permission, best.subject)), muted(`, ${stand.have} of ${stand.need} for ${entry.agent}`)]]
562
+ });
563
+ }
564
+ if (family.widened.length > 0) facts.push({
565
+ label: "Still asks",
566
+ keep: 6,
567
+ lines: [[muted(`${NOT_COVERED}.`)]]
568
+ });
569
+ const fam = familyFact(family, undefined, reading);
570
+ if (fam) facts.push({
571
+ ...fam,
572
+ label: "Widen",
573
+ lines: fam.lines.slice(1)
574
+ });
575
+ return {
576
+ title: [{
577
+ text: familyText(family),
578
+ tone: "text",
579
+ bold: true
580
+ }, muted(` family of ${plural(family.commands.length, "command")}`)],
581
+ standing,
582
+ facts,
583
+ buttons: []
584
+ };
585
+ }
586
+ function alwaysCard(groups, reading) {
587
+ const bash = groups.every(group => group.permission === "bash");
588
+ return {
589
+ title: [{
590
+ text: `OpenCode's own "always"`,
591
+ tone: "text",
592
+ bold: true
593
+ }],
594
+ standing: groups.map(group => [{
595
+ text: "! ",
596
+ tone: "warning",
597
+ bold: true
598
+ }, {
599
+ text: plural(group.patterns.length, "broad rule"),
600
+ tone: "warning",
601
+ bold: true
602
+ }, muted(" for "), chip(group.agent), muted(" · until OpenCode restarts")]),
603
+ facts: [...groups.map(group => ({
604
+ label: "Patterns",
605
+ keep: 7,
606
+ lines: [[...(group.permission === "bash" ? [] : [muted(`${group.permission} `)]), plain(group.patterns.join(" ")), muted(` ${when(reading.now - group.at)}`)]]
607
+ })), {
608
+ label: "Means",
609
+ keep: 6,
610
+ lines: [[muted(`OpenCode answers ${bash ? "every command that starts this way" : "everything these match"} — not only the one you approved.`)]]
611
+ }, {
612
+ label: "Until",
613
+ keep: 5,
614
+ lines: [[muted("OpenCode restarts. Trust cannot take it back: restarting OpenCode ends it.")]]
615
+ }],
616
+ buttons: []
617
+ };
618
+ }
619
+ export function cardOf(node, reading) {
620
+ const parts = node.kind === "always" ? alwaysCard(node.groups, reading) : node.kind === "command" ? commandCard(node.command, node.family, reading) : familyCard(node.family, reading);
621
+ return {
622
+ ...parts,
623
+ buttons: buttonsOf(node, reading.families)
624
+ };
625
+ }
626
+
627
+ /** The card's buttons as one row, the focused one solid. */
628
+ function buttonRow(buttons, focus, width) {
629
+ const runs = [{
630
+ text: " "
631
+ }];
632
+ buttons.forEach((each, at) => {
633
+ if (at > 0) runs.push({
634
+ text: " "
635
+ });
636
+ runs.push(...button(each.key, each.label, {
637
+ on: focus === at,
638
+ off: each.off
639
+ }));
640
+ });
641
+ return fit(runs, width);
642
+ }
643
+
644
+ /**
645
+ * The card in exactly `room` rows of `width`: the command whole (at most three rows), each agent's
646
+ * standing, the facts, the buttons. Short of room, the rows of air go first, then the least telling
647
+ * facts, then facts lose their second lines — never the title's first row or the buttons.
648
+ */
649
+ function cardRows(parts, width, room, focus) {
650
+ const panel = runs => filled(fit([{
651
+ text: " "
652
+ }, ...runs, {
653
+ text: " "
654
+ }], width), "panel");
655
+ const titleRows = wrapRuns(parts.title, Math.max(1, width - 2), 3).map(row => panel(row));
656
+ const standing = parts.standing.map(runs => panel(runs));
657
+ const textWidth = Math.max(1, width - 2 - LABEL);
658
+ const factRows = (fact, max) => {
659
+ const rows = factLines(fact, textWidth);
660
+ const kept = rows.length > max ? [...rows.slice(0, max - 1), fitLast(rows.slice(max - 1), textWidth)] : rows;
661
+ return kept.map((row, at) => fit([{
662
+ text: " "
663
+ }, muted((at === 0 ? fact.label : "").padEnd(LABEL)), ...row], width));
664
+ };
665
+ const buttons = buttonRow(parts.buttons, focus, width);
666
+ let facts = [...parts.facts];
667
+ const need = (list, full) => list.reduce((sum, fact) => sum + (full ? factLines(fact, textWidth).length : 1), 0);
668
+ let title = titleRows;
669
+ let stand = standing;
670
+ /** Rows of air: under the standing, and above the buttons. */
671
+ let air = 2;
672
+ const total = full => title.length + stand.length + air + need(facts, full) + 1;
673
+ while (total(false) > room && air > 0) air--;
674
+ while (total(false) > room && title.length > 1) title = title.slice(0, -1);
675
+ while (total(false) > room && stand.length > 1) stand = stand.slice(0, -1);
676
+ while (total(false) > room && facts.length > 0) {
677
+ const lowest = Math.min(...facts.map(fact => fact.keep));
678
+ const at = facts.findLastIndex(fact => fact.keep === lowest);
679
+ facts = facts.filter((_, index) => index !== at);
680
+ }
681
+ /** Every fact has a row; what is left goes to their second lines, in order. */
682
+ let spare = room - (title.length + stand.length + air + facts.length + 1);
683
+ const give = facts.map(() => 1);
684
+ facts.forEach((fact, index) => {
685
+ const want = factLines(fact, textWidth).length;
686
+ while (spare > 0 && give[index] < want) {
687
+ give[index] = give[index] + 1;
688
+ spare--;
689
+ }
690
+ });
691
+ const rows = [...title, ...stand];
692
+ if (air >= 1) rows.push(fit([], width));
693
+ facts.forEach((fact, index) => {
694
+ rows.push(...factRows(fact, give[index]));
695
+ });
696
+ /** The buttons sit right under the facts, and the card's spare room is below them. */
697
+ if (air >= 2) rows.push(fit([], width));
698
+ rows.push(buttons);
699
+ while (rows.length < room) rows.push(fit([], width));
700
+ return rows.slice(0, room);
701
+ }
702
+
703
+ /** Rows past what a fact was given, as its last row, cut with `…`. */
704
+ function fitLast(rows, width) {
705
+ /** Each row without the padding `fit` gave it, joined by one space. */
706
+ const bare = row => {
707
+ const out = [...row];
708
+ while (out.length > 0 && out.at(-1).text.trim() === "" && !out.at(-1).fill) out.pop();
709
+ const last = out.at(-1);
710
+ if (last) out[out.length - 1] = {
711
+ ...last,
712
+ text: last.text.trimEnd()
713
+ };
714
+ return out;
715
+ };
716
+ return fit(rows.flatMap((row, at) => [...(at > 0 ? [{
717
+ text: " "
718
+ }] : []), ...bare(row)]), width);
719
+ }
720
+
721
+ /** Where the buttons row landed in `cardRows`' output. */
722
+ const buttonsAt = rows => rows.findIndex(row => row.some(run => run.fill === "button" || run.fill === "buttonOn"));
723
+
724
+ /* ─── the screen ─────────────────────────────────────────────────────────────────────────────── */
725
+
726
+ /** At this width and wider the card is beside the tree; narrower, under it. */
727
+ export const WIDE = 90;
728
+ export const MIN_HEIGHT = 11;
729
+ export const EXPLORER_KEYS = [{
730
+ keys: ["j/k", "↑/↓"],
731
+ does: "Move through the families and their commands; the wheel moves too"
732
+ }, {
733
+ keys: ["←/h", "→/l"],
734
+ does: "Fold or open a family; ← on a command goes to its family"
735
+ }, {
736
+ keys: ["space", "enter"],
737
+ does: "Open or fold a family, or list the rest of it"
738
+ }, {
739
+ keys: ["tab"],
740
+ does: "Into the card's buttons, and back; ←/→ choose one, enter presses it"
741
+ }, {
742
+ keys: ["x"],
743
+ does: "Revoke what answers, or forget a count still learning"
744
+ }, {
745
+ keys: ["w"],
746
+ does: "Trust any command of the family, or go back to exact rules"
747
+ }, {
748
+ keys: ["c"],
749
+ does: "Copy the rule as opencode.json config"
750
+ }, {
751
+ keys: ["/"],
752
+ does: "Filter by text; esc clears it"
753
+ }, {
754
+ keys: ["p"],
755
+ does: "Pause Trust in this project, or resume it"
756
+ }, {
757
+ keys: ["?", "esc"],
758
+ does: "Hide these keys; esc goes back to the activity, and closes from there"
759
+ }];
760
+ const rule = width => fit([{
761
+ text: "─".repeat(width),
762
+ tone: "border"
763
+ }], width);
764
+ export function explorerRows(input) {
765
+ const {
766
+ width,
767
+ state
768
+ } = input;
769
+ const height = Math.max(MIN_HEIGHT, input.height);
770
+ const model = explorerModel(input);
771
+ const index = Math.max(0, model.nodes.findIndex(node => node.key === input.selected));
772
+ const node = model.nodes[index];
773
+ const reading = {
774
+ ...input,
775
+ families: model.families
776
+ };
777
+ const card = node ? cardOf(node, reading) : undefined;
778
+ const buttons = card?.buttons ?? [];
779
+ const {
780
+ counts
781
+ } = model;
782
+ const said = [{
783
+ text: `${counts.trusted} trusted`,
784
+ tone: counts.trusted > 0 ? "success" : "muted"
785
+ }, muted(" · "), {
786
+ text: `${counts.learning} learning`,
787
+ tone: counts.learning > 0 ? "warning" : "muted"
788
+ }, muted(` · ${counts.once} seen once`)];
789
+ const rows = [headerRow(input.project, state.paused, said, width)];
790
+ const hits = [];
791
+ const wide = width >= WIDE;
792
+ const focus = input.focus && buttons.length > 0 ? Math.min(input.focus.button, buttons.length - 1) : undefined;
793
+ if (input.keys) {
794
+ rows.push(rule(width));
795
+ rows.push(...keyListBody(EXPLORER_KEYS, width, height - 4));
796
+ rows.push(rule(width));
797
+ rows.push(footerRow([closeHint("Hide Keys")], width));
798
+ return {
799
+ rows,
800
+ ...(node ? {
801
+ node
802
+ } : {}),
803
+ model,
804
+ buttons,
805
+ hits,
806
+ wide
807
+ };
808
+ }
809
+ const body = height - 4;
810
+ const treeWidth = wide ? Math.max(34, Math.min(46, Math.round(width * 0.4))) : width;
811
+ const cardWidth = wide ? width - treeWidth - 1 : width;
812
+ /** Stacked: the card under the tree, as tall as it needs up to a little over half, the tree keeping three rows. */
813
+ const cardNeed = card ? cardHeight(card, cardWidth) : 1;
814
+ const cardRoom = wide ? body : Math.max(3, Math.min(cardNeed, Math.max(6, Math.ceil((body - 1) * 0.55)), body - 1 - 3));
815
+ const treeRoom = wide ? body : body - 1 - cardRoom;
816
+
817
+ /* the tree: a heading, then a window of nodes around the cursor */
818
+ const listRoom = Math.max(1, treeRoom - 1);
819
+ const top = Math.max(0, Math.min(index - Math.floor(listRoom / 2), model.nodes.length - listRoom));
820
+ const shown = model.nodes.slice(top, top + listRoom);
821
+ const headRight = input.typing !== undefined ? [{
822
+ text: `/ ${input.typing}`,
823
+ tone: "text"
824
+ }, {
825
+ text: "▍",
826
+ tone: "accent"
827
+ }, {
828
+ text: " "
829
+ }] : input.filter !== "" ? [{
830
+ text: `/ ${input.filter}`,
831
+ tone: "accent"
832
+ }, {
833
+ text: " "
834
+ }] : model.nodes.length > listRoom ? [muted(`${top + 1}–${top + shown.length} of ${model.nodes.length}`), {
835
+ text: " "
836
+ }] : [muted("/ filter"), {
837
+ text: " "
838
+ }];
839
+ const treeLines = [spread([muted(" FAMILIES")], headRight, treeWidth)];
840
+ const statusWidth = statusWidthOf(shown, treeWidth);
841
+ const badges = badgesOf(shown);
842
+ /** Nothing to list: a word in the tree, and in the card — where there is room — what to do about it. */
843
+ const nothing = input.filter !== "" ? `Nothing matches "${input.filter}". esc clears the filter.` : `Nothing learned yet. Approve the same command ${input.settings.threshold} times in a row and Trust answers it for you from then on — that exact command, for that agent. A dangerous one takes ${input.settings.threshold + input.settings.dangerExtra}.`;
844
+ if (model.nodes.length === 0) treeLines.push(fit([muted(input.filter !== "" ? " No match." : " Nothing yet.")], treeWidth));
845
+ for (const each of shown) treeLines.push(treeRow(each, treeWidth, statusWidth, badges, each === node));
846
+ while (treeLines.length < treeRoom) treeLines.push(fit([], treeWidth));
847
+ const cardLines = card ? cardRows(card, cardWidth, cardRoom, focus) : [...wrapRuns([muted(nothing)], Math.max(1, cardWidth - 3), Math.max(1, cardRoom)).map(row => fit([{
848
+ text: " "
849
+ }, ...row], cardWidth)), ...Array.from({
850
+ length: cardRoom
851
+ }, () => fit([], cardWidth))].slice(0, cardRoom);
852
+ const buttonsRow = buttonsAt(cardLines);
853
+ const actions = buttons.map(each => each.action);
854
+ if (wide) {
855
+ rows.push(fit([{
856
+ text: `${"─".repeat(treeWidth)}┬${"─".repeat(cardWidth)}`,
857
+ tone: "border"
858
+ }], width));
859
+ for (let i = 0; i < body; i++) {
860
+ rows.push([...treeLines[i], {
861
+ text: "│",
862
+ tone: "border"
863
+ }, ...cardLines[i]]);
864
+ const y = rows.length - 1;
865
+ const at = i - 1;
866
+ if (i >= 1 && at < shown.length) hits.push({
867
+ kind: "row",
868
+ y,
869
+ key: shown[at].key,
870
+ x0: 0,
871
+ x1: treeWidth
872
+ });
873
+ if (i === buttonsRow) for (const hit of buttonHits(cardLines[i], y, actions)) if (hit.kind === "button") hits.push({
874
+ ...hit,
875
+ x0: hit.x0 + treeWidth + 1,
876
+ x1: hit.x1 + treeWidth + 1
877
+ });
878
+ }
879
+ rows.push(input.notice ? fit([{
880
+ text: ` ${input.notice.text}`,
881
+ tone: input.notice.tone
882
+ }], width) : fit([{
883
+ text: `${"─".repeat(treeWidth)}┴${"─".repeat(cardWidth)}`,
884
+ tone: "border"
885
+ }], width));
886
+ } else {
887
+ rows.push(rule(width));
888
+ treeLines.forEach((line, i) => {
889
+ rows.push(line);
890
+ const at = i - 1;
891
+ if (i >= 1 && at < shown.length) hits.push({
892
+ kind: "row",
893
+ y: rows.length - 1,
894
+ key: shown[at].key
895
+ });
896
+ });
897
+ rows.push(rule(width));
898
+ cardLines.forEach((line, i) => {
899
+ rows.push(line);
900
+ if (i === buttonsRow) hits.push(...buttonHits(line, rows.length - 1, actions));
901
+ });
902
+ rows.push(input.notice ? fit([{
903
+ text: ` ${input.notice.text}`,
904
+ tone: input.notice.tone
905
+ }], width) : rule(width));
906
+ }
907
+ rows.push(footerRow(hintsFor(input, node, focus !== undefined, buttons), width));
908
+ return {
909
+ rows,
910
+ ...(node ? {
911
+ node
912
+ } : {}),
913
+ model,
914
+ buttons,
915
+ hits,
916
+ wide
917
+ };
918
+ }
919
+
920
+ /** Rows the card wants at this width, nothing cut. */
921
+ function cardHeight(card, width) {
922
+ const textWidth = Math.max(1, width - 2 - LABEL);
923
+ return wrapRuns(card.title, Math.max(1, width - 2), 3).length + card.standing.length + 2 + card.facts.reduce((sum, fact) => sum + factLines(fact, textWidth).length, 0) + 1;
924
+ }
925
+ function hintsFor(input, node, inCard, buttons) {
926
+ if (input.typing !== undefined) return [{
927
+ key: "enter",
928
+ label: "Apply",
929
+ priority: 2
930
+ }, closeHint("Cancel")];
931
+ if (inCard) return [{
932
+ key: "←/→",
933
+ label: "Button",
934
+ priority: 4
935
+ }, {
936
+ key: "enter",
937
+ label: "Press",
938
+ priority: 5
939
+ }, {
940
+ key: "tab",
941
+ label: "Tree",
942
+ priority: 3
943
+ }, closeHint("Back")];
944
+ const hints = [{
945
+ key: "↑/↓",
946
+ label: "Move",
947
+ priority: 6
948
+ }, ...(node && node.kind !== "always" ? [{
949
+ key: "←/→",
950
+ label: "Fold",
951
+ priority: 4
952
+ }] : []), ...(buttons.length > 0 ? [{
953
+ key: "tab",
954
+ label: "Card",
955
+ priority: 3
956
+ }] : []), {
957
+ key: "/",
958
+ label: "Filter",
959
+ priority: 2
960
+ }, {
961
+ key: "p",
962
+ label: input.state.paused ? "Resume" : "Pause",
963
+ priority: input.state.paused ? 5.5 : 1
964
+ }, {
965
+ key: "?",
966
+ label: "Keys",
967
+ priority: 5
968
+ }, closeHint(input.filter ? "Clear" : "Back")];
969
+ return hints;
970
+ }