@azure-id/orc 1.2.0 → 1.4.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/CHANGELOG.md +296 -0
- package/README-id.md +44 -0
- package/README.md +54 -78
- package/bin/cli.js +2615 -5
- package/bin/verify-contracts.js +60 -1
- package/bin/verify-package.js +588 -568
- package/bin/webui/api.js +71 -0
- package/bin/webui/app.html +213 -210
- package/bin/webui/css/06-responsive.css +28 -0
- package/bin/webui/css/panels/hookui.css +379 -0
- package/bin/webui/fixtures/hookui.js +409 -0
- package/bin/webui/fixtures/index.js +17 -0
- package/bin/webui/i18n/en/hookui.json +119 -0
- package/bin/webui/i18n/en/nav.json +22 -21
- package/bin/webui/i18n/en/overview.json +4 -1
- package/bin/webui/i18n/en/tour.json +3 -1
- package/bin/webui/i18n/id/hookui.json +119 -0
- package/bin/webui/i18n/id/nav.json +22 -21
- package/bin/webui/i18n/id/overview.json +4 -1
- package/bin/webui/i18n/id/tour.json +3 -1
- package/bin/webui/js/01-i18n.js +152 -151
- package/bin/webui/js/90-tour.js +6 -0
- package/bin/webui/js/panels/hookui.js +891 -0
- package/bin/webui/js/panels/overview.js +7 -0
- package/package.json +1 -1
- package/templates/hooks/README.md +280 -0
- package/templates/hooks/orc-statusline-render.js +921 -0
- package/templates/hooks/orc-statusline.js +1090 -87
- package/templates/hooks/orc-subagent-line.js +219 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* panels/hookui.js — orc ui client
|
|
3
|
+
The CLI Hook Interface: compose ORC's status line.
|
|
4
|
+
|
|
5
|
+
THE ONE DESIGN DECISION THIS PANEL IS BUILT AROUND: you are composing
|
|
6
|
+
something you cannot see while you compose it. The bar lives in a terminal at
|
|
7
|
+
the bottom of a different window. Every other ORC panel shows you STATE; this
|
|
8
|
+
one has to show you a RESULT — continuously, accurately, and in the forms it
|
|
9
|
+
can take. If the preview is not what the hook will print, this panel is worse
|
|
10
|
+
than editing JSON, because it is confidently wrong.
|
|
11
|
+
|
|
12
|
+
It is not, and that is structural rather than careful: `orc statusline
|
|
13
|
+
preview` renders through the SAME engine the hook does
|
|
14
|
+
(templates/hooks/orc-statusline-render.js), so the two cannot diverge.
|
|
15
|
+
|
|
16
|
+
THE PANEL DERIVES NOTHING. Not a component id, not a renderer name, not a
|
|
17
|
+
glyph set, not a ramp, not a colour token, not a state word, not the board's
|
|
18
|
+
own rules. All of it arrives from `statusline components --json`, and a test
|
|
19
|
+
greps this file and both string tables for those literals. (The Flow-stepper
|
|
20
|
+
rule, on a fourth surface.)
|
|
21
|
+
|
|
22
|
+
Loaded by app.html in the order its numeric prefix names. Classic script,
|
|
23
|
+
no import/export: an ES module import carries no query string, and every
|
|
24
|
+
static request here needs the per-launch session token. */
|
|
25
|
+
|
|
26
|
+
/* -------------------------------------------------------------- HOOK UI */
|
|
27
|
+
|
|
28
|
+
// The preview widths the panel offers. Not a fact about the layout — a fact
|
|
29
|
+
// about terminals people actually use — so it lives here rather than coming
|
|
30
|
+
// from the CLI.
|
|
31
|
+
const HK_WIDTHS = [80, 120, 160];
|
|
32
|
+
let HK_WIDTH = 120;
|
|
33
|
+
let HK_STATE = "healthy";
|
|
34
|
+
// Which chip's editor is open, so a re-render does not close it under the user.
|
|
35
|
+
let HK_OPEN = null;
|
|
36
|
+
// The palette's search box and its open group, both surviving a re-render for
|
|
37
|
+
// the same reason.
|
|
38
|
+
let HK_SEARCH = "";
|
|
39
|
+
let HK_GROUP = null;
|
|
40
|
+
// WHICH BOARD. The main status line, or the row Claude Code draws per subagent
|
|
41
|
+
// in the agent panel. The panel does not know what boards exist — `components
|
|
42
|
+
// --json` carries the list — it only remembers which one you are looking at, so
|
|
43
|
+
// a re-render does not put you back on the other one.
|
|
44
|
+
let HK_BOARD = "status";
|
|
45
|
+
|
|
46
|
+
PANELS.hookui = function (host) {
|
|
47
|
+
head(host, t("hookui.title"), t("hookui.sub"));
|
|
48
|
+
|
|
49
|
+
section(
|
|
50
|
+
host,
|
|
51
|
+
() =>
|
|
52
|
+
Promise.all([
|
|
53
|
+
read("/api/statusline/show?board=" + HK_BOARD).then((r) => r.data),
|
|
54
|
+
read("/api/statusline/components?board=" + HK_BOARD).then((r) => r.data),
|
|
55
|
+
read("/api/statusline/presets?board=" + HK_BOARD).then((r) => r.data),
|
|
56
|
+
read("/api/statusline/preview?board=" + HK_BOARD + "&width=" + HK_WIDTH + "&state=" + HK_STATE).then((r) => r.data),
|
|
57
|
+
]),
|
|
58
|
+
([show, cat, presets, prev]) => {
|
|
59
|
+
const out = frag();
|
|
60
|
+
if (!show || !cat) {
|
|
61
|
+
out.append(empty(t("hookui.noData")));
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
const edits = editSet(() => bar.paint());
|
|
65
|
+
const bar = editBar(edits, {
|
|
66
|
+
onApply: async (b) => {
|
|
67
|
+
await applyActions(edits, b);
|
|
68
|
+
edits.clear();
|
|
69
|
+
rerender();
|
|
70
|
+
},
|
|
71
|
+
onReset: () => confirmReset(),
|
|
72
|
+
onCancel: () => {
|
|
73
|
+
edits.clear();
|
|
74
|
+
rerender();
|
|
75
|
+
},
|
|
76
|
+
resetLabel: t("hookui.resetLayout"),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
out.append(boardTabs(cat));
|
|
80
|
+
out.append(gateCard(show, cat, edits));
|
|
81
|
+
// A caution renders ABOVE the preview, never inside a tab — a caution you
|
|
82
|
+
// have to hunt for is a caution nobody reads.
|
|
83
|
+
if (show.errors.length || show.warnings.length) out.append(cautionCard(show, edits));
|
|
84
|
+
out.append(previewCard(prev, show));
|
|
85
|
+
out.append(boardCard(show, cat, edits));
|
|
86
|
+
out.append(paletteCard(show, cat, edits));
|
|
87
|
+
out.append(presetsCard(presets));
|
|
88
|
+
out.append(advancedCard(show, cat, edits));
|
|
89
|
+
out.append(bar);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// EVERY write carries the board it is about. Forgetting it on one route would
|
|
96
|
+
// edit the other board's layout, which is the one mistake here that is silent.
|
|
97
|
+
function bd(body) {
|
|
98
|
+
return Object.assign({ board: HK_BOARD }, body);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The two boards. Named by the CLI (`boards` in the catalogue), labelled by the
|
|
102
|
+
// panel — the id is data and the label is prose, which is the split everywhere
|
|
103
|
+
// else in this file too.
|
|
104
|
+
//
|
|
105
|
+
// The keys are SPELLED OUT rather than assembled from the id, for the same
|
|
106
|
+
// reason the tier labels are: a key built from a fragment is invisible to the
|
|
107
|
+
// coverage check that keeps the two string tables honest.
|
|
108
|
+
const HK_BOARD_LABEL = { status: "hookui.boardStatus", subagent: "hookui.boardSubagent" };
|
|
109
|
+
const HK_BOARD_SUB = { status: "hookui.boardStatusSub", subagent: "hookui.boardSubagentSub" };
|
|
110
|
+
function boardTabs(cat) {
|
|
111
|
+
const c = card(null);
|
|
112
|
+
const row = el("div", "row-actions hk-boards");
|
|
113
|
+
for (const b of cat.boards || ["status"]) {
|
|
114
|
+
const btn = el("button", "btn btn-sm" + (HK_BOARD === b ? " btn-primary" : " btn-ghost"),
|
|
115
|
+
t(HK_BOARD_LABEL[b] || HK_BOARD_LABEL.status));
|
|
116
|
+
btn.type = "button";
|
|
117
|
+
btn.addEventListener("click", () => {
|
|
118
|
+
if (HK_BOARD === b) return;
|
|
119
|
+
HK_BOARD = b;
|
|
120
|
+
// Opening the other board with a chip's editor still open would open a
|
|
121
|
+
// drawer for an item that is not there.
|
|
122
|
+
HK_OPEN = null;
|
|
123
|
+
HK_SEARCH = "";
|
|
124
|
+
rerender();
|
|
125
|
+
});
|
|
126
|
+
row.append(btn);
|
|
127
|
+
}
|
|
128
|
+
c.append(row);
|
|
129
|
+
c.append(el("div", "note", t(HK_BOARD_SUB[HK_BOARD] || HK_BOARD_SUB.status)));
|
|
130
|
+
return c;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rerender() {
|
|
134
|
+
const h = location.hash;
|
|
135
|
+
location.hash = "#/";
|
|
136
|
+
location.hash = h || "#/hookui";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── the gate ───────────────────────────────────────────────────────────────
|
|
140
|
+
// INVERTED from the Extra panel's. There, nothing exists until you connect;
|
|
141
|
+
// here the board is LIVE WHILE OFF, because you must be able to compose before
|
|
142
|
+
// you arm. What is gated is the switch, not the work.
|
|
143
|
+
function gateCard(show, cat, edits) {
|
|
144
|
+
const c = card(t("hookui.gate"));
|
|
145
|
+
const row = el("div", "row-actions");
|
|
146
|
+
row.append(chip(show.enabled ? t("hookui.on") : t("hookui.off"), show.enabled ? "ok" : null));
|
|
147
|
+
if (show.saved) row.append(chip(t("hookui.saved"), null));
|
|
148
|
+
c.append(row);
|
|
149
|
+
c.append(el("div", "note", show.enabled ? t("hookui.gateOnNote") : t("hookui.gateOffNote")));
|
|
150
|
+
|
|
151
|
+
const b = el("button", "btn btn-sm" + (show.enabled ? " btn-ghost" : " btn-primary"),
|
|
152
|
+
show.enabled ? t("hookui.turnOff") : t("hookui.turnOn"));
|
|
153
|
+
b.type = "button";
|
|
154
|
+
// ENABLED WITH NOTHING SAVED IS NOT A STATE. The switch cannot reach `on`
|
|
155
|
+
// past a layout that does not validate, and the refusal names the violation
|
|
156
|
+
// rather than saying "invalid".
|
|
157
|
+
if (!show.enabled && !show.ok) {
|
|
158
|
+
b.disabled = true;
|
|
159
|
+
b.title = show.errors[0] || "";
|
|
160
|
+
c.append(el("div", "note note-warn", show.errors[0] || ""));
|
|
161
|
+
}
|
|
162
|
+
b.addEventListener("click", async () => {
|
|
163
|
+
// The KEY IS THE CLI'S. Naming it here would be a second idea of which
|
|
164
|
+
// switch belongs to which board.
|
|
165
|
+
const r = await post("/api/config/set", { key: cat.config_key, value: show.enabled ? "off" : "on" });
|
|
166
|
+
if (!r.ok) toast(t("hookui.switchFailed"), "bad", (r.output || "").trim());
|
|
167
|
+
rerender();
|
|
168
|
+
});
|
|
169
|
+
const acts = el("div", "row-actions");
|
|
170
|
+
acts.append(b);
|
|
171
|
+
c.append(acts);
|
|
172
|
+
return c;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── cautions ───────────────────────────────────────────────────────────────
|
|
176
|
+
// An error is a refusal; a warning is a fact the user then owns. They are drawn
|
|
177
|
+
// differently and they are never merged into one count.
|
|
178
|
+
function cautionCard(show, edits) {
|
|
179
|
+
const c = card(t("hookui.cautions"));
|
|
180
|
+
for (const e of show.errors) {
|
|
181
|
+
const row = el("div", "hk-caution hk-caution-bad");
|
|
182
|
+
row.append(el("span", "hk-caution-mark", "✕"));
|
|
183
|
+
row.append(el("span", null, e));
|
|
184
|
+
c.append(row);
|
|
185
|
+
}
|
|
186
|
+
for (const w of show.warnings) {
|
|
187
|
+
const row = el("div", "hk-caution");
|
|
188
|
+
row.append(el("span", "hk-caution-mark", "⚠"));
|
|
189
|
+
row.append(el("span", null, w));
|
|
190
|
+
c.append(row);
|
|
191
|
+
}
|
|
192
|
+
return c;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── the preview ────────────────────────────────────────────────────────────
|
|
196
|
+
// Pinned above the board and never scrolled away. `orc statusline preview
|
|
197
|
+
// --json`'s output, rendered as DOM and never as HTML.
|
|
198
|
+
function previewCard(prev, show) {
|
|
199
|
+
const right = el("div", "row-actions");
|
|
200
|
+
for (const w of HK_WIDTHS) {
|
|
201
|
+
const b = el("button", "btn btn-xs" + (HK_WIDTH === w ? " btn-primary" : " btn-ghost"), String(w));
|
|
202
|
+
b.type = "button";
|
|
203
|
+
b.addEventListener("click", () => {
|
|
204
|
+
HK_WIDTH = w;
|
|
205
|
+
rerender();
|
|
206
|
+
});
|
|
207
|
+
right.append(b);
|
|
208
|
+
}
|
|
209
|
+
const c = card(t("hookui.preview"), right);
|
|
210
|
+
if (!prev || !prev.ok) {
|
|
211
|
+
c.append(el("div", "note", t("hookui.previewUnavailable")));
|
|
212
|
+
return c;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The fixture picker. `--fixtures` must carry one of every state INCLUDING
|
|
216
|
+
// the ugly ones — you cannot design a degraded line on a healthy session.
|
|
217
|
+
const states = el("div", "row-actions hk-fixtures");
|
|
218
|
+
for (const [k, label] of Object.entries(prev.fixtures || {})) {
|
|
219
|
+
const b = el("button", "btn btn-xs" + (HK_STATE === k ? " btn-primary" : " btn-ghost"), label);
|
|
220
|
+
b.type = "button";
|
|
221
|
+
b.addEventListener("click", () => {
|
|
222
|
+
HK_STATE = k;
|
|
223
|
+
rerender();
|
|
224
|
+
});
|
|
225
|
+
states.append(b);
|
|
226
|
+
}
|
|
227
|
+
c.append(states);
|
|
228
|
+
|
|
229
|
+
c.append(ansiBlock(prev.text, "hk-preview"));
|
|
230
|
+
|
|
231
|
+
// THE FOUR STRIPPINGS, always visible — not an afterthought tab. You cannot
|
|
232
|
+
// design an ASCII fallback you cannot see, and a design whose only
|
|
233
|
+
// distinction is colour becomes ambiguous the moment NO_COLOR is set.
|
|
234
|
+
const deg = el("div", "hk-degrade");
|
|
235
|
+
deg.append(el("div", "note", t("hookui.degrades")));
|
|
236
|
+
for (const [key, label] of [
|
|
237
|
+
["no_color", t("hookui.degNoColor")],
|
|
238
|
+
["ascii", t("hookui.degAscii")],
|
|
239
|
+
["no_motion", t("hookui.degNoMotion")],
|
|
240
|
+
]) {
|
|
241
|
+
const row = el("div", "hk-degrade-row");
|
|
242
|
+
row.append(el("span", "hk-degrade-label", label));
|
|
243
|
+
row.append(ansiBlock(prev.strippings[key], "hk-preview hk-preview-sm"));
|
|
244
|
+
deg.append(row);
|
|
245
|
+
}
|
|
246
|
+
c.append(deg);
|
|
247
|
+
|
|
248
|
+
const meta = el("div", "row-actions");
|
|
249
|
+
for (const w of prev.static_width || []) if (w) meta.append(chip(tn(w, "hookui.cells"), null));
|
|
250
|
+
c.append(meta);
|
|
251
|
+
return c;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ANSI → DOM. Never innerHTML: this string comes from the CLI, and the rule for
|
|
255
|
+
// every other rendered artefact in this panel holds here too.
|
|
256
|
+
function ansiBlock(text, cls) {
|
|
257
|
+
const pre = el("pre", cls);
|
|
258
|
+
for (const line of String(text || "").split("\n")) {
|
|
259
|
+
const row = el("div", "hk-line");
|
|
260
|
+
let style = null;
|
|
261
|
+
// A minimal SGR reader: colour, background and the emphasis attributes the
|
|
262
|
+
// compiler can emit. Anything it does not recognise is DROPPED rather than
|
|
263
|
+
// printed, because a stray escape in the middle of a preview is worse than
|
|
264
|
+
// a missing colour.
|
|
265
|
+
const re = /\[([0-9;]*)m/g;
|
|
266
|
+
let last = 0;
|
|
267
|
+
let m;
|
|
268
|
+
const push = (s) => {
|
|
269
|
+
if (!s) return;
|
|
270
|
+
const span = el("span", null, s);
|
|
271
|
+
if (style) span.setAttribute("style", style);
|
|
272
|
+
row.append(span);
|
|
273
|
+
};
|
|
274
|
+
while ((m = re.exec(line))) {
|
|
275
|
+
push(line.slice(last, m.index));
|
|
276
|
+
style = sgrStyle(m[1], style);
|
|
277
|
+
last = m.index + m[0].length;
|
|
278
|
+
}
|
|
279
|
+
push(line.slice(last));
|
|
280
|
+
if (!row.childNodes.length) row.append(document.createTextNode(" "));
|
|
281
|
+
pre.append(row);
|
|
282
|
+
}
|
|
283
|
+
return pre;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The 16 ANSI slots, as the browser's own colours. These are NOT ORC's colour
|
|
287
|
+
// tokens — they are what a terminal would paint, approximated, because the
|
|
288
|
+
// panel cannot know the user's theme and says so.
|
|
289
|
+
const HK_ANSI = [
|
|
290
|
+
"#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
|
|
291
|
+
];
|
|
292
|
+
const HK_ANSI_BRIGHT = [
|
|
293
|
+
"#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
function sgrStyle(codes, prev) {
|
|
297
|
+
const parts = String(codes || "0").split(";").filter((x) => x !== "");
|
|
298
|
+
if (!parts.length || parts[0] === "0") return null;
|
|
299
|
+
const out = [];
|
|
300
|
+
if (prev) out.push(prev);
|
|
301
|
+
for (let i = 0; i < parts.length; i++) {
|
|
302
|
+
const n = Number(parts[i]);
|
|
303
|
+
if (n === 1) out.push("font-weight:700");
|
|
304
|
+
else if (n === 2) out.push("opacity:.6");
|
|
305
|
+
else if (n === 3) out.push("font-style:italic");
|
|
306
|
+
else if (n === 4) out.push("text-decoration:underline");
|
|
307
|
+
else if (n === 9) out.push("text-decoration:line-through");
|
|
308
|
+
else if (n === 7) out.push("filter:invert(1)");
|
|
309
|
+
else if (n >= 30 && n <= 37) out.push("color:" + HK_ANSI[n - 30]);
|
|
310
|
+
else if (n >= 90 && n <= 97) out.push("color:" + HK_ANSI_BRIGHT[n - 90]);
|
|
311
|
+
else if (n >= 40 && n <= 47) out.push("background:" + HK_ANSI[n - 40]);
|
|
312
|
+
else if (n === 39) out.push("color:inherit");
|
|
313
|
+
else if (n === 38 && parts[i + 1] === "2") {
|
|
314
|
+
out.push("color:rgb(" + parts[i + 2] + "," + parts[i + 3] + "," + parts[i + 4] + ")");
|
|
315
|
+
i += 4;
|
|
316
|
+
} else if (n === 48 && parts[i + 1] === "2") {
|
|
317
|
+
out.push("background:rgb(" + parts[i + 2] + "," + parts[i + 3] + "," + parts[i + 4] + ")");
|
|
318
|
+
i += 4;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return out.join(";");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── the board ──────────────────────────────────────────────────────────────
|
|
325
|
+
// Three drop zones. THE ILLEGAL DROP IS MADE IMPOSSIBLE rather than allowed and
|
|
326
|
+
// then complained about: an illegal zone renders disabled with its reason on
|
|
327
|
+
// the zone itself. A user should never be able to do the wrong thing and then
|
|
328
|
+
// be told off for it.
|
|
329
|
+
//
|
|
330
|
+
// The CLI still validates. The board is a convenience; it is never the
|
|
331
|
+
// guarantee.
|
|
332
|
+
function boardCard(show, cat, edits) {
|
|
333
|
+
const c = card(t("hookui.board"));
|
|
334
|
+
c.append(el("div", "note", cat.dense_prefix));
|
|
335
|
+
|
|
336
|
+
for (const line of show.lines) {
|
|
337
|
+
const legal = lineLegal(show, line.line);
|
|
338
|
+
const wrap = el("div", "hk-line-wrap" + (legal ? "" : " hk-line-blocked"));
|
|
339
|
+
const h = el("div", "hk-line-head");
|
|
340
|
+
h.append(el("span", "hk-line-name", tn(line.line, "hookui.lineN")));
|
|
341
|
+
h.append(el("span", "hk-line-count" + (line.full ? " hk-full" : ""), line.counted + "/" + cat.max_per_line));
|
|
342
|
+
wrap.append(h);
|
|
343
|
+
|
|
344
|
+
const zone = el("div", "hk-zone");
|
|
345
|
+
zone.setAttribute("role", "list");
|
|
346
|
+
for (const item of line.items) zone.append(chipEl(item, line, show, cat, edits));
|
|
347
|
+
|
|
348
|
+
if (!legal.ok) {
|
|
349
|
+
const blocked = el("div", "hk-blocked");
|
|
350
|
+
blocked.append(el("span", "hk-caution-mark", "✕"));
|
|
351
|
+
blocked.append(el("span", null, legal.why));
|
|
352
|
+
zone.append(blocked);
|
|
353
|
+
} else if (!line.full) {
|
|
354
|
+
const add = el("button", "hk-add", t("hookui.add"));
|
|
355
|
+
add.type = "button";
|
|
356
|
+
add.addEventListener("click", () => {
|
|
357
|
+
HK_GROUP = HK_GROUP || Object.keys(cat.groups)[0];
|
|
358
|
+
const p = document.querySelector(".hk-palette-search");
|
|
359
|
+
if (p) p.focus();
|
|
360
|
+
});
|
|
361
|
+
zone.append(add);
|
|
362
|
+
}
|
|
363
|
+
wrap.append(zone);
|
|
364
|
+
|
|
365
|
+
// Per-line options. The separator is what gives a line its rhythm, and it
|
|
366
|
+
// is the one line-level control worth surfacing on the board itself.
|
|
367
|
+
const opts = el("div", "hk-line-opts");
|
|
368
|
+
const sep = el("input", "hk-sep");
|
|
369
|
+
sep.type = "text";
|
|
370
|
+
sep.value = line.separator;
|
|
371
|
+
sep.setAttribute("aria-label", t("hookui.separator"));
|
|
372
|
+
sep.addEventListener("change", () => {
|
|
373
|
+
edits.action("line" + line.line, "/api/statusline/line",
|
|
374
|
+
bd({ line: line.line, separator: sep.value }),
|
|
375
|
+
t("hookui.sepChange", { n: line.line, s: sep.value }));
|
|
376
|
+
});
|
|
377
|
+
opts.append(el("span", "note", t("hookui.separator")), sep);
|
|
378
|
+
wrap.append(opts);
|
|
379
|
+
c.append(wrap);
|
|
380
|
+
}
|
|
381
|
+
return c;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// The dense-prefix rule, applied to ONE line. The reason travels with the
|
|
385
|
+
// answer, because a disabled zone with no reason is a shrug.
|
|
386
|
+
function lineLegal(show, n) {
|
|
387
|
+
if (n === 1) return { ok: true };
|
|
388
|
+
const above = show.lines[n - 2];
|
|
389
|
+
if (above && above.count > 0) return { ok: true };
|
|
390
|
+
return { ok: false, why: t("hookui.fillFirst", { n: n - 1 }) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// A chip shows ITS OWN RENDERED OUTPUT, not its component id — that is what the
|
|
394
|
+
// user is arranging. The id lives in the tooltip and in the editor header.
|
|
395
|
+
function chipEl(item, line, show, cat, edits) {
|
|
396
|
+
const comp = (cat.components || []).find((x) => x.id === item.type);
|
|
397
|
+
const wrap = el("div", "hk-chip" + (HK_OPEN === item.id ? " hk-chip-open" : ""));
|
|
398
|
+
wrap.setAttribute("role", "listitem");
|
|
399
|
+
wrap.setAttribute("tabindex", "0");
|
|
400
|
+
wrap.title = item.type;
|
|
401
|
+
|
|
402
|
+
const face = el("div", "hk-chip-face");
|
|
403
|
+
const sample = comp && comp.previews ? comp.previews[item.render] : null;
|
|
404
|
+
face.append(ansiBlock(sample || item.type, "hk-preview hk-preview-chip"));
|
|
405
|
+
wrap.append(face);
|
|
406
|
+
|
|
407
|
+
const foot = el("div", "hk-chip-foot");
|
|
408
|
+
foot.append(el("span", "hk-chip-id", item.type));
|
|
409
|
+
const edit = el("button", "hk-icon", "⋯");
|
|
410
|
+
edit.type = "button";
|
|
411
|
+
edit.title = t("hookui.edit");
|
|
412
|
+
edit.addEventListener("click", () => {
|
|
413
|
+
HK_OPEN = HK_OPEN === item.id ? null : item.id;
|
|
414
|
+
rerender();
|
|
415
|
+
});
|
|
416
|
+
const rm = el("button", "hk-icon", "×");
|
|
417
|
+
rm.type = "button";
|
|
418
|
+
rm.title = t("hookui.remove");
|
|
419
|
+
rm.addEventListener("click", () => {
|
|
420
|
+
edits.action("rm" + item.id, "/api/statusline/remove",
|
|
421
|
+
bd({ at: line.line + ":" + item.pos }),
|
|
422
|
+
t("hookui.removed", { id: item.type }));
|
|
423
|
+
});
|
|
424
|
+
foot.append(edit, rm);
|
|
425
|
+
wrap.append(foot);
|
|
426
|
+
|
|
427
|
+
// THE NON-DRAG PATH, and it is not an afterthought: pointer drag is unusable
|
|
428
|
+
// for a real fraction of people, so every move a drag can make is on this
|
|
429
|
+
// menu and on the keyboard.
|
|
430
|
+
const menu = el("div", "hk-move");
|
|
431
|
+
for (const target of [1, 2, 3]) {
|
|
432
|
+
if (target === line.line) continue;
|
|
433
|
+
const legal = lineLegal(show, target);
|
|
434
|
+
const b = el("button", "btn btn-xs btn-ghost", tn(target, "hookui.toLine"));
|
|
435
|
+
b.type = "button";
|
|
436
|
+
if (!legal.ok) {
|
|
437
|
+
b.disabled = true;
|
|
438
|
+
b.title = legal.why;
|
|
439
|
+
} else {
|
|
440
|
+
b.addEventListener("click", () => moveTo(item, line, target, 1, edits));
|
|
441
|
+
}
|
|
442
|
+
menu.append(b);
|
|
443
|
+
}
|
|
444
|
+
if (item.pos > 1) {
|
|
445
|
+
const b = el("button", "btn btn-xs btn-ghost", "←");
|
|
446
|
+
b.type = "button";
|
|
447
|
+
b.title = t("hookui.moveLeft");
|
|
448
|
+
b.addEventListener("click", () => moveTo(item, line, line.line, item.pos - 1, edits));
|
|
449
|
+
menu.append(b);
|
|
450
|
+
}
|
|
451
|
+
if (item.pos < line.items.length) {
|
|
452
|
+
const b = el("button", "btn btn-xs btn-ghost", "→");
|
|
453
|
+
b.type = "button";
|
|
454
|
+
b.title = t("hookui.moveRight");
|
|
455
|
+
b.addEventListener("click", () => moveTo(item, line, line.line, item.pos + 1, edits));
|
|
456
|
+
menu.append(b);
|
|
457
|
+
}
|
|
458
|
+
// CLONE is on every chip: two `config` chips on different keys, or two
|
|
459
|
+
// token-kind chips on different kinds, is a normal thing to want.
|
|
460
|
+
const cl = el("button", "btn btn-xs btn-ghost", t("hookui.clone"));
|
|
461
|
+
cl.type = "button";
|
|
462
|
+
cl.addEventListener("click", () =>
|
|
463
|
+
edits.action("cl" + item.id, "/api/statusline/clone",
|
|
464
|
+
bd({ at: line.line + ":" + item.pos }), t("hookui.cloned", { id: item.type })));
|
|
465
|
+
menu.append(cl);
|
|
466
|
+
// EXPAND turns a composite or a group back into its parts — which is how a
|
|
467
|
+
// three-in-one chip becomes three chips the moment you want to restyle one.
|
|
468
|
+
if ((comp && comp.composite) || item.type === "group") {
|
|
469
|
+
const ex = el("button", "btn btn-xs btn-ghost", t("hookui.expand"));
|
|
470
|
+
ex.type = "button";
|
|
471
|
+
ex.addEventListener("click", () =>
|
|
472
|
+
edits.action("ex" + item.id, "/api/statusline/expand",
|
|
473
|
+
bd({ at: line.line + ":" + item.pos }), t("hookui.expanded", { id: item.type })));
|
|
474
|
+
menu.append(ex);
|
|
475
|
+
}
|
|
476
|
+
wrap.append(menu);
|
|
477
|
+
|
|
478
|
+
// Keyboard: the settled pattern, and every drag has an equivalent here. A
|
|
479
|
+
// board only reachable by mouse is a board a lot of people cannot use.
|
|
480
|
+
wrap.addEventListener("keydown", (ev) => {
|
|
481
|
+
if (ev.key === "Delete" || ev.key === "Backspace") {
|
|
482
|
+
ev.preventDefault();
|
|
483
|
+
edits.action("rm" + item.id, "/api/statusline/remove",
|
|
484
|
+
bd({ at: line.line + ":" + item.pos }), t("hookui.removed", { id: item.type }));
|
|
485
|
+
} else if (ev.key === "e" || ev.key === "E") {
|
|
486
|
+
ev.preventDefault();
|
|
487
|
+
HK_OPEN = HK_OPEN === item.id ? null : item.id;
|
|
488
|
+
rerender();
|
|
489
|
+
} else if (ev.key === "ArrowLeft" && item.pos > 1) {
|
|
490
|
+
ev.preventDefault();
|
|
491
|
+
moveTo(item, line, line.line, item.pos - 1, edits);
|
|
492
|
+
} else if (ev.key === "ArrowRight" && item.pos < line.items.length) {
|
|
493
|
+
ev.preventDefault();
|
|
494
|
+
moveTo(item, line, line.line, item.pos + 1, edits);
|
|
495
|
+
} else if (ev.key === "ArrowUp" || ev.key === "ArrowDown") {
|
|
496
|
+
const target = line.line + (ev.key === "ArrowUp" ? -1 : 1);
|
|
497
|
+
if (target < 1 || target > 3) return;
|
|
498
|
+
ev.preventDefault();
|
|
499
|
+
const legal = lineLegal(show, target);
|
|
500
|
+
// An illegal line is SKIPPED and the live region says why — never a
|
|
501
|
+
// silent no-op.
|
|
502
|
+
if (!legal.ok) return announce(legal.why);
|
|
503
|
+
moveTo(item, line, target, 1, edits);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
if (HK_OPEN === item.id) wrap.append(editorDrawer(item, line, comp, cat, edits));
|
|
508
|
+
return wrap;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function moveTo(item, line, toLine, toPos, edits) {
|
|
512
|
+
edits.action("mv" + item.id, "/api/statusline/move",
|
|
513
|
+
bd({ from: line.line + ":" + item.pos, to: toLine + ":" + toPos }),
|
|
514
|
+
t("hookui.moved", { id: item.type, n: toLine }));
|
|
515
|
+
announce(t("hookui.moved", { id: item.type, n: toLine }));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// `aria-grabbed` is deprecated and is not used. State is announced through a
|
|
519
|
+
// polite live region instead — VoiceOver does not reliably read a dynamically
|
|
520
|
+
// updated description, so the live region is the mechanism, not a fallback.
|
|
521
|
+
function announce(msg) {
|
|
522
|
+
let region = document.getElementById("hk-live");
|
|
523
|
+
if (!region) {
|
|
524
|
+
region = el("div", "sr-only");
|
|
525
|
+
region.id = "hk-live";
|
|
526
|
+
region.setAttribute("role", "status");
|
|
527
|
+
region.setAttribute("aria-live", "polite");
|
|
528
|
+
document.body.append(region);
|
|
529
|
+
}
|
|
530
|
+
region.textContent = msg;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── the editor drawer ──────────────────────────────────────────────────────
|
|
534
|
+
// A drawer, not a modal: the preview stays visible while you edit, which is the
|
|
535
|
+
// entire point.
|
|
536
|
+
//
|
|
537
|
+
// NO FIELD NAMES. `min_width` and `hide_when` are the JSON's words; the drawer
|
|
538
|
+
// says "Min width" and "Hide when". The JSON is for the diff, the drawer is for
|
|
539
|
+
// the person — and the ids, renderers and state words that DO appear are the
|
|
540
|
+
// CLI's own and are never translated.
|
|
541
|
+
function editorDrawer(item, line, comp, cat, edits) {
|
|
542
|
+
const d = el("div", "hk-drawer");
|
|
543
|
+
if (!comp) {
|
|
544
|
+
d.append(el("div", "note", t("hookui.unknownComponent")));
|
|
545
|
+
return d;
|
|
546
|
+
}
|
|
547
|
+
const stage = (field, value, label) => {
|
|
548
|
+
edits.action("set" + item.id + field, "/api/statusline/set",
|
|
549
|
+
bd(Object.assign({ line: line.line, pos: item.pos }, { [field]: value })),
|
|
550
|
+
label);
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
d.append(el("div", "hk-drawer-head", comp.id));
|
|
554
|
+
if (comp.summary) d.append(el("div", "note", comp.summary));
|
|
555
|
+
|
|
556
|
+
// THE SHAPE PICKER IS A GALLERY, NOT A DROPDOWN. Every renderer the component
|
|
557
|
+
// declares, drawn with this component's real current value. A dropdown
|
|
558
|
+
// containing the word `braille-bar` tells nobody anything, and this is the
|
|
559
|
+
// difference between a feature people use and a settings form they close.
|
|
560
|
+
d.append(el("div", "hk-section", t("hookui.shape")));
|
|
561
|
+
const gallery = el("div", "hk-gallery");
|
|
562
|
+
for (const r of comp.renderers) {
|
|
563
|
+
const b = el("button", "hk-sample" + (item.render === r ? " hk-sample-on" : ""));
|
|
564
|
+
b.type = "button";
|
|
565
|
+
b.title = r;
|
|
566
|
+
b.append(ansiBlock(comp.previews && comp.previews[r] ? comp.previews[r] : r, "hk-preview hk-preview-chip"));
|
|
567
|
+
b.append(el("span", "hk-sample-id", r));
|
|
568
|
+
b.addEventListener("click", () => stage("render", r, t("hookui.shapeChange", { id: comp.id, r })));
|
|
569
|
+
gallery.append(b);
|
|
570
|
+
}
|
|
571
|
+
d.append(gallery);
|
|
572
|
+
|
|
573
|
+
// The glyph set, same treatment and for the same reason.
|
|
574
|
+
d.append(el("div", "hk-section", t("hookui.glyphs")));
|
|
575
|
+
d.append(pickRow(cat.glyph_sets, null, (v) => stage("glyphs", v, t("hookui.glyphChange", { id: comp.id, v }))));
|
|
576
|
+
|
|
577
|
+
d.append(el("div", "hk-section", t("hookui.words")));
|
|
578
|
+
const label = el("input", "hk-input");
|
|
579
|
+
label.type = "text";
|
|
580
|
+
label.value = item.label == null ? "" : item.label;
|
|
581
|
+
label.placeholder = t("hookui.labelPlaceholder");
|
|
582
|
+
label.addEventListener("change", () => stage("label", label.value, t("hookui.labelChange", { id: comp.id, v: label.value })));
|
|
583
|
+
d.append(labelled(t("hookui.label"), label));
|
|
584
|
+
d.append(labelled(t("hookui.case"), pickRow(cat.cases, item.case, (v) => stage("case", v, t("hookui.caseChange", { id: comp.id, v })))));
|
|
585
|
+
|
|
586
|
+
d.append(el("div", "hk-section", t("hookui.colour")));
|
|
587
|
+
d.append(labelled(t("hookui.labelColour"), pickRow(cat.colors, item.label_color, (v) => stage("label_color", v, t("hookui.colourChange", { id: comp.id, v })))));
|
|
588
|
+
d.append(labelled(t("hookui.valueColour"), pickRow(cat.colors, item.value_color, (v) => stage("value_color", v, t("hookui.colourChange", { id: comp.id, v })))));
|
|
589
|
+
if (comp.bounded)
|
|
590
|
+
d.append(labelled(t("hookui.ramp"), pickRow(Object.keys(cat.ramps), item.ramp, (v) => stage("ramp", v, t("hookui.rampChange", { id: comp.id, v })))));
|
|
591
|
+
// R3, AT THE CONTROL, at the moment the choice is made — never in a
|
|
592
|
+
// validation summary later.
|
|
593
|
+
if (comp.states) d.append(el("div", "note note-warn", t("hookui.stateColourWarn", { s: comp.states.join(", ") })));
|
|
594
|
+
|
|
595
|
+
// "Emphasis", never "font size". A terminal owns its font, and a picker that
|
|
596
|
+
// did nothing would be worse than not offering one.
|
|
597
|
+
d.append(labelled(t("hookui.emphasis"), pickRow(cat.emphasis.filter((e) => !cat.refused_emphasis.includes(e)), (item.emphasis || [])[0], (v) => stage("emphasis", v, t("hookui.emphasisChange", { id: comp.id, v })))));
|
|
598
|
+
d.append(el("div", "note", t("hookui.fontWhy")));
|
|
599
|
+
|
|
600
|
+
if (comp.bounded || comp.defaults.format) {
|
|
601
|
+
d.append(el("div", "hk-section", t("hookui.numbers")));
|
|
602
|
+
d.append(labelled(t("hookui.format"), pickRow(cat.formats, item.format, (v) => stage("format", v, t("hookui.formatChange", { id: comp.id, v })))));
|
|
603
|
+
d.append(labelled(t("hookui.compact"), pickRow(cat.compact, item.compact, (v) => stage("compact", v, t("hookui.compactChange", { id: comp.id, v })))));
|
|
604
|
+
d.append(el("div", "note note-warn", t("hookui.minWidthWarn")));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// A CHECKLIST, not an enum: "hide when zero" and "hide when there is no run"
|
|
608
|
+
// are independent facts and a user wants both at once.
|
|
609
|
+
d.append(el("div", "hk-section", t("hookui.whenToShow")));
|
|
610
|
+
const checks = el("div", "hk-checks");
|
|
611
|
+
const active = new Set(item.hide_when || []);
|
|
612
|
+
for (const h of cat.hide_when) {
|
|
613
|
+
if (h.id === "never") continue;
|
|
614
|
+
const lab = el("label", "hk-check");
|
|
615
|
+
const box = el("input");
|
|
616
|
+
box.type = "checkbox";
|
|
617
|
+
box.checked = active.has(h.id);
|
|
618
|
+
box.addEventListener("change", () => {
|
|
619
|
+
const next = new Set(active);
|
|
620
|
+
if (box.checked) next.add(h.id);
|
|
621
|
+
else next.delete(h.id);
|
|
622
|
+
const v = next.size ? [...next].join(",") : "never";
|
|
623
|
+
stage("hide_when", v, t("hookui.hideChange", { id: comp.id, v }));
|
|
624
|
+
});
|
|
625
|
+
lab.append(box, el("span", null, h.id), el("span", "note", h.says));
|
|
626
|
+
checks.append(lab);
|
|
627
|
+
}
|
|
628
|
+
d.append(checks);
|
|
629
|
+
|
|
630
|
+
// The rest of the number controls. `min_width` is not cosmetic: a value that
|
|
631
|
+
// changes width shifts every component to its right on the keystroke it
|
|
632
|
+
// happens, and that jitter is the main reason people turn a status line off.
|
|
633
|
+
if (comp.bounded || comp.defaults.format) {
|
|
634
|
+
d.append(labelled(t("hookui.minWidth"), numField(item.min_width, 0, 8, (v) =>
|
|
635
|
+
stage("min_width", v, t("hookui.minWidthChange", { id: comp.id, v })))));
|
|
636
|
+
d.append(labelled(t("hookui.precision"), numField(item.precision, 0, 2, (v) =>
|
|
637
|
+
stage("precision", v, t("hookui.precisionChange", { id: comp.id, v })))));
|
|
638
|
+
}
|
|
639
|
+
const rend = cat.renderers[item.render];
|
|
640
|
+
if (rend && rend.width) {
|
|
641
|
+
d.append(labelled(t("hookui.width"), numField(item.width, rend.width[0], rend.width[1], (v) =>
|
|
642
|
+
stage("width", v, t("hookui.widthChange", { id: comp.id, v })))));
|
|
643
|
+
d.append(el("div", "note", t("hookui.widthWhy")));
|
|
644
|
+
}
|
|
645
|
+
d.append(labelled(t("hookui.before"), textField(item.prefix, (v) =>
|
|
646
|
+
stage("prefix", v, t("hookui.beforeChange", { id: comp.id, v })))));
|
|
647
|
+
d.append(labelled(t("hookui.after"), textField(item.suffix, (v) =>
|
|
648
|
+
stage("suffix", v, t("hookui.afterChange", { id: comp.id, v })))));
|
|
649
|
+
|
|
650
|
+
// RESPONSIVE WIDTH: the range of terminal widths in which this part is worth
|
|
651
|
+
// its cells. Strictly better than truncating the right of an overflowing
|
|
652
|
+
// line, because the USER chooses what survives a narrow terminal.
|
|
653
|
+
d.append(el("div", "hk-section", t("hookui.narrow")));
|
|
654
|
+
d.append(labelled(t("hookui.minCols"), numField(item.min_cols, 0, 200, (v) =>
|
|
655
|
+
stage("min_cols", v, t("hookui.minColsChange", { id: comp.id, v })))));
|
|
656
|
+
d.append(labelled(t("hookui.priority"), numField(item.priority, 1, 5, (v) =>
|
|
657
|
+
stage("priority", v, t("hookui.priorityChange", { id: comp.id, v })))));
|
|
658
|
+
d.append(el("div", "note", t("hookui.priorityWhy")));
|
|
659
|
+
|
|
660
|
+
// WHERE EACH VALUE CAME FROM. It is how a user finds their own overrides
|
|
661
|
+
// again after a theme change — `orc lane config`'s problem, at component
|
|
662
|
+
// scale.
|
|
663
|
+
const ex = el("button", "btn btn-xs btn-ghost", t("hookui.explain"));
|
|
664
|
+
ex.type = "button";
|
|
665
|
+
ex.addEventListener("click", async () => {
|
|
666
|
+
const r = await read("/api/statusline/explain?board=" + HK_BOARD + "&at=" + line.line + ":" + item.pos);
|
|
667
|
+
if (!r.data || !r.data.ok) return toast(t("hookui.explainFailed"), "bad");
|
|
668
|
+
const rows = r.data.resolved.map((x) => [x.field, x.source]);
|
|
669
|
+
modal({ title: comp.id, body: kvList(rows), actions: [] });
|
|
670
|
+
});
|
|
671
|
+
d.append(ex);
|
|
672
|
+
return d;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function numField(value, lo, hi, onSet) {
|
|
676
|
+
const i = el("input", "hk-input hk-num");
|
|
677
|
+
i.type = "number";
|
|
678
|
+
i.min = String(lo);
|
|
679
|
+
i.max = String(hi);
|
|
680
|
+
if (value !== null && value !== undefined) i.value = String(value);
|
|
681
|
+
i.addEventListener("change", () => onSet(i.value));
|
|
682
|
+
return i;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function textField(value, onSet) {
|
|
686
|
+
const i = el("input", "hk-input");
|
|
687
|
+
i.type = "text";
|
|
688
|
+
i.value = value == null ? "" : String(value);
|
|
689
|
+
i.addEventListener("change", () => onSet(i.value));
|
|
690
|
+
return i;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function labelled(name, node) {
|
|
694
|
+
const row = el("div", "hk-field");
|
|
695
|
+
row.append(el("span", "hk-field-name", name));
|
|
696
|
+
row.append(node);
|
|
697
|
+
return row;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// A row of options. The VALUES are the CLI's and are printed verbatim — a
|
|
701
|
+
// translated renderer name is a renderer that does not exist.
|
|
702
|
+
function pickRow(options, current, onPick) {
|
|
703
|
+
const row = el("div", "hk-picks");
|
|
704
|
+
for (const o of options || []) {
|
|
705
|
+
const b = el("button", "hk-pick" + (String(current) === String(o) ? " hk-pick-on" : ""), String(o));
|
|
706
|
+
b.type = "button";
|
|
707
|
+
b.addEventListener("click", () => onPick(o));
|
|
708
|
+
row.append(b);
|
|
709
|
+
}
|
|
710
|
+
return row;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── the palette ────────────────────────────────────────────────────────────
|
|
714
|
+
function paletteCard(show, cat, edits) {
|
|
715
|
+
const right = el("div", "row-actions");
|
|
716
|
+
const search = el("input", "hk-palette-search");
|
|
717
|
+
search.type = "search";
|
|
718
|
+
search.value = HK_SEARCH;
|
|
719
|
+
search.placeholder = t("hookui.searchPlaceholder");
|
|
720
|
+
search.addEventListener("input", () => {
|
|
721
|
+
HK_SEARCH = search.value;
|
|
722
|
+
paint();
|
|
723
|
+
});
|
|
724
|
+
right.append(search);
|
|
725
|
+
|
|
726
|
+
const c = card(t("hookui.palette"), right);
|
|
727
|
+
const body = el("div", "stack");
|
|
728
|
+
c.append(body);
|
|
729
|
+
|
|
730
|
+
function paint() {
|
|
731
|
+
body.replaceChildren();
|
|
732
|
+
const q = HK_SEARCH.trim().toLowerCase();
|
|
733
|
+
const hits = q ? cat.components.filter((x) => matches(x, q)) : null;
|
|
734
|
+
if (hits) {
|
|
735
|
+
body.append(el("div", "note", tn(hits.length, "hookui.matches")));
|
|
736
|
+
for (const comp of hits) body.append(paletteRow(comp, show, cat, edits));
|
|
737
|
+
if (!hits.length) body.append(empty(t("hookui.noMatch")));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
for (const [g, name] of Object.entries(cat.groups)) {
|
|
741
|
+
const rows = cat.components.filter((x) => x.group === g);
|
|
742
|
+
if (!rows.length) continue;
|
|
743
|
+
const inner = el("div");
|
|
744
|
+
for (const comp of rows) inner.append(paletteRow(comp, show, cat, edits));
|
|
745
|
+
body.append(collapsible({
|
|
746
|
+
title: name,
|
|
747
|
+
count: String(rows.length),
|
|
748
|
+
content: inner,
|
|
749
|
+
collapsed: HK_GROUP !== g,
|
|
750
|
+
}));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
paint();
|
|
754
|
+
return c;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Fuzzy plus INITIALISM: with 128 components a substring search is not enough —
|
|
758
|
+
// `cw` should find `cache-write`, which plain substring matching does not.
|
|
759
|
+
function matches(comp, q) {
|
|
760
|
+
const id = comp.id.toLowerCase();
|
|
761
|
+
if (id.includes(q)) return true;
|
|
762
|
+
if ((comp.summary || "").toLowerCase().includes(q)) return true;
|
|
763
|
+
const initials = id.split("-").map((p) => p[0]).join("");
|
|
764
|
+
if (initials.startsWith(q)) return true;
|
|
765
|
+
let i = 0;
|
|
766
|
+
for (const ch of id) if (ch === q[i]) i++;
|
|
767
|
+
return i === q.length;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function paletteRow(comp, show, cat, edits) {
|
|
771
|
+
const row = el("div", "hk-prow");
|
|
772
|
+
row.append(el("span", "hk-prow-id", comp.id));
|
|
773
|
+
// The cost chip is the CLI's own word. A friendlier synonym would be a state
|
|
774
|
+
// that does not exist.
|
|
775
|
+
row.append(chip(comp.cost, comp.cost === "refused" ? "bad" : null));
|
|
776
|
+
const sample = comp.previews && comp.previews[comp.defaults.render];
|
|
777
|
+
row.append(ansiBlock(sample || "", "hk-preview hk-preview-chip"));
|
|
778
|
+
|
|
779
|
+
// A REFUSED component gets NO BUTTON AT ALL, never a disabled one — the same
|
|
780
|
+
// rule a connected tool's Connect button follows. "We decided against this"
|
|
781
|
+
// and "we forgot" must not look the same, which is why the row exists at all.
|
|
782
|
+
if (comp.cost === "refused") {
|
|
783
|
+
row.append(el("div", "note note-warn hk-prow-why", comp.refused_reason));
|
|
784
|
+
return row;
|
|
785
|
+
}
|
|
786
|
+
const acts = el("div", "row-actions");
|
|
787
|
+
for (const n of [1, 2, 3]) {
|
|
788
|
+
const legal = lineLegal(show, n);
|
|
789
|
+
const line = show.lines[n - 1];
|
|
790
|
+
const b = el("button", "btn btn-xs btn-ghost", tn(n, "hookui.addTo"));
|
|
791
|
+
b.type = "button";
|
|
792
|
+
if (!legal.ok) {
|
|
793
|
+
b.disabled = true;
|
|
794
|
+
b.title = legal.why;
|
|
795
|
+
} else if (line.full) {
|
|
796
|
+
b.disabled = true;
|
|
797
|
+
b.title = t("hookui.lineFull", { n });
|
|
798
|
+
} else {
|
|
799
|
+
b.addEventListener("click", () =>
|
|
800
|
+
edits.action("add" + comp.id + n, "/api/statusline/set",
|
|
801
|
+
bd({ line: n, pos: line.items.length + 1, type: comp.id }),
|
|
802
|
+
t("hookui.added", { id: comp.id, n })));
|
|
803
|
+
}
|
|
804
|
+
acts.append(b);
|
|
805
|
+
}
|
|
806
|
+
row.append(acts);
|
|
807
|
+
return row;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ── presets ────────────────────────────────────────────────────────────────
|
|
811
|
+
// Directly below the gate — the `orc diy` presets position. Applying one
|
|
812
|
+
// REPLACES the layout, so it is always confirmed and the loss is NAMED.
|
|
813
|
+
function presetsCard(presets) {
|
|
814
|
+
const c = card(t("hookui.presets"));
|
|
815
|
+
if (!presets || !presets.presets) return c;
|
|
816
|
+
for (const p of presets.presets) {
|
|
817
|
+
const row = el("div", "hk-preset");
|
|
818
|
+
const h = el("div", "hk-preset-head");
|
|
819
|
+
h.append(el("span", "hk-prow-id", p.name));
|
|
820
|
+
if (p.active) h.append(chip(t("hookui.active"), "ok"));
|
|
821
|
+
row.append(h);
|
|
822
|
+
row.append(el("div", "note", p.summary));
|
|
823
|
+
if (p.preview) row.append(ansiBlock(p.preview, "hk-preview hk-preview-sm"));
|
|
824
|
+
// The ACTIVE row keeps its slot and drops its button.
|
|
825
|
+
if (!p.active) {
|
|
826
|
+
const b = el("button", "btn btn-xs btn-ghost", t("hookui.apply"));
|
|
827
|
+
b.type = "button";
|
|
828
|
+
b.addEventListener("click", () => confirmApply(p.name));
|
|
829
|
+
row.append(b);
|
|
830
|
+
}
|
|
831
|
+
c.append(row);
|
|
832
|
+
}
|
|
833
|
+
return c;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function confirmApply(name) {
|
|
837
|
+
modal({
|
|
838
|
+
title: t("hookui.applyTitle", { name }),
|
|
839
|
+
body: el("div", "note", t("hookui.applyWarn")),
|
|
840
|
+
actions: [
|
|
841
|
+
{ label: t("common.cancel"), kind: "ghost" },
|
|
842
|
+
{
|
|
843
|
+
label: t("hookui.apply"),
|
|
844
|
+
kind: "primary",
|
|
845
|
+
onClick: async () => {
|
|
846
|
+
const r = await post("/api/statusline/apply", bd({ name }));
|
|
847
|
+
if (!r.ok) toast(t("hookui.applyFailed"), "bad", (r.output || "").trim());
|
|
848
|
+
rerender();
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
],
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function confirmReset() {
|
|
856
|
+
modal({
|
|
857
|
+
title: t("hookui.resetTitle"),
|
|
858
|
+
body: el("div", "note", t("hookui.resetWarn")),
|
|
859
|
+
actions: [
|
|
860
|
+
{ label: t("common.cancel"), kind: "ghost" },
|
|
861
|
+
{
|
|
862
|
+
label: t("hookui.resetLayout"),
|
|
863
|
+
kind: "primary",
|
|
864
|
+
onClick: async () => {
|
|
865
|
+
await post("/api/statusline/reset", bd({}));
|
|
866
|
+
rerender();
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
],
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ── advanced ───────────────────────────────────────────────────────────────
|
|
874
|
+
function advancedCard(show, cat, edits) {
|
|
875
|
+
const c = card(t("hookui.advanced"));
|
|
876
|
+
c.append(labelled(t("hookui.theme"), pickRow(Object.keys(cat.themes), show.theme, (v) =>
|
|
877
|
+
edits.action("theme", "/api/statusline/line", bd({ line: 1, theme: v }), t("hookui.themeChange", { v })))));
|
|
878
|
+
|
|
879
|
+
const re = el("button", "btn btn-sm btn-ghost", t("hookui.recompile"));
|
|
880
|
+
re.type = "button";
|
|
881
|
+
re.addEventListener("click", async () => {
|
|
882
|
+
const r = await post("/api/statusline/compile", bd({}));
|
|
883
|
+
toast(r.ok ? t("hookui.recompiled") : t("hookui.recompileFailed"), r.ok ? "ok" : "bad");
|
|
884
|
+
rerender();
|
|
885
|
+
});
|
|
886
|
+
const acts = el("div", "row-actions");
|
|
887
|
+
acts.append(re);
|
|
888
|
+
c.append(acts);
|
|
889
|
+
c.append(el("div", "note", t("hookui.compileNote")));
|
|
890
|
+
return c;
|
|
891
|
+
}
|