@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/dist/cli/preview.js +251 -0
  4. package/dist/core/adapt/seen.js +24 -0
  5. package/dist/core/adapt/v1.js +109 -0
  6. package/dist/core/adapt/v2.js +109 -0
  7. package/dist/core/config.js +93 -0
  8. package/dist/core/danger.js +366 -0
  9. package/dist/core/engine.js +245 -0
  10. package/dist/core/family.js +512 -0
  11. package/dist/core/history.js +169 -0
  12. package/dist/core/index.js +16 -0
  13. package/dist/core/keys.js +149 -0
  14. package/dist/core/ledger.js +202 -0
  15. package/dist/core/paths.js +38 -0
  16. package/dist/core/policy.js +128 -0
  17. package/dist/core/rules.js +152 -0
  18. package/dist/core/sample.js +311 -0
  19. package/dist/core/shell.js +253 -0
  20. package/dist/core/signature.js +38 -0
  21. package/dist/core/view/actions.js +321 -0
  22. package/dist/core/view/activity.js +557 -0
  23. package/dist/core/view/explorer.js +970 -0
  24. package/dist/core/view/model.js +193 -0
  25. package/dist/core/view/parts.js +290 -0
  26. package/dist/core/view/rows.js +165 -0
  27. package/dist/core/view/sidebar.js +170 -0
  28. package/dist/tui/index.js +920 -0
  29. package/dist/tui/journal.js +80 -0
  30. package/dist/tui/render.js +85 -0
  31. package/dist/tui/source.js +112 -0
  32. package/dist/tui/view/dialog.js +43 -0
  33. package/dist/tui/view/rows.js +69 -0
  34. package/package.json +61 -4
  35. package/tui.js +6 -0
  36. package/types/cli/preview.d.ts +22 -0
  37. package/types/core/adapt/seen.d.ts +46 -0
  38. package/types/core/adapt/v1.d.ts +18 -0
  39. package/types/core/adapt/v2.d.ts +20 -0
  40. package/types/core/config.d.ts +57 -0
  41. package/types/core/danger.d.ts +50 -0
  42. package/types/core/engine.d.ts +117 -0
  43. package/types/core/family.d.ts +97 -0
  44. package/types/core/history.d.ts +81 -0
  45. package/types/core/index.d.ts +16 -0
  46. package/types/core/keys.d.ts +68 -0
  47. package/types/core/ledger.d.ts +158 -0
  48. package/types/core/paths.d.ts +24 -0
  49. package/types/core/policy.d.ts +52 -0
  50. package/types/core/rules.d.ts +55 -0
  51. package/types/core/sample.d.ts +22 -0
  52. package/types/core/shell.d.ts +40 -0
  53. package/types/core/signature.d.ts +18 -0
  54. package/types/core/view/actions.d.ts +76 -0
  55. package/types/core/view/activity.d.ts +101 -0
  56. package/types/core/view/explorer.d.ts +154 -0
  57. package/types/core/view/model.d.ts +106 -0
  58. package/types/core/view/parts.d.ts +76 -0
  59. package/types/core/view/rows.d.ts +60 -0
  60. package/types/core/view/sidebar.d.ts +62 -0
  61. package/types/tui/index.d.ts +15 -0
  62. package/types/tui/journal.d.ts +19 -0
  63. package/types/tui/render.d.ts +14 -0
  64. package/types/tui/source.d.ts +35 -0
  65. package/types/tui/view/dialog.d.ts +24 -0
  66. package/types/tui/view/rows.d.ts +19 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * What both Trust screens read: every command Trust has counted, once, with where each agent stands
3
+ * on it; the families they group into; and the three numbers that sum a project up.
4
+ *
5
+ * **One command, one place.** The ledger used to split a family by section, so `head -30` was under
6
+ * Answers while `head` was also under Learning and its details repeated. Here a command is one
7
+ * `Command` whatever its agents say about it — trusted for one, two of three for another — and a
8
+ * family one `Family`. The screens draw each once.
9
+ */
10
+
11
+ import { familyOf, outside } from "../family.js";
12
+ import { keyOf, standing } from "../ledger.js";
13
+
14
+ /** Where one agent stands on one command. */
15
+
16
+ export function standOf(entry, {
17
+ state,
18
+ settings,
19
+ now
20
+ }) {
21
+ const where = standing(entry, entry.danger, settings, now);
22
+ if (where.trusted) return {
23
+ kind: "trusted"
24
+ };
25
+ const family = familyOf(entry.permission, entry.subject);
26
+ if (state.widened.has(keyOf(entry.permission, entry.agent, family)) && outside(entry.permission, entry.subject) === undefined) return {
27
+ kind: "widened",
28
+ family
29
+ };
30
+ return {
31
+ kind: "counting",
32
+ have: where.have,
33
+ need: where.need,
34
+ expired: where.expired
35
+ };
36
+ }
37
+ export const answers = stand => stand.kind !== "counting";
38
+
39
+ /** Approvals still needed; an expired count is as far as it gets. Zero: it answers. */
40
+ export const distance = stand => stand.kind !== "counting" ? 0 : stand.expired ? Number.MAX_SAFE_INTEGER : stand.need - stand.have;
41
+
42
+ /** One agent's standing on a command. */
43
+
44
+ /**
45
+ * Where a command is, all agents together:
46
+ * - `answering`: Trust answers it for at least one agent;
47
+ * - `learning`: counting, with approvals that matter (two in a row, or answered before);
48
+ * - `once`: approved once and never again — most never come back, so they are kept out of the way.
49
+ */
50
+
51
+ export const commandKey = (permission, subject) => JSON.stringify([permission, subject]);
52
+
53
+ /** The standing the command is drawn by: the one that answers, else the closest. */
54
+ export const leadOf = command => command.standings[0];
55
+
56
+ /** Approvals the closest agent still needs. */
57
+ export const closeness = command => distance(leadOf(command).stand);
58
+ function phaseOf(standings) {
59
+ if (standings.some(each => answers(each.stand))) return "answering";
60
+ const once = standings.every(each => each.stand.kind === "counting" && each.stand.have <= 1 && each.entry.autos === 0);
61
+ return once ? "once" : "learning";
62
+ }
63
+
64
+ /**
65
+ * Every command anyone approved or Trust answered, one each. A subject only ever rejected has
66
+ * nothing to show and is left out.
67
+ */
68
+ export function commandsOf(reading) {
69
+ const byKey = new Map();
70
+ for (const entry of reading.state.entries.values()) {
71
+ if (entry.approvals === 0 && entry.autos === 0) continue;
72
+ const key = commandKey(entry.permission, entry.subject);
73
+ const list = byKey.get(key) ?? [];
74
+ list.push({
75
+ entry,
76
+ stand: standOf(entry, reading)
77
+ });
78
+ byKey.set(key, list);
79
+ }
80
+ const out = [];
81
+ for (const [key, standings] of byKey) {
82
+ standings.sort((a, b) => distance(a.stand) - distance(b.stand) || b.entry.lastAt - a.entry.lastAt);
83
+ const first = standings[0].entry;
84
+ const danger = standings.find(each => each.entry.danger)?.entry.danger;
85
+ out.push({
86
+ key,
87
+ permission: first.permission,
88
+ subject: first.subject,
89
+ family: familyOf(first.permission, first.subject),
90
+ standings,
91
+ phase: phaseOf(standings),
92
+ ...(danger ? {
93
+ danger
94
+ } : {}),
95
+ lastAt: Math.max(...standings.map(each => each.entry.lastAt))
96
+ });
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** Answering newest first; then learning closest first, a dangerous one after the rest; then once. */
102
+ export function byPhase(a, b) {
103
+ const order = {
104
+ answering: 0,
105
+ learning: 1,
106
+ once: 2
107
+ };
108
+ return order[a.phase] - order[b.phase] || (a.phase === "learning" ? Number(a.danger !== undefined) - Number(b.danger !== undefined) || closeness(a) - closeness(b) : 0) || b.lastAt - a.lastAt;
109
+ }
110
+ export const familyKey = (permission, family) => JSON.stringify([permission, family]);
111
+
112
+ /** Where a family sorts: what answers, then what learns, then what is dangerous, then what was seen once. */
113
+ function familyRank(family) {
114
+ if (family.widened.length > 0) return 0;
115
+ const best = family.commands[0];
116
+ if (!best) return 4;
117
+ if (best.phase === "answering") return 0;
118
+ if (best.phase === "once") return 3;
119
+ return best.danger ? 2 : 1;
120
+ }
121
+
122
+ /**
123
+ * The commands grouped by family, with every widened family — even one with no command left. Families
124
+ * that answer first (newest first), then those learning (closest first), the dangerous ones after,
125
+ * and families seen only once last.
126
+ */
127
+ export function familiesOf(reading, commands = commandsOf(reading)) {
128
+ const families = new Map();
129
+ const familyFor = (permission, family) => {
130
+ const key = familyKey(permission, family);
131
+ let found = families.get(key);
132
+ if (!found) {
133
+ found = {
134
+ key,
135
+ permission,
136
+ family,
137
+ commands: [],
138
+ widened: [],
139
+ lastAt: 0
140
+ };
141
+ families.set(key, found);
142
+ }
143
+ return found;
144
+ };
145
+ for (const command of commands) {
146
+ const family = familyFor(command.permission, command.family);
147
+ family.commands.push(command);
148
+ family.lastAt = Math.max(family.lastAt, command.lastAt);
149
+ }
150
+ for (const widened of reading.state.widened.values()) {
151
+ const family = familyFor(widened.permission, widened.family);
152
+ family.widened.push(widened);
153
+ family.lastAt = Math.max(family.lastAt, widened.at);
154
+ }
155
+ for (const family of families.values()) family.commands.sort(byPhase);
156
+ const closest = family => family.commands[0] ? closeness(family.commands[0]) : 0;
157
+ return [...families.values()].sort((a, b) => familyRank(a) - familyRank(b) || (familyRank(a) === 1 || familyRank(a) === 2 ? closest(a) - closest(b) : 0) || b.lastAt - a.lastAt);
158
+ }
159
+
160
+ /** The project in three numbers, one per command. */
161
+
162
+ export function countsOf(commands) {
163
+ const counts = {
164
+ trusted: 0,
165
+ learning: 0,
166
+ once: 0
167
+ };
168
+ for (const command of commands) if (command.phase === "answering") counts.trusted++;else if (command.phase === "learning") counts.learning++;else counts.once++;
169
+ return counts;
170
+ }
171
+
172
+ /** OpenCode's own "always" approvals, one group per agent and permission, the newest first. */
173
+
174
+ export function alwaysGroups(state) {
175
+ const groups = new Map();
176
+ for (const always of state.always) {
177
+ const key = JSON.stringify([always.permission, always.agent]);
178
+ const found = groups.get(key);
179
+ if (found) {
180
+ for (const pattern of always.patterns) if (!found.patterns.includes(pattern)) found.patterns.push(pattern);
181
+ found.at = Math.max(found.at, always.at);
182
+ found.approvals.push(always);
183
+ } else groups.set(key, {
184
+ key,
185
+ permission: always.permission,
186
+ agent: always.agent,
187
+ patterns: [...always.patterns],
188
+ at: always.at,
189
+ approvals: [always]
190
+ });
191
+ }
192
+ return [...groups.values()].sort((a, b) => b.at - a.at);
193
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The pieces both Trust screens are built from: an agent's chip, a tinted badge, a button, a meter,
3
+ * the week's sparkline, the header and the key list.
4
+ *
5
+ * Colour came back to Trust as signal (docs/building/design-system.md): green is what answers, the
6
+ * warning what is close or needs a look, red what is dangerous. Each piece pairs its colour with a
7
+ * word or a shape — a meter beside `2 of 3`, a badge that says `dangerous` — so none of it is read
8
+ * by colour alone.
9
+ */
10
+
11
+ import { fitHints, GLYPH, keyName } from "@opencode-cockpit/client/design";
12
+ import { ago, fit, spread, widthOf } from "./rows.js";
13
+ export const muted = text => ({
14
+ text,
15
+ tone: "muted"
16
+ });
17
+ export const plain = text => ({
18
+ text,
19
+ tone: "text"
20
+ });
21
+ export const key = name => ({
22
+ text: `[${keyName(name)}]`,
23
+ tone: "accent",
24
+ bold: true
25
+ });
26
+
27
+ /** An agent, as a chip: its name in the info tone on a faint fill of the same. */
28
+ export const chip = agent => ({
29
+ text: ` ${agent} `,
30
+ tone: "info",
31
+ fill: "chip"
32
+ });
33
+
34
+ /** A word on a tint of its tone: ` ● answering `, ` dangerous `. */
35
+ export const badge = (text, tone) => ({
36
+ text: ` ${text} `,
37
+ tone,
38
+ fill: tone === "success" ? "ok" : tone === "warning" ? "warn" : "err",
39
+ bold: true
40
+ });
41
+
42
+ /**
43
+ * A button: its key, then what it does, on a raised fill — a run you can click as well as a key you
44
+ * can press. `on`: focused, drawn solid in the accent. `off`: nothing to act on here; dimmed rather
45
+ * than removed, because a button that comes and goes cannot be learned.
46
+ */
47
+ export function button(name, label, state = {}) {
48
+ if (state.on) return [{
49
+ text: ` ${name} `,
50
+ tone: "ink",
51
+ fill: "buttonOn",
52
+ bold: true
53
+ }, {
54
+ text: `${label} `,
55
+ tone: "ink",
56
+ fill: "buttonOn"
57
+ }];
58
+ return [{
59
+ text: ` ${name} `,
60
+ tone: state.off ? "muted" : "accent",
61
+ fill: "button",
62
+ bold: !state.off
63
+ }, {
64
+ text: `${label} `,
65
+ tone: state.off ? "muted" : "text",
66
+ fill: "button",
67
+ ...(state.off ? {
68
+ faint: true
69
+ } : {})
70
+ }];
71
+ }
72
+
73
+ /** How close: one `▰` per approval that counts, `▱` for each still to go. Dangerous ones in red. */
74
+ export function meter(have, need, danger) {
75
+ const filled = Math.max(0, Math.min(have, need));
76
+ return [...(filled > 0 ? [{
77
+ text: "▰".repeat(filled),
78
+ tone: danger ? "error" : "success"
79
+ }] : []), ...(need > filled ? [{
80
+ text: "▱".repeat(need - filled),
81
+ tone: "border"
82
+ }] : [])];
83
+ }
84
+ const BARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
85
+
86
+ /** A day per cell, scaled to the busiest: a day with none is the empty track, in the border tone. */
87
+ export function sparkline(counts) {
88
+ const most = Math.max(0, ...counts);
89
+ return counts.map(count => count === 0 || most === 0 ? {
90
+ text: BARS[0],
91
+ tone: "border"
92
+ } : {
93
+ text: BARS[Math.max(0, Math.ceil(count / most * BARS.length) - 1)],
94
+ tone: "success"
95
+ });
96
+ }
97
+
98
+ /** `9h`, `2d`, `now`: a time in a column of times, where `ago` on every one says nothing. */
99
+ export const since = ms => ago(ms).replace(/ ago$/, "");
100
+
101
+ /** `just now`, `15m ago`. */
102
+ export const when = ms => {
103
+ const said = ago(ms);
104
+ return said === "now" ? "just now" : said;
105
+ };
106
+ export const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
107
+ export const agentsText = agents => agents.length <= 1 ? agents[0] ?? "" : `${agents.slice(0, -1).join(", ")} and ${agents.at(-1)}`;
108
+ const pad2 = value => String(value).padStart(2, "0");
109
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
110
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
111
+
112
+ /** `09:41` today; `Tue 09:41` this week; `Sep 21` before that. Local time, as a clock on the wall. */
113
+ export function clock(at, now) {
114
+ const then = new Date(at);
115
+ const time = `${pad2(then.getHours())}:${pad2(then.getMinutes())}`;
116
+ const today = new Date(now);
117
+ if (then.toDateString() === today.toDateString()) return time;
118
+ if (now - at < 6 * 86_400_000) return `${WEEKDAYS[then.getDay()]} ${time}`;
119
+ return `${MONTHS[then.getMonth()]} ${then.getDate()}`;
120
+ }
121
+
122
+ /** `today`, `yesterday`, `on Tue`, `on Sep 21`: when, as a day. */
123
+ export function dayWord(at, now) {
124
+ const then = new Date(at);
125
+ const today = new Date(now);
126
+ if (then.toDateString() === today.toDateString()) return "today";
127
+ const yesterday = new Date(now);
128
+ yesterday.setDate(yesterday.getDate() - 1);
129
+ if (then.toDateString() === yesterday.toDateString()) return "yesterday";
130
+ if (now - at < 6 * 86_400_000) return `on ${WEEKDAYS[then.getDay()]}`;
131
+ return `on ${MONTHS[then.getMonth()]} ${then.getDate()}`;
132
+ }
133
+
134
+ /* ─── the header and the footer ──────────────────────────────────────────────────────────────── */
135
+
136
+ /**
137
+ * `Trust · project` on the left; on the right whatever the screen adds, then whether Trust is
138
+ * answering — the first thing to know, so it is a badge: `● answering`, or `○ paused`.
139
+ */
140
+ export function headerRow(project, paused, extra, width) {
141
+ const state = paused ? badge(`${GLYPH.ring} paused`, "warning") : badge(`${GLYPH.dot} answering`, "success");
142
+ const right = [...extra, ...(extra.length > 0 ? [{
143
+ text: " "
144
+ }] : []), state, {
145
+ text: " "
146
+ }];
147
+ /** Narrow, the screen's own words give way before the state does. */
148
+ const fits = widthOf(right.map(run => run.text).join("")) + 10 <= width;
149
+ return spread([{
150
+ text: " Trust",
151
+ tone: "text",
152
+ bold: true
153
+ }, ...(project ? [muted(` · ${project}`)] : [])], fits ? right : [state, {
154
+ text: " "
155
+ }], width);
156
+ }
157
+
158
+ /** The keys row: a cell of margin at each end, the way out last and never dropped. */
159
+ export function footerRow(hints, width) {
160
+ const fitted = fitHints(hints, Math.max(0, width - 2));
161
+ return fit([{
162
+ text: " "
163
+ }, ...fitted.runs], width);
164
+ }
165
+
166
+ /* ─── the key list ───────────────────────────────────────────────────────────────────────────── */
167
+
168
+ /** `runs` wrapped at spaces into rows of `width`, at most `max`; the last says `…` when cut. */
169
+ export function wrapRuns(runs, width, max = Number.POSITIVE_INFINITY) {
170
+ const words = [];
171
+ for (const run of runs) {
172
+ /** A command shown as an example is not broken across rows when it fits on one. */
173
+ if ((run.tone === "text" || run.fill !== undefined && run.fill !== "none") && widthOf(run.text) <= width) {
174
+ words.push(run);
175
+ continue;
176
+ }
177
+ for (const part of run.text.split(/(?<= )/)) if (part !== "") words.push({
178
+ ...run,
179
+ text: part
180
+ });
181
+ }
182
+ const lines = [[]];
183
+ let used = 0;
184
+ for (const word of words) {
185
+ const w = widthOf(word.text.trimEnd());
186
+ if (used > 0 && used + w > width) {
187
+ lines.push([]);
188
+ used = 0;
189
+ }
190
+ ;
191
+ lines.at(-1).push(word);
192
+ used += widthOf(word.text);
193
+ }
194
+ /** A row's last space is not part of it: counted, it pushed a word that fit exactly into `…`. */
195
+ for (const line of lines) {
196
+ const last = line.at(-1);
197
+ if (last && !last.fill) line[line.length - 1] = {
198
+ ...last,
199
+ text: last.text.trimEnd()
200
+ };
201
+ }
202
+ if (lines.length <= max) return lines.map(line => fit(line, width));
203
+ const kept = lines.slice(0, max);
204
+ const rest = lines.slice(max).flat();
205
+ /** The overflow joins the last row, which `fit` then cuts with `…`; the space its end lost goes back. */
206
+ kept[max - 1] = [...kept[max - 1], {
207
+ text: " "
208
+ }, ...rest];
209
+ return kept.map(line => fit(line, width));
210
+ }
211
+
212
+ /** Every key a screen takes, one line each, what each does wrapped under its column rather than cut. */
213
+ export function keyListRows(list, width) {
214
+ const column = Math.max(...list.map(each => widthOf(each.keys.map(name => `[${keyName(name)}]`).join(" "))));
215
+ const indent = 1 + column + 3;
216
+ return list.map(each => {
217
+ const keys = each.keys.flatMap((name, at) => [...(at > 0 ? [{
218
+ text: " "
219
+ }] : []), key(name)]);
220
+ const pad = " ".repeat(column - widthOf(keys.map(run => run.text).join("")) + 3);
221
+ return wrapRuns([plain(each.does)], Math.max(1, width - indent - 1)).map((row, at) => fit([{
222
+ text: " "
223
+ }, ...(at === 0 ? [...keys, {
224
+ text: pad
225
+ }] : [{
226
+ text: " ".repeat(indent - 1)
227
+ }]), ...row], width));
228
+ });
229
+ }
230
+
231
+ /** As many key lines as fit in `room` rows; a list cut short says how many keys are below. */
232
+ export function keyListBody(list, width, room) {
233
+ const each = keyListRows(list, width);
234
+ const rows = [];
235
+ let shown = 0;
236
+ for (const rowsOf of each) {
237
+ const last = shown === each.length - 1;
238
+ if (rows.length + rowsOf.length > (last ? room : room - 1)) break;
239
+ rows.push(...rowsOf);
240
+ shown++;
241
+ }
242
+ if (shown < each.length) rows.push(fit([muted(` ↓ ${plural(each.length - shown, "more key")} below`)], width));
243
+ while (rows.length < room) rows.push(fit([], width));
244
+ return rows.slice(0, room);
245
+ }
246
+
247
+ /* ─── where a click lands ────────────────────────────────────────────────────────────────────── */
248
+
249
+ /** Something on screen a click acts on: a row to select, or a button to press. */
250
+
251
+ /** The buttons in a row of runs, by where each starts and ends. `actions`: one per button, in order. */
252
+ export function buttonHits(row, y, actions) {
253
+ const hits = [];
254
+ let x = 0;
255
+ let at = -1;
256
+ let open;
257
+ for (const run of row) {
258
+ const w = widthOf(run.text);
259
+ const isButton = run.fill === "button" || run.fill === "buttonOn";
260
+ if (isButton && !open) {
261
+ open = {
262
+ x0: x
263
+ };
264
+ at++;
265
+ }
266
+ if (!isButton && open) {
267
+ const action = actions[at];
268
+ if (action) hits.push({
269
+ kind: "button",
270
+ y,
271
+ x0: open.x0,
272
+ x1: x,
273
+ action
274
+ });
275
+ open = undefined;
276
+ }
277
+ x += w;
278
+ }
279
+ if (open) {
280
+ const action = actions[at];
281
+ if (action) hits.push({
282
+ kind: "button",
283
+ y,
284
+ x0: open.x0,
285
+ x1: x,
286
+ action
287
+ });
288
+ }
289
+ return hits;
290
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Rows of styled runs — every Trust surface, pure so a test can measure it.
3
+ *
4
+ * Tones name a meaning, never a colour; `tui/render.ts` is the only place that knows what a tone
5
+ * looks like, so the preview CLI and OpenCode draw the same rows (docs/building/terminal-ui.md).
6
+ * The helpers are Subagents' (`core/view/rows.ts`), copied rather than imported: bays never import
7
+ * each other.
8
+ */
9
+
10
+ /**
11
+ * What sits behind a run. Two cover a whole row — the row under the cursor and the card's raised
12
+ * panel — and the rest a few cells: an agent's chip, a button, a focused button, and the tinted
13
+ * badges that say success, warning and error. A tint is the tone's colour faded into the dialog's
14
+ * own, so it follows the theme (`TINTS`; `tui/render.ts` mixes it).
15
+ */
16
+
17
+ /** The fills that belong to a row rather than a run: what a row is padded with, and what a cursor replaces. */
18
+ const ROW_FILLS = new Set(["selected", "panel"]);
19
+ const isRowFill = fill => fill !== undefined && ROW_FILLS.has(fill);
20
+
21
+ /**
22
+ * The tinted fills: which tone each is made of, and how much of it is mixed into the dialog's
23
+ * background. Little enough that the tone's own text on it stays legible; enough to be seen.
24
+ */
25
+ export const TINTS = {
26
+ chip: {
27
+ tone: "info",
28
+ amount: 0.12
29
+ },
30
+ ok: {
31
+ tone: "success",
32
+ amount: 0.16
33
+ },
34
+ warn: {
35
+ tone: "warning",
36
+ amount: 0.16
37
+ },
38
+ err: {
39
+ tone: "error",
40
+ amount: 0.16
41
+ }
42
+ };
43
+ export const rowText = row => row.map(run => run.text).join("");
44
+
45
+ /** Columns a string takes. Wide characters (CJK, most emoji) take two. */
46
+ export function widthOf(text, limit = Number.POSITIVE_INFINITY) {
47
+ let width = 0;
48
+ for (const char of text) {
49
+ if (width > limit) return width;
50
+ const code = char.codePointAt(0) ?? 0;
51
+ width += code >= 0x1100 && (code <= 0x115f || code >= 0x2e80 && code <= 0xa4cf || code >= 0xac00 && code <= 0xd7a3 || code >= 0xf900 && code <= 0xfaff || code >= 0xfe30 && code <= 0xfe4f || code >= 0xff00 && code <= 0xff60 || code >= 0xffe0 && code <= 0xffe6 || code >= 0x1f300 && code <= 0x1faff || code >= 0x20000 && code <= 0x3fffd) ? 2 : 1;
52
+ }
53
+ return width;
54
+ }
55
+
56
+ /** `text` in at most `width` columns, ending in `…` when cut. */
57
+ export function cut(text, width) {
58
+ if (width <= 0) return "";
59
+ if (widthOf(text, width) <= width) return text;
60
+ let out = "";
61
+ let used = 0;
62
+ for (const char of text) {
63
+ const w = widthOf(char);
64
+ if (used + w > width - 1) break;
65
+ out += char;
66
+ used += w;
67
+ }
68
+ return `${out}…`;
69
+ }
70
+
71
+ /**
72
+ * A row exactly `width` columns wide: runs cut where they overflow, padded in the last run's fill
73
+ * where they fall short. Every row every surface draws goes through here; the grid test holds it.
74
+ */
75
+ export function fit(row, width) {
76
+ const out = [];
77
+ let used = 0;
78
+ for (const run of row) {
79
+ if (used >= width) break;
80
+ const room = width - used;
81
+ const w = widthOf(run.text, room);
82
+ if (w <= room) {
83
+ out.push(run);
84
+ used += w;
85
+ } else {
86
+ const text = cut(run.text, room);
87
+ out.push({
88
+ ...run,
89
+ text
90
+ });
91
+ used += widthOf(text);
92
+ break;
93
+ }
94
+ }
95
+ if (used < width) {
96
+ const fill = rowFillOf(out);
97
+ out.push({
98
+ text: " ".repeat(width - used),
99
+ ...(fill ? {
100
+ fill
101
+ } : {})
102
+ });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** The fill a row is drawn on: the last row-wide fill among its runs. A chip at its end is not one. */
108
+ export function rowFillOf(row) {
109
+ for (let i = row.length - 1; i >= 0; i--) {
110
+ const fill = row[i]?.fill;
111
+ if (isRowFill(fill)) return fill;
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /** Left and right parts on one row, the right part flush against the edge; the left gives way. */
117
+ export function spread(left, right, width) {
118
+ const rightWidth = widthOf(rowText(right));
119
+ if (rightWidth >= width) return fit(right, width);
120
+ const leftRoom = width - rightWidth - 1;
121
+ const shown = fit(left, leftRoom);
122
+ const fill = rowFillOf([...left, ...right]);
123
+ return [...shown, {
124
+ text: " ",
125
+ ...(fill ? {
126
+ fill
127
+ } : {})
128
+ }, ...right];
129
+ }
130
+
131
+ /**
132
+ * A row on one surface — the row under the cursor, the card's panel. A run with a fill of its own
133
+ * (a chip, a badge, a button) keeps it: the surface is what is behind them.
134
+ */
135
+ export const filled = (row, fill) => row.map(run => run.fill === undefined || run.fill === "none" || isRowFill(run.fill) ? {
136
+ ...run,
137
+ fill
138
+ } : run);
139
+
140
+ /**
141
+ * The row under the cursor: `▌` in its first cell — the margin every row keeps for it — and the
142
+ * selected fill to both edges (docs/building/design-system.md: `▌` is the cursor and nothing else).
143
+ */
144
+ export function cursorRow(row, width) {
145
+ const [first, ...rest] = row;
146
+ const trimmed = !first ? [] : widthOf(first.text) > 1 ? [{
147
+ ...first,
148
+ text: [...first.text].slice(1).join("")
149
+ }, ...rest] : rest;
150
+ return filled(fit([{
151
+ text: "▌",
152
+ tone: "accent"
153
+ }, ...trimmed], width), "selected");
154
+ }
155
+
156
+ /** How long ago, in the fewest characters that still read: `now`, `5m`, `2h`, `3d`. */
157
+ export function ago(ms) {
158
+ const s = Math.max(0, Math.floor(ms / 1000));
159
+ if (s < 60) return "now";
160
+ const m = Math.floor(s / 60);
161
+ if (m < 60) return `${m}m ago`;
162
+ const h = Math.floor(m / 60);
163
+ if (h < 48) return `${h}h ago`;
164
+ return `${Math.floor(h / 24)}d ago`;
165
+ }