tuiboard 0.8.3 → 0.9.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/.tuiboard/config.example.yaml +9 -0
- package/CHANGELOG.md +102 -0
- package/README.md +111 -1
- package/bin/tuiboard.ts +12 -0
- package/package.json +5 -1
- package/src/app.tsx +70 -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/cli/summary.ts +284 -0
- package/src/cli/task.ts +235 -0
- package/src/config/loader.ts +60 -13
- package/src/input/handleKey.ts +72 -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 +133 -2
- 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 +36 -8
- package/tsconfig.json +27 -0
- package/src/cli/args.test.ts +0 -40
- 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 -224
- 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
|
@@ -55,6 +55,7 @@ function ModalRouter(props: { store: TuiStore; modal: NonNullable<TuiStore["stat
|
|
|
55
55
|
case "event-edit": return <EventEditModal store={props.store} />;
|
|
56
56
|
case "confirm-delete-event": return <ConfirmDeleteEventModal store={props.store} />;
|
|
57
57
|
case "search": return <SearchModal store={props.store} />;
|
|
58
|
+
case "board-new": return <BoardNewModal store={props.store} />;
|
|
58
59
|
case "help": return <HelpModal store={props.store} />;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -68,6 +69,131 @@ interface DialogShellProps {
|
|
|
68
69
|
width?: number;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// ─── New board ───────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Onboarding and the `+` button are the same screen, differing only in title
|
|
76
|
+
* and in whether Escape works: on first run there is nothing behind it.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately thin. Every step that touches the disk calls into
|
|
79
|
+
* `src/boards/`, which is also what `tuiboard board add` calls, so the two
|
|
80
|
+
* entry points cannot drift apart.
|
|
81
|
+
*/
|
|
82
|
+
function BoardNewModal(props: { store: TuiStore }) {
|
|
83
|
+
const w = createMemo(() => props.store.state.ui.boardNew);
|
|
84
|
+
|
|
85
|
+
const title = createMemo(() =>
|
|
86
|
+
w()?.mandatory ? "Welcome to tuiboard" : "New board",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const hint = createMemo(() => {
|
|
90
|
+
const b = w();
|
|
91
|
+
if (!b) return "";
|
|
92
|
+
if (b.step === "mode") return "j/k choose · Enter confirm" + (b.mandatory ? "" : " · Esc cancel");
|
|
93
|
+
if (b.step === "pick") return "j/k move · Space tick · Enter adopt · Esc back";
|
|
94
|
+
return "Enter confirm" + (b.mandatory ? "" : " · Esc cancel");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<DialogShell title={title()} hint={hint()} width={MODAL_WIDTH}>
|
|
99
|
+
<Show when={w()}>
|
|
100
|
+
{(b: () => NonNullable<ReturnType<typeof w>>) => (
|
|
101
|
+
<box style={{ flexDirection: "column" }}>
|
|
102
|
+
<Show when={b().mandatory && b().step === "mode"}>
|
|
103
|
+
<text>
|
|
104
|
+
<span style={{ fg: T.textDim }}>
|
|
105
|
+
No boards configured yet. Boards are plain markdown files.
|
|
106
|
+
</span>
|
|
107
|
+
</text>
|
|
108
|
+
<text> </text>
|
|
109
|
+
</Show>
|
|
110
|
+
|
|
111
|
+
{/* Step 1 — which way through */}
|
|
112
|
+
<Show when={b().step === "mode"}>
|
|
113
|
+
<For each={[
|
|
114
|
+
{ key: "create", label: "Create a new board", desc: "a new markdown file" },
|
|
115
|
+
{ key: "adopt", label: "Use files I already have", desc: "scan a folder" },
|
|
116
|
+
]}>
|
|
117
|
+
{(opt, i) => (
|
|
118
|
+
<text>
|
|
119
|
+
<span style={{ fg: b().sel === i() ? T.accent : T.text }}>
|
|
120
|
+
{b().sel === i() ? "▶ " : " "}{opt.label}
|
|
121
|
+
</span>
|
|
122
|
+
<span style={{ fg: T.textDim }}>{" — " + opt.desc}</span>
|
|
123
|
+
</text>
|
|
124
|
+
)}
|
|
125
|
+
</For>
|
|
126
|
+
</Show>
|
|
127
|
+
|
|
128
|
+
{/* Step 2a — name, then columns */}
|
|
129
|
+
<Show when={b().step === "name"}>
|
|
130
|
+
<text><span style={{ fg: T.textDim }}>Board name</span></text>
|
|
131
|
+
<input
|
|
132
|
+
focused
|
|
133
|
+
value=""
|
|
134
|
+
onSubmit={((v: string) => props.store.boardNewSubmitText(v)) as any}
|
|
135
|
+
/>
|
|
136
|
+
<text>
|
|
137
|
+
<span style={{ fg: T.textDim }}>{"It will live in " + b().dir}</span>
|
|
138
|
+
</text>
|
|
139
|
+
</Show>
|
|
140
|
+
|
|
141
|
+
<Show when={b().step === "columns"}>
|
|
142
|
+
<text><span style={{ fg: T.textDim }}>Columns, comma separated</span></text>
|
|
143
|
+
<input
|
|
144
|
+
focused
|
|
145
|
+
value={b().columns}
|
|
146
|
+
onSubmit={((v: string) => props.store.boardNewSubmitText(v)) as any}
|
|
147
|
+
/>
|
|
148
|
+
<text>
|
|
149
|
+
<span style={{ fg: T.textDim }}>{"Creating " + b().name + ".md"}</span>
|
|
150
|
+
</text>
|
|
151
|
+
</Show>
|
|
152
|
+
|
|
153
|
+
{/* Step 2b — a folder, then what was found in it */}
|
|
154
|
+
<Show when={b().step === "dir"}>
|
|
155
|
+
<text><span style={{ fg: T.textDim }}>Folder to scan</span></text>
|
|
156
|
+
<input
|
|
157
|
+
focused
|
|
158
|
+
value={b().dir}
|
|
159
|
+
onSubmit={((v: string) => props.store.boardNewSubmitText(v)) as any}
|
|
160
|
+
/>
|
|
161
|
+
</Show>
|
|
162
|
+
|
|
163
|
+
<Show when={b().step === "pick"}>
|
|
164
|
+
<text>
|
|
165
|
+
<span style={{ fg: T.textDim }}>
|
|
166
|
+
{b().candidates.length + " board file(s) in " + b().dir}
|
|
167
|
+
</span>
|
|
168
|
+
</text>
|
|
169
|
+
<For each={b().candidates}>
|
|
170
|
+
{(c, i) => (
|
|
171
|
+
<text>
|
|
172
|
+
<span style={{ fg: b().sel === i() ? T.accent : T.text }}>
|
|
173
|
+
{b().sel === i() ? "▶ " : " "}
|
|
174
|
+
{c.alreadyInConfig ? "· " : b().ticked.includes(i()) ? "✓ " : " "}
|
|
175
|
+
{c.suggestedName}
|
|
176
|
+
</span>
|
|
177
|
+
<span style={{ fg: T.textDim }}>
|
|
178
|
+
{" " + c.taskCount + (c.taskCount === 1 ? " task" : " tasks")
|
|
179
|
+
+ (c.alreadyInConfig ? " · already open" : "")}
|
|
180
|
+
</span>
|
|
181
|
+
</text>
|
|
182
|
+
)}
|
|
183
|
+
</For>
|
|
184
|
+
</Show>
|
|
185
|
+
|
|
186
|
+
<Show when={b().error}>
|
|
187
|
+
<text> </text>
|
|
188
|
+
<text><span style={{ fg: T.overdue }}>{b().error}</span></text>
|
|
189
|
+
</Show>
|
|
190
|
+
</box>
|
|
191
|
+
)}
|
|
192
|
+
</Show>
|
|
193
|
+
</DialogShell>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
71
197
|
function DialogShell(props: DialogShellProps) {
|
|
72
198
|
void props.width;
|
|
73
199
|
return (
|
|
@@ -772,12 +898,14 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
|
|
|
772
898
|
<box style={{ height: 1 }} />
|
|
773
899
|
<text>
|
|
774
900
|
<span style={{ fg: T.textDim }}>
|
|
775
|
-
resume —
|
|
901
|
+
resume — Enter opens this in WezTerm; c copies this command to paste anywhere:
|
|
776
902
|
</span>
|
|
777
903
|
</text>
|
|
778
904
|
<text wrapMode="word">
|
|
779
905
|
<span style={{ fg: T.scheduled }}>
|
|
780
|
-
|
|
906
|
+
{props.store.config.copyResumeCommand
|
|
907
|
+
.replaceAll("{cwd}", s().cwd)
|
|
908
|
+
.replaceAll("{sessionId}", s().sessionId)}
|
|
781
909
|
</span>
|
|
782
910
|
</text>
|
|
783
911
|
</box>
|
|
@@ -803,6 +931,8 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
803
931
|
<span style={{ fg: T.textDim }}>{"Navigation\n"}</span>
|
|
804
932
|
<span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
|
|
805
933
|
<span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
|
|
934
|
+
<span style={{ fg: T.text }}>{" + New board — create one, or adopt files you have\n"}</span>
|
|
935
|
+
<span style={{ fg: T.text }}>{" z Focus one pane (automatic below 100 columns)\n"}</span>
|
|
806
936
|
<span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
|
|
807
937
|
<span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow planner panel focus\n"}</span>
|
|
808
938
|
<span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (planner → board → timeline → agents)\n"}</span>
|
|
@@ -845,6 +975,7 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
845
975
|
<span style={{ fg: T.text }}>{" / Search task titles — jumps cursor to first match\n"}</span>
|
|
846
976
|
<span style={{ fg: T.textDim }}>{"\nAgents zone\n"}</span>
|
|
847
977
|
<span style={{ fg: T.text }}>{" Enter Open (resume) the selected session in a new WezTerm tab\n"}</span>
|
|
978
|
+
<span style={{ fg: T.text }}>{" c Copy a 'cd + claude --resume' command for the session\n"}</span>
|
|
848
979
|
<span style={{ fg: T.text }}>{" o Session detail (cwd, branch, last prompts, resume cmd)\n"}</span>
|
|
849
980
|
<span style={{ fg: T.textDim }}>{"\nMulti-select\n"}</span>
|
|
850
981
|
<span style={{ fg: T.text }}>{" Space Mark / unmark task (cursor stays — mark in any order)\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} />
|
|
@@ -54,18 +54,46 @@ export function Dashboard(props: { store: TuiStore }) {
|
|
|
54
54
|
* Board + planner share BoardOnly because both live in the top-left
|
|
55
55
|
* zone of the normal layout and BoardOnly already respects ui.zoomed
|
|
56
56
|
* to render only the active panel between them.
|
|
57
|
+
*
|
|
58
|
+
* Modals: the normal layout drops them into the Agenda's slot, which doesn't
|
|
59
|
+
* exist while zoomed — so here the modal floats as a centered absolute overlay
|
|
60
|
+
* on top of the zoomed view. The user stays zoomed; close the modal and the
|
|
61
|
+
* zoomed view is exactly as they left it (no exit-zoom / re-zoom flip).
|
|
57
62
|
*/
|
|
58
63
|
function ZoomedLayout(props: { store: TuiStore }) {
|
|
59
|
-
const
|
|
64
|
+
const ui = () => props.store.state.ui;
|
|
65
|
+
const zone = () => ui().activeZone;
|
|
60
66
|
|
|
61
67
|
return (
|
|
62
|
-
|
|
63
|
-
<Show when={zone() === "
|
|
64
|
-
<
|
|
68
|
+
<>
|
|
69
|
+
<Show when={zone() === "timeline"} fallback={
|
|
70
|
+
<Show when={zone() === "agents"} fallback={<BoardOnly store={props.store} />}>
|
|
71
|
+
<AgentsOnly store={props.store} />
|
|
72
|
+
</Show>
|
|
73
|
+
}>
|
|
74
|
+
<TimelineOnly store={props.store} />
|
|
65
75
|
</Show>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
{/* Modal overlay — only while zoomed AND a modal is open. Absolute + high
|
|
77
|
+
zIndex so it paints over the zoomed view; centered; transparent
|
|
78
|
+
backdrop so the board stays visible behind the (opaque) modal panel. */}
|
|
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
|
+
</>
|
|
69
97
|
);
|
|
70
98
|
}
|
|
71
99
|
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ESNext"],
|
|
7
|
+
"types": ["bun-types"],
|
|
8
|
+
|
|
9
|
+
"jsx": "preserve",
|
|
10
|
+
"jsxImportSource": "@opentui/solid",
|
|
11
|
+
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"noImplicitOverride": true,
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"esModuleInterop": true,
|
|
19
|
+
"resolveJsonModule": true,
|
|
20
|
+
|
|
21
|
+
"baseUrl": ".",
|
|
22
|
+
"paths": {
|
|
23
|
+
"~/*": ["src/*"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"include": ["src/**/*"]
|
|
27
|
+
}
|
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;
|
|
@@ -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();
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI smoke-test for the markdown parser.
|
|
3
|
-
*
|
|
4
|
-
* Usage:
|
|
5
|
-
* bun run src/scripts/parse-check.ts # uses config-discovered boards
|
|
6
|
-
* bun run src/scripts/parse-check.ts <file.md>... # parses specific files
|
|
7
|
-
*
|
|
8
|
-
* Prints per-board summary: columns, task counts, metadata coverage,
|
|
9
|
-
* diagnostics, and a sample of parsed tasks so we can eyeball correctness.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { readFileSync } from "node:fs";
|
|
13
|
-
import { loadConfig } from "~/config/loader";
|
|
14
|
-
import { isTask, parseBoard } from "~/parser/markdown";
|
|
15
|
-
import type { Task } from "~/types";
|
|
16
|
-
|
|
17
|
-
const args = process.argv.slice(2);
|
|
18
|
-
const files: string[] = [];
|
|
19
|
-
|
|
20
|
-
if (args.length > 0) {
|
|
21
|
-
files.push(...args);
|
|
22
|
-
} else {
|
|
23
|
-
const cfg = loadConfig();
|
|
24
|
-
if (!cfg.loaded) {
|
|
25
|
-
console.error(
|
|
26
|
-
`No .tuiboard/config.yaml found from ${cfg.root}. Using fallback scan of cwd.`,
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
if (cfg.boards.length === 0) {
|
|
30
|
-
console.error("No boards configured and none found via fallback scan.");
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
files.push(...cfg.boards.map((b) => b.path));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
let totalTasks = 0;
|
|
37
|
-
let totalDone = 0;
|
|
38
|
-
let totalWithSched = 0;
|
|
39
|
-
let totalWithTimeBlock = 0;
|
|
40
|
-
let totalLegacyTimeBlock = 0;
|
|
41
|
-
let totalWithPriority = 0;
|
|
42
|
-
let totalDiagnostics = 0;
|
|
43
|
-
|
|
44
|
-
for (const file of files) {
|
|
45
|
-
let content: string;
|
|
46
|
-
try {
|
|
47
|
-
content = readFileSync(file, "utf-8");
|
|
48
|
-
} catch (e) {
|
|
49
|
-
console.error(`✗ Cannot read ${file}: ${(e as Error).message}`);
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const { board, diagnostics } = parseBoard(content, { filepath: file });
|
|
54
|
-
const tasks: Task[] = [];
|
|
55
|
-
for (const col of board.columns) {
|
|
56
|
-
for (const child of col.children) {
|
|
57
|
-
if (isTask(child)) tasks.push(child);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
const done = tasks.filter((t) => t.done).length;
|
|
61
|
-
const withSched = tasks.filter((t) => t.scheduled).length;
|
|
62
|
-
const withTime = tasks.filter((t) => t.timeBlock).length;
|
|
63
|
-
const legacyTime = tasks.filter((t) => t.timeBlockSource === "legacy-prefix").length;
|
|
64
|
-
const withPrio = tasks.filter((t) => t.priority !== "none").length;
|
|
65
|
-
|
|
66
|
-
totalTasks += tasks.length;
|
|
67
|
-
totalDone += done;
|
|
68
|
-
totalWithSched += withSched;
|
|
69
|
-
totalWithTimeBlock += withTime;
|
|
70
|
-
totalLegacyTimeBlock += legacyTime;
|
|
71
|
-
totalWithPriority += withPrio;
|
|
72
|
-
totalDiagnostics += diagnostics.length;
|
|
73
|
-
|
|
74
|
-
console.log(`\n━━━ ${board.name} ━━━`);
|
|
75
|
-
console.log(` file: ${file}`);
|
|
76
|
-
console.log(` frontmatter: ${board.frontmatter ? "yes" : "no"}`);
|
|
77
|
-
console.log(` trailer: ${board.trailer ? "yes" : "no"}`);
|
|
78
|
-
console.log(` columns: ${board.columns.length} — ${board.columns.map((c) => c.name).join(" │ ")}`);
|
|
79
|
-
console.log(` tasks total: ${tasks.length} (${done} done, ${tasks.length - done} open)`);
|
|
80
|
-
console.log(` scheduled: ${withSched}`);
|
|
81
|
-
console.log(` time blocks: ${withTime} (${legacyTime} legacy prefix, ${withTime - legacyTime} ⌚)`);
|
|
82
|
-
console.log(` priority: ${withPrio}`);
|
|
83
|
-
console.log(` diagnostics: ${diagnostics.length}`);
|
|
84
|
-
|
|
85
|
-
// Show first 3 diagnostics
|
|
86
|
-
for (const d of diagnostics.slice(0, 3)) {
|
|
87
|
-
console.log(` [L${d.line}] ${d.level}: ${d.message}`);
|
|
88
|
-
}
|
|
89
|
-
if (diagnostics.length > 3) console.log(` … and ${diagnostics.length - 3} more`);
|
|
90
|
-
|
|
91
|
-
// Show first 5 parsed open tasks for eyeballing
|
|
92
|
-
const sample = tasks.filter((t) => !t.done).slice(0, 5);
|
|
93
|
-
if (sample.length > 0) {
|
|
94
|
-
console.log("\n sample tasks:");
|
|
95
|
-
for (const t of sample) {
|
|
96
|
-
const tb = t.timeBlock
|
|
97
|
-
? ` ⌚${fmtMin(t.timeBlock.startMin)}-${fmtMin(t.timeBlock.endMin)}`
|
|
98
|
-
: "";
|
|
99
|
-
const sched = t.scheduled ? ` ⏳${t.scheduled}` : "";
|
|
100
|
-
const prio = t.priority !== "none" ? ` [${t.priority}]` : "";
|
|
101
|
-
const assignee = t.assignee ? ` @${t.assignee}` : "";
|
|
102
|
-
const tags = t.tags.length ? ` ${t.tags.map((x) => "#" + x).join(" ")}` : "";
|
|
103
|
-
console.log(` • ${truncate(t.displayTitle, 60)}${prio}${assignee}${sched}${tb}${tags}`);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
console.log("\n━━━ TOTALS ━━━");
|
|
109
|
-
console.log(` files: ${files.length}`);
|
|
110
|
-
console.log(` tasks: ${totalTasks} (${totalDone} done)`);
|
|
111
|
-
console.log(` scheduled: ${totalWithSched}`);
|
|
112
|
-
console.log(` time blocks: ${totalWithTimeBlock} (${totalLegacyTimeBlock} legacy, ${totalWithTimeBlock - totalLegacyTimeBlock} ⌚)`);
|
|
113
|
-
console.log(` priority: ${totalWithPriority}`);
|
|
114
|
-
console.log(` diagnostics: ${totalDiagnostics}`);
|
|
115
|
-
|
|
116
|
-
function fmtMin(m: number): string {
|
|
117
|
-
const h = Math.floor(m / 60).toString().padStart(2, "0");
|
|
118
|
-
const mm = (m % 60).toString().padStart(2, "0");
|
|
119
|
-
return `${h}:${mm}`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function truncate(s: string, n: number): string {
|
|
123
|
-
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
124
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Roundtrip integrity check.
|
|
3
|
-
*
|
|
4
|
-
* Parses each board, serializes it back, and verifies bit-for-bit equality
|
|
5
|
-
* with the original (no tasks are dirty since we haven't mutated anything,
|
|
6
|
-
* so `serializeTask` falls through to `rawLine`). Any diff indicates a bug
|
|
7
|
-
* in the parser or serializer's handling of structural elements (frontmatter,
|
|
8
|
-
* headings, section breaks, trailer).
|
|
9
|
-
*
|
|
10
|
-
* Usage:
|
|
11
|
-
* bun run src/scripts/roundtrip-check.ts [file.md ...]
|
|
12
|
-
*
|
|
13
|
-
* Exit code 0 on success, 1 on any mismatch.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
17
|
-
import { loadConfig } from "~/config/loader";
|
|
18
|
-
import { parseBoard } from "~/parser/markdown";
|
|
19
|
-
import { serializeBoard } from "~/parser/serialize";
|
|
20
|
-
|
|
21
|
-
const args = process.argv.slice(2);
|
|
22
|
-
const files = args.length > 0 ? args : loadConfig().boards.map((b) => b.path);
|
|
23
|
-
|
|
24
|
-
if (files.length === 0) {
|
|
25
|
-
console.error("No boards to check.");
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
let failures = 0;
|
|
30
|
-
|
|
31
|
-
for (const file of files) {
|
|
32
|
-
let original: string;
|
|
33
|
-
try {
|
|
34
|
-
original = readFileSync(file, "utf-8");
|
|
35
|
-
} catch (e) {
|
|
36
|
-
console.error(`✗ ${file}: ${(e as Error).message}`);
|
|
37
|
-
failures++;
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const { board } = parseBoard(original, { filepath: file });
|
|
42
|
-
const serialized = serializeBoard(board);
|
|
43
|
-
|
|
44
|
-
if (serialized === original) {
|
|
45
|
-
console.log(`✓ ${file} (${original.length} bytes, ${board.columns.length} cols)`);
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Find the first difference for diagnostics.
|
|
50
|
-
const minLen = Math.min(serialized.length, original.length);
|
|
51
|
-
let diffAt = -1;
|
|
52
|
-
for (let i = 0; i < minLen; i++) {
|
|
53
|
-
if (serialized[i] !== original[i]) {
|
|
54
|
-
diffAt = i;
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
if (diffAt === -1) diffAt = minLen;
|
|
59
|
-
|
|
60
|
-
const ctx = (s: string, at: number) => {
|
|
61
|
-
const from = Math.max(0, at - 40);
|
|
62
|
-
const to = Math.min(s.length, at + 40);
|
|
63
|
-
return JSON.stringify(s.slice(from, to));
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
console.error(`✗ ${file}: roundtrip differs at offset ${diffAt}`);
|
|
67
|
-
console.error(` original size: ${original.length}`);
|
|
68
|
-
console.error(` serialized size: ${serialized.length}`);
|
|
69
|
-
console.error(` original @ ${diffAt}: ${ctx(original, diffAt)}`);
|
|
70
|
-
console.error(` serialized @ ${diffAt}: ${ctx(serialized, diffAt)}`);
|
|
71
|
-
failures++;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (failures > 0) {
|
|
75
|
-
console.error(`\n${failures} file(s) failed roundtrip.`);
|
|
76
|
-
process.exit(1);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
console.log(`\nAll ${files.length} board(s) passed roundtrip.`);
|