@delorenj/pjangler 1.5.18 → 1.5.21

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.
@@ -0,0 +1,447 @@
1
+ // src/crank/tui.tsx
2
+ import { Box as Box3, Text as Text3, useInput as useInput2, useWindowSize } from "ink";
3
+ import { useCallback, useState as useState2 } from "react";
4
+
5
+ // src/tui/RadioGroup.tsx
6
+ import { Box, Text, useInput } from "ink";
7
+
8
+ // src/utils/style.ts
9
+ var env = process.env;
10
+ function detectColor() {
11
+ if ("NO_COLOR" in env && env.NO_COLOR !== "") return false;
12
+ const force = env.FORCE_COLOR;
13
+ if (force === "0" || force === "false") return false;
14
+ if (force !== void 0 && force !== "") return true;
15
+ if (env.TERM === "dumb") return false;
16
+ return Boolean(process.stdout.isTTY);
17
+ }
18
+ var colorEnabled = detectColor();
19
+ function sgr(open, close) {
20
+ const prefix = `\x1B[${open}m`;
21
+ const suffix = `\x1B[${close}m`;
22
+ return (value) => colorEnabled ? `${prefix}${value}${suffix}` : String(value);
23
+ }
24
+ var bold = sgr(1, 22);
25
+ var dim = sgr(2, 22);
26
+ var italic = sgr(3, 23);
27
+ var underline = sgr(4, 24);
28
+ var red = sgr(31, 39);
29
+ var green = sgr(32, 39);
30
+ var yellow = sgr(33, 39);
31
+ var blue = sgr(34, 39);
32
+ var magenta = sgr(35, 39);
33
+ var cyan = sgr(36, 39);
34
+ var gray = sgr(90, 39);
35
+ var glyph = {
36
+ pass: "\u2714",
37
+ fail: "\u2716",
38
+ warn: "\u26A0",
39
+ skip: "\u25CB",
40
+ info: "\u2139",
41
+ arrow: "\u21B3",
42
+ bullet: "\u2022",
43
+ dot: "\xB7",
44
+ add: "+",
45
+ chevron: "\u25B8",
46
+ pointer: "\u276F"
47
+ };
48
+ var STATUS_STYLES = {
49
+ pass: { glyph: glyph.pass, color: green, label: "pass" },
50
+ fail: { glyph: glyph.fail, color: red, label: "fail" },
51
+ warn: { glyph: glyph.warn, color: yellow, label: "warn" },
52
+ skip: { glyph: glyph.skip, color: gray, label: "skip" },
53
+ applied: { glyph: glyph.pass, color: green, label: "applied" },
54
+ noop: { glyph: glyph.skip, color: gray, label: "noop" },
55
+ blocked: { glyph: glyph.fail, color: red, label: "blocked" },
56
+ skipped: { glyph: glyph.skip, color: gray, label: "skipped" },
57
+ partial: { glyph: glyph.warn, color: yellow, label: "partial" }
58
+ };
59
+
60
+ // src/tui/theme.ts
61
+ var INK_COLOR = {
62
+ accent: "cyan",
63
+ ok: "green",
64
+ warn: "yellow",
65
+ fail: "red",
66
+ muted: "gray"
67
+ };
68
+ function themeColor(role) {
69
+ return colorEnabled ? INK_COLOR[role] : void 0;
70
+ }
71
+ var marks = {
72
+ radioOn: "(\u2022)",
73
+ radioOff: "( )",
74
+ dotOn: "\u25CF",
75
+ dotOff: "\u25CB",
76
+ pointer: "\u276F",
77
+ dot: "\xB7"
78
+ };
79
+
80
+ // src/tui/RadioGroup.tsx
81
+ import { jsx, jsxs } from "react/jsx-runtime";
82
+ function RadioGroup({
83
+ options,
84
+ value,
85
+ onChange,
86
+ orientation = "horizontal",
87
+ isActive = true,
88
+ numberKeys = true
89
+ }) {
90
+ const index = Math.max(0, options.findIndex((option) => option.value === value));
91
+ useInput(
92
+ (input, key) => {
93
+ const prev = orientation === "horizontal" ? key.leftArrow : key.upArrow;
94
+ const next = orientation === "horizontal" ? key.rightArrow : key.downArrow;
95
+ if (prev && index > 0) onChange(options[index - 1].value);
96
+ else if (next && index < options.length - 1) onChange(options[index + 1].value);
97
+ else if (numberKeys && /^[1-9]$/.test(input)) {
98
+ const picked = options[Number(input) - 1];
99
+ if (picked) onChange(picked.value);
100
+ }
101
+ },
102
+ { isActive }
103
+ );
104
+ return /* @__PURE__ */ jsx(Box, { flexDirection: orientation === "horizontal" ? "row" : "column", columnGap: 2, children: options.map((option, i) => {
105
+ const selected = option.value === value;
106
+ return /* @__PURE__ */ jsxs(Box, { children: [
107
+ /* @__PURE__ */ jsxs(Text, { color: selected ? themeColor("accent") : void 0, bold: selected, children: [
108
+ selected ? marks.radioOn : marks.radioOff,
109
+ " ",
110
+ option.label
111
+ ] }),
112
+ option.badge ? /* @__PURE__ */ jsxs(Text, { color: themeColor("muted"), children: [
113
+ " ",
114
+ option.badge
115
+ ] }) : null,
116
+ numberKeys ? /* @__PURE__ */ jsxs(Text, { color: themeColor("muted"), children: [
117
+ " ",
118
+ i + 1
119
+ ] }) : null
120
+ ] }, option.value);
121
+ }) });
122
+ }
123
+
124
+ // src/tui/KeyHints.tsx
125
+ import { Box as Box2, Text as Text2 } from "ink";
126
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
127
+ function KeyHints({ hints }) {
128
+ return /* @__PURE__ */ jsx2(Box2, { flexWrap: "wrap", columnGap: 1, children: hints.map((hint, i) => /* @__PURE__ */ jsxs2(Box2, { children: [
129
+ /* @__PURE__ */ jsx2(Text2, { color: themeColor("accent"), children: hint.keys }),
130
+ /* @__PURE__ */ jsxs2(Text2, { color: themeColor("muted"), children: [
131
+ " ",
132
+ hint.label,
133
+ i < hints.length - 1 ? ` ${marks.dot}` : ""
134
+ ] })
135
+ ] }, hint.keys)) });
136
+ }
137
+
138
+ // src/tui/run.tsx
139
+ import { render } from "ink";
140
+
141
+ // src/tui/load.ts
142
+ var TuiUnavailableError = class extends Error {
143
+ };
144
+ function isInteractive(streams = {}) {
145
+ const stdin = streams.stdin ?? process.stdin;
146
+ const stdout = streams.stdout ?? process.stdout;
147
+ return Boolean(stdin.isTTY && stdout.isTTY);
148
+ }
149
+
150
+ // src/tui/run.tsx
151
+ async function runInk(build, options = {}) {
152
+ if (!isInteractive({ stdin: options.stdin, stdout: options.stdout })) {
153
+ throw new TuiUnavailableError("this needs an interactive terminal (stdin and stdout must be a TTY)");
154
+ }
155
+ let result;
156
+ let instance;
157
+ let finished = false;
158
+ const finish = (value) => {
159
+ if (finished) return;
160
+ finished = true;
161
+ result = value;
162
+ instance?.unmount();
163
+ };
164
+ instance = render(build(finish), {
165
+ ...options.ink,
166
+ stdin: options.stdin,
167
+ stdout: options.stdout,
168
+ stderr: options.stderr,
169
+ alternateScreen: options.alternateScreen ?? false,
170
+ exitOnCtrlC: true
171
+ });
172
+ if (finished) instance.unmount();
173
+ await instance.waitUntilExit();
174
+ return result;
175
+ }
176
+
177
+ // src/tui/hooks.ts
178
+ import { useEffect, useState } from "react";
179
+ function useNow(ms = 1e3, clock = Date.now) {
180
+ const [now, setNow] = useState(clock);
181
+ useEffect(() => {
182
+ const timer = setInterval(() => setNow(clock()), ms);
183
+ return () => clearInterval(timer);
184
+ }, [ms, clock]);
185
+ return now;
186
+ }
187
+ function useInterval(tick, ms) {
188
+ useEffect(() => {
189
+ const timer = setInterval(tick, ms);
190
+ return () => clearInterval(timer);
191
+ }, [tick, ms]);
192
+ }
193
+
194
+ // src/utils/version.ts
195
+ import { readFileSync } from "node:fs";
196
+ import { dirname, join } from "node:path";
197
+ import { fileURLToPath } from "node:url";
198
+ var PJANGLER_VERSION = (() => {
199
+ try {
200
+ let dir = dirname(fileURLToPath(import.meta.url));
201
+ for (let i = 0; i < 4; i++) {
202
+ try {
203
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
204
+ return JSON.parse(raw).version ?? "0.0.0";
205
+ } catch {
206
+ const parent = dirname(dir);
207
+ if (parent === dir) break;
208
+ dir = parent;
209
+ }
210
+ }
211
+ } catch {
212
+ }
213
+ return "0.0.0";
214
+ })();
215
+
216
+ // src/crank/index.ts
217
+ var ZCRANK_INTERVALS = ["5m", "1h", "6h", "1d"];
218
+ var INTERVAL_SECONDS = { "5m": 300, "1h": 3600, "6h": 21600, "1d": 86400 };
219
+ var ZCRANK_CRON_SECONDS = 300;
220
+ var ZCRANK_GRACE_SECONDS = 300;
221
+ function nextRunMs(input) {
222
+ if (!input.enabled) return void 0;
223
+ const tick = ZCRANK_CRON_SECONDS * 1e3;
224
+ let earliest = input.nowMs;
225
+ if (INTERVAL_SECONDS[input.interval] > ZCRANK_CRON_SECONDS && input.lastRun !== void 0) {
226
+ earliest = Math.max(earliest, input.lastRun + (INTERVAL_SECONDS[input.interval] - ZCRANK_GRACE_SECONDS) * 1e3);
227
+ }
228
+ return Math.ceil(earliest / tick) * tick;
229
+ }
230
+ function formatDuration(totalSeconds) {
231
+ const s = Math.max(0, Math.round(totalSeconds));
232
+ if (s < 60) return `${s}s`;
233
+ if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
234
+ if (s < 86400) return `${Math.floor(s / 3600)}h ${String(Math.floor(s % 3600 / 60)).padStart(2, "0")}m`;
235
+ return `${Math.floor(s / 86400)}d ${Math.floor(s % 86400 / 3600)}h`;
236
+ }
237
+ function formatAge(thenMs, nowMs) {
238
+ const s = Math.max(0, Math.round((nowMs - thenMs) / 1e3));
239
+ if (s < 60) return `${s}s ago`;
240
+ return `${formatDuration(s)} ago`;
241
+ }
242
+ function renderLogRow(raw) {
243
+ try {
244
+ const parsed = JSON.parse(raw);
245
+ const ts = typeof parsed.ts === "string" ? parsed.ts : "";
246
+ const state = typeof parsed.state === "string" ? parsed.state : "?";
247
+ const action = typeof parsed.action === "string" ? parsed.action : "";
248
+ return `${ts} ${state.padEnd(12)}${action}`;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ // src/crank/tui.tsx
255
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
256
+ function intervalOptions(global) {
257
+ return ZCRANK_INTERVALS.map((value) => ({ value, label: value, badge: value === global ? "global" : void 0 }));
258
+ }
259
+ function inheritanceNote(inherited, global) {
260
+ return inherited ? `inherits global (${global})` : `project override (global is ${global})`;
261
+ }
262
+ function IntervalPicker({ slug, tab, effective, inherited, global, onDone }) {
263
+ const [value, setValue] = useState2(effective);
264
+ useInput2((input, key) => {
265
+ if (key.return) onDone(value);
266
+ else if (key.escape || input === "q") onDone(null);
267
+ });
268
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 1, children: [
269
+ /* @__PURE__ */ jsxs3(Text3, { children: [
270
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("accent"), bold: true, children: "crank interval" }),
271
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
272
+ " ",
273
+ marks.dot,
274
+ " "
275
+ ] }),
276
+ /* @__PURE__ */ jsx3(Text3, { children: slug }),
277
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
278
+ " ",
279
+ marks.pointer,
280
+ " "
281
+ ] }),
282
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: tab })
283
+ ] }),
284
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
285
+ "How often may the crank beat touch this tab? Now: ",
286
+ inheritanceNote(inherited, global),
287
+ "."
288
+ ] }),
289
+ /* @__PURE__ */ jsx3(Box3, { marginY: 1, children: /* @__PURE__ */ jsx3(RadioGroup, { options: intervalOptions(global), value, onChange: setValue }) }),
290
+ /* @__PURE__ */ jsx3(KeyHints, { hints: [{ keys: "\u2190/\u2192 1-4", label: "choose" }, { keys: "enter", label: "enable" }, { keys: "esc q", label: "abort" }] })
291
+ ] });
292
+ }
293
+ async function pickInterval(props, run = {}) {
294
+ const choice = await runInk((finish) => /* @__PURE__ */ jsx3(IntervalPicker, { ...props, onDone: finish }), run);
295
+ return choice ?? null;
296
+ }
297
+ function clockTime(ms) {
298
+ const d = new Date(ms);
299
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
300
+ }
301
+ function describeMatch(view) {
302
+ const { resolution } = view;
303
+ if (resolution.storedAsIs) return `no live tab or key matches "${view.name}" - stored under the project name`;
304
+ const how = resolution.source.replace("-", " ");
305
+ return resolution.liveTab ? `live tab "${resolution.liveTab}" via ${how} "${resolution.matched}"` : `stored key via ${how} "${resolution.matched}"`;
306
+ }
307
+ function CrankDashboard({ controller, refreshMs = 5e3, clock = Date.now, onQuit }) {
308
+ const [view, setView] = useState2(() => controller.read(clock()));
309
+ const [error, setError] = useState2();
310
+ const [flash, setFlash] = useState2();
311
+ const now = useNow(1e3, clock);
312
+ const { rows } = useWindowSize();
313
+ const refresh = useCallback(() => {
314
+ try {
315
+ setView(controller.read(clock()));
316
+ setError(void 0);
317
+ } catch (caught) {
318
+ setError(caught instanceof Error ? caught.message : String(caught));
319
+ }
320
+ }, [controller, clock]);
321
+ useInterval(refresh, refreshMs);
322
+ const act = (label, fn) => {
323
+ try {
324
+ const note = fn();
325
+ setFlash(note || label);
326
+ setError(void 0);
327
+ } catch (caught) {
328
+ setError(caught instanceof Error ? caught.message : String(caught));
329
+ }
330
+ refresh();
331
+ };
332
+ const snap = view.snapshot;
333
+ const isOn = snap.enabled === true;
334
+ const toggle = () => act(isOn ? "crank OFF" : "crank ON", () => {
335
+ if (isOn) {
336
+ controller.disable();
337
+ return;
338
+ }
339
+ const result = controller.enable();
340
+ return result.renamed.length ? `crank ON (renamed ${result.renamed.map((r) => `${r.from} -> ${r.to}`).join(", ")})` : void 0;
341
+ });
342
+ useInput2((input, key) => {
343
+ if (key.escape || input === "q") onQuit();
344
+ else if (input === " " || input === "t") toggle();
345
+ else if (input === "r") act(`interval reset to global (${snap.global})`, () => controller.setInterval(void 0));
346
+ });
347
+ const onInterval = (interval) => act(`interval ${interval}`, () => controller.setInterval(interval));
348
+ const next = nextRunMs({ enabled: isOn, interval: snap.interval, lastRun: snap.lastRunMs, nowMs: now });
349
+ const logRows = Math.max(3, (rows || 24) - 19);
350
+ const tail = snap.log.slice(-logRows);
351
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 1, children: [
352
+ /* @__PURE__ */ jsxs3(Text3, { children: [
353
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("accent"), bold: true, children: "pj crank" }),
354
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
355
+ " ",
356
+ marks.dot,
357
+ " "
358
+ ] }),
359
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: view.slug }),
360
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
361
+ " (",
362
+ view.name,
363
+ ")"
364
+ ] })
365
+ ] }),
366
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), wrap: "truncate-end", children: [
367
+ "tab ",
368
+ /* @__PURE__ */ jsx3(Text3, { color: void 0, bold: true, children: snap.tab }),
369
+ " ",
370
+ describeMatch(view)
371
+ ] }),
372
+ view.resolution.storedAsIs ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("warn"), children: `no tab matched yet - toggling stores "${snap.tab}" as-is` }) : null,
373
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { children: [
374
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "Crank " }),
375
+ isOn ? /* @__PURE__ */ jsxs3(Text3, { color: themeColor("ok"), bold: true, children: [
376
+ marks.dotOn,
377
+ " ON"
378
+ ] }) : /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
379
+ marks.dotOff,
380
+ " OFF"
381
+ ] })
382
+ ] }) }),
383
+ /* @__PURE__ */ jsxs3(Box3, { children: [
384
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "Interval " }),
385
+ /* @__PURE__ */ jsx3(RadioGroup, { options: intervalOptions(snap.global), value: snap.interval, onChange: onInterval })
386
+ ] }),
387
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
388
+ " ",
389
+ inheritanceNote(snap.inherited, snap.global)
390
+ ] }),
391
+ /* @__PURE__ */ jsxs3(Text3, { children: [
392
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "Next run " }),
393
+ next === void 0 ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "off" }) : /* @__PURE__ */ jsxs3(Text3, { children: [
394
+ next <= now ? "now" : `in ${formatDuration((next - now) / 1e3)}`,
395
+ " ",
396
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
397
+ "(",
398
+ clockTime(next),
399
+ ")"
400
+ ] })
401
+ ] })
402
+ ] }),
403
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, children: [
404
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "Last runs" }),
405
+ snap.beats.length === 0 ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: " none yet" }) : snap.beats.map((beat, i) => /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate-end", children: [
406
+ " ",
407
+ formatAge(beat.tsMs, now).padEnd(10),
408
+ " ",
409
+ beat.state.padEnd(9),
410
+ " ",
411
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: beat.action })
412
+ ] }, `${beat.ts}-${i}`))
413
+ ] }),
414
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, children: [
415
+ /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
416
+ "Log (",
417
+ snap.log.length,
418
+ " rows)"
419
+ ] }),
420
+ tail.map((raw, i) => /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate-end", color: themeColor("muted"), children: [
421
+ " ",
422
+ renderLogRow(raw) ?? raw
423
+ ] }, i))
424
+ ] }),
425
+ /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, flexDirection: "column", children: [
426
+ error ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("fail"), children: `${marks.dot} ${error}` }) : flash ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("ok"), children: `${marks.dot} ${flash}` }) : /* @__PURE__ */ jsx3(Text3, { children: " " }),
427
+ /* @__PURE__ */ jsx3(KeyHints, { hints: [
428
+ { keys: "space t", label: "toggle" },
429
+ { keys: "\u2190/\u2192 1-4", label: "interval" },
430
+ { keys: "r", label: "reset to global" },
431
+ { keys: "q esc", label: "quit" }
432
+ ] })
433
+ ] })
434
+ ] });
435
+ }
436
+ async function runCrankDashboard(controller, run = {}) {
437
+ const { refreshMs, ...ink } = run;
438
+ await runInk((finish) => /* @__PURE__ */ jsx3(CrankDashboard, { controller, refreshMs, onQuit: () => finish() }), { alternateScreen: true, ...ink });
439
+ }
440
+ export {
441
+ CrankDashboard,
442
+ IntervalPicker,
443
+ intervalOptions,
444
+ pickInterval,
445
+ runCrankDashboard
446
+ };
447
+ //# sourceMappingURL=tui.js.map