tuiboard 0.8.1 → 0.8.3
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 +24 -0
- package/README.md +6 -2
- package/bin/tuiboard.ts +54 -5
- package/package.json +1 -1
- package/src/app.tsx +18 -0
- package/src/store/parsers.test.ts +64 -0
- package/src/store/parsers.ts +6 -5
- package/src/ui/Modal.tsx +6 -6
- package/src/ui/splash-boot.ts +21 -0
- package/src/ui/splash.ts +133 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ All notable changes to **tuiboard** are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.3] - 2026-06-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Boot splash.** Launching tuiboard now paints a `tuiboard` wordmark (FIGlet
|
|
12
|
+
"Rectangles", in the tool's light-yellow accent) the instant the process
|
|
13
|
+
starts, so the ~1s cold start (runtime + store build + first calendar/agents
|
|
14
|
+
read) isn't a blank terminal. The launcher animates the booting dots while the
|
|
15
|
+
dashboard process loads in parallel, then hands the screen over cleanly — no
|
|
16
|
+
startup time added. Set `TUIBOARD_NO_SPLASH=1` to disable; it also no-ops when
|
|
17
|
+
output isn't a TTY or the terminal is tiny.
|
|
18
|
+
|
|
19
|
+
## [0.8.2] - 2026-06-04
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **Consistent date shortcuts everywhere.** `m` now means "tomorrow" in every
|
|
23
|
+
date input (the schedule modal, the new-event/edit modals, and quick-add),
|
|
24
|
+
matching the board's `m` = tomorrow key — so `t`/`m` = today/tomorrow whether
|
|
25
|
+
you press them on a card or type them into a field. `tm`/`tom`/`tomorrow`/
|
|
26
|
+
`domani` still work as aliases. Hints and the help screen updated to lead with
|
|
27
|
+
`m`. (Audit of all shortcut surfaces found this was the only divergence; the
|
|
28
|
+
rest — `t`, `-`/empty to clear, weekdays, ±N — were already aligned.)
|
|
29
|
+
|
|
8
30
|
## [0.8.1] - 2026-06-04
|
|
9
31
|
|
|
10
32
|
### Added
|
|
@@ -152,6 +174,8 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
152
174
|
|
|
153
175
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
154
176
|
|
|
177
|
+
[0.8.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.3
|
|
178
|
+
[0.8.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.2
|
|
155
179
|
[0.8.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.1
|
|
156
180
|
[0.8.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.0
|
|
157
181
|
[0.7.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.3
|
package/README.md
CHANGED
|
@@ -261,14 +261,15 @@ show in the picker. Append tokens to the title to set the **time** and **date**:
|
|
|
261
261
|
|
|
262
262
|
```
|
|
263
263
|
Standup 9:00-9:30 # today (or the viewed day), 09:00–09:30
|
|
264
|
-
Lunch
|
|
264
|
+
Lunch m 12-13 # tomorrow (m), 12:00–13:00
|
|
265
265
|
Review 2026-06-10 15-16 # that date, 15:00–16:00
|
|
266
266
|
Call +3 16:00-16:30 # in 3 days · lun = next Monday also works
|
|
267
267
|
Holiday 2026-12-25 allday # an all-day event (no time)
|
|
268
268
|
```
|
|
269
269
|
|
|
270
270
|
The date defaults to whichever day the Agenda is showing; an explicit date token
|
|
271
|
-
(`t` / `
|
|
271
|
+
(`t` / `m` / `+N` / weekday / `YYYY-MM-DD` — the same `t`/`m` = today/tomorrow as
|
|
272
|
+
the board keys) overrides it. The time is taken from
|
|
272
273
|
the clicked slot, or `HH:MM-HH:MM`. Add **`allday`** (or `all-day`) anywhere in
|
|
273
274
|
the title to create an all-day event instead — it lands in the top chip strip.
|
|
274
275
|
|
|
@@ -436,6 +437,9 @@ session (until the next terminal resize).
|
|
|
436
437
|
|
|
437
438
|
See [CHANGELOG.md](CHANGELOG.md) for the full release history.
|
|
438
439
|
|
|
440
|
+
- **v0.8** — write to Google Calendar from the Agenda: create, edit, and delete
|
|
441
|
+
events (opt-in), set their date and time in the modal, plus all-day events in
|
|
442
|
+
the top strip, consistent `t`/`m` date shortcuts, and a boot splash.
|
|
439
443
|
- **v0.7** — configurable zones: turn the planner, agenda, or agents view off
|
|
440
444
|
(or start it collapsed) via the `zones:` config, so tuiboard can be a pure
|
|
441
445
|
kanban, kanban + calendar, or any mix.
|
package/bin/tuiboard.ts
CHANGED
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
* any CLI args, and inherit stdio so the TUI keeps the real terminal.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
15
17
|
import { dirname, join } from "node:path";
|
|
16
18
|
import { fileURLToPath } from "node:url";
|
|
17
19
|
|
|
20
|
+
import pkg from "../package.json";
|
|
21
|
+
import { animateBooting, printSplash, showCursor } from "../src/ui/splash.ts";
|
|
22
|
+
|
|
18
23
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
24
|
const appPath = join(here, "..", "src", "app.tsx");
|
|
20
25
|
|
|
@@ -26,10 +31,54 @@ if (process.argv[2] === "calendar-setup") {
|
|
|
26
31
|
}
|
|
27
32
|
const preload = fileURLToPath(import.meta.resolve("@opentui/solid/preload"));
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
// Paint the splash from the (already-running) launcher and animate its booting
|
|
35
|
+
// dots while the child cold-starts. Using `spawn` (not `spawnSync`) keeps this
|
|
36
|
+
// process's event loop free to run the animation. We MUST stop animating the
|
|
37
|
+
// instant before the child enters OpenTUI's alternate screen, or our writes
|
|
38
|
+
// would land on the dashboard — so the child drops a "ready" flag file just
|
|
39
|
+
// before render() and we poll for it.
|
|
40
|
+
printSplash(pkg.version);
|
|
41
|
+
const stopAnim = animateBooting(pkg.version);
|
|
42
|
+
// The splash hides the cursor; make sure it comes back when the launcher exits
|
|
43
|
+
// (after the child has torn down), so the shell is never left cursor-less.
|
|
44
|
+
process.on("exit", showCursor);
|
|
45
|
+
const readyFlag = join(tmpdir(), `tuiboard-ready-${process.pid}`);
|
|
46
|
+
try { rmSync(readyFlag, { force: true }); } catch { /* ignore */ }
|
|
47
|
+
|
|
48
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
49
|
+
let safety: ReturnType<typeof setTimeout> | undefined;
|
|
50
|
+
let stopped = false;
|
|
51
|
+
const stopSplash = () => {
|
|
52
|
+
if (stopped) return;
|
|
53
|
+
stopped = true;
|
|
54
|
+
stopAnim();
|
|
55
|
+
if (poll) clearInterval(poll);
|
|
56
|
+
if (safety) clearTimeout(safety);
|
|
57
|
+
try { rmSync(readyFlag, { force: true }); } catch { /* ignore */ }
|
|
58
|
+
};
|
|
59
|
+
poll = setInterval(() => { if (existsSync(readyFlag)) stopSplash(); }, 30);
|
|
60
|
+
safety = setTimeout(stopSplash, 4000); // fallback if the child never signals
|
|
61
|
+
|
|
62
|
+
// Ctrl-C reaches the child directly (same process group); it cleans up and
|
|
63
|
+
// exits, then we mirror its code below. Ignore the signal here so the launcher
|
|
64
|
+
// doesn't die first and orphan the child mid-teardown.
|
|
65
|
+
process.on("SIGINT", () => {});
|
|
66
|
+
process.on("SIGTERM", () => {});
|
|
67
|
+
|
|
68
|
+
const child = spawn(
|
|
30
69
|
process.execPath, // the bun binary running this script
|
|
31
70
|
["--preload", preload, appPath, ...process.argv.slice(2)],
|
|
32
|
-
{
|
|
71
|
+
{
|
|
72
|
+
stdio: "inherit",
|
|
73
|
+
env: { ...process.env, TUIBOARD_SPLASH_DONE: "1", TUIBOARD_READY_FLAG: readyFlag },
|
|
74
|
+
},
|
|
33
75
|
);
|
|
34
|
-
|
|
35
|
-
|
|
76
|
+
child.on("exit", (code, signal) => {
|
|
77
|
+
stopSplash();
|
|
78
|
+
process.exit(code ?? (signal ? 1 : 0));
|
|
79
|
+
});
|
|
80
|
+
child.on("error", (err) => {
|
|
81
|
+
stopSplash();
|
|
82
|
+
console.error(String(err));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tuiboard",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live Claude Code agent view. Use only the panels you want.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/app.tsx
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* root layout component changes.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
// FIRST import on purpose: paints the boot splash before the heavy imports
|
|
16
|
+
// (OpenTUI) and the ~600ms store build below run. See ui/splash-boot.ts.
|
|
17
|
+
import "~/ui/splash-boot";
|
|
18
|
+
|
|
15
19
|
import { createMemo } from "solid-js";
|
|
16
20
|
import { render, useKeyboard } from "@opentui/solid";
|
|
17
21
|
|
|
@@ -150,4 +154,18 @@ function App() {
|
|
|
150
154
|
);
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
// Signal the launcher (if we were spawned by the `tuiboard` bin) that we're
|
|
158
|
+
// about to take the screen, so it stops animating the splash a beat before
|
|
159
|
+
// OpenTUI enters the alternate buffer — otherwise its writes would land on the
|
|
160
|
+
// dashboard. The short delay gives the launcher's poll a cycle to notice.
|
|
161
|
+
if (process.env.TUIBOARD_READY_FLAG) {
|
|
162
|
+
try {
|
|
163
|
+
const { writeFileSync } = await import("node:fs");
|
|
164
|
+
writeFileSync(process.env.TUIBOARD_READY_FLAG, "1");
|
|
165
|
+
await new Promise((r) => setTimeout(r, 70));
|
|
166
|
+
} catch {
|
|
167
|
+
// Cosmetic only — never block startup on the splash handshake.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
153
171
|
await render(() => <App />, { useMouse: true });
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { isoToday, isoTomorrow } from "./index";
|
|
4
|
+
import { parseDateShortcut, parseQuickAdd, parseTimeBlockShortcut } from "./parsers";
|
|
5
|
+
|
|
6
|
+
describe("parseDateShortcut", () => {
|
|
7
|
+
it("maps t / today / oggi to today", () => {
|
|
8
|
+
expect(parseDateShortcut("t")).toBe(isoToday());
|
|
9
|
+
expect(parseDateShortcut("today")).toBe(isoToday());
|
|
10
|
+
expect(parseDateShortcut("oggi")).toBe(isoToday());
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("maps m to tomorrow (consistent with the board's m key)", () => {
|
|
14
|
+
expect(parseDateShortcut("m")).toBe(isoTomorrow());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps tm / tom / tomorrow / domani as tomorrow aliases", () => {
|
|
18
|
+
expect(parseDateShortcut("tm")).toBe(isoTomorrow());
|
|
19
|
+
expect(parseDateShortcut("tom")).toBe(isoTomorrow());
|
|
20
|
+
expect(parseDateShortcut("tomorrow")).toBe(isoTomorrow());
|
|
21
|
+
expect(parseDateShortcut("domani")).toBe(isoTomorrow());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("is case-insensitive", () => {
|
|
25
|
+
expect(parseDateShortcut("M")).toBe(isoTomorrow());
|
|
26
|
+
expect(parseDateShortcut("T")).toBe(isoToday());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("clears on empty / dash, fails on garbage", () => {
|
|
30
|
+
expect(parseDateShortcut("")).toBeUndefined();
|
|
31
|
+
expect(parseDateShortcut("-")).toBeUndefined();
|
|
32
|
+
expect(parseDateShortcut("zzz")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("parses ISO dates literally", () => {
|
|
36
|
+
expect(parseDateShortcut("2026-06-10")).toBe("2026-06-10");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("parseQuickAdd date tokens", () => {
|
|
41
|
+
it("treats a standalone m as tomorrow and strips it from the title", () => {
|
|
42
|
+
const r = parseQuickAdd("Pay invoice m");
|
|
43
|
+
expect(r.scheduled).toBe(isoTomorrow());
|
|
44
|
+
expect(r.title).toBe("Pay invoice");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("treats a standalone t as today", () => {
|
|
48
|
+
const r = parseQuickAdd("Standup t");
|
|
49
|
+
expect(r.scheduled).toBe(isoToday());
|
|
50
|
+
expect(r.title).toBe("Standup");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("parseTimeBlockShortcut", () => {
|
|
55
|
+
it("parses loose H-H ranges and HH:MM-HH:MM", () => {
|
|
56
|
+
expect(parseTimeBlockShortcut("9-11")).toEqual({ startMin: 540, endMin: 660 });
|
|
57
|
+
expect(parseTimeBlockShortcut("09:30-10:45")).toEqual({ startMin: 570, endMin: 645 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("clears on empty / dash", () => {
|
|
61
|
+
expect(parseTimeBlockShortcut("")).toBeUndefined();
|
|
62
|
+
expect(parseTimeBlockShortcut("-")).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/store/parsers.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Date input shortcuts:
|
|
5
5
|
* t → today
|
|
6
|
-
* tm | tom
|
|
6
|
+
* m | tm | tom → tomorrow (m matches the board's `m` = tomorrow key)
|
|
7
7
|
* -N → N days ago
|
|
8
8
|
* +N → N days ahead
|
|
9
9
|
* lun/mar/.../dom → next weekday (Italian short)
|
|
@@ -47,7 +47,8 @@ export function parseDateShortcut(input: string): string | undefined | null {
|
|
|
47
47
|
if (s === "-") return undefined;
|
|
48
48
|
|
|
49
49
|
if (s === "t" || s === "today" || s === "oggi") return isoToday();
|
|
50
|
-
|
|
50
|
+
// `m` mirrors the board's `m` = tomorrow key; `tm`/`tom`/… kept as aliases.
|
|
51
|
+
if (s === "m" || s === "tm" || s === "tom" || s === "tomorrow" || s === "domani") return isoTomorrow();
|
|
51
52
|
|
|
52
53
|
// Relative ±N
|
|
53
54
|
const rel = s.match(/^([+-])(\d+)$/);
|
|
@@ -167,7 +168,7 @@ export interface QuickAddResult {
|
|
|
167
168
|
* Parse a free-form quick-add string. Recognized tokens:
|
|
168
169
|
* @name → assignee
|
|
169
170
|
* #tag → tag
|
|
170
|
-
* t,
|
|
171
|
+
* t, m, +N → scheduled date shortcut (m = tomorrow, matches the board key)
|
|
171
172
|
* YYYY-MM-DD → scheduled date literal
|
|
172
173
|
* HH:MM-HH:MM → time block (also sets scheduled to today if missing)
|
|
173
174
|
* 9-11 → 09:00-11:00 time block
|
|
@@ -220,10 +221,10 @@ export function parseQuickAdd(input: string): QuickAddResult {
|
|
|
220
221
|
}
|
|
221
222
|
continue;
|
|
222
223
|
}
|
|
223
|
-
// Date: t, tm, +N, YYYY-MM-DD
|
|
224
|
+
// Date: t, m/tm, +N, YYYY-MM-DD
|
|
224
225
|
const lower = tok.toLowerCase();
|
|
225
226
|
if (
|
|
226
|
-
lower === "t" || lower === "tm" || lower === "tom" ||
|
|
227
|
+
lower === "t" || lower === "m" || lower === "tm" || lower === "tom" ||
|
|
227
228
|
lower === "today" || lower === "tomorrow" ||
|
|
228
229
|
lower === "oggi" || lower === "domani" ||
|
|
229
230
|
/^[+-]\d+$/.test(lower) ||
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -139,7 +139,7 @@ function AddModal(props: { store: TuiStore; columnIndex: number }) {
|
|
|
139
139
|
return (
|
|
140
140
|
<DialogShell
|
|
141
141
|
title="New task"
|
|
142
|
-
hint="Quick syntax: @assignee #tag t/
|
|
142
|
+
hint="Quick syntax: @assignee #tag t/m/+N HH:MM-HH:MM 🔺 · Enter to add, Esc to cancel"
|
|
143
143
|
width={70}
|
|
144
144
|
>
|
|
145
145
|
<input
|
|
@@ -195,7 +195,7 @@ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiS
|
|
|
195
195
|
function submit(text: string) {
|
|
196
196
|
const d = parseDateShortcut(text);
|
|
197
197
|
if (d === null) {
|
|
198
|
-
setError(`Cannot parse "${text}". Try: t ·
|
|
198
|
+
setError(`Cannot parse "${text}". Try: t · m · +3 · lun · 2026-06-15`);
|
|
199
199
|
return;
|
|
200
200
|
}
|
|
201
201
|
const n = props.store.applyToMarkedOr(props.modal.ref, (r) =>
|
|
@@ -208,7 +208,7 @@ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiS
|
|
|
208
208
|
return (
|
|
209
209
|
<DialogShell
|
|
210
210
|
title={markedCount > 1 ? `Schedule · ${markedCount} tasks` : "Schedule"}
|
|
211
|
-
hint="t = today ·
|
|
211
|
+
hint="t = today · m = tomorrow · +3 = in 3 days · lun = next Monday · 2026-06-15 · empty/-clear · Esc to cancel"
|
|
212
212
|
width={70}
|
|
213
213
|
>
|
|
214
214
|
<input
|
|
@@ -370,7 +370,7 @@ function EventModal(props: { store: TuiStore }) {
|
|
|
370
370
|
fallback={
|
|
371
371
|
<DialogShell
|
|
372
372
|
title="New event"
|
|
373
|
-
hint={`${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)} · add a time, a date (
|
|
373
|
+
hint={`${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)} · add a time, a date (m · +3 · 2026-06-10), or "allday" · Enter add · Esc`}
|
|
374
374
|
>
|
|
375
375
|
<input
|
|
376
376
|
focused
|
|
@@ -452,7 +452,7 @@ function EventEditModal(props: { store: TuiStore }) {
|
|
|
452
452
|
<Show when={sel()}>
|
|
453
453
|
<DialogShell
|
|
454
454
|
title={`Edit event · ${shortDate(sel()!.dateIso)}`}
|
|
455
|
-
hint="change title, HH:MM-HH:MM, and/or a date (
|
|
455
|
+
hint="change title, HH:MM-HH:MM, and/or a date (m · +3 · lun · 2026-06-10) · Enter save · Esc"
|
|
456
456
|
>
|
|
457
457
|
<input
|
|
458
458
|
focused
|
|
@@ -813,7 +813,7 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
813
813
|
<span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
|
|
814
814
|
<span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
|
|
815
815
|
<span style={{ fg: T.text }}>{" e Edit task text\n"}</span>
|
|
816
|
-
<span style={{ fg: T.text }}>{" s Schedule date modal (t/
|
|
816
|
+
<span style={{ fg: T.text }}>{" s Schedule date modal (t/m/+N/lun/YYYY-MM-DD — same t/m as the board)\n"}</span>
|
|
817
817
|
<span style={{ fg: T.text }}>{" t Set scheduled = today\n"}</span>
|
|
818
818
|
<span style={{ fg: T.text }}>{" m Set scheduled = tomorrow\n"}</span>
|
|
819
819
|
<span style={{ fg: T.text }}>{" . Schedule now — time block at next 15-min slot (30min)\n"}</span>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-effect module: paints the boot splash the moment it's imported.
|
|
3
|
+
*
|
|
4
|
+
* app.tsx imports this FIRST so the splash prints before `@opentui/solid`
|
|
5
|
+
* (and the ~600ms store build) load — ES modules run imported modules in
|
|
6
|
+
* source order, so a first-position side-effect import is the only way to
|
|
7
|
+
* paint before the heavy imports execute.
|
|
8
|
+
*
|
|
9
|
+
* When launched via the `tuiboard` bin, the launcher already printed the splash
|
|
10
|
+
* (and sets TUIBOARD_SPLASH_DONE), so this no-ops to avoid a double paint.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import pkg from "../../package.json";
|
|
14
|
+
import { printSplash, showCursor } from "./splash";
|
|
15
|
+
|
|
16
|
+
if (!process.env.TUIBOARD_SPLASH_DONE) printSplash(pkg.version);
|
|
17
|
+
|
|
18
|
+
// The splash hides the cursor; guarantee it's restored on every exit path of
|
|
19
|
+
// this process, so quitting never leaves the shell without a cursor. (OpenTUI
|
|
20
|
+
// also restores on clean exit; this is the belt-and-suspenders backstop.)
|
|
21
|
+
process.on("exit", showCursor);
|
package/src/ui/splash.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot splash — a raw-ANSI "tuiboard" wordmark printed the instant the process
|
|
3
|
+
* starts, so the ~1s cold-start window (Bun init + module load + store build +
|
|
4
|
+
* first calendar/agents read) isn't a blank terminal.
|
|
5
|
+
*
|
|
6
|
+
* Why raw ANSI and not an OpenTUI component: the slow part is *synchronous* and
|
|
7
|
+
* happens BEFORE OpenTUI mounts (createTuiStore alone is ~600ms), so a reactive
|
|
8
|
+
* component can't paint during it. We print straight to stdout first; when
|
|
9
|
+
* OpenTUI mounts it enters the alternate screen buffer (`?1049h`), which hides
|
|
10
|
+
* this splash and shows the dashboard. No animation — the main thread is busy
|
|
11
|
+
* the whole time — so we lean on a static wordmark with a subtle colour ramp.
|
|
12
|
+
*
|
|
13
|
+
* The wordmark is the FIGlet "Rectangles" font; the colour is the tool's light
|
|
14
|
+
* "today" yellow (#eaf6ad), rendered as a gentle top-to-bottom gradient.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** "tuiboard" in the FIGlet Rectangles font (4 glyph rows, 31 cols). */
|
|
18
|
+
const WORDMARK = [
|
|
19
|
+
" _ _ _ _ ",
|
|
20
|
+
"| |_ _ _|_| |_ ___ ___ ___ _| |",
|
|
21
|
+
"| _| | | | . | . | .'| _| . |",
|
|
22
|
+
"|_| |___|_|___|___|__,|_| |___|",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Top→bottom gradient of light yellows around the #eaf6ad "today" accent. */
|
|
26
|
+
const GRADIENT: Array<[number, number, number]> = [
|
|
27
|
+
[244, 250, 200], // #f4fac8
|
|
28
|
+
[238, 247, 182], // #eef7b6
|
|
29
|
+
[234, 246, 173], // #eaf6ad (the tool's todayPale)
|
|
30
|
+
[224, 239, 154], // #e0ef9a
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const SUBTITLE = "terminal kanban · agenda · agents";
|
|
34
|
+
|
|
35
|
+
const ESC = "\x1b[";
|
|
36
|
+
const RESET = `${ESC}0m`;
|
|
37
|
+
const HIDE_CURSOR = `${ESC}?25l`;
|
|
38
|
+
const SHOW_CURSOR = `${ESC}?25h`;
|
|
39
|
+
const fg = (r: number, g: number, b: number) => `${ESC}38;2;${r};${g};${b}m`;
|
|
40
|
+
const DIM = `${ESC}38;2;110;120;110m`; // muted grey-green for the sub-lines
|
|
41
|
+
|
|
42
|
+
/** Visible width of a string (the wordmark/subtitle are plain ASCII). */
|
|
43
|
+
function center(line: string, cols: number): string {
|
|
44
|
+
const pad = Math.max(0, Math.floor((cols - line.length) / 2));
|
|
45
|
+
return " ".repeat(pad) + line;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the full splash frame for a terminal of `cols`×`rows`. Clears the
|
|
50
|
+
* screen, vertically centres the block, and colours each wordmark row with its
|
|
51
|
+
* gradient shade. Returns the raw string to write.
|
|
52
|
+
*/
|
|
53
|
+
export function splashFrame(cols: number, rows: number, version: string): string {
|
|
54
|
+
const blockHeight = WORDMARK.length + 3; // wordmark + blank + subtitle + version
|
|
55
|
+
const top = Math.max(0, Math.floor((rows - blockHeight) / 2));
|
|
56
|
+
|
|
57
|
+
let out = `${ESC}2J${ESC}H`; // clear + home
|
|
58
|
+
out += "\n".repeat(top);
|
|
59
|
+
|
|
60
|
+
WORDMARK.forEach((line, i) => {
|
|
61
|
+
const [r, g, b] = GRADIENT[Math.min(i, GRADIENT.length - 1)]!;
|
|
62
|
+
out += fg(r, g, b) + center(line, cols) + RESET + "\n";
|
|
63
|
+
});
|
|
64
|
+
out += "\n";
|
|
65
|
+
out += DIM + center(SUBTITLE, cols) + RESET + "\n";
|
|
66
|
+
out += DIM + center(bootingLine(version, 0), cols) + RESET;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Cycling dot suffixes for the booting line — a gentle left-to-right wave.
|
|
71
|
+
* All frames are the same visible width so the centred line never jitters. */
|
|
72
|
+
const BOOT_FRAMES = [" ", "· ", "·· ", "···", " ··", " ·"];
|
|
73
|
+
|
|
74
|
+
/** The booting line text for animation frame `f` (without colour/centering). */
|
|
75
|
+
function bootingLine(version: string, f: number): string {
|
|
76
|
+
return `booting v${version} ${BOOT_FRAMES[f % BOOT_FRAMES.length]}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Animate the booting line in place (the launcher calls this while the child
|
|
81
|
+
* cold-starts). Rewrites just that one line — the cursor is already parked on
|
|
82
|
+
* it after `printSplash`. Returns a `stop` function the caller MUST invoke
|
|
83
|
+
* before the child takes the screen, so we never draw onto the dashboard.
|
|
84
|
+
* No-ops (returns a no-op stop) when output isn't an animatable TTY.
|
|
85
|
+
*/
|
|
86
|
+
export function animateBooting(version: string): () => void {
|
|
87
|
+
if (!process.stdout.isTTY || process.env.TUIBOARD_NO_SPLASH) return () => {};
|
|
88
|
+
const cols = process.stdout.columns ?? 0;
|
|
89
|
+
const rows = process.stdout.rows ?? 0;
|
|
90
|
+
if (cols < 34 || rows < 9) return () => {};
|
|
91
|
+
let f = 1;
|
|
92
|
+
const tick = () => {
|
|
93
|
+
try {
|
|
94
|
+
process.stdout.write(`\r${ESC}2K` + DIM + center(bootingLine(version, f), cols) + RESET);
|
|
95
|
+
f++;
|
|
96
|
+
} catch {
|
|
97
|
+
/* ignore */
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const handle = setInterval(tick, 230);
|
|
101
|
+
return () => clearInterval(handle);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Print the splash to stdout if it makes sense to: an interactive TTY, wide and
|
|
106
|
+
* tall enough not to garble, and not disabled via `TUIBOARD_NO_SPLASH`. Safe to
|
|
107
|
+
* call more than once; safe to call when not a TTY (it just no-ops).
|
|
108
|
+
*/
|
|
109
|
+
export function printSplash(version: string): void {
|
|
110
|
+
try {
|
|
111
|
+
if (!process.stdout.isTTY) return;
|
|
112
|
+
if (process.env.TUIBOARD_NO_SPLASH) return;
|
|
113
|
+
const cols = process.stdout.columns ?? 0;
|
|
114
|
+
const rows = process.stdout.rows ?? 0;
|
|
115
|
+
if (cols < 34 || rows < 9) return; // too small — skip rather than mangle
|
|
116
|
+
// Hide the terminal cursor so its blinking bar doesn't sit next to the
|
|
117
|
+
// booting dots. ALWAYS paired with showCursor() on exit (see splash-boot.ts
|
|
118
|
+
// and the bin launcher) so the shell never ends up cursor-less.
|
|
119
|
+
process.stdout.write(HIDE_CURSOR + splashFrame(cols, rows, version));
|
|
120
|
+
} catch {
|
|
121
|
+
// Cosmetic only — never let the splash break startup.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Restore the terminal cursor that the splash hid. Idempotent; safe to call on
|
|
126
|
+
* every exit path and when no splash was ever shown. */
|
|
127
|
+
export function showCursor(): void {
|
|
128
|
+
try {
|
|
129
|
+
if (process.stdout.isTTY) process.stdout.write(SHOW_CURSOR);
|
|
130
|
+
} catch {
|
|
131
|
+
/* ignore */
|
|
132
|
+
}
|
|
133
|
+
}
|