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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kanban board view — horizontal scrolling row of full-height columns.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the Today/Tomorrow virtual panel: each column has a fixed width
|
|
5
|
+
* and stretches vertically to fill the board area. Columns that don't fit
|
|
6
|
+
* the available horizontal space are reached via horizontal scroll. Zoom
|
|
7
|
+
* mode collapses everything down to the single active column at full
|
|
8
|
+
* board width.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
|
12
|
+
|
|
13
|
+
import { isHiddenColumn } from "~/config/loader";
|
|
14
|
+
import { isTask } from "~/parser/markdown";
|
|
15
|
+
import { computeColumnScrollLeft } from "~/ui/board-scroll";
|
|
16
|
+
import { T } from "~/ui/glyphs";
|
|
17
|
+
import { TaskRow } from "~/ui/TaskRow";
|
|
18
|
+
import type { TuiStore } from "~/store/index";
|
|
19
|
+
import type { Board, Column } from "~/types";
|
|
20
|
+
|
|
21
|
+
// Minimal structural typing for the scrollbox ref so we don't depend on
|
|
22
|
+
// importing OpenTUI's internal Renderable types just to call its methods.
|
|
23
|
+
interface ScrollBoxLike {
|
|
24
|
+
scrollChildIntoView(id: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Just enough of a box renderable to read its laid-out width. */
|
|
28
|
+
interface SizedBoxLike {
|
|
29
|
+
readonly width: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fixed column width when not zoomed. Single source of truth for layout. */
|
|
33
|
+
const COL_WIDTH = 42;
|
|
34
|
+
/**
|
|
35
|
+
* Width of a collapsed column — one with no OPEN tasks (an all-done lane like
|
|
36
|
+
* "Done", or an empty column). It shows just the `✓ N` counter; zoom (`z`)
|
|
37
|
+
* expands it back to full width so the done tasks can be scrolled on demand.
|
|
38
|
+
*/
|
|
39
|
+
const COL_WIDTH_COLLAPSED = 18;
|
|
40
|
+
/** Horizontal gap between adjacent columns. */
|
|
41
|
+
const COL_GAP = 1;
|
|
42
|
+
|
|
43
|
+
/** Open (non-done) task count for a column, honoring the active board filter. */
|
|
44
|
+
function openCountOf(store: TuiStore, column: Column): number {
|
|
45
|
+
return store.applyBoardFilter(
|
|
46
|
+
column.children.filter(isTask).filter((t) => !t.done),
|
|
47
|
+
).length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface BoardViewProps {
|
|
51
|
+
store: TuiStore;
|
|
52
|
+
board: Board;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Stable id for the column box at original board index `idx`. */
|
|
56
|
+
function columnId(boardPath: string, idx: number): string {
|
|
57
|
+
// boardPath is encoded so cross-board column ids don't collide if we
|
|
58
|
+
// ever render multiple boards side-by-side in the future.
|
|
59
|
+
return `tuiboard-col-${boardPath.replace(/[^a-zA-Z0-9]/g, "_")}-${idx}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function BoardView(props: BoardViewProps) {
|
|
63
|
+
const ui = () => props.store.state.ui;
|
|
64
|
+
// Width of the clipping viewport (the board zone), read from layout.
|
|
65
|
+
let viewportRef: SizedBoxLike | undefined;
|
|
66
|
+
// Horizontal scroll offset in cells, applied as a negative left margin on
|
|
67
|
+
// the inner column row. A signal so the shift re-renders reactively.
|
|
68
|
+
const [scrollX, setScrollX] = createSignal(0);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Columns shown in the view — the Done and Archive columns are filtered
|
|
72
|
+
* out entirely (completed-work logs; never displayed on the board). Their
|
|
73
|
+
* tasks still live in the model so tasks can be moved into them; we just
|
|
74
|
+
* don't render them.
|
|
75
|
+
*/
|
|
76
|
+
const visibleColumns = createMemo(() =>
|
|
77
|
+
props.board.columns.filter((c) => !isHiddenColumn(props.store.config, c.name)),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* In zoom mode we render only the column under the cursor, expanded
|
|
82
|
+
* to fill the board area. This is the "focus on one column" mode
|
|
83
|
+
* (Python kanban `z`).
|
|
84
|
+
*/
|
|
85
|
+
const renderedColumns = createMemo(() => {
|
|
86
|
+
if (!ui().zoomed || ui().activeZone === "virtual") return visibleColumns();
|
|
87
|
+
const cols = visibleColumns();
|
|
88
|
+
// ui.col is a board.columns index (carries Archive); map it to the
|
|
89
|
+
// rendered list so zoom focuses the column actually under the cursor.
|
|
90
|
+
const boardCols = props.board.columns;
|
|
91
|
+
const visibleIndex = cols.findIndex(
|
|
92
|
+
(c) => boardCols.indexOf(c) === ui().col,
|
|
93
|
+
);
|
|
94
|
+
const idx = visibleIndex >= 0 ? visibleIndex : Math.min(ui().col, cols.length - 1);
|
|
95
|
+
return idx >= 0 ? [cols[idx]!] : cols;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Auto-scroll horizontally so the active column is always fully visible.
|
|
99
|
+
//
|
|
100
|
+
// We do this MANUALLY rather than with OpenTUI's <scrollbox>: on the
|
|
101
|
+
// horizontal axis the scrollbox's content box never grows past the
|
|
102
|
+
// viewport width (scrollWidth stays == viewportWidth), so it reports
|
|
103
|
+
// "nothing to scroll" even when fixed-width columns overflow. Instead we
|
|
104
|
+
// clip with a plain overflow:hidden box and shift an inner row left by a
|
|
105
|
+
// negative margin. The shift amount comes from the same deterministic
|
|
106
|
+
// geometry used everywhere (COL_WIDTH + COL_GAP).
|
|
107
|
+
createEffect(() => {
|
|
108
|
+
const colIdx = ui().col;
|
|
109
|
+
if (ui().zoomed || ui().activeZone === "virtual") {
|
|
110
|
+
setScrollX(0);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const cols = props.board.columns;
|
|
114
|
+
const visibleIndex = visibleColumns().findIndex(
|
|
115
|
+
(c) => cols.indexOf(c) === colIdx,
|
|
116
|
+
);
|
|
117
|
+
if (visibleIndex < 0) return;
|
|
118
|
+
// Columns are uniform width today, so the active column's start offset is
|
|
119
|
+
// visibleIndex * stride. Passing an explicit start/width (rather than an
|
|
120
|
+
// index) keeps the geometry correct if columns ever become variable-width.
|
|
121
|
+
const colStart = visibleIndex * (COL_WIDTH + COL_GAP);
|
|
122
|
+
// setTimeout(0) lets OpenTUI commit layout so viewportRef.width is current.
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
const vw = viewportRef?.width ?? 0;
|
|
125
|
+
if (vw <= 0) return;
|
|
126
|
+
setScrollX((prev) =>
|
|
127
|
+
computeColumnScrollLeft({
|
|
128
|
+
colStart,
|
|
129
|
+
colWidth: COL_WIDTH,
|
|
130
|
+
viewportWidth: vw,
|
|
131
|
+
currentScroll: prev,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}, 0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
139
|
+
{/* Clipping viewport: fills the board zone, hides overflow. */}
|
|
140
|
+
<box
|
|
141
|
+
ref={(r: SizedBoxLike) => (viewportRef = r)}
|
|
142
|
+
style={{
|
|
143
|
+
width: "100%",
|
|
144
|
+
flexGrow: 1,
|
|
145
|
+
flexDirection: "row",
|
|
146
|
+
overflow: "hidden",
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
{/* Inner row: shifted left by the scroll offset to reveal columns. */}
|
|
150
|
+
<box
|
|
151
|
+
style={{
|
|
152
|
+
flexDirection: "row",
|
|
153
|
+
flexGrow: ui().zoomed ? 1 : 0,
|
|
154
|
+
flexShrink: 0,
|
|
155
|
+
height: "100%",
|
|
156
|
+
alignItems: "stretch",
|
|
157
|
+
marginLeft: ui().zoomed ? 0 : -scrollX(),
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<For each={renderedColumns()}>
|
|
161
|
+
{(col) => {
|
|
162
|
+
const originalIndex = props.board.columns.indexOf(col);
|
|
163
|
+
const isActive = () =>
|
|
164
|
+
ui().activeZone === "board" && ui().col === originalIndex;
|
|
165
|
+
return (
|
|
166
|
+
<ColumnView
|
|
167
|
+
store={props.store}
|
|
168
|
+
board={props.board}
|
|
169
|
+
column={col}
|
|
170
|
+
columnIndex={originalIndex}
|
|
171
|
+
active={isActive()}
|
|
172
|
+
zoomed={ui().zoomed && isActive()}
|
|
173
|
+
boxId={columnId(props.board.filepath, originalIndex)}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
}}
|
|
177
|
+
</For>
|
|
178
|
+
</box>
|
|
179
|
+
</box>
|
|
180
|
+
</box>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface ColumnViewProps {
|
|
185
|
+
store: TuiStore;
|
|
186
|
+
board: Board;
|
|
187
|
+
column: Column;
|
|
188
|
+
columnIndex: number;
|
|
189
|
+
active: boolean;
|
|
190
|
+
/**
|
|
191
|
+
* When true, the column was zoomed to full width — done tasks are
|
|
192
|
+
* shown inline because the user has explicitly focused this column.
|
|
193
|
+
*/
|
|
194
|
+
zoomed: boolean;
|
|
195
|
+
/** Stable DOM-equivalent id used by `scrollChildIntoView`. */
|
|
196
|
+
boxId: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Stable id for a task row inside a column, used by scrollChildIntoView. */
|
|
200
|
+
function taskRowId(boardPath: string, colIdx: number, rowIdx: number): string {
|
|
201
|
+
return `tuiboard-task-${boardPath.replace(/[^a-zA-Z0-9]/g, "_")}-${colIdx}-${rowIdx}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function ColumnView(props: ColumnViewProps) {
|
|
205
|
+
const allTasks = createMemo(() => props.column.children.filter(isTask));
|
|
206
|
+
const openTasks = createMemo(() =>
|
|
207
|
+
props.store.applyBoardFilter(allTasks().filter((t) => !t.done)),
|
|
208
|
+
);
|
|
209
|
+
const doneTasks = createMemo(() => allTasks().filter((t) => t.done));
|
|
210
|
+
|
|
211
|
+
// In zoom mode, show open tasks first, then a divider, then done tasks.
|
|
212
|
+
// In normal mode, show only open tasks; done collapse to a counter.
|
|
213
|
+
const visibleTasks = createMemo(() => {
|
|
214
|
+
if (props.zoomed) return [...openTasks(), ...doneTasks()];
|
|
215
|
+
return openTasks();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const cursorRow = createMemo(() => props.store.state.ui.row);
|
|
219
|
+
|
|
220
|
+
// Auto-scroll the column's inner scrollbox so the cursor row is always in
|
|
221
|
+
// view. Without this, OpenTUI's scrollbox didn't know to follow the
|
|
222
|
+
// cursor — user reported pressing arrow down and seeing the cursor stuck
|
|
223
|
+
// until the visible window happened to catch up. setTimeout(0) waits for
|
|
224
|
+
// OpenTUI to commit layout before the scroll, same pattern as BoardView's
|
|
225
|
+
// column auto-scroll and TimelineView's now-line auto-scroll.
|
|
226
|
+
let innerScrollBoxRef: ScrollBoxLike | undefined;
|
|
227
|
+
createEffect(() => {
|
|
228
|
+
if (!props.active) return;
|
|
229
|
+
const row = cursorRow();
|
|
230
|
+
if (!innerScrollBoxRef) return;
|
|
231
|
+
setTimeout(() => {
|
|
232
|
+
try {
|
|
233
|
+
innerScrollBoxRef?.scrollChildIntoView(
|
|
234
|
+
taskRowId(props.board.filepath, props.columnIndex, row),
|
|
235
|
+
);
|
|
236
|
+
} catch {
|
|
237
|
+
// Child not mounted yet — harmless.
|
|
238
|
+
}
|
|
239
|
+
}, 0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const titleText = () => {
|
|
243
|
+
const zoomMark = props.zoomed ? "⤢ " : "";
|
|
244
|
+
let s = `┤ ${zoomMark}${props.column.name} ${openTasks().length}`;
|
|
245
|
+
if (doneTasks().length > 0) s += ` ✓${doneTasks().length}`;
|
|
246
|
+
return s + " ├";
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<box
|
|
251
|
+
id={props.boxId}
|
|
252
|
+
style={{
|
|
253
|
+
flexDirection: "column",
|
|
254
|
+
// Fixed width when not zoomed; the zoomed column grows to fill
|
|
255
|
+
// whatever horizontal space the board zone has been given.
|
|
256
|
+
width: props.zoomed ? undefined : COL_WIDTH,
|
|
257
|
+
minWidth: props.zoomed ? undefined : COL_WIDTH,
|
|
258
|
+
flexGrow: props.zoomed ? 1 : 0,
|
|
259
|
+
flexShrink: 0,
|
|
260
|
+
// No explicit height — Yoga stretches us along the row's cross
|
|
261
|
+
// axis, so the column always fills the full board height. Same
|
|
262
|
+
// contract as the Today/Tomorrow virtual panel next door.
|
|
263
|
+
marginRight: COL_GAP,
|
|
264
|
+
border: true,
|
|
265
|
+
borderStyle: "rounded",
|
|
266
|
+
borderColor: props.active ? T.borderActive : T.border,
|
|
267
|
+
paddingLeft: 1,
|
|
268
|
+
paddingRight: 1,
|
|
269
|
+
}}
|
|
270
|
+
title={titleText()}
|
|
271
|
+
titleAlignment="left"
|
|
272
|
+
>
|
|
273
|
+
<scrollbox
|
|
274
|
+
ref={(r: ScrollBoxLike) => (innerScrollBoxRef = r)}
|
|
275
|
+
style={{
|
|
276
|
+
width: "100%",
|
|
277
|
+
flexGrow: 1,
|
|
278
|
+
rootOptions: {},
|
|
279
|
+
contentOptions: {},
|
|
280
|
+
scrollbarOptions: { visible: false },
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<For each={visibleTasks()}>
|
|
284
|
+
{(task, ri) => {
|
|
285
|
+
const ref = {
|
|
286
|
+
boardPath: props.board.filepath,
|
|
287
|
+
columnIndex: props.columnIndex,
|
|
288
|
+
taskIndex: allTasks().indexOf(task),
|
|
289
|
+
};
|
|
290
|
+
return (
|
|
291
|
+
<box id={taskRowId(props.board.filepath, props.columnIndex, ri())}>
|
|
292
|
+
<TaskRow
|
|
293
|
+
task={task}
|
|
294
|
+
cursor={props.active && ri() === cursorRow()}
|
|
295
|
+
marked={props.store.isMarked(ref)}
|
|
296
|
+
grabbed={
|
|
297
|
+
props.active &&
|
|
298
|
+
ri() === cursorRow() &&
|
|
299
|
+
props.store.state.ui.grabbing
|
|
300
|
+
}
|
|
301
|
+
// Column inner cell width for a TaskRow: COL_WIDTH 42 −
|
|
302
|
+
// border 2 − col padding 2 − TaskRow padding 2 = 36 cols
|
|
303
|
+
// (when not zoomed). Zoomed → column grows to fill, so
|
|
304
|
+
// ~terminal width − some chrome.
|
|
305
|
+
availableWidth={props.zoomed ? 100 : 36}
|
|
306
|
+
onClick={() => {
|
|
307
|
+
props.store.setActiveZone("board");
|
|
308
|
+
props.store.setCursor(props.columnIndex, ri());
|
|
309
|
+
}}
|
|
310
|
+
/>
|
|
311
|
+
</box>
|
|
312
|
+
);
|
|
313
|
+
}}
|
|
314
|
+
</For>
|
|
315
|
+
|
|
316
|
+
<Show when={!props.zoomed && doneTasks().length > 0}>
|
|
317
|
+
<box
|
|
318
|
+
style={{
|
|
319
|
+
flexDirection: "row",
|
|
320
|
+
marginTop: 1,
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
<text wrapMode="none" truncate>
|
|
324
|
+
<span style={{ fg: T.textDim }}>
|
|
325
|
+
{"✓ "}{doneTasks().length}{" done (z to focus)"}
|
|
326
|
+
</span>
|
|
327
|
+
</text>
|
|
328
|
+
</box>
|
|
329
|
+
</Show>
|
|
330
|
+
</scrollbox>
|
|
331
|
+
</box>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared chrome — top tab bar (boards + brand + stats) and bottom keybar
|
|
3
|
+
* (banner + shortcut hint line). Used by every root view so the user
|
|
4
|
+
* always sees the same orientation regardless of --view=X mode.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { For, Show } from "solid-js";
|
|
8
|
+
|
|
9
|
+
import { isHiddenColumn } from "~/config/loader";
|
|
10
|
+
import { isoToday } from "~/store/index";
|
|
11
|
+
import { ATTR, T } from "~/ui/glyphs";
|
|
12
|
+
import type { TuiStore } from "~/store/index";
|
|
13
|
+
|
|
14
|
+
export function TopBar(props: { store: TuiStore }) {
|
|
15
|
+
const boards = () => props.store.state.boards;
|
|
16
|
+
const active = () => props.store.state.ui.activeBoardIndex;
|
|
17
|
+
const activeStats = () => {
|
|
18
|
+
const b = boards()[active()]?.board;
|
|
19
|
+
if (!b) return undefined;
|
|
20
|
+
let open = 0, done = 0, cols = 0;
|
|
21
|
+
for (const c of b.columns) {
|
|
22
|
+
if (!isHiddenColumn(props.store.config, c.name)) cols++;
|
|
23
|
+
for (const child of c.children) {
|
|
24
|
+
if (!("kind" in child)) {
|
|
25
|
+
if (child.done) done++;
|
|
26
|
+
else open++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { open, done, cols };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Build a flat token list for the tab row so we can render as a single
|
|
34
|
+
// <text> without JSX fragments (which OpenTUI's Solid renderer doesn't
|
|
35
|
+
// play well with inside <text>).
|
|
36
|
+
const tabsText = () => {
|
|
37
|
+
const parts: Array<{ text: string; active: boolean; brand?: boolean }> = [];
|
|
38
|
+
parts.push({ text: "tuiboard", active: false, brand: true });
|
|
39
|
+
parts.push({ text: ` ${isoToday()} `, active: false });
|
|
40
|
+
boards().forEach((b: { board: { name: string } }, i: number) => {
|
|
41
|
+
const isActive = i === active();
|
|
42
|
+
parts.push({
|
|
43
|
+
text: isActive ? `[${i + 1} ${b.board.name}]` : ` ${i + 1} ${b.board.name} `,
|
|
44
|
+
active: isActive,
|
|
45
|
+
});
|
|
46
|
+
parts.push({ text: " ", active: false });
|
|
47
|
+
});
|
|
48
|
+
return parts;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
|
|
53
|
+
<text wrapMode="none" truncate style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
54
|
+
<For each={tabsText()}>
|
|
55
|
+
{(p) => (
|
|
56
|
+
<span
|
|
57
|
+
style={{
|
|
58
|
+
fg: p.brand
|
|
59
|
+
? T.accent
|
|
60
|
+
: p.active
|
|
61
|
+
? T.accent
|
|
62
|
+
: T.textDim,
|
|
63
|
+
attributes: p.brand || p.active ? ATTR.bold : 0,
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{p.text}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
</For>
|
|
70
|
+
</text>
|
|
71
|
+
<Show when={activeStats()}>
|
|
72
|
+
<text wrapMode="none" style={{ flexShrink: 0, marginLeft: 2 }}>
|
|
73
|
+
<span style={{ fg: T.textDim }}>
|
|
74
|
+
{activeStats()!.open} open · {activeStats()!.done} done · {activeStats()!.cols} cols
|
|
75
|
+
</span>
|
|
76
|
+
</text>
|
|
77
|
+
</Show>
|
|
78
|
+
</box>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function BottomBar(props: { store: TuiStore }) {
|
|
83
|
+
const banner = () => props.store.state.ui.banner;
|
|
84
|
+
return (
|
|
85
|
+
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
86
|
+
<box style={{ height: 1, flexDirection: "row" }}>
|
|
87
|
+
<Show
|
|
88
|
+
when={banner()}
|
|
89
|
+
fallback={
|
|
90
|
+
<text>
|
|
91
|
+
<span style={{ fg: T.textDim }}>{" "}</span>
|
|
92
|
+
</text>
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
{(b: () => NonNullable<ReturnType<typeof banner>>) => (
|
|
96
|
+
<text>
|
|
97
|
+
<span
|
|
98
|
+
style={{
|
|
99
|
+
fg:
|
|
100
|
+
b().kind === "error"
|
|
101
|
+
? T.bannerError
|
|
102
|
+
: b().kind === "warn"
|
|
103
|
+
? T.bannerWarn
|
|
104
|
+
: T.bannerInfo,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{"⚑ "}{b().text}
|
|
108
|
+
</span>
|
|
109
|
+
</text>
|
|
110
|
+
)}
|
|
111
|
+
</Show>
|
|
112
|
+
</box>
|
|
113
|
+
<box style={{ height: 1, flexDirection: "row" }}>
|
|
114
|
+
<text>
|
|
115
|
+
<span style={{ fg: T.textDim }}>
|
|
116
|
+
{"hjkl move · Tab/1-9 board · S-Tab zone · F1/F2/F3 toggle · v panel · z zoom · Space mark · ⏎ done · o detail · n/e/s/b/a/X act · d del · ⌃Z undo · ? help · q quit"}
|
|
117
|
+
</span>
|
|
118
|
+
</text>
|
|
119
|
+
</box>
|
|
120
|
+
</box>
|
|
121
|
+
);
|
|
122
|
+
}
|