tuiboard 0.5.2 → 0.5.3
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/package.json +1 -1
- package/src/app.tsx +7 -0
- package/src/input/handleKey.ts +6 -4
- package/src/store/index.ts +77 -20
- package/src/ui/AgentRow.tsx +6 -3
- package/src/ui/AgentsBar.tsx +4 -1
- package/src/ui/BoardView.tsx +130 -47
- package/src/ui/Chrome.tsx +32 -36
- package/src/ui/Modal.tsx +43 -12
- package/src/ui/TaskRow.tsx +21 -24
- package/src/ui/TimelineView.tsx +5 -4
- package/src/ui/VirtualPanel.tsx +41 -21
- package/src/ui/glyphs.ts +4 -1
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -76,6 +76,13 @@ function applyResponsiveLayout(): void {
|
|
|
76
76
|
applyResponsiveLayout();
|
|
77
77
|
process.stdout.on("resize", applyResponsiveLayout);
|
|
78
78
|
|
|
79
|
+
// Land on the Today/Tomorrow panel by default — for a daily-planning tool the
|
|
80
|
+
// first question is "what's on my plate today", and that panel answers it. On
|
|
81
|
+
// a narrow terminal where the panel auto-hides, fall back to the board.
|
|
82
|
+
if (store.state.ui.visibleZones.virtual) {
|
|
83
|
+
store.setActiveZone("virtual");
|
|
84
|
+
}
|
|
85
|
+
|
|
79
86
|
const { view } = parseArgs(process.argv.slice(2));
|
|
80
87
|
|
|
81
88
|
// ─── App shell ──────────────────────────────────────────────────────────────
|
package/src/input/handleKey.ts
CHANGED
|
@@ -49,10 +49,11 @@ export function handleKey(
|
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
51
|
if (ui.modal.kind === "confirm-delete") {
|
|
52
|
-
if (key.name === "y") {
|
|
53
|
-
|
|
54
|
-
store.
|
|
52
|
+
if (key.name === "y" || key.name === "enter" || key.name === "return") {
|
|
53
|
+
// Delete the whole multi-selection if any, else just the cursor task.
|
|
54
|
+
const n = store.applyToMarkedOr(ui.modal.ref, (r) => store.deleteTask(r));
|
|
55
55
|
store.closeModal();
|
|
56
|
+
if (n > 1) store.flashBanner("info", `Deleted ${n} tasks`);
|
|
56
57
|
} else if (key.name === "n") {
|
|
57
58
|
store.closeModal();
|
|
58
59
|
}
|
|
@@ -532,7 +533,8 @@ function dispatchTaskAction(
|
|
|
532
533
|
return true;
|
|
533
534
|
}
|
|
534
535
|
|
|
535
|
-
// Multi-select toggle
|
|
536
|
+
// Multi-select toggle. The cursor stays put so you can mark in any order
|
|
537
|
+
// (contiguous or sparse) and unmark freely with j/k + Space.
|
|
536
538
|
if (key.name === "space") {
|
|
537
539
|
store.toggleMark(ref);
|
|
538
540
|
return true;
|
package/src/store/index.ts
CHANGED
|
@@ -141,6 +141,16 @@ export interface StoreState {
|
|
|
141
141
|
boards: LoadedBoard[];
|
|
142
142
|
ui: UIState;
|
|
143
143
|
undo: UndoEntry[];
|
|
144
|
+
/**
|
|
145
|
+
* Monotonic mutation counter. Bumped on every board mutation (via
|
|
146
|
+
* `saveBoard`). Derived views (board columns, virtual panel, timeline) read
|
|
147
|
+
* it so their memos recompute on any change — a reliable top-level signal
|
|
148
|
+
* dependency, since OpenTUI/Solid's fine-grained tracking of deeply-nested
|
|
149
|
+
* store edits (e.g. growing a column's `children` array) doesn't always
|
|
150
|
+
* propagate to an already-mounted `<For>`. This is the same mechanism that
|
|
151
|
+
* makes a board switch refresh everything.
|
|
152
|
+
*/
|
|
153
|
+
rev: number;
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
// ─── Construction ───────────────────────────────────────────────────────────
|
|
@@ -168,9 +178,13 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
168
178
|
filter: "all",
|
|
169
179
|
},
|
|
170
180
|
undo: [],
|
|
181
|
+
rev: 0,
|
|
171
182
|
});
|
|
172
183
|
|
|
173
184
|
// ─── Watcher ─────────────────────────────────────────────────────────────
|
|
185
|
+
// Last content tuiboard itself wrote per board path — used by the watcher's
|
|
186
|
+
// self-write guard to ignore our own writes echoed back by the OS / sync.
|
|
187
|
+
const lastWrittenContent = new Map<string, string>();
|
|
174
188
|
const watcher: BoardWatcher = createBoardWatcher(
|
|
175
189
|
initialBoards.map((b) => b.board.filepath),
|
|
176
190
|
);
|
|
@@ -182,6 +196,13 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
182
196
|
// External edit. Re-read this board from disk.
|
|
183
197
|
try {
|
|
184
198
|
const content = readFileSync(filepath, "utf-8");
|
|
199
|
+
// Robust self-write guard: if what's on disk is byte-identical to what we
|
|
200
|
+
// last wrote, this event is our own write echoed back — even if the mtime
|
|
201
|
+
// changed (Windows fs latency, antivirus, or Obsidian/vault-sync
|
|
202
|
+
// re-saving the same bytes). The in-memory board is authoritative, so
|
|
203
|
+
// reloading would only clobber a just-added/edited task with no net
|
|
204
|
+
// change — and spuriously flash "Reloaded after external edit". Skip.
|
|
205
|
+
if (lastWrittenContent.get(filepath) === content) return;
|
|
185
206
|
const { board } = parseBoard(content, { filepath });
|
|
186
207
|
const mtimeMs = statMtime(filepath);
|
|
187
208
|
setState(
|
|
@@ -192,6 +213,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
192
213
|
lb.mtimeMs = mtimeMs;
|
|
193
214
|
}),
|
|
194
215
|
);
|
|
216
|
+
setState("rev", (r) => r + 1);
|
|
195
217
|
flashBanner("info", `Reloaded ${board.name} after external edit`);
|
|
196
218
|
} catch (e) {
|
|
197
219
|
flashBanner("error", `Reload failed: ${(e as Error).message}`);
|
|
@@ -256,8 +278,16 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
256
278
|
function saveBoard(boardPath: string): void {
|
|
257
279
|
const lb = getBoardByPath(boardPath);
|
|
258
280
|
if (!lb) return;
|
|
281
|
+
// Signal "data changed" to every derived view (see StoreState.rev). This
|
|
282
|
+
// runs for every mutation since they all persist through saveBoard.
|
|
283
|
+
setState("rev", (r) => r + 1);
|
|
259
284
|
try {
|
|
260
285
|
const content = serializeBoard(lb.board);
|
|
286
|
+
// Record what we're writing so the watcher can recognize this exact
|
|
287
|
+
// content echoed back (Obsidian / vault-sync may re-save the file with
|
|
288
|
+
// identical bytes but a fresh mtime — a content match means it's still
|
|
289
|
+
// our write, not a genuine external edit).
|
|
290
|
+
lastWrittenContent.set(boardPath, content);
|
|
261
291
|
watcher.markSelfWrite(boardPath);
|
|
262
292
|
const { mtimeMs } = writeBoardFile(boardPath, content, {
|
|
263
293
|
expectedMtimeMs: lb.mtimeMs,
|
|
@@ -504,23 +534,24 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
504
534
|
timeBlockSource: init.timeBlock ? "watch-emoji" : undefined,
|
|
505
535
|
};
|
|
506
536
|
|
|
507
|
-
|
|
537
|
+
// Insert by REASSIGNING the children array (not an in-place unshift/push
|
|
538
|
+
// inside produce). A new array reference is what Solid's <For> reliably
|
|
539
|
+
// reconciles; the in-place mutation left the mounted column stale until the
|
|
540
|
+
// board was switched away and back. (Delete uses splice and happened to
|
|
541
|
+
// re-render, but reassignment is the dependable pattern for both grow and
|
|
542
|
+
// shrink.)
|
|
543
|
+
const prevTaskCount = listTasks(col).length;
|
|
508
544
|
setState(
|
|
509
545
|
"boards",
|
|
510
546
|
(b) => b.board.filepath === boardPath,
|
|
511
547
|
"board",
|
|
512
548
|
"columns",
|
|
513
549
|
columnIndex,
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
taskIndex = 0;
|
|
518
|
-
} else {
|
|
519
|
-
c.children.push(newTask);
|
|
520
|
-
taskIndex = listTasks(c).length - 1;
|
|
521
|
-
}
|
|
522
|
-
}),
|
|
550
|
+
"children",
|
|
551
|
+
(prev: Column["children"]) =>
|
|
552
|
+
insertPos === "top" ? [newTask, ...prev] : [...prev, newTask],
|
|
523
553
|
);
|
|
554
|
+
const taskIndex = insertPos === "top" ? 0 : prevTaskCount;
|
|
524
555
|
|
|
525
556
|
pushUndo({
|
|
526
557
|
description: `add task: ${init.displayTitle.slice(0, 40)}`,
|
|
@@ -763,26 +794,52 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
763
794
|
if (m[key]) delete m[key];
|
|
764
795
|
else m[key] = true;
|
|
765
796
|
}));
|
|
797
|
+
// Bump rev so the ● indicators re-render. OpenTUI/Solid's fine-grained
|
|
798
|
+
// tracking of a dynamic-key Record doesn't reliably reach the mounted
|
|
799
|
+
// task rows; rev is the proven top-level signal (same as the cursor).
|
|
800
|
+
setState("rev", (r) => r + 1);
|
|
766
801
|
}
|
|
767
802
|
|
|
768
803
|
function isMarked(ref: TaskRef): boolean {
|
|
804
|
+
state.rev; // subscribe to the mutation counter (see toggleMark/clearMarks)
|
|
769
805
|
return state.ui.marked[markKey(ref)] === true;
|
|
770
806
|
}
|
|
771
807
|
|
|
772
808
|
function clearMarks(): void {
|
|
773
|
-
setState("ui",
|
|
809
|
+
// Delete keys via produce (NOT `setState("ui","marked",{})`): replacing the
|
|
810
|
+
// whole object doesn't notify subscribers that read it via Object.keys(),
|
|
811
|
+
// so the ● indicators wouldn't repaint. A produce-mutation does notify
|
|
812
|
+
// granularly — same path toggleMark uses.
|
|
813
|
+
setState("ui", "marked", produce((m: Record<string, true>) => {
|
|
814
|
+
for (const k of Object.keys(m)) delete m[k];
|
|
815
|
+
}));
|
|
816
|
+
setState("rev", (r) => r + 1);
|
|
774
817
|
}
|
|
775
818
|
|
|
776
|
-
/**
|
|
819
|
+
/**
|
|
820
|
+
* Decoded list of currently marked refs, sorted by board, then column, then
|
|
821
|
+
* taskIndex DESCENDING. The descending order matters for index-shifting
|
|
822
|
+
* operations (delete, archive/move): processing the highest taskIndex first
|
|
823
|
+
* means earlier indices stay valid as later tasks are removed. Order is
|
|
824
|
+
* irrelevant for in-place edits (schedule, time block, priority, done).
|
|
825
|
+
*/
|
|
777
826
|
function getMarkedRefs(): TaskRef[] {
|
|
778
|
-
return Object.keys(state.ui.marked)
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
827
|
+
return Object.keys(state.ui.marked)
|
|
828
|
+
.map((k) => {
|
|
829
|
+
const [boardPath, ci, ti] = k.split("::");
|
|
830
|
+
return {
|
|
831
|
+
boardPath: boardPath!,
|
|
832
|
+
columnIndex: Number(ci),
|
|
833
|
+
taskIndex: Number(ti),
|
|
834
|
+
};
|
|
835
|
+
})
|
|
836
|
+
.sort((a, b) =>
|
|
837
|
+
a.boardPath !== b.boardPath
|
|
838
|
+
? a.boardPath.localeCompare(b.boardPath)
|
|
839
|
+
: a.columnIndex !== b.columnIndex
|
|
840
|
+
? a.columnIndex - b.columnIndex
|
|
841
|
+
: b.taskIndex - a.taskIndex,
|
|
842
|
+
);
|
|
786
843
|
}
|
|
787
844
|
|
|
788
845
|
/**
|
package/src/ui/AgentRow.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Single-line render of an AgentSession. Used in both AgentsBar (compact
|
|
3
3
|
* dashboard strip) and AgentsOnly (fullscreen list).
|
|
4
4
|
*
|
|
5
|
-
* Layout: cursor · status-dot · name · git branch
|
|
5
|
+
* Layout: cursor · status-dot · name · git branch ……right-pinned: cwd_short · age
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Show, createMemo } from "solid-js";
|
|
@@ -69,10 +69,13 @@ export function AgentRow(props: AgentRowProps) {
|
|
|
69
69
|
<Show when={props.session.gitBranch}>
|
|
70
70
|
<span style={{ fg: T.textDim }}>{" "}{props.session.gitBranch}</span>
|
|
71
71
|
</Show>
|
|
72
|
-
<span style={{ fg: T.textDim }}>{" "}{props.session.cwdShort}</span>
|
|
73
72
|
</text>
|
|
73
|
+
{/* cwd + age pinned together on the right, so the session titles align
|
|
74
|
+
cleanly on the left instead of being pushed around by the path. */}
|
|
74
75
|
<text style={{ flexShrink: 0 }} wrapMode="none">
|
|
75
|
-
<span style={{ fg: T.textDim }}>
|
|
76
|
+
<span style={{ fg: T.textDim }}>
|
|
77
|
+
{props.session.cwdShort}{" "}{ageStr()}
|
|
78
|
+
</span>
|
|
76
79
|
</text>
|
|
77
80
|
</box>
|
|
78
81
|
);
|
package/src/ui/AgentsBar.tsx
CHANGED
|
@@ -58,7 +58,10 @@ export function AgentsBar(props: AgentsBarProps) {
|
|
|
58
58
|
flexDirection: "column",
|
|
59
59
|
height: props.height,
|
|
60
60
|
flexGrow: props.height ? 0 : 1,
|
|
61
|
-
|
|
61
|
+
// No top gap — the agents strip sits flush under the board columns so
|
|
62
|
+
// the columns reclaim that row (the 1-row gap read as ~double the
|
|
63
|
+
// timeline's 1-col gap because terminal cells are taller than wide).
|
|
64
|
+
marginTop: 0,
|
|
62
65
|
border: true,
|
|
63
66
|
borderStyle: "rounded",
|
|
64
67
|
borderColor: isActive() ? T.borderActive : T.border,
|
package/src/ui/BoardView.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* board width.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
|
11
|
+
import { For, Show, createEffect, createMemo, createSignal, onMount } from "solid-js";
|
|
12
12
|
|
|
13
13
|
import { isHiddenColumn } from "~/config/loader";
|
|
14
14
|
import { isTask } from "~/parser/markdown";
|
|
@@ -66,6 +66,11 @@ export function BoardView(props: BoardViewProps) {
|
|
|
66
66
|
// Horizontal scroll offset in cells, applied as a negative left margin on
|
|
67
67
|
// the inner column row. A signal so the shift re-renders reactively.
|
|
68
68
|
const [scrollX, setScrollX] = createSignal(0);
|
|
69
|
+
// Laid-out viewport width, captured alongside the scroll so we can tell
|
|
70
|
+
// which columns are fully visible (and blank the task rows of the ones that
|
|
71
|
+
// are only partly on-screen — keeps a clipped column's title as a "there's
|
|
72
|
+
// more" hint without the chopped-word task text).
|
|
73
|
+
const [viewportW, setViewportW] = createSignal(0);
|
|
69
74
|
|
|
70
75
|
/**
|
|
71
76
|
* Columns shown in the view — the Done and Archive columns are filtered
|
|
@@ -115,14 +120,14 @@ export function BoardView(props: BoardViewProps) {
|
|
|
115
120
|
(c) => cols.indexOf(c) === colIdx,
|
|
116
121
|
);
|
|
117
122
|
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
123
|
const colStart = visibleIndex * (COL_WIDTH + COL_GAP);
|
|
122
124
|
// setTimeout(0) lets OpenTUI commit layout so viewportRef.width is current.
|
|
125
|
+
// Minimal scroll (right-align when off-screen) — a partly-cut neighbouring
|
|
126
|
+
// column is left as a "there's more to scroll" hint.
|
|
123
127
|
setTimeout(() => {
|
|
124
128
|
const vw = viewportRef?.width ?? 0;
|
|
125
129
|
if (vw <= 0) return;
|
|
130
|
+
setViewportW(vw);
|
|
126
131
|
setScrollX((prev) =>
|
|
127
132
|
computeColumnScrollLeft({
|
|
128
133
|
colStart,
|
|
@@ -134,6 +139,30 @@ export function BoardView(props: BoardViewProps) {
|
|
|
134
139
|
}, 0);
|
|
135
140
|
});
|
|
136
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Is the column at rendered index `i` fully inside the viewport? Used to
|
|
144
|
+
* blank the task rows of a column that's only partly on-screen. Defaults to
|
|
145
|
+
* true while the viewport width is still unknown (first paint) and in zoom.
|
|
146
|
+
*/
|
|
147
|
+
const columnFullyVisible = (i: number): boolean => {
|
|
148
|
+
const vw = viewportW();
|
|
149
|
+
if (vw <= 0 || ui().zoomed) return true;
|
|
150
|
+
const stride = COL_WIDTH + COL_GAP;
|
|
151
|
+
const start = i * stride;
|
|
152
|
+
return start >= scrollX() && start + COL_WIDTH <= scrollX() + vw;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Measure the viewport width once at mount, regardless of the active zone.
|
|
156
|
+
// Otherwise — since tuiboard now starts focused on the virtual panel — the
|
|
157
|
+
// scroll effect early-returns and viewportW stays 0, so partly-clipped
|
|
158
|
+
// columns briefly show their task rows until the board is first touched.
|
|
159
|
+
onMount(() => {
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
const vw = viewportRef?.width ?? 0;
|
|
162
|
+
if (vw > 0) setViewportW(vw);
|
|
163
|
+
}, 0);
|
|
164
|
+
});
|
|
165
|
+
|
|
137
166
|
return (
|
|
138
167
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
139
168
|
{/* Clipping viewport: fills the board zone, hides overflow. */}
|
|
@@ -158,7 +187,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
158
187
|
}}
|
|
159
188
|
>
|
|
160
189
|
<For each={renderedColumns()}>
|
|
161
|
-
{(col) => {
|
|
190
|
+
{(col, i) => {
|
|
162
191
|
const originalIndex = props.board.columns.indexOf(col);
|
|
163
192
|
const isActive = () =>
|
|
164
193
|
ui().activeZone === "board" && ui().col === originalIndex;
|
|
@@ -170,6 +199,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
170
199
|
columnIndex={originalIndex}
|
|
171
200
|
active={isActive()}
|
|
172
201
|
zoomed={ui().zoomed && isActive()}
|
|
202
|
+
fullyVisible={columnFullyVisible(i())}
|
|
173
203
|
boxId={columnId(props.board.filepath, originalIndex)}
|
|
174
204
|
/>
|
|
175
205
|
);
|
|
@@ -192,6 +222,12 @@ interface ColumnViewProps {
|
|
|
192
222
|
* shown inline because the user has explicitly focused this column.
|
|
193
223
|
*/
|
|
194
224
|
zoomed: boolean;
|
|
225
|
+
/**
|
|
226
|
+
* False when the column is only partly on-screen (clipped by horizontal
|
|
227
|
+
* scroll). Its title still renders (clipped) as a "more columns" hint, but
|
|
228
|
+
* the task rows are blanked so no half-cut task text shows.
|
|
229
|
+
*/
|
|
230
|
+
fullyVisible?: boolean;
|
|
195
231
|
/** Stable DOM-equivalent id used by `scrollChildIntoView`. */
|
|
196
232
|
boxId: string;
|
|
197
233
|
}
|
|
@@ -202,7 +238,13 @@ function taskRowId(boardPath: string, colIdx: number, rowIdx: number): string {
|
|
|
202
238
|
}
|
|
203
239
|
|
|
204
240
|
function ColumnView(props: ColumnViewProps) {
|
|
205
|
-
const allTasks = createMemo(() =>
|
|
241
|
+
const allTasks = createMemo(() => {
|
|
242
|
+
// Subscribe to the store's mutation counter so this list recomputes on
|
|
243
|
+
// any board change — fine-grained tracking of a nested children-array
|
|
244
|
+
// edit doesn't reliably re-render an already-mounted <For> here.
|
|
245
|
+
props.store.state.rev;
|
|
246
|
+
return props.column.children.filter(isTask);
|
|
247
|
+
});
|
|
206
248
|
const openTasks = createMemo(() =>
|
|
207
249
|
props.store.applyBoardFilter(allTasks().filter((t) => !t.done)),
|
|
208
250
|
);
|
|
@@ -210,9 +252,36 @@ function ColumnView(props: ColumnViewProps) {
|
|
|
210
252
|
|
|
211
253
|
// In zoom mode, show open tasks first, then a divider, then done tasks.
|
|
212
254
|
// In normal mode, show only open tasks; done collapse to a counter.
|
|
213
|
-
const visibleTasks = createMemo(() =>
|
|
214
|
-
|
|
215
|
-
|
|
255
|
+
const visibleTasks = createMemo(() =>
|
|
256
|
+
props.zoomed ? [...openTasks(), ...doneTasks()] : openTasks(),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Structural signature of the visible task list (id + order). OpenTUI's <For>
|
|
260
|
+
// appends a prepended/inserted item to the END of the rendered container
|
|
261
|
+
// instead of placing it at its array index — so after an add the data is
|
|
262
|
+
// right but the on-screen order is wrong until a full remount. We key a
|
|
263
|
+
// <Show> on this signature: when membership/order changes (add / delete /
|
|
264
|
+
// move) it changes, forcing the list to rebuild fresh in the correct order
|
|
265
|
+
// (the same thing a board switch does). A plain text edit leaves ids/order
|
|
266
|
+
// untouched → no remount → cheap in-place update.
|
|
267
|
+
const taskListKey = createMemo(() => {
|
|
268
|
+
props.store.state.rev; // recompute on any mutation, including mark changes
|
|
269
|
+
const ids = visibleTasks().map((t) => t.id).join("|");
|
|
270
|
+
// Fold the current selection into the key too. OpenTUI doesn't reliably
|
|
271
|
+
// re-render a per-row `marked` prop on a store change, so a selection
|
|
272
|
+
// change (mark / unmark / clear) must rebuild the list to repaint the ●
|
|
273
|
+
// dots — same remount trick the add fix relies on. Without this, cleared
|
|
274
|
+
// marks stayed stuck on whatever task now sits at that position.
|
|
275
|
+
const marks = props.store
|
|
276
|
+
.getMarkedRefs()
|
|
277
|
+
.filter(
|
|
278
|
+
(r) =>
|
|
279
|
+
r.boardPath === props.board.filepath &&
|
|
280
|
+
r.columnIndex === props.columnIndex,
|
|
281
|
+
)
|
|
282
|
+
.map((r) => r.taskIndex)
|
|
283
|
+
.join(",");
|
|
284
|
+
return `${ids}#${marks}`;
|
|
216
285
|
});
|
|
217
286
|
|
|
218
287
|
const cursorRow = createMemo(() => props.store.state.ui.row);
|
|
@@ -280,44 +349,57 @@ function ColumnView(props: ColumnViewProps) {
|
|
|
280
349
|
scrollbarOptions: { visible: false },
|
|
281
350
|
}}
|
|
282
351
|
>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
352
|
+
{/* Blank the task rows when the column is only partly on-screen — its
|
|
353
|
+
(clipped) title still shows as a "more columns" hint. */}
|
|
354
|
+
<Show when={props.fullyVisible !== false}>
|
|
355
|
+
{/*
|
|
356
|
+
Keyed on the task-list signature so a structural change (add/delete/
|
|
357
|
+
move) rebuilds the <For> fresh in the correct order, working around
|
|
358
|
+
OpenTUI's <For> appending inserted items to the end. Text-only edits
|
|
359
|
+
keep the same key → no rebuild → in-place update.
|
|
360
|
+
*/}
|
|
361
|
+
<Show when={taskListKey()} keyed>
|
|
362
|
+
{() => (
|
|
363
|
+
<For each={visibleTasks()}>
|
|
364
|
+
{(task, ri) => {
|
|
365
|
+
const ref = {
|
|
366
|
+
boardPath: props.board.filepath,
|
|
367
|
+
columnIndex: props.columnIndex,
|
|
368
|
+
taskIndex: allTasks().indexOf(task),
|
|
369
|
+
};
|
|
370
|
+
return (
|
|
371
|
+
<box id={taskRowId(props.board.filepath, props.columnIndex, ri())}>
|
|
372
|
+
<TaskRow
|
|
373
|
+
task={task}
|
|
374
|
+
cursor={props.active && ri() === cursorRow()}
|
|
375
|
+
marked={props.store.isMarked(ref)}
|
|
376
|
+
grabbed={
|
|
377
|
+
props.active &&
|
|
378
|
+
ri() === cursorRow() &&
|
|
379
|
+
props.store.state.ui.grabbing
|
|
380
|
+
}
|
|
381
|
+
// Column inner cell width for a TaskRow: COL_WIDTH 42 −
|
|
382
|
+
// border 2 − col padding 2 − TaskRow padding 2 = 36 cols
|
|
383
|
+
// (when not zoomed). Zoomed → column grows to fill, so
|
|
384
|
+
// ~terminal width − some chrome.
|
|
385
|
+
availableWidth={props.zoomed ? 100 : 36}
|
|
386
|
+
onClick={() => {
|
|
387
|
+
props.store.setActiveZone("board");
|
|
388
|
+
props.store.setCursor(props.columnIndex, ri());
|
|
389
|
+
// In calendar arm mode, a click also arms the task so
|
|
390
|
+
// the user can immediately drop it on a timeline slot.
|
|
391
|
+
if (props.store.state.ui.armMode) {
|
|
392
|
+
props.store.armTimeline(ref);
|
|
393
|
+
props.store.setZoneVisible("timeline", true);
|
|
394
|
+
}
|
|
395
|
+
}}
|
|
396
|
+
/>
|
|
397
|
+
</box>
|
|
398
|
+
);
|
|
399
|
+
}}
|
|
400
|
+
</For>
|
|
401
|
+
)}
|
|
402
|
+
</Show>
|
|
321
403
|
|
|
322
404
|
<Show when={!props.zoomed && doneTasks().length > 0}>
|
|
323
405
|
<box
|
|
@@ -333,6 +415,7 @@ function ColumnView(props: ColumnViewProps) {
|
|
|
333
415
|
</text>
|
|
334
416
|
</box>
|
|
335
417
|
</Show>
|
|
418
|
+
</Show>
|
|
336
419
|
</scrollbox>
|
|
337
420
|
</box>
|
|
338
421
|
);
|
package/src/ui/Chrome.tsx
CHANGED
|
@@ -30,44 +30,40 @@ export function TopBar(props: { store: TuiStore }) {
|
|
|
30
30
|
return { open, done, cols };
|
|
31
31
|
};
|
|
32
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
33
|
return (
|
|
52
34
|
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
35
|
+
<box style={{ flexDirection: "row", flexShrink: 1, overflow: "hidden" }}>
|
|
36
|
+
{/* Brand + date */}
|
|
37
|
+
<text wrapMode="none" style={{ flexShrink: 0 }}>
|
|
38
|
+
<span style={{ fg: T.todayPale, attributes: ATTR.bold }}>tuiboard</span>
|
|
39
|
+
<span style={{ fg: T.textDim }}>{` ${isoToday()} `}</span>
|
|
40
|
+
</text>
|
|
41
|
+
{/* Clickable board tabs */}
|
|
42
|
+
<For each={boards()}>
|
|
43
|
+
{(b: { board: { name: string } }, i) => {
|
|
44
|
+
const isActive = () => i() === active();
|
|
45
|
+
return (
|
|
46
|
+
<box
|
|
47
|
+
style={{ flexShrink: 0, flexDirection: "row" }}
|
|
48
|
+
onMouseDown={() => props.store.setActiveBoard(i())}
|
|
49
|
+
>
|
|
50
|
+
<text wrapMode="none">
|
|
51
|
+
<span
|
|
52
|
+
style={{
|
|
53
|
+
fg: isActive() ? T.accent : T.textDim,
|
|
54
|
+
attributes: isActive() ? ATTR.bold : 0,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{isActive()
|
|
58
|
+
? `[${i() + 1} ${b.board.name}] `
|
|
59
|
+
: ` ${i() + 1} ${b.board.name} `}
|
|
60
|
+
</span>
|
|
61
|
+
</text>
|
|
62
|
+
</box>
|
|
63
|
+
);
|
|
64
|
+
}}
|
|
69
65
|
</For>
|
|
70
|
-
</
|
|
66
|
+
</box>
|
|
71
67
|
<Show when={activeStats()}>
|
|
72
68
|
<text wrapMode="none" style={{ flexShrink: 0, marginLeft: 2 }}>
|
|
73
69
|
<span style={{ fg: T.textDim }}>
|
|
@@ -119,7 +115,7 @@ export function BottomBar(props: { store: TuiStore }) {
|
|
|
119
115
|
*/}
|
|
120
116
|
<text wrapMode="none" truncate>
|
|
121
117
|
<span style={{ fg: T.textDim }}>
|
|
122
|
-
{"hjkl move · Tab board · ⇧Tab zone · ⏎ done · n new · c schedule · ? help · q quit"}
|
|
118
|
+
{"hjkl move · Tab board · ⇧Tab zone · ⏎ done · n new · t today · b block · c schedule · z zoom · ? help · q quit"}
|
|
123
119
|
</span>
|
|
124
120
|
</text>
|
|
125
121
|
</box>
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -188,6 +188,7 @@ function EditModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore
|
|
|
188
188
|
|
|
189
189
|
function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "schedule" }> }) {
|
|
190
190
|
const task = props.store.getTask(props.modal.ref);
|
|
191
|
+
const markedCount = props.store.getMarkedRefs().length;
|
|
191
192
|
const [value, setValue] = createSignal(task?.scheduled ?? "");
|
|
192
193
|
const [error, setError] = createSignal<string | undefined>();
|
|
193
194
|
|
|
@@ -197,13 +198,20 @@ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiS
|
|
|
197
198
|
setError(`Cannot parse "${text}". Try: t · tm · +3 · lun · 2026-06-15`);
|
|
198
199
|
return;
|
|
199
200
|
}
|
|
200
|
-
props.store.
|
|
201
|
+
const n = props.store.applyToMarkedOr(props.modal.ref, (r) =>
|
|
202
|
+
props.store.setScheduled(r, d ?? undefined),
|
|
203
|
+
);
|
|
204
|
+
if (n > 1) props.store.flashBanner("info", `${n} tasks scheduled`);
|
|
201
205
|
props.store.closeModal();
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
return (
|
|
205
209
|
<DialogShell
|
|
206
|
-
title={
|
|
210
|
+
title={
|
|
211
|
+
markedCount > 1
|
|
212
|
+
? `Schedule ${markedCount} selected tasks`
|
|
213
|
+
: `Schedule: ${task?.displayTitle.slice(0, 50) ?? ""}`
|
|
214
|
+
}
|
|
207
215
|
hint="t = today · tm = tomorrow · +3 = in 3 days · lun = next Monday · 2026-06-15 · empty/-clear · Esc to cancel"
|
|
208
216
|
width={70}
|
|
209
217
|
>
|
|
@@ -226,6 +234,7 @@ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiS
|
|
|
226
234
|
|
|
227
235
|
function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "timeblock" }> }) {
|
|
228
236
|
const task = props.store.getTask(props.modal.ref);
|
|
237
|
+
const markedCount = props.store.getMarkedRefs().length;
|
|
229
238
|
const init = task?.timeBlock
|
|
230
239
|
? `${fmtMin(task.timeBlock.startMin)}-${fmtMin(task.timeBlock.endMin)}`
|
|
231
240
|
: "";
|
|
@@ -238,13 +247,20 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
|
|
|
238
247
|
setError(`Cannot parse "${text}". Try: n · 9:00 · 9-11 · 09:30-10:45 · - to clear`);
|
|
239
248
|
return;
|
|
240
249
|
}
|
|
241
|
-
props.store.
|
|
250
|
+
const n = props.store.applyToMarkedOr(props.modal.ref, (ref) =>
|
|
251
|
+
props.store.setTimeBlock(ref, r ?? undefined),
|
|
252
|
+
);
|
|
253
|
+
if (n > 1) props.store.flashBanner("info", `${n} tasks time-blocked`);
|
|
242
254
|
props.store.closeModal();
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
return (
|
|
246
258
|
<DialogShell
|
|
247
|
-
title={
|
|
259
|
+
title={
|
|
260
|
+
markedCount > 1
|
|
261
|
+
? `Time block ${markedCount} selected tasks`
|
|
262
|
+
: `Time block: ${task?.displayTitle.slice(0, 50) ?? ""}`
|
|
263
|
+
}
|
|
248
264
|
hint="n = now+30 · 9:00 · 9-11 · 09:30-10:45 · - to clear · Esc to cancel"
|
|
249
265
|
width={70}
|
|
250
266
|
>
|
|
@@ -267,16 +283,24 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
|
|
|
267
283
|
|
|
268
284
|
function AssignModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "assign" }> }) {
|
|
269
285
|
const task = props.store.getTask(props.modal.ref);
|
|
286
|
+
const markedCount = props.store.getMarkedRefs().length;
|
|
270
287
|
const [value, setValue] = createSignal(task?.assignee ?? "");
|
|
271
288
|
|
|
272
289
|
function submit(text: string) {
|
|
273
290
|
const t = text.trim().replace(/^@/, "");
|
|
274
|
-
props.store.
|
|
291
|
+
const n = props.store.applyToMarkedOr(props.modal.ref, (r) =>
|
|
292
|
+
props.store.setAssignee(r, t || undefined),
|
|
293
|
+
);
|
|
294
|
+
if (n > 1) props.store.flashBanner("info", `${n} tasks assigned`);
|
|
275
295
|
props.store.closeModal();
|
|
276
296
|
}
|
|
277
297
|
|
|
278
298
|
return (
|
|
279
|
-
<DialogShell
|
|
299
|
+
<DialogShell
|
|
300
|
+
title={markedCount > 1 ? `Assignee — ${markedCount} selected tasks` : "Assignee"}
|
|
301
|
+
hint="Name without @ · empty to clear · Esc to cancel"
|
|
302
|
+
width={50}
|
|
303
|
+
>
|
|
280
304
|
<input
|
|
281
305
|
focused
|
|
282
306
|
value={value()}
|
|
@@ -291,14 +315,20 @@ function AssignModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiSto
|
|
|
291
315
|
|
|
292
316
|
function ConfirmDeleteModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "confirm-delete" }> }) {
|
|
293
317
|
const task = props.store.getTask(props.modal.ref);
|
|
318
|
+
const markedCount = props.store.getMarkedRefs().length;
|
|
319
|
+
const bulk = markedCount > 1;
|
|
294
320
|
return (
|
|
295
321
|
<DialogShell
|
|
296
|
-
title="Delete task?"
|
|
297
|
-
hint="y
|
|
322
|
+
title={bulk ? `Delete ${markedCount} selected tasks?` : "Delete task?"}
|
|
323
|
+
hint="⏎/y confirm · Esc/n cancel"
|
|
298
324
|
width={70}
|
|
299
325
|
>
|
|
300
326
|
<text>
|
|
301
|
-
<span style={{ fg: T.text }}>
|
|
327
|
+
<span style={{ fg: T.text }}>
|
|
328
|
+
{bulk
|
|
329
|
+
? `${markedCount} marked tasks will be deleted.`
|
|
330
|
+
: task?.displayTitle ?? "(missing)"}
|
|
331
|
+
</span>
|
|
302
332
|
</text>
|
|
303
333
|
</DialogShell>
|
|
304
334
|
);
|
|
@@ -594,9 +624,10 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
594
624
|
<span style={{ fg: T.text }}>{" Enter Open (resume) the selected session in a new WezTerm tab\n"}</span>
|
|
595
625
|
<span style={{ fg: T.text }}>{" o Session detail (cwd, branch, last prompts, resume cmd)\n"}</span>
|
|
596
626
|
<span style={{ fg: T.textDim }}>{"\nMulti-select\n"}</span>
|
|
597
|
-
<span style={{ fg: T.text }}>{" Space Mark / unmark task —
|
|
598
|
-
<span style={{ fg: T.text }}>{"
|
|
599
|
-
<span style={{ fg: T.text }}>{"
|
|
627
|
+
<span style={{ fg: T.text }}>{" Space Mark / unmark task (cursor stays — mark in any order)\n"}</span>
|
|
628
|
+
<span style={{ fg: T.text }}>{" Every task action (done/schedule/time block/assign/\n"}</span>
|
|
629
|
+
<span style={{ fg: T.text }}>{" priority/archive/delete) then applies to ALL marked\n"}</span>
|
|
630
|
+
<span style={{ fg: T.text }}>{" Esc Clear the selection (when no modal is open)\n"}</span>
|
|
600
631
|
<span style={{ fg: T.textDim }}>{"\nBulk\n"}</span>
|
|
601
632
|
<span style={{ fg: T.text }}>{" T Reset ALL overdue tasks (any board) to today\n"}</span>
|
|
602
633
|
<span style={{ fg: T.textDim }}>{"\nGlobal\n"}</span>
|
package/src/ui/TaskRow.tsx
CHANGED
|
@@ -29,12 +29,6 @@ interface TaskRowProps {
|
|
|
29
29
|
contextColor?: string;
|
|
30
30
|
/** If true, hide the date suffix (used when group header already conveys date). */
|
|
31
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
32
|
/**
|
|
39
33
|
* Total cell width available to this row, in terminal columns. When set,
|
|
40
34
|
* TaskRow computes the exact title budget from this width minus the row's
|
|
@@ -52,9 +46,7 @@ interface TaskRowProps {
|
|
|
52
46
|
export function TaskRow(props: TaskRowProps) {
|
|
53
47
|
const status = createMemo(() => statusOf(props.task));
|
|
54
48
|
const suffix = createMemo(() => buildSuffix(props.task, props.hideDateSuffix));
|
|
55
|
-
const titleColor = createMemo(() =>
|
|
56
|
-
titleColorFor(props.task, status(), props.tintColor),
|
|
57
|
-
);
|
|
49
|
+
const titleColor = createMemo(() => titleColorFor(props.task, status()));
|
|
58
50
|
const suffixColor = createMemo(() => suffixColorFor(props.task, status()));
|
|
59
51
|
|
|
60
52
|
// Compute the title budget from availableWidth + this row's actual overhead
|
|
@@ -157,7 +149,13 @@ export function TaskRow(props: TaskRowProps) {
|
|
|
157
149
|
);
|
|
158
150
|
}
|
|
159
151
|
|
|
160
|
-
type TaskStatus =
|
|
152
|
+
type TaskStatus =
|
|
153
|
+
| "done"
|
|
154
|
+
| "overdue"
|
|
155
|
+
| "today"
|
|
156
|
+
| "tomorrow"
|
|
157
|
+
| "future"
|
|
158
|
+
| "unscheduled";
|
|
161
159
|
|
|
162
160
|
function statusOf(t: Task): TaskStatus {
|
|
163
161
|
if (t.done) return "done";
|
|
@@ -165,26 +163,24 @@ function statusOf(t: Task): TaskStatus {
|
|
|
165
163
|
if (!d) return "unscheduled";
|
|
166
164
|
if (d < isoToday()) return "overdue";
|
|
167
165
|
if (d === isoToday()) return "today";
|
|
166
|
+
if (d === isoTomorrow()) return "tomorrow";
|
|
168
167
|
return "future";
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
function titleColorFor(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// Done-green always wins so a completed task reads as "done" at a glance,
|
|
177
|
-
// regardless of which board it came from.
|
|
170
|
+
function titleColorFor(task: Task, status: TaskStatus): string | undefined {
|
|
171
|
+
// Precedence: done (green) > overdue (red) > priority (orange) > today
|
|
172
|
+
// (pale yellow) > tomorrow (grey) > default. The orange now *means*
|
|
173
|
+
// "priority flag" — only tasks with a priority get it; everything scheduled
|
|
174
|
+
// today is the calm pale yellow instead.
|
|
178
175
|
if (status === "done") return T.done;
|
|
179
|
-
// A board tint (virtual panel) takes precedence over the date-status colors:
|
|
180
|
-
// the panel's section headers (Overdue/Today/Tomorrow) already convey the
|
|
181
|
-
// date, so the row color is freed up to signal the *source board* instead.
|
|
182
|
-
if (tintColor) return tintColor;
|
|
183
176
|
if (status === "overdue") return T.overdue;
|
|
184
|
-
|
|
177
|
+
// Tomorrow is uniformly grey — even priority tasks — so everything set for
|
|
178
|
+
// tomorrow reads consistently as "later, de-emphasized".
|
|
179
|
+
if (status === "tomorrow") return T.textDim;
|
|
180
|
+
if (task.priority !== "none") return T.today;
|
|
181
|
+
if (status === "today") return T.todayPale;
|
|
185
182
|
// future / unscheduled: terminal default fg (looks right on any theme).
|
|
186
183
|
return T.text;
|
|
187
|
-
void task;
|
|
188
184
|
}
|
|
189
185
|
|
|
190
186
|
/**
|
|
@@ -218,7 +214,8 @@ function buildSuffix(task: Task, hideDate?: boolean): string | undefined {
|
|
|
218
214
|
function suffixColorFor(task: Task, status: TaskStatus): string | undefined {
|
|
219
215
|
if (status === "done") return T.textDone;
|
|
220
216
|
if (status === "overdue") return T.overdue;
|
|
221
|
-
if (status === "today") return T.
|
|
217
|
+
if (status === "today") return T.todayPale;
|
|
218
|
+
if (status === "tomorrow") return T.textDim;
|
|
222
219
|
if (status === "future") return T.scheduled;
|
|
223
220
|
return T.textDim;
|
|
224
221
|
void task;
|
package/src/ui/TimelineView.tsx
CHANGED
|
@@ -85,12 +85,13 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
85
85
|
const armedRef = () => props.store.state.ui.armedTimelineRef;
|
|
86
86
|
const armMode = () => props.store.state.ui.armMode;
|
|
87
87
|
|
|
88
|
-
const entries = createMemo(() =>
|
|
89
|
-
|
|
88
|
+
const entries = createMemo(() => {
|
|
89
|
+
props.store.state.rev; // recompute on any board mutation
|
|
90
|
+
return buildTimelineEntries(
|
|
90
91
|
props.store.state.boards.map((b) => b.board),
|
|
91
92
|
isoToday(),
|
|
92
|
-
)
|
|
93
|
-
);
|
|
93
|
+
);
|
|
94
|
+
});
|
|
94
95
|
|
|
95
96
|
// Recompute the row map every minute so the "now" marker stays current.
|
|
96
97
|
// No more sticky-unscheduled trimming — the unscheduled list lived at the
|
package/src/ui/VirtualPanel.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { For, Show, createEffect, createMemo } from "solid-js";
|
|
4
4
|
|
|
5
|
-
import { ATTR, T
|
|
5
|
+
import { ATTR, T } from "~/ui/glyphs";
|
|
6
6
|
import { TaskRow } from "~/ui/TaskRow";
|
|
7
7
|
import {
|
|
8
8
|
buildVirtualItems,
|
|
@@ -20,20 +20,21 @@ const vpRowId = (flatIndex: number) => `${VP_ROW_PREFIX}${flatIndex}`;
|
|
|
20
20
|
|
|
21
21
|
const SECTION_HEADER: Record<string, { label: string; color: string }> = {
|
|
22
22
|
overdue: { label: "● Overdue", color: T.overdue },
|
|
23
|
-
today: { label: "● Today", color: T.
|
|
24
|
-
tomorrow: { label: "→ Tomorrow", color: T.
|
|
23
|
+
today: { label: "● Today", color: T.todayPale },
|
|
24
|
+
tomorrow: { label: "→ Tomorrow", color: T.textDim },
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
// Only the agenda and priority buckets get a header. The "rest" bucket has
|
|
28
28
|
// no header of its own — its items already sit under their own
|
|
29
29
|
// `— board · column —` sub-dividers, so a generic label would be redundant.
|
|
30
30
|
const BUCKET_HEADER: Record<string, { label: string; color: string }> = {
|
|
31
|
-
agenda: { label: "⏰ Agenda", color: T.
|
|
32
|
-
priority: { label: "🔺 Priority", color: T.
|
|
31
|
+
agenda: { label: "⏰ Agenda", color: T.todayPale },
|
|
32
|
+
priority: { label: "🔺 Priority", color: T.today },
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
export function VirtualPanel(props: { store: TuiStore }) {
|
|
36
36
|
const items = createMemo(() => {
|
|
37
|
+
props.store.state.rev; // recompute on any board mutation
|
|
37
38
|
return buildVirtualItems(props.store.state.boards.map((b) => b.board));
|
|
38
39
|
});
|
|
39
40
|
const groups = createMemo(() => groupVirtualItems(items()));
|
|
@@ -70,15 +71,18 @@ export function VirtualPanel(props: { store: TuiStore }) {
|
|
|
70
71
|
<box
|
|
71
72
|
style={{
|
|
72
73
|
flexDirection: "column",
|
|
73
|
-
width
|
|
74
|
-
|
|
74
|
+
// Match the kanban column width (BoardView COL_WIDTH = 42) so the
|
|
75
|
+
// Today/Tomorrow panel reads as just another column.
|
|
76
|
+
width: isZoomed() ? undefined : 42,
|
|
77
|
+
minWidth: isZoomed() ? undefined : 42,
|
|
75
78
|
flexGrow: isZoomed() ? 1 : 0,
|
|
76
79
|
marginRight: 1,
|
|
77
80
|
border: true,
|
|
78
81
|
borderStyle: "rounded",
|
|
79
|
-
// Today/Tomorrow
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
+
// Today/Tomorrow identity: a soft pale yellow. Brighter when focused,
|
|
83
|
+
// muted when not — but always the same calm hue (the title rides the
|
|
84
|
+
// border color too).
|
|
85
|
+
borderColor: isActive() ? T.todayPale : T.todayPaleDim,
|
|
82
86
|
paddingLeft: 1,
|
|
83
87
|
paddingRight: 1,
|
|
84
88
|
}}
|
|
@@ -109,12 +113,12 @@ export function VirtualPanel(props: { store: TuiStore }) {
|
|
|
109
113
|
groups={groups()}
|
|
110
114
|
isActive={isActive()}
|
|
111
115
|
cursorRow={cursorRow()}
|
|
112
|
-
// Panel inner cell width seen by a TaskRow: panel
|
|
116
|
+
// Panel inner cell width seen by a TaskRow: panel 42 col
|
|
113
117
|
// (or full width in zoom) − border 2 − panel padding 2 −
|
|
114
|
-
// TaskRow padding 2 =
|
|
118
|
+
// TaskRow padding 2 = 36 cols normal, ~terminal width − 6
|
|
115
119
|
// when zoomed. Pass that so TaskRow can budget the title
|
|
116
120
|
// dynamically against the row's actual overhead.
|
|
117
|
-
availableWidth={isZoomed() ? 100 :
|
|
121
|
+
availableWidth={isZoomed() ? 100 : 36}
|
|
118
122
|
isMarkedFn={(r) => props.store.isMarked(r)}
|
|
119
123
|
onClickItem={(flatIndex) => {
|
|
120
124
|
props.store.setActiveZone("virtual");
|
|
@@ -147,13 +151,22 @@ function RenderGroups(props: {
|
|
|
147
151
|
return (
|
|
148
152
|
<For each={props.groups}>
|
|
149
153
|
{(group, gi) => {
|
|
150
|
-
const
|
|
154
|
+
const firstOfSection = isFirstOfSection(props.groups, gi());
|
|
155
|
+
const sectionHeader = firstOfSection
|
|
151
156
|
? SECTION_HEADER[group.section]
|
|
152
157
|
: undefined;
|
|
153
158
|
const bucketHeader = BUCKET_HEADER[group.bucket];
|
|
154
159
|
|
|
155
160
|
return (
|
|
156
|
-
|
|
161
|
+
// Breathing room between the macro buckets within a section
|
|
162
|
+
// (Agenda → Priority → normal tasks). The first bucket of a section
|
|
163
|
+
// gets its gap from the section header's own marginTop instead.
|
|
164
|
+
<box
|
|
165
|
+
style={{
|
|
166
|
+
flexDirection: "column",
|
|
167
|
+
marginTop: firstOfSection ? 0 : 1,
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
157
170
|
<Show when={sectionHeader}>
|
|
158
171
|
<box style={{ paddingLeft: 1, paddingRight: 1, marginTop: 1 }}>
|
|
159
172
|
<text>
|
|
@@ -166,7 +179,19 @@ function RenderGroups(props: {
|
|
|
166
179
|
<Show when={bucketHeader}>
|
|
167
180
|
<box style={{ paddingLeft: 1, paddingRight: 1 }}>
|
|
168
181
|
<text>
|
|
169
|
-
|
|
182
|
+
{/* In the Tomorrow section everything is grey — including the
|
|
183
|
+
Agenda / Priority bucket labels — so the whole section
|
|
184
|
+
reads as "later". */}
|
|
185
|
+
<span
|
|
186
|
+
style={{
|
|
187
|
+
fg:
|
|
188
|
+
group.section === "tomorrow"
|
|
189
|
+
? T.textDim
|
|
190
|
+
: bucketHeader!.color,
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{" "}{bucketHeader!.label}
|
|
194
|
+
</span>
|
|
170
195
|
</text>
|
|
171
196
|
</box>
|
|
172
197
|
</Show>
|
|
@@ -181,10 +206,6 @@ function RenderGroups(props: {
|
|
|
181
206
|
cursor={props.isActive && item.flatIndex === props.cursorRow}
|
|
182
207
|
marked={props.isMarkedFn(item.ref)}
|
|
183
208
|
availableWidth={props.availableWidth}
|
|
184
|
-
// Tint the title with the source board's accent so a
|
|
185
|
-
// cross-cutting Today/Tomorrow item is recognizable by
|
|
186
|
-
// its board at a glance (done-green still wins).
|
|
187
|
-
tintColor={boardColor(item.boardIndex)}
|
|
188
209
|
// Today / Tomorrow sections already say so in their
|
|
189
210
|
// header — drop the redundant per-row "today"/"tmrw"
|
|
190
211
|
// date label (the ⌚ time block stays). Overdue rows
|
|
@@ -217,7 +238,6 @@ function RenderGroups(props: {
|
|
|
217
238
|
cursor={props.isActive && item.flatIndex === props.cursorRow}
|
|
218
239
|
marked={props.isMarkedFn(item.ref)}
|
|
219
240
|
availableWidth={props.availableWidth}
|
|
220
|
-
tintColor={boardColor(item.boardIndex)}
|
|
221
241
|
hideDateSuffix={
|
|
222
242
|
group.section === "today" || group.section === "tomorrow"
|
|
223
243
|
}
|
package/src/ui/glyphs.ts
CHANGED
|
@@ -74,7 +74,10 @@ export const T = {
|
|
|
74
74
|
|
|
75
75
|
// Status-based row colors — kept clearly distinct in hue + brightness
|
|
76
76
|
overdue: "#e26a6a", // hue 0°, sat 65%, light 65% — clearly red
|
|
77
|
-
today: "#e8a05c", //
|
|
77
|
+
today: "#e8a05c", // warm orange — now the PRIORITY accent (flagged tasks)
|
|
78
|
+
// Today/Tomorrow identity + today-scheduled task titles: a soft pale yellow.
|
|
79
|
+
todayPale: "#eaf6ad", // bright, calm — the Today/Tomorrow zone accent
|
|
80
|
+
todayPaleDim: "#9aa06f", // muted version for the unfocused panel border
|
|
78
81
|
scheduled: "#c89a6a", // dimmer warm for non-today future
|
|
79
82
|
future: "#8a90a8",
|
|
80
83
|
|