@opencode-cockpit/trust 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/dist/cli/preview.js +251 -0
- package/dist/core/adapt/seen.js +24 -0
- package/dist/core/adapt/v1.js +109 -0
- package/dist/core/adapt/v2.js +109 -0
- package/dist/core/config.js +93 -0
- package/dist/core/danger.js +366 -0
- package/dist/core/engine.js +245 -0
- package/dist/core/family.js +512 -0
- package/dist/core/history.js +169 -0
- package/dist/core/index.js +16 -0
- package/dist/core/keys.js +149 -0
- package/dist/core/ledger.js +202 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/policy.js +128 -0
- package/dist/core/rules.js +152 -0
- package/dist/core/sample.js +311 -0
- package/dist/core/shell.js +253 -0
- package/dist/core/signature.js +38 -0
- package/dist/core/view/actions.js +321 -0
- package/dist/core/view/activity.js +557 -0
- package/dist/core/view/explorer.js +970 -0
- package/dist/core/view/model.js +193 -0
- package/dist/core/view/parts.js +290 -0
- package/dist/core/view/rows.js +165 -0
- package/dist/core/view/sidebar.js +170 -0
- package/dist/tui/index.js +920 -0
- package/dist/tui/journal.js +80 -0
- package/dist/tui/render.js +85 -0
- package/dist/tui/source.js +112 -0
- package/dist/tui/view/dialog.js +43 -0
- package/dist/tui/view/rows.js +69 -0
- package/package.json +61 -4
- package/tui.js +6 -0
- package/types/cli/preview.d.ts +22 -0
- package/types/core/adapt/seen.d.ts +46 -0
- package/types/core/adapt/v1.d.ts +18 -0
- package/types/core/adapt/v2.d.ts +20 -0
- package/types/core/config.d.ts +57 -0
- package/types/core/danger.d.ts +50 -0
- package/types/core/engine.d.ts +117 -0
- package/types/core/family.d.ts +97 -0
- package/types/core/history.d.ts +81 -0
- package/types/core/index.d.ts +16 -0
- package/types/core/keys.d.ts +68 -0
- package/types/core/ledger.d.ts +158 -0
- package/types/core/paths.d.ts +24 -0
- package/types/core/policy.d.ts +52 -0
- package/types/core/rules.d.ts +55 -0
- package/types/core/sample.d.ts +22 -0
- package/types/core/shell.d.ts +40 -0
- package/types/core/signature.d.ts +18 -0
- package/types/core/view/actions.d.ts +76 -0
- package/types/core/view/activity.d.ts +101 -0
- package/types/core/view/explorer.d.ts +154 -0
- package/types/core/view/model.d.ts +106 -0
- package/types/core/view/parts.d.ts +76 -0
- package/types/core/view/rows.d.ts +60 -0
- package/types/core/view/sidebar.d.ts +62 -0
- package/types/tui/index.d.ts +15 -0
- package/types/tui/journal.d.ts +19 -0
- package/types/tui/render.d.ts +14 -0
- package/types/tui/source.d.ts +35 -0
- package/types/tui/view/dialog.d.ts +24 -0
- 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
|
+
}
|