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,237 @@
|
|
|
1
|
+
/** The fixed Today/Tomorrow virtual panel — always on the left. */
|
|
2
|
+
|
|
3
|
+
import { For, Show, createEffect, createMemo } from "solid-js";
|
|
4
|
+
|
|
5
|
+
import { ATTR, T, boardColor } from "~/ui/glyphs";
|
|
6
|
+
import { TaskRow } from "~/ui/TaskRow";
|
|
7
|
+
import {
|
|
8
|
+
buildVirtualItems,
|
|
9
|
+
groupVirtualItems,
|
|
10
|
+
type VirtualGroup,
|
|
11
|
+
} from "~/store/virtual-panel";
|
|
12
|
+
import type { TuiStore } from "~/store/index";
|
|
13
|
+
|
|
14
|
+
interface ScrollBoxLike {
|
|
15
|
+
scrollChildIntoView(id: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VP_ROW_PREFIX = "tuiboard-vp-row-";
|
|
19
|
+
const vpRowId = (flatIndex: number) => `${VP_ROW_PREFIX}${flatIndex}`;
|
|
20
|
+
|
|
21
|
+
const SECTION_HEADER: Record<string, { label: string; color: string }> = {
|
|
22
|
+
overdue: { label: "● Overdue", color: T.overdue },
|
|
23
|
+
today: { label: "● Today", color: T.today },
|
|
24
|
+
tomorrow: { label: "→ Tomorrow", color: T.warmDim },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Only the agenda and priority buckets get a header. The "rest" bucket has
|
|
28
|
+
// no header of its own — its items already sit under their own
|
|
29
|
+
// `— board · column —` sub-dividers, so a generic label would be redundant.
|
|
30
|
+
const BUCKET_HEADER: Record<string, { label: string; color: string }> = {
|
|
31
|
+
agenda: { label: "⏰ Agenda", color: T.accent },
|
|
32
|
+
priority: { label: "🔺 Priority", color: T.highest },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function VirtualPanel(props: { store: TuiStore }) {
|
|
36
|
+
const items = createMemo(() => {
|
|
37
|
+
return buildVirtualItems(props.store.state.boards.map((b) => b.board));
|
|
38
|
+
});
|
|
39
|
+
const groups = createMemo(() => groupVirtualItems(items()));
|
|
40
|
+
const isActive = createMemo(() => props.store.state.ui.activeZone === "virtual");
|
|
41
|
+
const isZoomed = createMemo(
|
|
42
|
+
() => props.store.state.ui.zoomed && props.store.state.ui.activeZone === "virtual",
|
|
43
|
+
);
|
|
44
|
+
const cursorRow = createMemo(() => props.store.state.ui.row);
|
|
45
|
+
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
46
|
+
|
|
47
|
+
// Auto-scroll so the cursor's row stays visible. Without this, when the
|
|
48
|
+
// panel's content overflows, pressing j/k advanced ui.row but the
|
|
49
|
+
// scrollbox didn't follow — the cursor moved invisibly until the bottom
|
|
50
|
+
// of the visible window happened to scroll past it. Now the scroll
|
|
51
|
+
// tracks the cursor on every move (BoardView pattern).
|
|
52
|
+
createEffect(() => {
|
|
53
|
+
const row = cursorRow();
|
|
54
|
+
if (!isActive() || !scrollBoxRef) return;
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
try {
|
|
57
|
+
scrollBoxRef?.scrollChildIntoView(vpRowId(row));
|
|
58
|
+
} catch {
|
|
59
|
+
// Child not mounted yet — harmless.
|
|
60
|
+
}
|
|
61
|
+
}, 0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Decorate title with vertical "tabs" `┤ … ├` so it visually breaks
|
|
65
|
+
// through the rounded border line (Superfile-style).
|
|
66
|
+
const titleText = () =>
|
|
67
|
+
`┤ ${isZoomed() ? "⤢ " : ""}Today / Tomorrow ${items().length} ├`;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<box
|
|
71
|
+
style={{
|
|
72
|
+
flexDirection: "column",
|
|
73
|
+
width: isZoomed() ? undefined : 38,
|
|
74
|
+
minWidth: isZoomed() ? undefined : 38,
|
|
75
|
+
flexGrow: isZoomed() ? 1 : 0,
|
|
76
|
+
marginRight: 1,
|
|
77
|
+
border: true,
|
|
78
|
+
borderStyle: "rounded",
|
|
79
|
+
// Today/Tomorrow panel keeps its warm identity at all times — when
|
|
80
|
+
// focused it brightens, otherwise it dims, but never goes cool.
|
|
81
|
+
borderColor: isActive() ? T.warmActive : T.warm,
|
|
82
|
+
paddingLeft: 1,
|
|
83
|
+
paddingRight: 1,
|
|
84
|
+
}}
|
|
85
|
+
title={titleText()}
|
|
86
|
+
titleAlignment="left"
|
|
87
|
+
>
|
|
88
|
+
<Show
|
|
89
|
+
when={items().length > 0}
|
|
90
|
+
fallback={
|
|
91
|
+
<text>
|
|
92
|
+
<span style={{ fg: T.textDim }}>Nothing scheduled.</span>
|
|
93
|
+
</text>
|
|
94
|
+
}
|
|
95
|
+
>
|
|
96
|
+
<scrollbox
|
|
97
|
+
ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
|
|
98
|
+
style={{
|
|
99
|
+
width: "100%",
|
|
100
|
+
flexGrow: 1,
|
|
101
|
+
rootOptions: {},
|
|
102
|
+
contentOptions: {},
|
|
103
|
+
scrollbarOptions: {
|
|
104
|
+
visible: false,
|
|
105
|
+
},
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<RenderGroups
|
|
109
|
+
groups={groups()}
|
|
110
|
+
isActive={isActive()}
|
|
111
|
+
cursorRow={cursorRow()}
|
|
112
|
+
// Panel inner cell width seen by a TaskRow: panel 38 col
|
|
113
|
+
// (or full width in zoom) − border 2 − panel padding 2 −
|
|
114
|
+
// TaskRow padding 2 = 32 cols normal, ~terminal width − 6
|
|
115
|
+
// when zoomed. Pass that so TaskRow can budget the title
|
|
116
|
+
// dynamically against the row's actual overhead.
|
|
117
|
+
availableWidth={isZoomed() ? 100 : 32}
|
|
118
|
+
isMarkedFn={(r) => props.store.isMarked(r)}
|
|
119
|
+
onClickItem={(flatIndex) => {
|
|
120
|
+
props.store.setActiveZone("virtual");
|
|
121
|
+
props.store.setCursor(0, flatIndex);
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
</scrollbox>
|
|
125
|
+
</Show>
|
|
126
|
+
</box>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function RenderGroups(props: {
|
|
131
|
+
groups: VirtualGroup[];
|
|
132
|
+
isActive: boolean;
|
|
133
|
+
cursorRow: number;
|
|
134
|
+
availableWidth: number;
|
|
135
|
+
isMarkedFn: (ref: import("~/store/index").TaskRef) => boolean;
|
|
136
|
+
onClickItem: (flatIndex: number) => void;
|
|
137
|
+
}) {
|
|
138
|
+
return (
|
|
139
|
+
<For each={props.groups}>
|
|
140
|
+
{(group, gi) => {
|
|
141
|
+
const sectionHeader = isFirstOfSection(props.groups, gi())
|
|
142
|
+
? SECTION_HEADER[group.section]
|
|
143
|
+
: undefined;
|
|
144
|
+
const bucketHeader = BUCKET_HEADER[group.bucket];
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<box style={{ flexDirection: "column" }}>
|
|
148
|
+
<Show when={sectionHeader}>
|
|
149
|
+
<box style={{ paddingLeft: 1, paddingRight: 1, marginTop: 1 }}>
|
|
150
|
+
<text>
|
|
151
|
+
<span style={{ fg: sectionHeader!.color, attributes: ATTR.bold }}>
|
|
152
|
+
{sectionHeader!.label}
|
|
153
|
+
</span>
|
|
154
|
+
</text>
|
|
155
|
+
</box>
|
|
156
|
+
</Show>
|
|
157
|
+
<Show when={bucketHeader}>
|
|
158
|
+
<box style={{ paddingLeft: 1, paddingRight: 1 }}>
|
|
159
|
+
<text>
|
|
160
|
+
<span style={{ fg: bucketHeader!.color }}>{" "}{bucketHeader!.label}</span>
|
|
161
|
+
</text>
|
|
162
|
+
</box>
|
|
163
|
+
</Show>
|
|
164
|
+
<Show
|
|
165
|
+
when={group.subgroups && group.subgroups.length > 0}
|
|
166
|
+
fallback={
|
|
167
|
+
<For each={group.items}>
|
|
168
|
+
{(item) => (
|
|
169
|
+
<box id={vpRowId(item.flatIndex)}>
|
|
170
|
+
<TaskRow
|
|
171
|
+
task={item.task}
|
|
172
|
+
cursor={props.isActive && item.flatIndex === props.cursorRow}
|
|
173
|
+
marked={props.isMarkedFn(item.ref)}
|
|
174
|
+
availableWidth={props.availableWidth}
|
|
175
|
+
// Tint the title with the source board's accent so a
|
|
176
|
+
// cross-cutting Today/Tomorrow item is recognizable by
|
|
177
|
+
// its board at a glance (done-green still wins).
|
|
178
|
+
tintColor={boardColor(item.boardIndex)}
|
|
179
|
+
// Today / Tomorrow sections already say so in their
|
|
180
|
+
// header — drop the redundant per-row "today"/"tmrw"
|
|
181
|
+
// date label (the ⌚ time block stays). Overdue rows
|
|
182
|
+
// keep their MM/DD so you can see HOW overdue.
|
|
183
|
+
hideDateSuffix={
|
|
184
|
+
group.section === "today" || group.section === "tomorrow"
|
|
185
|
+
}
|
|
186
|
+
onClick={() => props.onClickItem(item.flatIndex)}
|
|
187
|
+
/>
|
|
188
|
+
</box>
|
|
189
|
+
)}
|
|
190
|
+
</For>
|
|
191
|
+
}
|
|
192
|
+
>
|
|
193
|
+
<For each={group.subgroups!}>
|
|
194
|
+
{(sub) => (
|
|
195
|
+
<box style={{ flexDirection: "column" }}>
|
|
196
|
+
<box style={{ paddingLeft: 1, paddingRight: 1 }}>
|
|
197
|
+
<text wrapMode="none" truncate>
|
|
198
|
+
<span style={{ fg: T.textDim }}>
|
|
199
|
+
{" — "}{sub.boardName}{" · "}{sub.columnName}{" —"}
|
|
200
|
+
</span>
|
|
201
|
+
</text>
|
|
202
|
+
</box>
|
|
203
|
+
<For each={sub.items}>
|
|
204
|
+
{(item) => (
|
|
205
|
+
<box id={vpRowId(item.flatIndex)}>
|
|
206
|
+
<TaskRow
|
|
207
|
+
task={item.task}
|
|
208
|
+
cursor={props.isActive && item.flatIndex === props.cursorRow}
|
|
209
|
+
marked={props.isMarkedFn(item.ref)}
|
|
210
|
+
availableWidth={props.availableWidth}
|
|
211
|
+
tintColor={boardColor(item.boardIndex)}
|
|
212
|
+
hideDateSuffix={
|
|
213
|
+
group.section === "today" || group.section === "tomorrow"
|
|
214
|
+
}
|
|
215
|
+
onClick={() => props.onClickItem(item.flatIndex)}
|
|
216
|
+
/>
|
|
217
|
+
</box>
|
|
218
|
+
)}
|
|
219
|
+
</For>
|
|
220
|
+
</box>
|
|
221
|
+
)}
|
|
222
|
+
</For>
|
|
223
|
+
</Show>
|
|
224
|
+
</box>
|
|
225
|
+
);
|
|
226
|
+
}}
|
|
227
|
+
</For>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isFirstOfSection(
|
|
232
|
+
groups: VirtualGroup[],
|
|
233
|
+
idx: number,
|
|
234
|
+
): boolean {
|
|
235
|
+
if (idx === 0) return true;
|
|
236
|
+
return groups[idx - 1]!.section !== groups[idx]!.section;
|
|
237
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { computeColumnScrollLeft } from "~/ui/board-scroll";
|
|
4
|
+
|
|
5
|
+
const COL = 42;
|
|
6
|
+
const GAP = 1;
|
|
7
|
+
const STRIDE = COL + GAP;
|
|
8
|
+
|
|
9
|
+
// Helper for the common uniform-width case: column at a given index.
|
|
10
|
+
function scroll(index: number, viewportWidth: number, currentScroll: number) {
|
|
11
|
+
return computeColumnScrollLeft({
|
|
12
|
+
colStart: index * STRIDE,
|
|
13
|
+
colWidth: COL,
|
|
14
|
+
viewportWidth,
|
|
15
|
+
currentScroll,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("computeColumnScrollLeft", () => {
|
|
20
|
+
test("column already fully visible → scroll unchanged", () => {
|
|
21
|
+
expect(scroll(0, 100, 0)).toBe(0);
|
|
22
|
+
expect(scroll(1, 100, 0)).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("column off the right edge → align its right edge to viewport", () => {
|
|
26
|
+
// viewport 60, col 1 spans 43..85 → right-align: 85-60 = 25.
|
|
27
|
+
expect(scroll(1, 60, 0)).toBe(25);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("column off the left edge → align its left edge to viewport", () => {
|
|
31
|
+
expect(scroll(0, 60, 25)).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("far-right hidden column scrolls fully into view", () => {
|
|
35
|
+
// col 5 spans 215..257 → right-align: 257-60 = 197.
|
|
36
|
+
expect(scroll(5, 60, 0)).toBe(197);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("column wider than viewport → align left edge (show the start)", () => {
|
|
40
|
+
// viewport 30 < column 42. col 2 starts at 86 → align left edge at 86.
|
|
41
|
+
expect(scroll(2, 30, 0)).toBe(86);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("negative start or zero viewport → no change", () => {
|
|
45
|
+
expect(
|
|
46
|
+
computeColumnScrollLeft({ colStart: -1, colWidth: COL, viewportWidth: 60, currentScroll: 17 }),
|
|
47
|
+
).toBe(17);
|
|
48
|
+
expect(scroll(3, 0, 17)).toBe(17);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("never returns a negative scroll offset", () => {
|
|
52
|
+
expect(scroll(0, 200, 0)).toBeGreaterThanOrEqual(0);
|
|
53
|
+
expect(scroll(0, 30, 5)).toBeGreaterThanOrEqual(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("variable widths: narrow collapsed column to the left shifts offsets", () => {
|
|
57
|
+
// Columns: [42], [18 collapsed], [42]. Third column starts at
|
|
58
|
+
// 43 + 19 = 62, spans 62..104. viewport 50 → right-align 104-50 = 54.
|
|
59
|
+
expect(
|
|
60
|
+
computeColumnScrollLeft({ colStart: 62, colWidth: 42, viewportWidth: 50, currentScroll: 0 }),
|
|
61
|
+
).toBe(54);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic horizontal scroll geometry for the kanban board.
|
|
3
|
+
*
|
|
4
|
+
* The board renders a horizontal row of columns inside a clipping viewport.
|
|
5
|
+
* When the cursor moves to a column that is partly or fully out of view, the
|
|
6
|
+
* row is shifted (via negative margin) so that column becomes visible.
|
|
7
|
+
*
|
|
8
|
+
* Columns are NOT uniform width (a collapsed all-done column is narrower), so
|
|
9
|
+
* the caller passes the active column's actual laid-out start offset and width
|
|
10
|
+
* rather than an index + stride.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ColumnScrollInput {
|
|
14
|
+
/** Left offset of the active column within the column row, in cells. */
|
|
15
|
+
colStart: number;
|
|
16
|
+
/** Width of the active column in cells. */
|
|
17
|
+
colWidth: number;
|
|
18
|
+
/** Currently visible width of the viewport in cells. */
|
|
19
|
+
viewportWidth: number;
|
|
20
|
+
/** Current horizontal scroll offset in cells. */
|
|
21
|
+
currentScroll: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the scroll offset that brings the active column fully into view,
|
|
26
|
+
* scrolling the minimum distance needed:
|
|
27
|
+
*
|
|
28
|
+
* - already fully visible → unchanged
|
|
29
|
+
* - off the left edge → align column's left edge to viewport left
|
|
30
|
+
* - off the right edge → align column's right edge to viewport right
|
|
31
|
+
* - wider than the viewport → align left edge (show as much from the
|
|
32
|
+
* start of the column as possible)
|
|
33
|
+
*
|
|
34
|
+
* Pure and side-effect free so it can be unit-tested without a terminal.
|
|
35
|
+
*/
|
|
36
|
+
export function computeColumnScrollLeft(input: ColumnScrollInput): number {
|
|
37
|
+
const { colStart, colWidth, viewportWidth, currentScroll } = input;
|
|
38
|
+
if (colStart < 0 || viewportWidth <= 0) return currentScroll;
|
|
39
|
+
|
|
40
|
+
const colEnd = colStart + colWidth;
|
|
41
|
+
|
|
42
|
+
if (colStart < currentScroll) {
|
|
43
|
+
return Math.max(0, colStart);
|
|
44
|
+
}
|
|
45
|
+
if (colEnd > currentScroll + viewportWidth) {
|
|
46
|
+
return Math.max(0, Math.min(colStart, colEnd - viewportWidth));
|
|
47
|
+
}
|
|
48
|
+
return currentScroll;
|
|
49
|
+
}
|
package/src/ui/glyphs.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Centralized glyph + color tokens for the UI. */
|
|
2
|
+
|
|
3
|
+
import { TextAttributes } from "@opentui/core";
|
|
4
|
+
|
|
5
|
+
export const ATTR = {
|
|
6
|
+
bold: TextAttributes.BOLD,
|
|
7
|
+
dim: TextAttributes.DIM,
|
|
8
|
+
italic: TextAttributes.ITALIC,
|
|
9
|
+
underline: TextAttributes.UNDERLINE,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Theme tokens.
|
|
14
|
+
*
|
|
15
|
+
* Foreground colors use ANSI names (`"red"`, `"yellow"`, `"brightBlack"`,
|
|
16
|
+
* …) so they pick up the user's configured terminal palette. The terminal
|
|
17
|
+
* theme — Nord, Tokyo Night, Gruvbox, Solarized, whatever — decides what
|
|
18
|
+
* those names look like.
|
|
19
|
+
*
|
|
20
|
+
* Backgrounds are mostly `undefined` (transparent → terminal default).
|
|
21
|
+
* The only opaque backgrounds we paint are the cursor row highlight and
|
|
22
|
+
* the banner row.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Palette philosophy:
|
|
26
|
+
*
|
|
27
|
+
* - Backgrounds stay transparent so the terminal theme bleeds through.
|
|
28
|
+
* - The few accent colors are *muted hex* (mid-saturation, mid-luminance)
|
|
29
|
+
* instead of raw ANSI names like "red"/"yellow" — those tend to render
|
|
30
|
+
* as fully-saturated traffic-light colors in most themes and clash
|
|
31
|
+
* with everything around them.
|
|
32
|
+
* - The Today/Tomorrow virtual panel has its own *warm* identity
|
|
33
|
+
* (peach/orange) so the user instantly knows "this is the time zone";
|
|
34
|
+
* everything else uses a *cool* identity (soft cyan) for active focus.
|
|
35
|
+
* - Cursor row highlight is the only opaque paint outside the modal.
|
|
36
|
+
*/
|
|
37
|
+
export const T = {
|
|
38
|
+
// Backgrounds
|
|
39
|
+
bg: undefined as string | undefined,
|
|
40
|
+
panelBg: undefined as string | undefined,
|
|
41
|
+
panelBgActive: undefined as string | undefined,
|
|
42
|
+
cardBg: undefined as string | undefined,
|
|
43
|
+
cardBgDone: undefined as string | undefined,
|
|
44
|
+
cardBgCursor: "#2a2f3c",
|
|
45
|
+
// Subtle fill behind timeline block rows so the bands separate from the
|
|
46
|
+
// dotted/empty gutters around them. Just barely darker-than-cursor; on a
|
|
47
|
+
// typical dark terminal it reads as "filled card", not as "highlighted".
|
|
48
|
+
cardBlockBg: "#1c2030",
|
|
49
|
+
|
|
50
|
+
// Foreground neutrals — readable mid-grays so dim chrome doesn't disappear
|
|
51
|
+
text: undefined as string | undefined, // terminal default fg
|
|
52
|
+
textDim: "#8a90a8", // bumped up from #6b7089 for legibility
|
|
53
|
+
textDone: "#6b7089", // formerly textDim — done tasks are dim but still readable
|
|
54
|
+
done: "#6aaf57", // muted-but-clear green for completed task titles + ✓
|
|
55
|
+
|
|
56
|
+
// Cool accents — used for active board columns and generic focus
|
|
57
|
+
accent: "#7eb6d6", // clearer blue-cyan, hue ~200°
|
|
58
|
+
border: "#5c627a", // mid-gray, visible against terminal default bg
|
|
59
|
+
borderActive: "#7eb6d6", // same as accent
|
|
60
|
+
|
|
61
|
+
// Warm accents — Today/Tomorrow identity (hue ~30°)
|
|
62
|
+
warm: "#e8a05c", // clear warm orange, distinct from red
|
|
63
|
+
warmActive: "#f2b272", // brighter peach when panel is focused
|
|
64
|
+
warmDim: "#a07a52", // dim version for tomorrow header
|
|
65
|
+
|
|
66
|
+
// Priority emoji colors
|
|
67
|
+
highest: "#e26a6a", // clearly red (hue 0°)
|
|
68
|
+
high: "#e8a05c", // warm orange (same as today)
|
|
69
|
+
medium: "#d8c074", // gold (hue 50°)
|
|
70
|
+
low: "#a4c98a", // sage (hue 95°)
|
|
71
|
+
|
|
72
|
+
// Status-based row colors — kept clearly distinct in hue + brightness
|
|
73
|
+
overdue: "#e26a6a", // hue 0°, sat 65%, light 65% — clearly red
|
|
74
|
+
today: "#e8a05c", // hue 30°, sat 75%, light 64% — clearly orange
|
|
75
|
+
scheduled: "#c89a6a", // dimmer warm for non-today future
|
|
76
|
+
future: "#8a90a8",
|
|
77
|
+
|
|
78
|
+
// Metadata
|
|
79
|
+
assignee: "#a4c98a",
|
|
80
|
+
tag: "#7eb6d6",
|
|
81
|
+
time: "#b3a3d8",
|
|
82
|
+
|
|
83
|
+
// Banner colors
|
|
84
|
+
bannerInfo: "#7eb6d6",
|
|
85
|
+
bannerWarn: "#e8a05c",
|
|
86
|
+
bannerError: "#e26a6a",
|
|
87
|
+
} as const;
|
|
88
|
+
|
|
89
|
+
export const PRIORITY_GLYPH: Record<string, string> = {
|
|
90
|
+
highest: "🔺",
|
|
91
|
+
high: "⏫",
|
|
92
|
+
medium: "🔼",
|
|
93
|
+
low: "🔽",
|
|
94
|
+
lowest: "⏬",
|
|
95
|
+
none: "",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const PRIORITY_COLOR: Record<string, string | undefined> = {
|
|
99
|
+
highest: T.highest,
|
|
100
|
+
high: T.high,
|
|
101
|
+
medium: T.medium,
|
|
102
|
+
low: T.low,
|
|
103
|
+
lowest: T.low,
|
|
104
|
+
none: T.text,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export function fmtMin(m: number): string {
|
|
108
|
+
const h = Math.floor(m / 60).toString().padStart(2, "0");
|
|
109
|
+
const mm = (m % 60).toString().padStart(2, "0");
|
|
110
|
+
return `${h}:${mm}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Per-board accent palette. Used by the virtual panel to color-code the
|
|
115
|
+
* source-board tag on priority/agenda items, so the user can recognize
|
|
116
|
+
* which board a cross-cutting item came from at a glance.
|
|
117
|
+
*
|
|
118
|
+
* Cycles by board index when there are more boards than colors.
|
|
119
|
+
*/
|
|
120
|
+
const BOARD_PALETTE: string[] = [
|
|
121
|
+
"#e8a05c", // warm orange (board 0 — typically the work board)
|
|
122
|
+
"#7eb6d6", // cyan-blue (board 1)
|
|
123
|
+
"#a4c98a", // sage green (board 2)
|
|
124
|
+
"#b3a3d8", // soft violet (board 3)
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
export function boardColor(idx: number): string {
|
|
128
|
+
return BOARD_PALETTE[((idx % BOARD_PALETTE.length) + BOARD_PALETTE.length) % BOARD_PALETTE.length]!;
|
|
129
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fullscreen list of every local Claude Code session.
|
|
3
|
+
* `tuiboard --view=agents`. Shows ALL sessions (including archived),
|
|
4
|
+
* scrollable, cursor-navigable. The scrollbox follows the cursor via
|
|
5
|
+
* scrollChildIntoView (same trick used in BoardView for active columns).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { For, Show, createEffect, createMemo } from "solid-js";
|
|
9
|
+
|
|
10
|
+
import { AgentRow } from "~/ui/AgentRow";
|
|
11
|
+
import { T } from "~/ui/glyphs";
|
|
12
|
+
import type { TuiStore } from "~/store/index";
|
|
13
|
+
|
|
14
|
+
interface ScrollBoxLike {
|
|
15
|
+
scrollChildIntoView(id: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function rowId(sessionId: string): string {
|
|
19
|
+
// Sanitize sessionId for use as an OpenTUI box id. UUIDs are already safe
|
|
20
|
+
// (alphanumeric + dashes), but defensive belt-and-braces doesn't hurt.
|
|
21
|
+
return `tuiboard-agent-${sessionId.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AgentsOnly(props: { store: TuiStore }) {
|
|
25
|
+
const isActive = () => props.store.state.ui.activeZone === "agents";
|
|
26
|
+
const agentRow = () => props.store.state.ui.row;
|
|
27
|
+
const sessions = createMemo(() => props.store.agents.sessions());
|
|
28
|
+
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
29
|
+
|
|
30
|
+
// Auto-scroll the list so the active row is visible. setTimeout(0) waits
|
|
31
|
+
// for OpenTUI to finish layout before requesting scroll.
|
|
32
|
+
createEffect(() => {
|
|
33
|
+
const row = agentRow();
|
|
34
|
+
if (!isActive() || !scrollBoxRef) return;
|
|
35
|
+
const target = sessions()[row];
|
|
36
|
+
if (!target) return;
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
try {
|
|
39
|
+
scrollBoxRef?.scrollChildIntoView(rowId(target.sessionId));
|
|
40
|
+
} catch {
|
|
41
|
+
// Child not mounted yet — harmless.
|
|
42
|
+
}
|
|
43
|
+
}, 0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
48
|
+
<box
|
|
49
|
+
style={{
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
flexGrow: 1,
|
|
52
|
+
border: true,
|
|
53
|
+
borderStyle: "rounded",
|
|
54
|
+
borderColor: isActive() ? T.borderActive : T.border,
|
|
55
|
+
paddingLeft: 1,
|
|
56
|
+
paddingRight: 1,
|
|
57
|
+
}}
|
|
58
|
+
title={`┤ Agents · ${sessions().length} sessions ├`}
|
|
59
|
+
titleAlignment="left"
|
|
60
|
+
>
|
|
61
|
+
<Show
|
|
62
|
+
when={sessions().length > 0}
|
|
63
|
+
fallback={
|
|
64
|
+
<text>
|
|
65
|
+
<span style={{ fg: T.textDim }}>
|
|
66
|
+
No sessions found in ~/.claude/projects.
|
|
67
|
+
</span>
|
|
68
|
+
</text>
|
|
69
|
+
}
|
|
70
|
+
>
|
|
71
|
+
<scrollbox
|
|
72
|
+
ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
|
|
73
|
+
style={{
|
|
74
|
+
width: "100%",
|
|
75
|
+
flexGrow: 1,
|
|
76
|
+
scrollX: false,
|
|
77
|
+
scrollY: true,
|
|
78
|
+
rootOptions: {},
|
|
79
|
+
contentOptions: {},
|
|
80
|
+
scrollbarOptions: { visible: false },
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<For each={sessions()}>
|
|
84
|
+
{(session, i) => (
|
|
85
|
+
<box id={rowId(session.sessionId)}>
|
|
86
|
+
<AgentRow
|
|
87
|
+
session={session}
|
|
88
|
+
cursor={isActive() && i() === agentRow()}
|
|
89
|
+
nameMaxChars={120}
|
|
90
|
+
onClick={() => {
|
|
91
|
+
props.store.setActiveZone("agents");
|
|
92
|
+
props.store.setCursor(0, i());
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</box>
|
|
96
|
+
)}
|
|
97
|
+
</For>
|
|
98
|
+
</scrollbox>
|
|
99
|
+
</Show>
|
|
100
|
+
</box>
|
|
101
|
+
</box>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone fullscreen view for the kanban board (with virtual panel).
|
|
3
|
+
* Mounted when the user launches `tuiboard --view=board`, AND used inside
|
|
4
|
+
* the dashboard via composition.
|
|
5
|
+
*
|
|
6
|
+
* The cursor and modals are governed by the same store as the dashboard;
|
|
7
|
+
* only the layout differs (no Timeline / Agents zones).
|
|
8
|
+
*
|
|
9
|
+
* The modal side-panel is rendered at App level, so this view just lays
|
|
10
|
+
* out its zones in a single row and lets the parent slot the modal in.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Show, createMemo } from "solid-js";
|
|
14
|
+
|
|
15
|
+
import { BoardView } from "~/ui/BoardView";
|
|
16
|
+
import { VirtualPanel } from "~/ui/VirtualPanel";
|
|
17
|
+
import type { TuiStore } from "~/store/index";
|
|
18
|
+
|
|
19
|
+
export function BoardOnly(props: { store: TuiStore }) {
|
|
20
|
+
const ui = () => props.store.state.ui;
|
|
21
|
+
const activeBoard = createMemo(
|
|
22
|
+
() => props.store.state.boards[ui().activeBoardIndex]?.board,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
27
|
+
<Show when={!ui().zoomed || ui().activeZone === "virtual"}>
|
|
28
|
+
<VirtualPanel store={props.store} />
|
|
29
|
+
</Show>
|
|
30
|
+
<Show when={(!ui().zoomed || ui().activeZone !== "virtual") && activeBoard()}>
|
|
31
|
+
<BoardView store={props.store} board={activeBoard()!} />
|
|
32
|
+
</Show>
|
|
33
|
+
</box>
|
|
34
|
+
);
|
|
35
|
+
}
|