tuiboard 0.5.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 +32 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bin/tuiboard.ts +28 -0
- package/package.json +62 -0
- package/src/app.tsx +129 -0
- package/src/cli/args.test.ts +40 -0
- package/src/cli/args.ts +41 -0
- package/src/config/loader.ts +169 -0
- package/src/input/handleKey.ts +733 -0
- package/src/io/watcher.ts +85 -0
- package/src/io/writer.ts +92 -0
- package/src/parser/markdown.ts +351 -0
- package/src/parser/serialize.ts +97 -0
- package/src/scripts/agents-check.ts +24 -0
- package/src/scripts/parse-check.ts +124 -0
- package/src/scripts/roundtrip-check.ts +79 -0
- package/src/store/agents.test.ts +181 -0
- package/src/store/agents.ts +435 -0
- package/src/store/index.test.ts +110 -0
- package/src/store/index.ts +972 -0
- package/src/store/parsers.ts +243 -0
- package/src/store/timeline.test.ts +279 -0
- package/src/store/timeline.ts +279 -0
- package/src/store/virtual-panel.ts +0 -0
- package/src/types.ts +116 -0
- package/src/ui/AgentRow.tsx +79 -0
- package/src/ui/AgentsBar.tsx +102 -0
- package/src/ui/BoardView.tsx +333 -0
- package/src/ui/Chrome.tsx +122 -0
- package/src/ui/Modal.tsx +613 -0
- package/src/ui/TaskRow.tsx +240 -0
- package/src/ui/TimelineView.tsx +643 -0
- package/src/ui/VirtualPanel.tsx +237 -0
- package/src/ui/board-scroll.test.ts +63 -0
- package/src/ui/board-scroll.ts +49 -0
- package/src/ui/glyphs.ts +129 -0
- package/src/views/AgentsOnly.tsx +103 -0
- package/src/views/BoardOnly.tsx +35 -0
- package/src/views/Dashboard.tsx +106 -0
- package/src/views/TimelineOnly.tsx +12 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-line task row — high-density rendering.
|
|
3
|
+
*
|
|
4
|
+
* Layout: left flex group (cursor + priority + title) takes available width
|
|
5
|
+
* and truncates. Right flex group (compact suffix: today/tmrw/DD/MM/⌚time)
|
|
6
|
+
* stays a fixed size and pins to the right.
|
|
7
|
+
*
|
|
8
|
+
* Inspired by the Python kanban view density: ~30-40 tasks visible per
|
|
9
|
+
* column at typical terminal sizes, instead of 8-10 in the previous card
|
|
10
|
+
* layout.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Show, createMemo } from "solid-js";
|
|
14
|
+
|
|
15
|
+
import { PRIORITY_COLOR, PRIORITY_GLYPH, T, fmtMin } from "~/ui/glyphs";
|
|
16
|
+
import { isoToday, isoTomorrow } from "~/store/index";
|
|
17
|
+
import type { Task } from "~/types";
|
|
18
|
+
|
|
19
|
+
interface TaskRowProps {
|
|
20
|
+
task: Task;
|
|
21
|
+
cursor?: boolean;
|
|
22
|
+
marked?: boolean;
|
|
23
|
+
/** True when grab mode is on AND this row is the cursor (about to move). */
|
|
24
|
+
grabbed?: boolean;
|
|
25
|
+
/** Optional `[board]` tag rendered as a separate small suffix. Used when
|
|
26
|
+
the row is not already grouped under a `— board · col —` header. */
|
|
27
|
+
contextTag?: string;
|
|
28
|
+
/** Custom fg color for the contextTag. Defaults to muted gray. */
|
|
29
|
+
contextColor?: string;
|
|
30
|
+
/** If true, hide the date suffix (used when group header already conveys date). */
|
|
31
|
+
hideDateSuffix?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Optional title tint (e.g. the source-board accent in the virtual panel).
|
|
34
|
+
* Applies only to non-done, non-overdue, non-today rows — those keep their
|
|
35
|
+
* status color. Done-green always wins over any tint.
|
|
36
|
+
*/
|
|
37
|
+
tintColor?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Total cell width available to this row, in terminal columns. When set,
|
|
40
|
+
* TaskRow computes the exact title budget from this width minus the row's
|
|
41
|
+
* actual overhead (cursor, marked dot, done check, priority emoji, context
|
|
42
|
+
* tag, suffix). This guarantees the tail-truncate `…` is always visible
|
|
43
|
+
* inside the cell — OpenTUI never needs to chop the row further.
|
|
44
|
+
*/
|
|
45
|
+
availableWidth?: number;
|
|
46
|
+
/** Hard cap on title chars regardless of availableWidth. Defaults to 60. */
|
|
47
|
+
titleMaxChars?: number;
|
|
48
|
+
/** Mouse click callback — called on left button down. */
|
|
49
|
+
onClick?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TaskRow(props: TaskRowProps) {
|
|
53
|
+
const status = createMemo(() => statusOf(props.task));
|
|
54
|
+
const suffix = createMemo(() => buildSuffix(props.task, props.hideDateSuffix));
|
|
55
|
+
const titleColor = createMemo(() =>
|
|
56
|
+
titleColorFor(props.task, status(), props.tintColor),
|
|
57
|
+
);
|
|
58
|
+
const suffixColor = createMemo(() => suffixColorFor(props.task, status()));
|
|
59
|
+
|
|
60
|
+
// Compute the title budget from availableWidth + this row's actual overhead
|
|
61
|
+
// so the tail-truncate `…` is always visible inside the cell. If the parent
|
|
62
|
+
// didn't pass a width, fall back to the legacy titleMaxChars-based behavior.
|
|
63
|
+
const titleBudget = createMemo(() => {
|
|
64
|
+
const hardCap = props.titleMaxChars ?? 60;
|
|
65
|
+
if (props.availableWidth === undefined) {
|
|
66
|
+
return hardCap;
|
|
67
|
+
}
|
|
68
|
+
let overhead = 2; // cursor "▶ " or " "
|
|
69
|
+
if (props.marked) overhead += 2;
|
|
70
|
+
if (props.task.done) overhead += 2;
|
|
71
|
+
if (props.task.priority !== "none") overhead += 3; // emoji 2 cells + space
|
|
72
|
+
if (props.contextTag) overhead += props.contextTag.length + 3; // " [" + tag + "]"
|
|
73
|
+
const sfx = suffix();
|
|
74
|
+
if (sfx) overhead += sfx.length + 1; // leading space + suffix
|
|
75
|
+
const computed = props.availableWidth - overhead;
|
|
76
|
+
return Math.max(6, Math.min(hardCap, computed));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const visibleTitle = createMemo(() =>
|
|
80
|
+
tailTruncate(props.task.displayTitle || "(empty)", titleBudget()),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<box
|
|
85
|
+
style={{
|
|
86
|
+
flexDirection: "row",
|
|
87
|
+
paddingLeft: 1,
|
|
88
|
+
paddingRight: 1,
|
|
89
|
+
// Grab mode tints the cursor row warm orange so the user always
|
|
90
|
+
// knows which task is "in transit". Plain cursor stays neutral.
|
|
91
|
+
backgroundColor: props.grabbed
|
|
92
|
+
? T.warmDim
|
|
93
|
+
: props.cursor
|
|
94
|
+
? T.cardBgCursor
|
|
95
|
+
: undefined,
|
|
96
|
+
}}
|
|
97
|
+
onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
|
|
98
|
+
>
|
|
99
|
+
{/*
|
|
100
|
+
`truncate` is on as a SAFETY NET. The titleBudget memo above
|
|
101
|
+
sizes our own tailTruncate so the text content fits exactly
|
|
102
|
+
in the flex-shrunk cell — when that calculation is right (the
|
|
103
|
+
common case), OpenTUI has nothing to truncate and our `…` is
|
|
104
|
+
the only ellipsis. When emoji width or terminal quirks throw
|
|
105
|
+
off the math by 1-2 cells, OpenTUI's truncate clips at the
|
|
106
|
+
cell boundary instead of letting the text overflow into
|
|
107
|
+
adjacent renderables (which produced visible 'Linktoday'
|
|
108
|
+
merges and bleed-into-neighbor-zone glitches before this).
|
|
109
|
+
*/}
|
|
110
|
+
<text
|
|
111
|
+
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
112
|
+
truncate
|
|
113
|
+
wrapMode="none"
|
|
114
|
+
>
|
|
115
|
+
<span style={{ fg: props.cursor ? T.accent : T.textDim }}>
|
|
116
|
+
{props.grabbed ? "⤤ " : props.cursor ? "▶ " : " "}
|
|
117
|
+
</span>
|
|
118
|
+
<Show when={props.marked}>
|
|
119
|
+
<span style={{ fg: T.accent }}>● </span>
|
|
120
|
+
</Show>
|
|
121
|
+
<Show when={props.task.done}>
|
|
122
|
+
<span style={{ fg: T.done }}>✓ </span>
|
|
123
|
+
</Show>
|
|
124
|
+
<Show when={props.task.priority !== "none"}>
|
|
125
|
+
<span style={{ fg: PRIORITY_COLOR[props.task.priority] }}>
|
|
126
|
+
{PRIORITY_GLYPH[props.task.priority]}{" "}
|
|
127
|
+
</span>
|
|
128
|
+
</Show>
|
|
129
|
+
<span style={{ fg: titleColor() }}>
|
|
130
|
+
{visibleTitle()}
|
|
131
|
+
</span>
|
|
132
|
+
</text>
|
|
133
|
+
|
|
134
|
+
<Show when={suffix()}>
|
|
135
|
+
<text style={{ flexShrink: 0 }} wrapMode="none">
|
|
136
|
+
<span style={{ fg: suffixColor() }}>{" "}{suffix()!}</span>
|
|
137
|
+
</text>
|
|
138
|
+
</Show>
|
|
139
|
+
|
|
140
|
+
<Show when={props.contextTag}>
|
|
141
|
+
{/*
|
|
142
|
+
flexShrink 5 (vs 1 on the title) — when the row is tight, the
|
|
143
|
+
contextTag is the first thing to lose space. `truncate` ON
|
|
144
|
+
as safety so the tag doesn't overflow into neighboring zones
|
|
145
|
+
when the cell shrinks below its content width.
|
|
146
|
+
*/}
|
|
147
|
+
<text style={{ flexShrink: 5 }} wrapMode="none" truncate>
|
|
148
|
+
<span style={{ fg: props.contextColor ?? T.textDim }}>
|
|
149
|
+
{" ["}{props.contextTag}{"]"}
|
|
150
|
+
</span>
|
|
151
|
+
</text>
|
|
152
|
+
</Show>
|
|
153
|
+
</box>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type TaskStatus = "done" | "overdue" | "today" | "future" | "unscheduled";
|
|
158
|
+
|
|
159
|
+
function statusOf(t: Task): TaskStatus {
|
|
160
|
+
if (t.done) return "done";
|
|
161
|
+
const d = t.scheduled ?? t.due;
|
|
162
|
+
if (!d) return "unscheduled";
|
|
163
|
+
if (d < isoToday()) return "overdue";
|
|
164
|
+
if (d === isoToday()) return "today";
|
|
165
|
+
return "future";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function titleColorFor(
|
|
169
|
+
task: Task,
|
|
170
|
+
status: TaskStatus,
|
|
171
|
+
tintColor?: string,
|
|
172
|
+
): string | undefined {
|
|
173
|
+
// Done-green always wins so a completed task reads as "done" at a glance,
|
|
174
|
+
// regardless of which board it came from.
|
|
175
|
+
if (status === "done") return T.done;
|
|
176
|
+
// A board tint (virtual panel) takes precedence over the date-status colors:
|
|
177
|
+
// the panel's section headers (Overdue/Today/Tomorrow) already convey the
|
|
178
|
+
// date, so the row color is freed up to signal the *source board* instead.
|
|
179
|
+
if (tintColor) return tintColor;
|
|
180
|
+
if (status === "overdue") return T.overdue;
|
|
181
|
+
if (status === "today") return T.today;
|
|
182
|
+
// future / unscheduled: terminal default fg (looks right on any theme).
|
|
183
|
+
return T.text;
|
|
184
|
+
void task;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Build the compact right-side suffix shown on a task row.
|
|
189
|
+
*
|
|
190
|
+
* Examples:
|
|
191
|
+
* ⌚09:00 today (today + time block)
|
|
192
|
+
* today (scheduled today, no time block)
|
|
193
|
+
* tmrw (tomorrow)
|
|
194
|
+
* ⌚09:00 (time block only)
|
|
195
|
+
* 25/05 (scheduled some other day)
|
|
196
|
+
* 25/05 ✓ (done; the date is the doneDate)
|
|
197
|
+
*/
|
|
198
|
+
function buildSuffix(task: Task, hideDate?: boolean): string | undefined {
|
|
199
|
+
const parts: string[] = [];
|
|
200
|
+
if (task.timeBlock) {
|
|
201
|
+
parts.push(`⌚${fmtMin(task.timeBlock.startMin)}`);
|
|
202
|
+
}
|
|
203
|
+
if (!hideDate) {
|
|
204
|
+
const date = task.scheduled ?? task.due ?? task.doneDate;
|
|
205
|
+
if (date) {
|
|
206
|
+
if (date === isoToday()) parts.push("today");
|
|
207
|
+
else if (date === isoTomorrow()) parts.push("tmrw");
|
|
208
|
+
else parts.push(date.slice(5).replace("-", "/")); // MM/DD → "MM/DD"
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (parts.length === 0) return undefined;
|
|
212
|
+
return parts.join(" ");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function suffixColorFor(task: Task, status: TaskStatus): string | undefined {
|
|
216
|
+
if (status === "done") return T.textDone;
|
|
217
|
+
if (status === "overdue") return T.overdue;
|
|
218
|
+
if (status === "today") return T.today;
|
|
219
|
+
if (status === "future") return T.scheduled;
|
|
220
|
+
return T.textDim;
|
|
221
|
+
void task;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Truncate to `max` chars, preserving as much of the head as possible.
|
|
226
|
+
* When the string fits, return it untouched. Otherwise show the first
|
|
227
|
+
* `max - 1` characters followed by an ellipsis.
|
|
228
|
+
*
|
|
229
|
+
* "Founder outreach LinkedIn — kickoff: estrai 5-15 paying users"
|
|
230
|
+
* tailTruncate(s, 22) → "Founder outreach Link…"
|
|
231
|
+
*
|
|
232
|
+
* The ellipsis counts toward `max`, so the visible glyph width never
|
|
233
|
+
* exceeds the available column budget.
|
|
234
|
+
*/
|
|
235
|
+
function tailTruncate(s: string, max: number): string {
|
|
236
|
+
if (max <= 0) return "";
|
|
237
|
+
if (s.length <= max) return s;
|
|
238
|
+
if (max < 2) return s.slice(0, max);
|
|
239
|
+
return s.slice(0, max - 1) + "…";
|
|
240
|
+
}
|