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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Terminal dashboard for markdown task boards. Kanban + Today/Tomorrow + 24h timeline + Claude Code agent view, all in one TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 ──────────────────────────────────────────────────────────────
@@ -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
- const ref = ui.modal.ref;
54
- store.deleteTask(ref);
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;
@@ -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
- let taskIndex = 0;
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
- produce((c: Column) => {
515
- if (insertPos === "top") {
516
- c.children.unshift(newTask);
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", "marked", {});
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
- /** Decoded list of currently marked refs. */
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).map((k) => {
779
- const [boardPath, ci, ti] = k.split("::");
780
- return {
781
- boardPath: boardPath!,
782
- columnIndex: Number(ci),
783
- taskIndex: Number(ti),
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
  /**
@@ -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 · cwd_short · age
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 }}>{" "}{ageStr()}</span>
76
+ <span style={{ fg: T.textDim }}>
77
+ {props.session.cwdShort}{" "}{ageStr()}
78
+ </span>
76
79
  </text>
77
80
  </box>
78
81
  );
@@ -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
- marginTop: 1,
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,
@@ -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(() => props.column.children.filter(isTask));
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
- if (props.zoomed) return [...openTasks(), ...doneTasks()];
215
- return openTasks();
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
- <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
- // In calendar arm mode, a click also arms the task so the
310
- // user can immediately drop it on a timeline slot.
311
- if (props.store.state.ui.armMode) {
312
- props.store.armTimeline(ref);
313
- props.store.setZoneVisible("timeline", true);
314
- }
315
- }}
316
- />
317
- </box>
318
- );
319
- }}
320
- </For>
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
- <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
- )}
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
- </text>
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.setScheduled(props.modal.ref, d ?? undefined);
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={`Schedule: ${task?.displayTitle.slice(0, 50) ?? ""}`}
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.setTimeBlock(props.modal.ref, r ?? undefined);
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={`Time block: ${task?.displayTitle.slice(0, 50) ?? ""}`}
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.setAssignee(props.modal.ref, t || undefined);
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 title="Assignee" hint="Name without @ · empty to clear · Esc to cancel" width={50}>
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 to confirm · Esc/n to cancel"
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 }}>{task?.displayTitle ?? "(missing)"}</span>
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 — single-task actions then\n"}</span>
598
- <span style={{ fg: T.text }}>{" apply to ALL marked instead of just the cursor\n"}</span>
599
- <span style={{ fg: T.text }}>{" Esc Clear marks (when no modal is open)\n"}</span>
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>
@@ -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 = "done" | "overdue" | "today" | "future" | "unscheduled";
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
- task: Task,
173
- status: TaskStatus,
174
- tintColor?: string,
175
- ): string | undefined {
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
- if (status === "today") return T.today;
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.today;
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;
@@ -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
- buildTimelineEntries(
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { For, Show, createEffect, createMemo } from "solid-js";
4
4
 
5
- import { ATTR, T, boardColor } from "~/ui/glyphs";
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.today },
24
- tomorrow: { label: "→ Tomorrow", color: T.warmDim },
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.accent },
32
- priority: { label: "🔺 Priority", color: T.highest },
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: isZoomed() ? undefined : 38,
74
- minWidth: isZoomed() ? undefined : 38,
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 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
+ // 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 38 col
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 = 32 cols normal, ~terminal width − 6
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 : 32}
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 sectionHeader = isFirstOfSection(props.groups, gi())
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
- <box style={{ flexDirection: "column" }}>
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
- <span style={{ fg: bucketHeader!.color }}>{" "}{bucketHeader!.label}</span>
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", // hue 30°, sat 75%, light 64% clearly orange
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