tuiboard 0.8.5 → 0.9.1
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 +63 -0
- package/README.md +37 -1
- package/bin/tuiboard.ts +4 -0
- package/package.json +3 -1
- package/src/app.tsx +36 -17
- package/src/boards/config-writer.ts +191 -0
- package/src/boards/create.ts +57 -0
- package/src/boards/scan.ts +95 -0
- package/src/boards/suggest.ts +34 -0
- package/src/cli/args.ts +2 -2
- package/src/cli/board.ts +151 -0
- package/src/config/loader.ts +37 -13
- package/src/input/handleKey.ts +58 -1
- package/src/io/watcher.ts +11 -0
- package/src/parser/markdown.ts +10 -0
- package/src/store/index.ts +379 -12
- package/src/ui/BoardView.tsx +8 -6
- package/src/ui/Chrome.tsx +65 -9
- package/src/ui/Modal.tsx +167 -4
- package/src/ui/PlannerPanel.tsx +1 -1
- package/src/ui/pane-ring.ts +73 -0
- package/src/views/BoardOnly.tsx +3 -2
- package/src/views/Dashboard.tsx +11 -27
- package/src/cli/args.test.ts +0 -40
- package/src/cli/headless.test.ts +0 -202
- package/src/scripts/agents-check.ts +0 -24
- package/src/scripts/parse-check.ts +0 -124
- package/src/scripts/roundtrip-check.ts +0 -79
- package/src/store/agents.test.ts +0 -181
- package/src/store/index.test.ts +0 -225
- package/src/store/parsers.test.ts +0 -64
- package/src/store/timeline.test.ts +0 -332
- package/src/ui/board-scroll.test.ts +0 -63
- package/src/ui/glyphs.test.ts +0 -31
package/src/ui/Modal.tsx
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* checks `ui.modal` first and bails if set (only Escape passes through).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { For, Show, createMemo, createSignal } from "solid-js";
|
|
14
|
+
import { For, Show, createContext, createMemo, createSignal, useContext } from "solid-js";
|
|
15
15
|
|
|
16
16
|
import { isTask } from "~/parser/markdown";
|
|
17
17
|
import {
|
|
@@ -29,14 +29,23 @@ import type { PriorityLevel, TimeBlock } from "~/types";
|
|
|
29
29
|
* slot (the Dashboard renders it there while a modal is open) with no reflow. */
|
|
30
30
|
const MODAL_WIDTH = AGENDA_WIDTH;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Whether the dialog is standing in for a whole pane (single-pane) or sitting
|
|
34
|
+
* in the Agenda's slot (four-zone). It decides how wide the dialog may be, and
|
|
35
|
+
* only ModalLayer is in a position to know.
|
|
36
|
+
*/
|
|
37
|
+
const SinglePaneContext = createContext<() => boolean>(() => false);
|
|
38
|
+
|
|
32
39
|
export function ModalLayer(props: { store: TuiStore }) {
|
|
33
40
|
const modal = createMemo(() => props.store.state.ui.modal);
|
|
34
41
|
return (
|
|
42
|
+
<SinglePaneContext.Provider value={() => props.store.singlePane()}>
|
|
35
43
|
<Show when={modal()}>
|
|
36
44
|
{/* Each modal's DialogShell IS the panel box (border + title + slot
|
|
37
45
|
dimensions), so it drops into the Agenda's slot directly. */}
|
|
38
46
|
<ModalRouter store={props.store} modal={modal()!} />
|
|
39
47
|
</Show>
|
|
48
|
+
</SinglePaneContext.Provider>
|
|
40
49
|
);
|
|
41
50
|
}
|
|
42
51
|
|
|
@@ -55,6 +64,7 @@ function ModalRouter(props: { store: TuiStore; modal: NonNullable<TuiStore["stat
|
|
|
55
64
|
case "event-edit": return <EventEditModal store={props.store} />;
|
|
56
65
|
case "confirm-delete-event": return <ConfirmDeleteEventModal store={props.store} />;
|
|
57
66
|
case "search": return <SearchModal store={props.store} />;
|
|
67
|
+
case "board-new": return <BoardNewModal store={props.store} />;
|
|
58
68
|
case "help": return <HelpModal store={props.store} />;
|
|
59
69
|
}
|
|
60
70
|
}
|
|
@@ -68,7 +78,155 @@ interface DialogShellProps {
|
|
|
68
78
|
width?: number;
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
// ─── New board ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Onboarding and the `+` button are the same screen, differing only in title
|
|
85
|
+
* and in whether Escape works: on first run there is nothing behind it.
|
|
86
|
+
*
|
|
87
|
+
* Deliberately thin. Every step that touches the disk calls into
|
|
88
|
+
* `src/boards/`, which is also what `tuiboard board add` calls, so the two
|
|
89
|
+
* entry points cannot drift apart.
|
|
90
|
+
*/
|
|
91
|
+
function BoardNewModal(props: { store: TuiStore }) {
|
|
92
|
+
const w = createMemo(() => props.store.state.ui.boardNew);
|
|
93
|
+
|
|
94
|
+
const title = createMemo(() =>
|
|
95
|
+
w()?.mandatory ? "Welcome to tuiboard" : "New board",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const hint = createMemo(() => {
|
|
99
|
+
const b = w();
|
|
100
|
+
if (!b) return "";
|
|
101
|
+
if (b.step === "mode") return "j/k choose · Enter confirm" + (b.mandatory ? "" : " · Esc cancel");
|
|
102
|
+
if (b.step === "pick") return "j/k move · Space tick · Enter adopt · Esc back";
|
|
103
|
+
return "Enter confirm" + (b.mandatory ? "" : " · Esc cancel");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<DialogShell title={title()} hint={hint()} width={MODAL_WIDTH}>
|
|
108
|
+
<Show when={w()}>
|
|
109
|
+
{(b: () => NonNullable<ReturnType<typeof w>>) => (
|
|
110
|
+
<box style={{ flexDirection: "column" }}>
|
|
111
|
+
<Show when={b().mandatory && b().step === "mode"}>
|
|
112
|
+
<text>
|
|
113
|
+
<span style={{ fg: T.textDim }}>
|
|
114
|
+
No boards configured yet. Boards are plain markdown files.
|
|
115
|
+
</span>
|
|
116
|
+
</text>
|
|
117
|
+
<text> </text>
|
|
118
|
+
</Show>
|
|
119
|
+
|
|
120
|
+
{/* Step 1 — which way through */}
|
|
121
|
+
<Show when={b().step === "mode"}>
|
|
122
|
+
<For each={[
|
|
123
|
+
{ key: "create", label: "Create a new board", desc: "a new markdown file" },
|
|
124
|
+
{ key: "adopt", label: "Use files I already have", desc: "scan a folder" },
|
|
125
|
+
]}>
|
|
126
|
+
{(opt, i) => (
|
|
127
|
+
<text>
|
|
128
|
+
<span style={{ fg: b().sel === i() ? T.accent : T.text }}>
|
|
129
|
+
{b().sel === i() ? "▶ " : " "}{opt.label}
|
|
130
|
+
</span>
|
|
131
|
+
<span style={{ fg: T.textDim }}>{" — " + opt.desc}</span>
|
|
132
|
+
</text>
|
|
133
|
+
)}
|
|
134
|
+
</For>
|
|
135
|
+
</Show>
|
|
136
|
+
|
|
137
|
+
{/* Step 2a — name, then columns */}
|
|
138
|
+
<Show when={b().step === "name"}>
|
|
139
|
+
<text><span style={{ fg: T.textDim }}>Board name</span></text>
|
|
140
|
+
<input
|
|
141
|
+
focused
|
|
142
|
+
value=""
|
|
143
|
+
onSubmit={((v: string) => props.store.boardNewSubmitText(v)) as any}
|
|
144
|
+
/>
|
|
145
|
+
<text>
|
|
146
|
+
<span style={{ fg: T.textDim }}>{"It will live in " + b().dir}</span>
|
|
147
|
+
</text>
|
|
148
|
+
</Show>
|
|
149
|
+
|
|
150
|
+
<Show when={b().step === "columns"}>
|
|
151
|
+
<text><span style={{ fg: T.textDim }}>Columns, comma separated</span></text>
|
|
152
|
+
<input
|
|
153
|
+
focused
|
|
154
|
+
value={b().columns}
|
|
155
|
+
onSubmit={((v: string) => props.store.boardNewSubmitText(v)) as any}
|
|
156
|
+
/>
|
|
157
|
+
<text>
|
|
158
|
+
<span style={{ fg: T.textDim }}>{"Creating " + b().name + ".md"}</span>
|
|
159
|
+
</text>
|
|
160
|
+
</Show>
|
|
161
|
+
|
|
162
|
+
{/* Step 2b — a folder, then what was found in it */}
|
|
163
|
+
<Show when={b().step === "dir"}>
|
|
164
|
+
<text><span style={{ fg: T.textDim }}>Folder to scan</span></text>
|
|
165
|
+
<input
|
|
166
|
+
focused
|
|
167
|
+
value={b().dir}
|
|
168
|
+
onSubmit={((v: string) => props.store.boardNewSubmitText(v)) as any}
|
|
169
|
+
/>
|
|
170
|
+
</Show>
|
|
171
|
+
|
|
172
|
+
<Show when={b().step === "pick"}>
|
|
173
|
+
<text>
|
|
174
|
+
<span style={{ fg: T.textDim }}>
|
|
175
|
+
{b().candidates.length + " board file(s) in " + b().dir}
|
|
176
|
+
</span>
|
|
177
|
+
</text>
|
|
178
|
+
<For each={b().candidates}>
|
|
179
|
+
{(c, i) => (
|
|
180
|
+
<text>
|
|
181
|
+
<span style={{ fg: b().sel === i() ? T.accent : T.text }}>
|
|
182
|
+
{b().sel === i() ? "▶ " : " "}
|
|
183
|
+
{c.alreadyInConfig ? "· " : b().ticked.includes(i()) ? "✓ " : " "}
|
|
184
|
+
{c.suggestedName}
|
|
185
|
+
</span>
|
|
186
|
+
<span style={{ fg: T.textDim }}>
|
|
187
|
+
{" " + c.taskCount + (c.taskCount === 1 ? " task" : " tasks")
|
|
188
|
+
+ (c.alreadyInConfig ? " · already open" : "")}
|
|
189
|
+
</span>
|
|
190
|
+
</text>
|
|
191
|
+
)}
|
|
192
|
+
</For>
|
|
193
|
+
</Show>
|
|
194
|
+
|
|
195
|
+
<Show when={b().error}>
|
|
196
|
+
<text> </text>
|
|
197
|
+
<text><span style={{ fg: T.overdue }}>{b().error}</span></text>
|
|
198
|
+
</Show>
|
|
199
|
+
</box>
|
|
200
|
+
)}
|
|
201
|
+
</Show>
|
|
202
|
+
</DialogShell>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* How wide the dialog may be.
|
|
208
|
+
*
|
|
209
|
+
* In the four-zone layout it takes the Agenda's slot and must match it to the
|
|
210
|
+
* cell, or the whole dashboard shifts when a modal opens — that invariant is
|
|
211
|
+
* why the requested width used to be ignored outright. In single-pane the
|
|
212
|
+
* dialog takes a whole pane's place instead, so there it can use what the
|
|
213
|
+
* terminal actually offers: the keyboard reference asks for 92 columns and is
|
|
214
|
+
* unreadable squeezed into 50, while a 60-column strip needs it to shrink
|
|
215
|
+
* rather than overflow.
|
|
216
|
+
*/
|
|
217
|
+
function dialogWidth(singlePane: boolean): number {
|
|
218
|
+
if (!singlePane) return MODAL_WIDTH;
|
|
219
|
+
// Standing in for a pane means behaving like one: take the strip. A dialog
|
|
220
|
+
// that keeps its slot-sized box while the rest of the screen sits empty
|
|
221
|
+
// reads as a window that failed to open, not as a panel.
|
|
222
|
+
const terminal = process.stdout.columns ?? 80;
|
|
223
|
+
return Math.max(20, terminal - 4);
|
|
224
|
+
}
|
|
225
|
+
|
|
71
226
|
function DialogShell(props: DialogShellProps) {
|
|
227
|
+
const singlePane = useContext(SinglePaneContext);
|
|
228
|
+
const width = () => dialogWidth(singlePane());
|
|
229
|
+
// `width` survives as the four-zone hint it always was; single-pane fills.
|
|
72
230
|
void props.width;
|
|
73
231
|
return (
|
|
74
232
|
<box
|
|
@@ -78,9 +236,12 @@ function DialogShell(props: DialogShellProps) {
|
|
|
78
236
|
// slot at the exact same size and the dashboard doesn't shift when a
|
|
79
237
|
// modal opens. The title rides in the top border like the columns/zones.
|
|
80
238
|
flexDirection: "column",
|
|
81
|
-
width:
|
|
82
|
-
minWidth: MODAL_WIDTH,
|
|
83
|
-
|
|
239
|
+
width: width(),
|
|
240
|
+
minWidth: Math.min(MODAL_WIDTH, width()),
|
|
241
|
+
// In the Agenda's slot the box must not grow, or the dashboard shifts.
|
|
242
|
+
// Filling a pane, it must — in both axes, like the zone it replaces.
|
|
243
|
+
flexGrow: singlePane() ? 1 : 0,
|
|
244
|
+
height: singlePane() ? "100%" : undefined,
|
|
84
245
|
marginLeft: 1,
|
|
85
246
|
backgroundColor: T.panelBgActive,
|
|
86
247
|
border: true,
|
|
@@ -805,6 +966,8 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
805
966
|
<span style={{ fg: T.textDim }}>{"Navigation\n"}</span>
|
|
806
967
|
<span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
|
|
807
968
|
<span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
|
|
969
|
+
<span style={{ fg: T.text }}>{" + New board — create one, or adopt files you have\n"}</span>
|
|
970
|
+
<span style={{ fg: T.text }}>{" z Focus one pane (automatic below 100 columns)\n"}</span>
|
|
808
971
|
<span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
|
|
809
972
|
<span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow planner panel focus\n"}</span>
|
|
810
973
|
<span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (planner → board → timeline → agents)\n"}</span>
|
package/src/ui/PlannerPanel.tsx
CHANGED
|
@@ -40,7 +40,7 @@ export function PlannerPanel(props: { store: TuiStore }) {
|
|
|
40
40
|
const groups = createMemo(() => groupPlannerItems(items()));
|
|
41
41
|
const isActive = createMemo(() => props.store.state.ui.activeZone === "planner");
|
|
42
42
|
const isZoomed = createMemo(
|
|
43
|
-
() => props.store.
|
|
43
|
+
() => props.store.singlePane() && props.store.state.ui.activeZone === "planner",
|
|
44
44
|
);
|
|
45
45
|
const cursorRow = createMemo(() => props.store.state.ui.row);
|
|
46
46
|
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single-pane ring.
|
|
3
|
+
*
|
|
4
|
+
* When only one pane fits on screen, the zones stop being a layout — left and
|
|
5
|
+
* right no longer mean anything geometric — and become a sequence. This module
|
|
6
|
+
* builds that sequence and steps through it.
|
|
7
|
+
*
|
|
8
|
+
* The board is not one stop but many: in single-pane it renders a single
|
|
9
|
+
* column at full width, so each drawn column is its own pane. The result reads
|
|
10
|
+
* planner → column → column → … → agenda → agents, and closes into a ring.
|
|
11
|
+
*
|
|
12
|
+
* Pure: no store, no renderer, no terminal. The caller decides which zones are
|
|
13
|
+
* enabled and which columns are drawn — this only decides what comes next.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type RingZone = "planner" | "timeline" | "agents";
|
|
17
|
+
|
|
18
|
+
export type Pane =
|
|
19
|
+
| { kind: "zone"; zone: RingZone }
|
|
20
|
+
| { kind: "column"; index: number };
|
|
21
|
+
|
|
22
|
+
export interface RingInput {
|
|
23
|
+
/** Which zones the config enables. `board` is implied by renderedColumns. */
|
|
24
|
+
enabledZones: Record<"planner" | "board" | "timeline" | "agents", boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Board column indexes actually drawn, in order. Hidden columns (Done,
|
|
27
|
+
* Archive) must already be filtered out by the caller: a pane you cannot
|
|
28
|
+
* see is a dead end to step onto.
|
|
29
|
+
*/
|
|
30
|
+
renderedColumns: readonly number[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildRing({ enabledZones, renderedColumns }: RingInput): Pane[] {
|
|
34
|
+
const ring: Pane[] = [];
|
|
35
|
+
if (enabledZones.planner) ring.push({ kind: "zone", zone: "planner" });
|
|
36
|
+
if (enabledZones.board) {
|
|
37
|
+
for (const index of renderedColumns) ring.push({ kind: "column", index });
|
|
38
|
+
}
|
|
39
|
+
if (enabledZones.timeline) ring.push({ kind: "zone", zone: "timeline" });
|
|
40
|
+
if (enabledZones.agents) ring.push({ kind: "zone", zone: "agents" });
|
|
41
|
+
return ring;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The next pane in `delta`'s direction, wrapping at both ends.
|
|
46
|
+
*
|
|
47
|
+
* A ring of one returns itself: stepping should feel like nothing happened,
|
|
48
|
+
* not like a wrap. If `current` is no longer in the ring — a column removed by
|
|
49
|
+
* a filter change or an external edit — the first pane is returned rather than
|
|
50
|
+
* something unrenderable.
|
|
51
|
+
*/
|
|
52
|
+
export function stepRing(ring: readonly Pane[], current: Pane, delta: 1 | -1): Pane {
|
|
53
|
+
if (ring.length === 0) return current;
|
|
54
|
+
if (ring.length === 1) return ring[0]!;
|
|
55
|
+
|
|
56
|
+
const at = ring.findIndex((p) => samePane(p, current));
|
|
57
|
+
if (at < 0) return ring[0]!;
|
|
58
|
+
|
|
59
|
+
const next = (at + delta + ring.length) % ring.length;
|
|
60
|
+
return ring[next]!;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function samePane(a: Pane, b: Pane): boolean {
|
|
64
|
+
if (a.kind === "zone" && b.kind === "zone") return a.zone === b.zone;
|
|
65
|
+
if (a.kind === "column" && b.kind === "column") return a.index === b.index;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where the current pane sits in the ring, for the position indicator. */
|
|
70
|
+
export function ringPosition(ring: readonly Pane[], current: Pane): { at: number; of: number } {
|
|
71
|
+
const at = ring.findIndex((p) => samePane(p, current));
|
|
72
|
+
return { at: at < 0 ? 0 : at, of: ring.length };
|
|
73
|
+
}
|
package/src/views/BoardOnly.tsx
CHANGED
|
@@ -18,16 +18,17 @@ import type { TuiStore } from "~/store/index";
|
|
|
18
18
|
|
|
19
19
|
export function BoardOnly(props: { store: TuiStore }) {
|
|
20
20
|
const ui = () => props.store.state.ui;
|
|
21
|
+
const singlePane = () => props.store.singlePane();
|
|
21
22
|
const activeBoard = createMemo(
|
|
22
23
|
() => props.store.state.boards[ui().activeBoardIndex]?.board,
|
|
23
24
|
);
|
|
24
25
|
|
|
25
26
|
return (
|
|
26
27
|
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
27
|
-
<Show when={!
|
|
28
|
+
<Show when={!singlePane() || ui().activeZone === "planner"}>
|
|
28
29
|
<PlannerPanel store={props.store} />
|
|
29
30
|
</Show>
|
|
30
|
-
<Show when={(!
|
|
31
|
+
<Show when={(!singlePane() || ui().activeZone !== "planner") && activeBoard()}>
|
|
31
32
|
<BoardView store={props.store} board={activeBoard()!} />
|
|
32
33
|
</Show>
|
|
33
34
|
</box>
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -38,7 +38,7 @@ export function Dashboard(props: { store: TuiStore }) {
|
|
|
38
38
|
|
|
39
39
|
return (
|
|
40
40
|
<Show
|
|
41
|
-
when={
|
|
41
|
+
when={props.store.singlePane()}
|
|
42
42
|
fallback={<FourZoneLayout store={props.store} />}
|
|
43
43
|
>
|
|
44
44
|
<ZoomedLayout store={props.store} />
|
|
@@ -55,17 +55,19 @@ export function Dashboard(props: { store: TuiStore }) {
|
|
|
55
55
|
* zone of the normal layout and BoardOnly already respects ui.zoomed
|
|
56
56
|
* to render only the active panel between them.
|
|
57
57
|
*
|
|
58
|
-
* Modals
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
58
|
+
* Modals take the pane's place, exactly as the four-zone layout drops them
|
|
59
|
+
* into the Agenda's slot: with one zone filling the screen, that zone IS the
|
|
60
|
+
* slot. They used to float as an absolute overlay, which had nothing to paint
|
|
61
|
+
* over — the theme leaves panel backgrounds transparent so the terminal shows
|
|
62
|
+
* through — so the dialog's text interleaved with the pane underneath and both
|
|
63
|
+
* became unreadable. Closing returns to the pane exactly as it was.
|
|
62
64
|
*/
|
|
63
65
|
function ZoomedLayout(props: { store: TuiStore }) {
|
|
64
66
|
const ui = () => props.store.state.ui;
|
|
65
67
|
const zone = () => ui().activeZone;
|
|
66
68
|
|
|
67
69
|
return (
|
|
68
|
-
|
|
70
|
+
<Show when={ui().modal} fallback={
|
|
69
71
|
<Show when={zone() === "timeline"} fallback={
|
|
70
72
|
<Show when={zone() === "agents"} fallback={<BoardOnly store={props.store} />}>
|
|
71
73
|
<AgentsOnly store={props.store} />
|
|
@@ -73,27 +75,9 @@ function ZoomedLayout(props: { store: TuiStore }) {
|
|
|
73
75
|
}>
|
|
74
76
|
<TimelineOnly store={props.store} />
|
|
75
77
|
</Show>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<Show when={ui().modal}>
|
|
80
|
-
<box
|
|
81
|
-
style={{
|
|
82
|
-
position: "absolute",
|
|
83
|
-
top: 0,
|
|
84
|
-
left: 0,
|
|
85
|
-
right: 0,
|
|
86
|
-
bottom: 0,
|
|
87
|
-
zIndex: 100,
|
|
88
|
-
flexDirection: "column",
|
|
89
|
-
alignItems: "center",
|
|
90
|
-
justifyContent: "center",
|
|
91
|
-
}}
|
|
92
|
-
>
|
|
93
|
-
<ModalLayer store={props.store} />
|
|
94
|
-
</box>
|
|
95
|
-
</Show>
|
|
96
|
-
</>
|
|
78
|
+
}>
|
|
79
|
+
<ModalLayer store={props.store} />
|
|
80
|
+
</Show>
|
|
97
81
|
);
|
|
98
82
|
}
|
|
99
83
|
|
package/src/cli/args.test.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { parseArgs, type ViewKind } from "./args";
|
|
3
|
-
|
|
4
|
-
describe("parseArgs", () => {
|
|
5
|
-
it("returns view=undefined when no flag is present", () => {
|
|
6
|
-
expect(parseArgs([])).toEqual({ view: undefined });
|
|
7
|
-
expect(parseArgs(["bun", "src/app.tsx"])).toEqual({ view: undefined });
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("parses --view=board", () => {
|
|
11
|
-
expect(parseArgs(["--view=board"])).toEqual({ view: "board" });
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("parses --view=timeline", () => {
|
|
15
|
-
expect(parseArgs(["--view=timeline"])).toEqual({ view: "timeline" });
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("parses --view=agents", () => {
|
|
19
|
-
expect(parseArgs(["--view=agents"])).toEqual({ view: "agents" });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("parses --view <value> with space separator", () => {
|
|
23
|
-
expect(parseArgs(["--view", "board"])).toEqual({ view: "board" });
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("returns view=undefined for unknown view value (with warning to stderr)", () => {
|
|
27
|
-
const result = parseArgs(["--view=garbage"]);
|
|
28
|
-
expect(result.view).toBeUndefined();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("ignores other flags", () => {
|
|
32
|
-
expect(parseArgs(["--debug", "--view=board", "--something-else"])).toEqual({
|
|
33
|
-
view: "board",
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// Type-level assertion: ensure ViewKind covers exactly the four expected values.
|
|
39
|
-
const _viewKinds: ViewKind[] = ["board", "timeline", "agents"];
|
|
40
|
-
void _viewKinds;
|
package/src/cli/headless.test.ts
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The headless CLI — `tuiboard task` and `tuiboard summary`.
|
|
3
|
-
*
|
|
4
|
-
* These are the only commands that write to the user's real board files from
|
|
5
|
-
* outside the TUI (a bar widget, a cron job), so they are tested against a
|
|
6
|
-
* real board on disk rather than a parsed fixture: the round trip through
|
|
7
|
-
* parse → mutate → serialize → write is exactly what can lose data.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
11
|
-
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { tmpdir } from "node:os";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
-
|
|
15
|
-
import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
|
|
16
|
-
import { buildSummary } from "./summary";
|
|
17
|
-
import { runTask } from "./task";
|
|
18
|
-
|
|
19
|
-
const BOARD = `---
|
|
20
|
-
|
|
21
|
-
kanban-plugin: board
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## Home
|
|
26
|
-
- [ ] Bollette ⏳ 2026-08-31
|
|
27
|
-
- [x] Spesa ⏳ 2026-08-31 ✅ 2026-08-31
|
|
28
|
-
- [ ] Chiamare idraulico 📅 2026-08-31
|
|
29
|
-
- [ ] Cambiare gomme
|
|
30
|
-
- [ ] Ambiguo uno
|
|
31
|
-
- [ ] Ambiguo due
|
|
32
|
-
`;
|
|
33
|
-
|
|
34
|
-
let dir: string;
|
|
35
|
-
let boardPath: string;
|
|
36
|
-
let configPath: string;
|
|
37
|
-
let previousConfig: string | undefined;
|
|
38
|
-
|
|
39
|
-
/** Days from today as YYYY-MM-DD, in local time — matches the CLI's own clock. */
|
|
40
|
-
function iso(offsetDays: number): string {
|
|
41
|
-
const d = new Date();
|
|
42
|
-
d.setDate(d.getDate() + offsetDays);
|
|
43
|
-
const p = (n: number) => String(n).padStart(2, "0");
|
|
44
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function board(): string {
|
|
48
|
-
return readFileSync(boardPath, "utf-8");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function line(title: string): string {
|
|
52
|
-
const hit = board()
|
|
53
|
-
.split("\n")
|
|
54
|
-
.find((l) => l.includes(title));
|
|
55
|
-
if (!hit) throw new Error(`no line matching "${title}" in board`);
|
|
56
|
-
return hit;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
beforeEach(() => {
|
|
60
|
-
dir = mkdtempSync(join(tmpdir(), "tuiboard-cli-"));
|
|
61
|
-
boardPath = join(dir, "Board.md");
|
|
62
|
-
configPath = join(dir, "config.yaml");
|
|
63
|
-
writeFileSync(boardPath, BOARD, "utf-8");
|
|
64
|
-
writeFileSync(configPath, `boards:\n - path: ${boardPath}\n name: Test\n`, "utf-8");
|
|
65
|
-
previousConfig = process.env.TUIBOARD_CONFIG;
|
|
66
|
-
process.env.TUIBOARD_CONFIG = configPath;
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
afterEach(() => {
|
|
70
|
-
if (previousConfig === undefined) delete process.env.TUIBOARD_CONFIG;
|
|
71
|
-
else process.env.TUIBOARD_CONFIG = previousConfig;
|
|
72
|
-
rmSync(dir, { recursive: true, force: true });
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const args = (...rest: string[]) => ["--board", "Test", "--column", "Home", ...rest];
|
|
76
|
-
|
|
77
|
-
describe("tuiboard task done", () => {
|
|
78
|
-
it("ticks the task and stamps today's completion date", async () => {
|
|
79
|
-
expect(await runTask(["done", ...args("--match", "Bollette")])).toBe(0);
|
|
80
|
-
expect(line("Bollette")).toBe(`- [x] Bollette ⏳ 2026-08-31 ✅ ${iso(0)}`);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("leaves an already-done task untouched", async () => {
|
|
84
|
-
const before = line("Spesa");
|
|
85
|
-
expect(await runTask(["done", ...args("--match", "Spesa")])).toBe(0);
|
|
86
|
-
expect(line("Spesa")).toBe(before);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("tuiboard task undone", () => {
|
|
91
|
-
it("reopens a completed task and drops the completion date with it", async () => {
|
|
92
|
-
expect(await runTask(["undone", ...args("--match", "Spesa")])).toBe(0);
|
|
93
|
-
expect(line("Spesa")).toBe("- [ ] Spesa ⏳ 2026-08-31");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("is the exact inverse of done — round trip restores the line", async () => {
|
|
97
|
-
const before = line("Bollette");
|
|
98
|
-
expect(await runTask(["done", ...args("--match", "Bollette")])).toBe(0);
|
|
99
|
-
expect(await runTask(["undone", ...args("--match", "Bollette")])).toBe(0);
|
|
100
|
-
expect(line("Bollette")).toBe(before);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("leaves an already-open task untouched", async () => {
|
|
104
|
-
const before = line("Bollette");
|
|
105
|
-
expect(await runTask(["undone", ...args("--match", "Bollette")])).toBe(0);
|
|
106
|
-
expect(line("Bollette")).toBe(before);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe("tuiboard task defer", () => {
|
|
111
|
-
it("moves `scheduled` when the task has one", async () => {
|
|
112
|
-
expect(await runTask(["defer", ...args("--match", "Bollette")])).toBe(0);
|
|
113
|
-
expect(line("Bollette")).toBe(`- [ ] Bollette ⏳ ${iso(1)}`);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("moves `due` when that is the only date — the field the planner reads", async () => {
|
|
117
|
-
expect(await runTask(["defer", ...args("--match", "idraulico")])).toBe(0);
|
|
118
|
-
expect(line("idraulico")).toBe(`- [ ] Chiamare idraulico 📅 ${iso(1)}`);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("schedules an undated task, which is what puts it on the agenda", async () => {
|
|
122
|
-
expect(await runTask(["defer", ...args("--match", "gomme")])).toBe(0);
|
|
123
|
-
expect(line("gomme")).toBe(`- [ ] Cambiare gomme ⏳ ${iso(1)}`);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("--days 0 pulls a task back to today", async () => {
|
|
127
|
-
expect(await runTask(["defer", ...args("--match", "Bollette", "--days", "0")])).toBe(0);
|
|
128
|
-
expect(line("Bollette")).toBe(`- [ ] Bollette ⏳ ${iso(0)}`);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("--to takes an explicit date", async () => {
|
|
132
|
-
expect(await runTask(["defer", ...args("--match", "Bollette", "--to", "2027-01-15")])).toBe(0);
|
|
133
|
-
expect(line("Bollette")).toBe("- [ ] Bollette ⏳ 2027-01-15");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("rejects a malformed --to without touching the board", async () => {
|
|
137
|
-
const before = board();
|
|
138
|
-
expect(await runTask(["defer", ...args("--match", "Bollette", "--to", "15/01/2027")])).toBe(2);
|
|
139
|
-
expect(board()).toBe(before);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe("tuiboard task — refusing to guess", () => {
|
|
144
|
-
it("writes nothing when the match is ambiguous", async () => {
|
|
145
|
-
const before = board();
|
|
146
|
-
expect(await runTask(["done", ...args("--match", "Ambiguo")])).toBe(1);
|
|
147
|
-
expect(board()).toBe(before);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("writes nothing when nothing matches", async () => {
|
|
151
|
-
const before = board();
|
|
152
|
-
expect(await runTask(["done", ...args("--match", "inesistente")])).toBe(1);
|
|
153
|
-
expect(board()).toBe(before);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("--dry-run reports success and leaves the file alone", async () => {
|
|
157
|
-
const before = board();
|
|
158
|
-
expect(await runTask(["done", ...args("--match", "Bollette", "--dry-run")])).toBe(0);
|
|
159
|
-
expect(board()).toBe(before);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("refuses the write when the board moved on since it was read", () => {
|
|
163
|
-
// What `runTask` maps to exit 3. Driven through the writer directly: the
|
|
164
|
-
// race it guards against — the file changing between the read and the
|
|
165
|
-
// write — cannot be staged from outside the function that spans it.
|
|
166
|
-
const stale = statMtime(boardPath);
|
|
167
|
-
const past = new Date(Date.now() - 60_000);
|
|
168
|
-
utimesSync(boardPath, past, past);
|
|
169
|
-
const before = board();
|
|
170
|
-
expect(() => writeBoardFile(boardPath, "clobbered", { expectedMtimeMs: stale }))
|
|
171
|
-
.toThrow(ConflictError);
|
|
172
|
-
expect(board()).toBe(before);
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
describe("tuiboard summary — planner entries", () => {
|
|
177
|
-
it("reports whether a Today entry is already ticked", async () => {
|
|
178
|
-
// The board's dates are fixed, so the summary is asked for that same day.
|
|
179
|
-
const s = buildSummary({ next: 0, today: "2026-08-31" });
|
|
180
|
-
const today = s.planner.today;
|
|
181
|
-
const bollette = today.find((e) => e.title === "Bollette");
|
|
182
|
-
const spesa = today.find((e) => e.title === "Spesa");
|
|
183
|
-
|
|
184
|
-
expect(bollette?.done).toBe(false);
|
|
185
|
-
expect(bollette?.doneDate).toBeUndefined();
|
|
186
|
-
expect(spesa?.done).toBe(true);
|
|
187
|
-
expect(spesa?.doneDate).toBe("2026-08-31");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("follows a task through done and back", async () => {
|
|
191
|
-
await runTask(["done", ...args("--match", "Bollette")]);
|
|
192
|
-
const done = buildSummary({ next: 0, today: "2026-08-31" })
|
|
193
|
-
.planner.today.find((e) => e.title === "Bollette");
|
|
194
|
-
expect(done?.done).toBe(true);
|
|
195
|
-
|
|
196
|
-
await runTask(["undone", ...args("--match", "Bollette")]);
|
|
197
|
-
const reopened = buildSummary({ next: 0, today: "2026-08-31" })
|
|
198
|
-
.planner.today.find((e) => e.title === "Bollette");
|
|
199
|
-
expect(reopened?.done).toBe(false);
|
|
200
|
-
expect(reopened?.doneDate).toBeUndefined();
|
|
201
|
-
});
|
|
202
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Smoke check for the agents store. Lists the first 10 sessions
|
|
3
|
-
* discovered on this machine with status, display name, and short cwd.
|
|
4
|
-
*
|
|
5
|
-
* Usage: bun run agents:check
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { createAgentsStore } from "~/store/agents";
|
|
9
|
-
|
|
10
|
-
const store = createAgentsStore();
|
|
11
|
-
const all = store.sessions();
|
|
12
|
-
const live = all.filter(
|
|
13
|
-
(s) => s.status === "live-busy" || s.status === "live-idle",
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
console.log(`Found ${all.length} sessions, ${live.length} live`);
|
|
17
|
-
console.log("");
|
|
18
|
-
for (const s of all.slice(0, 10)) {
|
|
19
|
-
console.log(
|
|
20
|
-
` ${s.status.padEnd(10)} ${s.displayName.padEnd(40)} ${s.cwdShort}`,
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
await store.dispose();
|