@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.
- package/README.md +240 -5
- 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 +49 -3
- 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,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
|
+
}
|