@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,557 @@
1
+ /**
2
+ * The screen `/trust` opens on: what Trust did for you, newest first, then what it is about to do.
3
+ *
4
+ * Trust · opencode-cockpit ● answering
5
+ *
6
+ * TODAY Trust answered 4 prompts for you ▁▁▃▁▅▂█ last 7 days
7
+ * ▌09:41 ✓ git status --short build trusted since yesterday, 3 in a row
8
+ * 09:40 ✓ ls -la general in a family you widened: ls
9
+ *
10
+ * ALMOST THERE one more approval and Trust answers these
11
+ * ○ bun --version build ▰▰▱ 2 of 3
12
+ * ○ git push origin feat/trust build ▰▰▰▰▰▱▱▱ 5 of 8 dangerous
13
+ *
14
+ * ! WATCH OUT OpenCode's own "always" approves more than it looks, until it restarts
15
+ * ! find * sed * general [enter] what it covers
16
+ *
17
+ * RULES 11 trusted · 14 learning · 101 seen once l Open the ledger
18
+ *
19
+ * [enter] Why [x] Revoke [w] Trust Family [l] Ledger [p] Pause [?] Keys [esc] Close
20
+ *
21
+ * Transparency is the point of Trust, so it leads with it: the question a person opens it with is
22
+ * "what did it do while I was not looking?", and the first line answers it. What is close comes
23
+ * next, because that is what will happen next; OpenCode's own broad approvals get a band of their
24
+ * own, so they cannot be mistaken for Trust's rules; and managing rules is one key away, in the
25
+ * ledger (explorer.ts). Every list is a window that follows the cursor, so a short dialog still
26
+ * reaches everything.
27
+ */
28
+
29
+ import { closeHint } from "@opencode-cockpit/client/design";
30
+ import { showSubject } from "../family.js";
31
+ import { answersPerDay, dayOf, earned, latestAnswers } from "../history.js";
32
+ import { keyOf, needFor } from "../ledger.js";
33
+ import { revokeLabel, widenLabel, widenScope } from "./actions.js";
34
+ import { alwaysGroups, commandsOf, countsOf, familiesOf, leadOf } from "./model.js";
35
+ import { badge, button, buttonHits, chip, clock, dayWord, footerRow, headerRow, keyListBody, meter, muted, plain, since, sparkline, wrapRuns } from "./parts.js";
36
+ import { cursorRow, cut, fit, spread, widthOf } from "./rows.js";
37
+ /** Answers the feed holds: far more than a screen shows, for the cursor to scroll through. */
38
+ const FEED_MAX = 60;
39
+ const labelOf = answer => answer.items.map(item => showSubject(answer.permission, item.subject)).join(" && ");
40
+ export function activityModel(input) {
41
+ const {
42
+ history,
43
+ now
44
+ } = input;
45
+ const commands = commandsOf(input);
46
+ const families = familiesOf(input, commands);
47
+ const latest = latestAnswers(history, FEED_MAX * 4);
48
+ const todays = latest.filter(answer => dayOf(answer.at) === dayOf(now));
49
+ const feed = [];
50
+ for (const answer of todays.length > 0 ? todays : latest) {
51
+ const last = feed.at(-1);
52
+ /** The same line answered again and again is one row with a count, not a wall of it. */
53
+ if (last?.kind === "answer" && last.answer.agent === answer.agent && last.answer.permission === answer.permission && labelOf(last.answer) === labelOf(answer)) {
54
+ last.count++;
55
+ continue;
56
+ }
57
+ if (feed.length >= FEED_MAX) break;
58
+ feed.push({
59
+ kind: "answer",
60
+ key: `a:${answer.request}`,
61
+ answer,
62
+ count: 1
63
+ });
64
+ }
65
+ const almost = commands.filter(command => command.phase === "learning").sort((a, b) => Number(a.danger !== undefined) - Number(b.danger !== undefined) || distanceOf(a) - distanceOf(b) || b.lastAt - a.lastAt).map(command => ({
66
+ kind: "almost",
67
+ key: `c:${command.key}`,
68
+ command
69
+ }));
70
+ const always = alwaysGroups(input.state).map(group => ({
71
+ kind: "always",
72
+ key: `o:${group.key}`,
73
+ group
74
+ }));
75
+ return {
76
+ items: [...feed, ...almost, ...always],
77
+ feed,
78
+ almost,
79
+ always,
80
+ today: todays.length,
81
+ week: answersPerDay(history, now, 7),
82
+ counts: countsOf(commands),
83
+ commands,
84
+ families
85
+ };
86
+ }
87
+
88
+ /** Approvals still to go for the agent closest to trusting it. */
89
+ function distanceOf(command) {
90
+ const {
91
+ stand
92
+ } = leadOf(command);
93
+ return stand.kind === "counting" ? stand.expired ? Number.MAX_SAFE_INTEGER : stand.need - stand.have : 0;
94
+ }
95
+
96
+ /** What the cursor's item is, for `x`, `w` and `c`. */
97
+ export function targetOf(item, model) {
98
+ if (item.kind === "answer") return {
99
+ kind: "answer",
100
+ answer: item.answer
101
+ };
102
+ if (item.kind === "always") return {
103
+ kind: "always",
104
+ groups: [item.group]
105
+ };
106
+ const family = model.families.find(each => each.permission === item.command.permission && each.family === item.command.family);
107
+ return family ? {
108
+ kind: "command",
109
+ command: item.command,
110
+ family
111
+ } : {
112
+ kind: "command",
113
+ command: item.command
114
+ };
115
+ }
116
+
117
+ /* ─── why it answered ────────────────────────────────────────────────────────────────────────── */
118
+
119
+ /** `9h ago`, `since yesterday`, `since Tue`. */
120
+ function trustedWhen(at, now) {
121
+ const day = dayWord(at, now);
122
+ if (day === "today") return `${since(now - at)} ago`.replace(/^now ago$/, "just now");
123
+ return `since ${day.replace(/^on /, "")}`;
124
+ }
125
+
126
+ /**
127
+ * Why Trust answered, in a few muted words: the approvals that earned it and when, or the family you
128
+ * widened. Read from the rule's own moments (history.ts); when the window no longer reaches back that
129
+ * far, the reason the answering window logged.
130
+ */
131
+ export function answerWhy(answer, reading) {
132
+ const {
133
+ settings,
134
+ now,
135
+ history
136
+ } = reading;
137
+ const widened = answer.items.filter(item => item.via !== undefined);
138
+ const count = answer.items.length;
139
+ if (count > 1) {
140
+ if (widened.length === 0) return count === 2 ? "both commands trusted" : `all ${count} commands trusted`;
141
+ if (count === 2) return widened.length === 2 ? "both through families you widened" : "both trusted, one through a family you widened";
142
+ return `all ${count} trusted, ${widened.length} through a family you widened`;
143
+ }
144
+ const item = answer.items[0];
145
+ if (!item) return answer.rule;
146
+ if (item.via !== undefined) return `in a family you widened: ${showSubject(answer.permission, item.via)}`;
147
+ const marks = history.marks.get(keyOf(answer.permission, answer.agent, item.subject)) ?? [];
148
+ const need = needFor(item.danger, settings);
149
+ const got = earned(marks, need, settings.expireDays * 86_400_000, answer.at);
150
+ if (got.since === undefined) return answer.rule;
151
+ return `trusted ${trustedWhen(got.since, now)}, ${got.streak} in a row${item.danger ? " · dangerous" : ""}`;
152
+ }
153
+
154
+ /* ─── drawing ────────────────────────────────────────────────────────────────────────────────── */
155
+
156
+ /** The shortest dialog drawn: a header, a little of each list, the rules line and a way out. */
157
+ export const MIN_HEIGHT = 11;
158
+ export const ACTIVITY_KEYS = [{
159
+ keys: ["j/k", "↑/↓"],
160
+ does: "Move between answers, rules close to trusted and OpenCode's own approvals"
161
+ }, {
162
+ keys: ["enter"],
163
+ does: "Why: the rule's card in the ledger — its history, what still asks, what to do"
164
+ }, {
165
+ keys: ["x"],
166
+ does: "Revoke what answered, or forget a count still learning"
167
+ }, {
168
+ keys: ["w"],
169
+ does: "Trust any command of the family, or go back to exact rules"
170
+ }, {
171
+ keys: ["c"],
172
+ does: "Copy the rule as opencode.json config"
173
+ }, {
174
+ keys: ["l"],
175
+ does: "Open the ledger: every rule, by family"
176
+ }, {
177
+ keys: ["/"],
178
+ does: "Open the ledger and filter it"
179
+ }, {
180
+ keys: ["p"],
181
+ does: "Pause Trust in this project, or resume it"
182
+ }, {
183
+ keys: ["?", "esc"],
184
+ does: "Hide these keys; esc closes the dialog"
185
+ }];
186
+
187
+ /** One section of the screen: a heading and the rows of its items, a window that follows the cursor. */
188
+
189
+ export function activityRows(input) {
190
+ const {
191
+ width,
192
+ state,
193
+ settings,
194
+ now
195
+ } = input;
196
+ const height = Math.max(MIN_HEIGHT, input.height);
197
+ const model = activityModel(input);
198
+ const index = Math.max(0, model.items.findIndex(item => item.key === input.selected));
199
+ const item = model.items[index];
200
+ /** Paused, the badge alone does not say what that means: it keeps counting, and answers nothing. */
201
+ const rows = [headerRow(input.project, state.paused, state.paused ? [muted("counting, answering nothing")] : [], width)];
202
+ const hits = [];
203
+ if (input.keys) {
204
+ rows.push(fit([], width));
205
+ rows.push(...keyListBody(ACTIVITY_KEYS, width, height - 4));
206
+ rows.push(fit([], width));
207
+ rows.push(footerRow([closeHint("Hide Keys")], width));
208
+ return {
209
+ rows,
210
+ ...(item ? {
211
+ item
212
+ } : {}),
213
+ model,
214
+ hits
215
+ };
216
+ }
217
+ const columns = columnsOf(model, width, now);
218
+ const sections = [];
219
+
220
+ /* TODAY */
221
+ const week = model.week.some(count => count > 0);
222
+ const todayLeft = model.today > 0 ? [{
223
+ text: " TODAY",
224
+ tone: "text",
225
+ bold: true
226
+ }, muted(" Trust answered "), {
227
+ text: String(model.today),
228
+ tone: "success",
229
+ bold: true
230
+ }, muted(` ${model.today === 1 ? "prompt" : "prompts"} for you`)] : [{
231
+ text: " TODAY",
232
+ tone: "text",
233
+ bold: true
234
+ }, muted(model.feed.length > 0 ? " nothing answered yet today · the latest before" : " Nothing answered yet")];
235
+ sections.push({
236
+ heading: week ? spread(todayLeft, [...sparkline(model.week), muted(" last 7 days ")], width) : fit(todayLeft, width),
237
+ ...(model.feed.length === 0 ? {
238
+ quiet: wrapRuns([muted(`Approve the same command ${settings.threshold} times in a row and Trust answers it from then on — that exact command, for that agent.`)], Math.max(1, width - 3), 2).map(row => fit([{
239
+ text: " "
240
+ }, ...row], width))
241
+ } : {}),
242
+ items: model.feed,
243
+ more: model.today > 0 ? "earlier today" : "earlier"
244
+ });
245
+
246
+ /* ALMOST THERE */
247
+ if (model.almost.length > 0) {
248
+ const allClose = model.almost.every(each => each.kind === "almost" && distanceOf(each.command) === 1);
249
+ sections.push({
250
+ heading: fit([{
251
+ text: " ALMOST THERE",
252
+ tone: "text",
253
+ bold: true
254
+ }, muted(allClose ? " one more approval and Trust answers these" : " closest first")], width),
255
+ items: model.almost,
256
+ more: "in the ledger"
257
+ });
258
+ }
259
+
260
+ /* WATCH OUT */
261
+ if (model.always.length > 0) sections.push({
262
+ heading: fit([{
263
+ text: " "
264
+ }, badge("! WATCH OUT", "warning"), muted(` OpenCode's own "always" approves more than it looks, until it restarts`)], width),
265
+ items: model.always,
266
+ more: ""
267
+ });
268
+
269
+ /* RULES, and the way into the ledger */
270
+ const {
271
+ counts
272
+ } = model;
273
+ const said = counts.trusted + counts.learning + counts.once === 0 ? [muted("nothing learned yet")] : [{
274
+ text: `${counts.trusted} trusted`,
275
+ tone: counts.trusted > 0 ? "success" : "muted"
276
+ }, muted(" · "), {
277
+ text: `${counts.learning} learning`,
278
+ tone: counts.learning > 0 ? "warning" : "muted"
279
+ }, muted(` · ${counts.once} seen once`)];
280
+ const ledgerButton = button("l", width >= 72 ? "Open the ledger" : "Ledger");
281
+ const rulesRow = spread([{
282
+ text: " RULES",
283
+ tone: "text",
284
+ bold: true
285
+ }, {
286
+ text: " "
287
+ }, ...said], [...ledgerButton, {
288
+ text: " "
289
+ }], width);
290
+
291
+ /**
292
+ * Rows: the header and a row of air, each section's heading with a row of air above it, the rules
293
+ * line, a row for a notice, the keys. What is left is the lists'.
294
+ */
295
+ const fixed = 1 + 1 + 1 + 1 + 1;
296
+ let gaps = sections.length;
297
+ const quietRows = sections.reduce((sum, section) => sum + (section.quiet?.length ?? 0), 0);
298
+ let room = height - fixed - sections.length - gaps - quietRows;
299
+ /** Short of room for one row per list, the rows of air go first, the one under the header last. */
300
+ const want = sections.filter(section => section.items.length > 0).length;
301
+ let headerGap = true;
302
+ while (room < want && gaps > 0) {
303
+ gaps--;
304
+ room++;
305
+ }
306
+ if (room < want && headerGap) {
307
+ headerGap = false;
308
+ room++;
309
+ }
310
+ const allot = allotRows(sections, room, item);
311
+ if (headerGap) rows.push(fit([], width));
312
+ sections.forEach((section, at) => {
313
+ /** The rows of air that survived sit above the later sections, so the first heads the screen. */
314
+ if (at > 0 && at <= gaps) rows.push(fit([], width));
315
+ rows.push(section.heading);
316
+ for (const quiet of section.quiet ?? []) rows.push(quiet);
317
+ const shown = windowOf(section.items, allot[at] ?? 0, item);
318
+ for (const each of shown.items) {
319
+ const selected = each === item;
320
+ const row = itemRow(each, input, columns, width);
321
+ rows.push(selected ? cursorRow(row, width) : row);
322
+ hits.push({
323
+ kind: "row",
324
+ y: rows.length - 1,
325
+ key: each.key
326
+ });
327
+ }
328
+ if (shown.hidden > 0) rows.push(fit([muted(` + ${shown.hidden} more${section.more ? ` ${section.more}` : ""}`)], width));
329
+ });
330
+ if (gaps === sections.length) rows.push(fit([], width));
331
+ rows.push(rulesRow);
332
+ hits.push(...buttonHits(rulesRow, rows.length - 1, ["ledger"]));
333
+ /** Whatever the lists did, the notice row and the keys are the last two rows. */
334
+ const body = rows.slice(0, height - 2);
335
+ while (body.length < height - 2) body.push(fit([], width));
336
+ body.push(input.notice ? fit([{
337
+ text: ` ${input.notice.text}`,
338
+ tone: input.notice.tone ?? "muted"
339
+ }], width) : fit([], width));
340
+ body.push(footerRow(hintsFor(item, model, state.paused), width));
341
+ return {
342
+ rows: body,
343
+ ...(item ? {
344
+ item
345
+ } : {}),
346
+ model,
347
+ hits: hits.filter(hit => hit.y < height - 2)
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Rows for each section's list, out of `room`: one each first, then the feed up to five, what is close
353
+ * up to four, OpenCode's up to two, then whatever is left to whichever still has more. The section
354
+ * the cursor is in is never left without it.
355
+ */
356
+ function allotRows(sections, room, item) {
357
+ const need = sections.map(section => section.items.length);
358
+ const out = sections.map(() => 0);
359
+ let left = Math.max(0, room);
360
+ const give = (at, upTo) => {
361
+ while (left > 0 && out[at] < Math.min(upTo, need[at])) {
362
+ out[at] = out[at] + 1;
363
+ left--;
364
+ }
365
+ };
366
+ const mine = item ? sections.findIndex(section => section.items.includes(item)) : -1;
367
+ if (mine >= 0) give(mine, 1);
368
+ for (let at = 0; at < sections.length; at++) give(at, 1);
369
+ const caps = [5, 4, 2];
370
+ for (let at = 0; at < sections.length; at++) give(at, caps[at] ?? 2);
371
+ for (let at = 0; at < sections.length; at++) give(at, Number.MAX_SAFE_INTEGER);
372
+ return out;
373
+ }
374
+
375
+ /**
376
+ * The items a section shows in `rows`: all of them when they fit; otherwise a window around the cursor
377
+ * with its last row saying how many are not shown.
378
+ */
379
+ function windowOf(items, rows, item) {
380
+ if (items.length <= rows) return {
381
+ items: [...items],
382
+ hidden: 0
383
+ };
384
+ if (rows <= 0) return {
385
+ items: [],
386
+ hidden: 0
387
+ };
388
+ const take = rows === 1 ? 1 : rows - 1;
389
+ const at = item ? items.indexOf(item) : -1;
390
+ const start = at < 0 ? 0 : Math.max(0, Math.min(at - Math.floor(take / 2), items.length - take));
391
+ const shown = items.slice(start, start + take);
392
+ return {
393
+ items: shown,
394
+ hidden: rows === 1 ? 0 : items.length - shown.length
395
+ };
396
+ }
397
+
398
+ /* ─── one row ────────────────────────────────────────────────────────────────────────────────── */
399
+
400
+ const FEED_LEAD = 1 + 2 + 2;
401
+ const LIST_LEAD = 4;
402
+ function columnsOf(model, width, now) {
403
+ const time = Math.max(5, ...model.feed.map(item => item.kind === "answer" ? clock(item.answer.at, now).length : 0));
404
+ const agents = model.items.map(item => item.kind === "answer" ? item.answer.agent : item.kind === "almost" ? leadOf(item.command).entry.agent : item.group.agent);
405
+ const chipWidth = Math.max(0, ...agents.map(agent => widthOf(agent) + 2));
406
+ const leads = model.items.map(item => {
407
+ if (item.kind === "answer") return FEED_LEAD + time + widthOf(labelOf(item.answer)) + (item.count > 1 ? 4 : 0);
408
+ if (item.kind === "almost") return LIST_LEAD + widthOf(showSubject(item.command.permission, item.command.subject));
409
+ return LIST_LEAD + widthOf(item.group.patterns.join(" "));
410
+ });
411
+ /** The commands get what they need up to about half the row; the reasons take the rest. */
412
+ const chipAt = Math.min(Math.max(24, ...leads.map(lead => lead + 2)), Math.floor(width * 0.42));
413
+ const meterWidth = Math.max(0, ...model.almost.map(item => item.kind === "almost" ? needOf(item.command) : 0));
414
+ return {
415
+ time,
416
+ chipAt,
417
+ chip: chipWidth,
418
+ meter: meterWidth
419
+ };
420
+ }
421
+ function needOf(command) {
422
+ const {
423
+ stand
424
+ } = leadOf(command);
425
+ return stand.kind === "counting" ? stand.need : 0;
426
+ }
427
+
428
+ /** `text` in exactly `room` columns: cut with `…`, or padded. */
429
+ const cell = (text, room) => {
430
+ const shown = cut(text, room);
431
+ return `${shown}${" ".repeat(Math.max(0, room - widthOf(shown)))}`;
432
+ };
433
+ function chipCell(agent, columns) {
434
+ return [chip(agent), {
435
+ text: " ".repeat(Math.max(0, columns.chip - widthOf(agent) - 2))
436
+ }];
437
+ }
438
+ function itemRow(item, input, columns, width) {
439
+ if (item.kind === "answer") {
440
+ const {
441
+ answer
442
+ } = item;
443
+ const lead = FEED_LEAD + columns.time;
444
+ const count = item.count > 1 ? ` ${item.count}×` : "";
445
+ const room = Math.max(4, columns.chipAt - lead - 2 - count.length);
446
+ const label = labelOf(answer);
447
+ return fit([{
448
+ text: " "
449
+ }, muted(clock(answer.at, input.now).padStart(columns.time)), {
450
+ text: " "
451
+ }, {
452
+ text: "✓ ",
453
+ tone: "success"
454
+ }, plain(cut(label, room)), muted(count), {
455
+ text: " ".repeat(Math.max(0, room - widthOf(cut(label, room))))
456
+ }, {
457
+ text: " "
458
+ }, ...chipCell(answer.agent, columns), {
459
+ text: " "
460
+ }, muted(answerWhy(answer, input))], width);
461
+ }
462
+ if (item.kind === "almost") {
463
+ const {
464
+ command
465
+ } = item;
466
+ const {
467
+ stand,
468
+ entry
469
+ } = leadOf(command);
470
+ const danger = command.danger !== undefined;
471
+ const room = Math.max(4, columns.chipAt - LIST_LEAD - 2);
472
+ const counting = stand.kind === "counting" ? stand : undefined;
473
+ const name = `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}`;
474
+ return fit([{
475
+ text: " "
476
+ }, {
477
+ text: "○ ",
478
+ tone: danger ? "error" : "warning"
479
+ }, plain(cell(name, room)), {
480
+ text: " "
481
+ }, ...chipCell(entry.agent, columns), {
482
+ text: " "
483
+ }, ...(counting ? meter(counting.have, counting.need, danger) : []), {
484
+ text: " ".repeat(Math.max(0, columns.meter - (counting?.need ?? 0)))
485
+ }, muted(counting ? ` ${counting.have} of ${counting.need}` : ""), ...(danger ? [{
486
+ text: " "
487
+ }, badge("dangerous", "error")] : [])], width);
488
+ }
489
+ const {
490
+ group
491
+ } = item;
492
+ const room = Math.max(4, columns.chipAt - LIST_LEAD - 2);
493
+ return fit([{
494
+ text: " "
495
+ }, {
496
+ text: "! ",
497
+ tone: "warning"
498
+ }, plain(cell(`${group.permission === "bash" ? "" : `${group.permission} `}${group.patterns.join(" ")}`, room)), {
499
+ text: " "
500
+ }, ...chipCell(group.agent, columns), {
501
+ text: " "
502
+ }, ...(width - columns.chipAt - columns.chip > 26 ? [muted(" "), keyRun("enter"), muted(" what it covers")] : [])], width);
503
+ }
504
+ const keyRun = name => ({
505
+ text: `[${name}]`,
506
+ tone: "accent",
507
+ bold: true
508
+ });
509
+
510
+ /* ─── the keys ───────────────────────────────────────────────────────────────────────────────── */
511
+
512
+ function hintsFor(item, model, paused) {
513
+ const hints = [];
514
+ if (item) {
515
+ const target = targetOf(item, model);
516
+ hints.push({
517
+ key: "enter",
518
+ label: item.kind === "always" ? "What It Covers" : "Why",
519
+ priority: 5
520
+ });
521
+ const x = revokeLabel(target);
522
+ hints.push({
523
+ key: "x",
524
+ label: x.label,
525
+ priority: 4,
526
+ ...(x.off ? {
527
+ off: true
528
+ } : {})
529
+ });
530
+ const w = widenLabel(widenScope(target, model.families), false);
531
+ hints.push({
532
+ key: "w",
533
+ label: w.label.startsWith("Undo") ? "Undo Family" : "Trust Family",
534
+ priority: 3,
535
+ ...(w.off ? {
536
+ off: true
537
+ } : {})
538
+ });
539
+ }
540
+ hints.push({
541
+ key: "l",
542
+ label: "Ledger",
543
+ priority: 6
544
+ });
545
+ hints.push({
546
+ key: "p",
547
+ label: paused ? "Resume" : "Pause",
548
+ priority: paused ? 5.5 : 2
549
+ });
550
+ hints.push({
551
+ key: "?",
552
+ label: "Keys",
553
+ priority: 4.5
554
+ });
555
+ hints.push(closeHint());
556
+ return hints;
557
+ }