tuiboard 0.6.2 → 0.7.1

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.
@@ -10,7 +10,7 @@ describe("test runner smoke", () => {
10
10
  });
11
11
 
12
12
  /** Builds a minimal config with no boards — enough to exercise pure UI actions. */
13
- function emptyConfig(): Config {
13
+ function emptyConfig(overrides: Partial<Config> = {}): Config {
14
14
  return {
15
15
  root: process.cwd(),
16
16
  loaded: false,
@@ -18,6 +18,8 @@ function emptyConfig(): Config {
18
18
  assignees: [],
19
19
  doneColumn: "Done",
20
20
  archiveColumn: "Archive",
21
+ zones: { planner: "on", agenda: "on", agents: "on" },
22
+ ...overrides,
21
23
  };
22
24
  }
23
25
 
@@ -29,16 +31,16 @@ describe("UI activeZone", () => {
29
31
 
30
32
  it("setActiveZone updates the zone", () => {
31
33
  const store = createTuiStore({ config: emptyConfig() });
32
- store.setActiveZone("virtual");
33
- expect(store.state.ui.activeZone).toBe("virtual");
34
+ store.setActiveZone("planner");
35
+ expect(store.state.ui.activeZone).toBe("planner");
34
36
  store.setActiveZone("timeline");
35
37
  expect(store.state.ui.activeZone).toBe("timeline");
36
38
  });
37
39
 
38
- it("setActiveZone('virtual') resets row to 0", () => {
40
+ it("setActiveZone('planner') resets row to 0", () => {
39
41
  const store = createTuiStore({ config: emptyConfig() });
40
42
  store.setCursor(0, 7);
41
- store.setActiveZone("virtual");
43
+ store.setActiveZone("planner");
42
44
  expect(store.state.ui.row).toBe(0);
43
45
  });
44
46
  });
@@ -47,7 +49,7 @@ describe("UI visibleZones", () => {
47
49
  it("defaults to all four zones visible", () => {
48
50
  const store = createTuiStore({ config: emptyConfig() });
49
51
  expect(store.state.ui.visibleZones).toEqual({
50
- virtual: true,
52
+ planner: true,
51
53
  board: true,
52
54
  timeline: true,
53
55
  agents: true,
@@ -58,7 +60,7 @@ describe("UI visibleZones", () => {
58
60
  const store = createTuiStore({ config: emptyConfig() });
59
61
  store.setZoneVisible("timeline", false);
60
62
  expect(store.state.ui.visibleZones.timeline).toBe(false);
61
- expect(store.state.ui.visibleZones.virtual).toBe(true);
63
+ expect(store.state.ui.visibleZones.planner).toBe(true);
62
64
  expect(store.state.ui.visibleZones.board).toBe(true);
63
65
  expect(store.state.ui.visibleZones.agents).toBe(true);
64
66
  });
@@ -80,7 +82,7 @@ describe("UI visibleZones", () => {
80
82
  describe("UI cycleActiveZone", () => {
81
83
  it("cycles through all visible zones in fixed order", () => {
82
84
  const store = createTuiStore({ config: emptyConfig() });
83
- store.setActiveZone("virtual");
85
+ store.setActiveZone("planner");
84
86
  store.cycleActiveZone();
85
87
  expect(store.state.ui.activeZone).toBe("board");
86
88
  store.cycleActiveZone();
@@ -88,7 +90,7 @@ describe("UI cycleActiveZone", () => {
88
90
  store.cycleActiveZone();
89
91
  expect(store.state.ui.activeZone).toBe("agents");
90
92
  store.cycleActiveZone();
91
- expect(store.state.ui.activeZone).toBe("virtual"); // wrap
93
+ expect(store.state.ui.activeZone).toBe("planner"); // wrap
92
94
  });
93
95
 
94
96
  it("skips hidden zones", () => {
@@ -101,7 +103,7 @@ describe("UI cycleActiveZone", () => {
101
103
 
102
104
  it("is a no-op when only one zone is visible (board only)", () => {
103
105
  const store = createTuiStore({ config: emptyConfig() });
104
- store.setZoneVisible("virtual", false);
106
+ store.setZoneVisible("planner", false);
105
107
  store.setZoneVisible("timeline", false);
106
108
  store.setZoneVisible("agents", false);
107
109
  store.cycleActiveZone();
@@ -156,3 +158,67 @@ describe("Agenda day navigation", () => {
156
158
  expect(store.state.ui.row).toBe(0);
157
159
  });
158
160
  });
161
+
162
+ describe("zones config", () => {
163
+ it("enables and shows all zones by default", () => {
164
+ const s = createTuiStore({ config: emptyConfig() });
165
+ expect(s.state.ui.enabledZones).toEqual({ board: true, planner: true, timeline: true, agents: true });
166
+ expect(s.state.ui.visibleZones).toEqual({ board: true, planner: true, timeline: true, agents: true });
167
+ });
168
+
169
+ it("disables a zone set to off (never enabled, never visible)", () => {
170
+ const s = createTuiStore({
171
+ config: emptyConfig({ zones: { planner: "on", agenda: "off", agents: "off" } }),
172
+ });
173
+ expect(s.state.ui.enabledZones.timeline).toBe(false);
174
+ expect(s.state.ui.enabledZones.agents).toBe(false);
175
+ expect(s.state.ui.visibleZones.timeline).toBe(false);
176
+ expect(s.state.ui.visibleZones.agents).toBe(false);
177
+ });
178
+
179
+ it("a hidden zone is enabled but not visible at start; F-key reveals it", () => {
180
+ const s = createTuiStore({
181
+ config: emptyConfig({ zones: { planner: "hidden", agenda: "on", agents: "on" } }),
182
+ });
183
+ expect(s.state.ui.enabledZones.planner).toBe(true);
184
+ expect(s.state.ui.visibleZones.planner).toBe(false);
185
+ s.toggleZoneDesired("planner");
186
+ expect(s.state.ui.visibleZones.planner).toBe(true);
187
+ });
188
+
189
+ it("F-key toggle is a no-op on a disabled zone", () => {
190
+ const s = createTuiStore({
191
+ config: emptyConfig({ zones: { planner: "on", agenda: "off", agents: "on" } }),
192
+ });
193
+ s.toggleZoneDesired("timeline");
194
+ expect(s.state.ui.visibleZones.timeline).toBe(false);
195
+ });
196
+
197
+ it("Shift-Tab cycle skips disabled zones", () => {
198
+ const s = createTuiStore({
199
+ config: emptyConfig({ zones: { planner: "on", agenda: "off", agents: "off" } }),
200
+ });
201
+ s.setActiveZone("board");
202
+ s.cycleActiveZone();
203
+ expect(s.state.ui.activeZone).toBe("planner");
204
+ s.cycleActiveZone();
205
+ expect(s.state.ui.activeZone).toBe("board"); // timeline + agents skipped
206
+ });
207
+
208
+ it("responsive fits never force-show a disabled zone", () => {
209
+ const s = createTuiStore({
210
+ config: emptyConfig({ zones: { planner: "on", agenda: "off", agents: "on" } }),
211
+ });
212
+ s.applyResponsiveFits({ planner: true, timeline: true, agents: true });
213
+ expect(s.state.ui.visibleZones.timeline).toBe(false); // disabled stays off
214
+ expect(s.state.ui.visibleZones.agents).toBe(true);
215
+ });
216
+
217
+ it("responsive hides a zone that doesn't fit; it returns when it fits again", () => {
218
+ const s = createTuiStore({ config: emptyConfig() });
219
+ s.applyResponsiveFits({ planner: true, timeline: false, agents: true });
220
+ expect(s.state.ui.visibleZones.timeline).toBe(false);
221
+ s.applyResponsiveFits({ planner: true, timeline: true, agents: true });
222
+ expect(s.state.ui.visibleZones.timeline).toBe(true);
223
+ });
224
+ });
@@ -8,7 +8,7 @@
8
8
  * The store also owns:
9
9
  * - The list of loaded boards (with their original mtime watermark)
10
10
  * - UI state: active board index, cursor (col/row), collapse flags,
11
- * filter, mode (kanban / virtual / list)
11
+ * filter, mode (kanban / planner / list)
12
12
  * - The undo log
13
13
  *
14
14
  * Mutations always:
@@ -71,17 +71,28 @@ export type ModalKind =
71
71
  | { kind: "help" };
72
72
 
73
73
  /** Which dashboard zone owns the keyboard cursor. */
74
- export type ActiveZone = "virtual" | "board" | "timeline" | "agents";
74
+ export type ActiveZone = "planner" | "board" | "timeline" | "agents";
75
75
 
76
76
  /** Fixed cycling order for Shift+Tab navigation. */
77
- const ZONE_ORDER: readonly ActiveZone[] = ["virtual", "board", "timeline", "agents"];
77
+ const ZONE_ORDER: readonly ActiveZone[] = ["planner", "board", "timeline", "agents"];
78
78
 
79
79
  export interface UIState {
80
80
  activeBoardIndex: number;
81
81
  /** Which dashboard zone owns the keyboard cursor right now. */
82
82
  activeZone: ActiveZone;
83
- /** Which zones are currently rendered. `board` cannot be hidden (load-bearing). */
83
+ /**
84
+ * Which zones are actually rendered right now. Derived from
85
+ * `enabledZones ∧ desiredVisible ∧ fits-at-current-width`. Read by the views;
86
+ * never set directly — go through setZoneVisible / toggleZoneDesired /
87
+ * applyResponsiveFits, which recompute it.
88
+ */
84
89
  visibleZones: Record<ActiveZone, boolean>;
90
+ /**
91
+ * Which zones are enabled at all (from the `zones:` config). A disabled zone
92
+ * is never rendered, is skipped by Shift-Tab, has an inert F-key, and its
93
+ * background work never started. `board` is always enabled.
94
+ */
95
+ enabledZones: Record<ActiveZone, boolean>;
85
96
  /** Column index (within active board) — meaningful only when `activeZone === "board"`. */
86
97
  col: number;
87
98
  /** Row index inside the active zone. */
@@ -109,7 +120,7 @@ export interface UIState {
109
120
  armedTimelineRef?: TaskRef;
110
121
  /**
111
122
  * Persistent calendar "arm mode" (toggled with `c`). While on, clicking any
112
- * task in the board / virtual panel arms it for the timeline, so you can
123
+ * task in the board / planner panel arms it for the timeline, so you can
113
124
  * schedule several tasks in a row — click a task, click a slot, repeat —
114
125
  * without re-pressing `c`. `Esc` (or `c` again) exits. Distinct from
115
126
  * `armedTimelineRef`, which is the single task currently armed.
@@ -150,7 +161,7 @@ export interface StoreState {
150
161
  undo: UndoEntry[];
151
162
  /**
152
163
  * Monotonic mutation counter. Bumped on every board mutation (via
153
- * `saveBoard`). Derived views (board columns, virtual panel, timeline) read
164
+ * `saveBoard`). Derived views (board columns, planner panel, timeline) read
154
165
  * it so their memos recompute on any change — a reliable top-level signal
155
166
  * dependency, since OpenTUI/Solid's fine-grained tracking of deeply-nested
156
167
  * store edits (e.g. growing a column's `children` array) doesn't always
@@ -169,12 +180,43 @@ export interface CreateStoreOptions {
169
180
  export function createTuiStore({ config }: CreateStoreOptions) {
170
181
  const initialBoards = loadAll(config);
171
182
 
183
+ // ─── Zone enablement (from `zones:` config) ──────────────────────────────
184
+ // `enabledZones` is a hard gate; `desiredVisible` is the user's runtime
185
+ // intent (F-keys flip it); `lastFits` is the terminal-width fit cache. The
186
+ // rendered `visibleZones` is the AND of all three. The board is always on.
187
+ const z = config.zones;
188
+ const enabledZones: Record<ActiveZone, boolean> = {
189
+ board: true,
190
+ planner: z.planner !== "off",
191
+ timeline: z.agenda !== "off",
192
+ agents: z.agents !== "off",
193
+ };
194
+ const desiredVisible: Record<ActiveZone, boolean> = {
195
+ board: true,
196
+ planner: z.planner === "on",
197
+ timeline: z.agenda === "on",
198
+ agents: z.agents === "on",
199
+ };
200
+ let lastFits: Record<ActiveZone, boolean> = {
201
+ board: true,
202
+ planner: true,
203
+ timeline: true,
204
+ agents: true,
205
+ };
206
+ const computeVisible = (): Record<ActiveZone, boolean> => ({
207
+ board: true,
208
+ planner: enabledZones.planner && desiredVisible.planner && lastFits.planner,
209
+ timeline: enabledZones.timeline && desiredVisible.timeline && lastFits.timeline,
210
+ agents: enabledZones.agents && desiredVisible.agents && lastFits.agents,
211
+ });
212
+
172
213
  const [state, setState] = createStore<StoreState>({
173
214
  boards: initialBoards,
174
215
  ui: {
175
216
  activeBoardIndex: 0,
176
217
  activeZone: "board",
177
- visibleZones: { virtual: true, board: true, timeline: true, agents: true },
218
+ visibleZones: computeVisible(),
219
+ enabledZones,
178
220
  col: 0,
179
221
  row: 0,
180
222
  zoomed: false,
@@ -198,11 +240,17 @@ export function createTuiStore({ config }: CreateStoreOptions) {
198
240
  );
199
241
 
200
242
  // Agents store has its own lifecycle (chokidar watcher on ~/.claude).
201
- // Shared dispose() boundary below so SIGINT cleans both.
202
- const agentsStore: AgentsStore = createAgentsStore();
203
- // Calendar feeds (read-only) merged into the Agenda zone. No-op when no
204
- // `calendars:` block is configured.
205
- const calendarStore: CalendarStore = createCalendarStore(config.calendars, isoToday);
243
+ // Shared dispose() boundary below so SIGINT cleans both. When the agents
244
+ // zone is disabled we skip the watcher entirely (no `~/.claude` reads at all)
245
+ // and hand back an inert stub.
246
+ const agentsStore: AgentsStore = enabledZones.agents
247
+ ? createAgentsStore()
248
+ : noopAgentsStore();
249
+ // Calendar feeds (read-only) merged into the Agenda zone. Skipped entirely
250
+ // (no network) when the agenda zone is disabled.
251
+ const calendarStore: CalendarStore = enabledZones.timeline
252
+ ? createCalendarStore(config.calendars, isoToday)
253
+ : noopCalendarStore();
206
254
  watcher.onChange((filepath) => {
207
255
  // External edit. Re-read this board from disk.
208
256
  try {
@@ -716,19 +764,46 @@ export function createTuiStore({ config }: CreateStoreOptions) {
716
764
 
717
765
  function setActiveZone(zone: ActiveZone): void {
718
766
  setState("ui", "activeZone", zone);
719
- // Moving to a vertical-list zone (virtual / agents / timeline) resets
767
+ // Moving to a vertical-list zone (planner / agents / timeline) resets
720
768
  // the row cursor to the top so the user always lands somewhere sensible.
721
769
  if (zone !== "board") setState("ui", "row", 0);
722
770
  }
723
771
 
772
+ /** Recompute `visibleZones` from enabled ∧ desired ∧ fits; bounce the cursor
773
+ * to the board if the active zone just became invisible. */
774
+ function recomputeVisible(): void {
775
+ const v = computeVisible();
776
+ setState("ui", "visibleZones", v);
777
+ if (!v[state.ui.activeZone]) setActiveZone("board");
778
+ }
779
+
780
+ /** Set a zone's desired visibility (user intent). Showing a disabled zone is
781
+ * ignored; the board can never be hidden. Used by direct reveals (day-nav,
782
+ * arm flow) and the responsive layout's per-zone calls. */
724
783
  function setZoneVisible(zone: ActiveZone, visible: boolean): void {
725
- // Board is the load-bearing zone — never allow it to be hidden.
726
784
  if (zone === "board" && !visible) return;
727
- setState("ui", "visibleZones", zone, visible);
728
- // If we just hid the active zone, bounce the cursor to "board".
729
- if (!visible && state.ui.activeZone === zone) {
730
- setActiveZone("board");
731
- }
785
+ if (visible && !enabledZones[zone]) return; // can't reveal a disabled zone
786
+ desiredVisible[zone] = visible;
787
+ recomputeVisible();
788
+ }
789
+
790
+ /** Flip a zone's visibility (the F1/F2/F3 toggles). No-op if disabled. */
791
+ function toggleZoneDesired(zone: ActiveZone): void {
792
+ if (zone === "board" || !enabledZones[zone]) return;
793
+ desiredVisible[zone] = !desiredVisible[zone];
794
+ recomputeVisible();
795
+ }
796
+
797
+ /** Update the terminal-width fit cache (called by the responsive layout) and
798
+ * recompute. Never overrides enablement or the user's desired visibility. */
799
+ function applyResponsiveFits(fits: Partial<Record<ActiveZone, boolean>>): void {
800
+ lastFits = {
801
+ board: true,
802
+ planner: fits.planner !== false,
803
+ timeline: fits.timeline !== false,
804
+ agents: fits.agents !== false,
805
+ };
806
+ recomputeVisible();
732
807
  }
733
808
 
734
809
  function cycleActiveZone(): void {
@@ -1034,6 +1109,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1034
1109
  setCursor,
1035
1110
  setActiveZone,
1036
1111
  setZoneVisible,
1112
+ toggleZoneDesired,
1113
+ applyResponsiveFits,
1037
1114
  cycleActiveZone,
1038
1115
  toggleZoom,
1039
1116
  toggleGrab,
@@ -1069,6 +1146,22 @@ export type TuiStore = ReturnType<typeof createTuiStore>;
1069
1146
 
1070
1147
  // ─── Load helpers ────────────────────────────────────────────────────────────
1071
1148
 
1149
+ /** Inert agents store for when the agents zone is disabled — no chokidar
1150
+ * watcher, no `~/.claude` reads at all. */
1151
+ function noopAgentsStore(): AgentsStore {
1152
+ return { sessions: () => [], refresh: () => {}, dispose: async () => {} };
1153
+ }
1154
+
1155
+ /** Inert calendar store for when the agenda zone is disabled — no fetching. */
1156
+ function noopCalendarStore(): CalendarStore {
1157
+ return {
1158
+ events: () => [],
1159
+ setActiveDate: () => {},
1160
+ refresh: () => {},
1161
+ dispose: async () => {},
1162
+ };
1163
+ }
1164
+
1072
1165
  function loadAll(config: Config): LoadedBoard[] {
1073
1166
  const out: LoadedBoard[] = [];
1074
1167
  for (const b of config.boards) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Compact agent status strip for the dashboard. The non-archived sessions
3
3
  * render inside a vertical scrollbox so the list scrolls with the mouse wheel
4
- * (like the board / virtual / timeline zones) AND follows the keyboard cursor
4
+ * (like the board / planner / timeline zones) AND follows the keyboard cursor
5
5
  * via scrollChildIntoView — same pattern as BoardView's columns.
6
6
  *
7
7
  * The border color reflects activeZone === "agents" so the cursor ring is
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Kanban board view — horizontal scrolling row of full-height columns.
3
3
  *
4
- * Mirrors the Today/Tomorrow virtual panel: each column has a fixed width
4
+ * Mirrors the Today/Tomorrow planner panel: each column has a fixed width
5
5
  * and stretches vertically to fill the board area. Columns that don't fit
6
6
  * the available horizontal space are reached via horizontal scroll. Zoom
7
7
  * mode collapses everything down to the single active column at full
@@ -31,6 +31,13 @@ interface SizedBoxLike {
31
31
 
32
32
  /** Fixed column width when not zoomed. Single source of truth for layout. */
33
33
  const COL_WIDTH = 42;
34
+ /**
35
+ * Minimum fraction of a column that must be inside the viewport for its task
36
+ * rows to render. Above this, a column that's mostly on-screen keeps its tasks
37
+ * (the last one may be cut at the edge); below it — a thin sliver clipped at the
38
+ * edge — only the title shows as a "more columns →" hint.
39
+ */
40
+ const MIN_TASKS_VISIBLE_FRACTION = 0.5;
34
41
  /**
35
42
  * Width of a collapsed column — one with no OPEN tasks (an all-done lane like
36
43
  * "Done", or an empty column). It shows just the `✓ N` counter; zoom (`z`)
@@ -88,7 +95,7 @@ export function BoardView(props: BoardViewProps) {
88
95
  * (Python kanban `z`).
89
96
  */
90
97
  const renderedColumns = createMemo(() => {
91
- if (!ui().zoomed || ui().activeZone === "virtual") return visibleColumns();
98
+ if (!ui().zoomed || ui().activeZone === "planner") return visibleColumns();
92
99
  const cols = visibleColumns();
93
100
  // ui.col is a board.columns index (carries Archive); map it to the
94
101
  // rendered list so zoom focuses the column actually under the cursor.
@@ -111,7 +118,7 @@ export function BoardView(props: BoardViewProps) {
111
118
  // geometry used everywhere (COL_WIDTH + COL_GAP).
112
119
  createEffect(() => {
113
120
  const colIdx = ui().col;
114
- if (ui().zoomed || ui().activeZone === "virtual") {
121
+ if (ui().zoomed || ui().activeZone === "planner") {
115
122
  setScrollX(0);
116
123
  return;
117
124
  }
@@ -140,20 +147,26 @@ export function BoardView(props: BoardViewProps) {
140
147
  });
141
148
 
142
149
  /**
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.
150
+ * Should the column at rendered index `i` render its task rows? Yes unless
151
+ * it's clipped down to a thin sliver at the viewport edge (less than
152
+ * MIN_TASKS_VISIBLE_FRACTION on-screen) then only its title shows as a
153
+ * "more columns →" hint. A mostly-visible column keeps its tasks even if the
154
+ * last one is cut at the edge. Defaults to true while the viewport width is
155
+ * still unknown (first paint) and in zoom.
146
156
  */
147
- const columnFullyVisible = (i: number): boolean => {
157
+ const columnTasksVisible = (i: number): boolean => {
148
158
  const vw = viewportW();
149
159
  if (vw <= 0 || ui().zoomed) return true;
150
160
  const stride = COL_WIDTH + COL_GAP;
151
161
  const start = i * stride;
152
- return start >= scrollX() && start + COL_WIDTH <= scrollX() + vw;
162
+ const left = Math.max(start, scrollX());
163
+ const right = Math.min(start + COL_WIDTH, scrollX() + vw);
164
+ const visibleFraction = Math.max(0, right - left) / COL_WIDTH;
165
+ return visibleFraction >= MIN_TASKS_VISIBLE_FRACTION;
153
166
  };
154
167
 
155
168
  // Measure the viewport width once at mount, regardless of the active zone.
156
- // Otherwise — since tuiboard now starts focused on the virtual panel — the
169
+ // Otherwise — since tuiboard now starts focused on the planner panel — the
157
170
  // scroll effect early-returns and viewportW stays 0, so partly-clipped
158
171
  // columns briefly show their task rows until the board is first touched.
159
172
  onMount(() => {
@@ -199,7 +212,7 @@ export function BoardView(props: BoardViewProps) {
199
212
  columnIndex={originalIndex}
200
213
  active={isActive()}
201
214
  zoomed={ui().zoomed && isActive()}
202
- fullyVisible={columnFullyVisible(i())}
215
+ tasksVisible={columnTasksVisible(i())}
203
216
  boxId={columnId(props.board.filepath, originalIndex)}
204
217
  />
205
218
  );
@@ -223,11 +236,11 @@ interface ColumnViewProps {
223
236
  */
224
237
  zoomed: boolean;
225
238
  /**
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.
239
+ * False when the column is clipped down to a thin sliver at the viewport
240
+ * edge. Its title still renders as a "more columns" hint, but the task rows
241
+ * are blanked. Mostly-visible columns keep their tasks.
229
242
  */
230
- fullyVisible?: boolean;
243
+ tasksVisible?: boolean;
231
244
  /** Stable DOM-equivalent id used by `scrollChildIntoView`. */
232
245
  boxId: string;
233
246
  }
@@ -328,7 +341,7 @@ function ColumnView(props: ColumnViewProps) {
328
341
  flexShrink: 0,
329
342
  // No explicit height — Yoga stretches us along the row's cross
330
343
  // axis, so the column always fills the full board height. Same
331
- // contract as the Today/Tomorrow virtual panel next door.
344
+ // contract as the Today/Tomorrow planner panel next door.
332
345
  marginRight: COL_GAP,
333
346
  border: true,
334
347
  borderStyle: "rounded",
@@ -349,9 +362,9 @@ function ColumnView(props: ColumnViewProps) {
349
362
  scrollbarOptions: { visible: false },
350
363
  }}
351
364
  >
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}>
365
+ {/* Blank the task rows only when the column is a thin clipped sliver
366
+ its title still shows as a "more columns" hint. */}
367
+ <Show when={props.tasksVisible !== false}>
355
368
  {/*
356
369
  Keyed on the task-list signature so a structural change (add/delete/
357
370
  move) rebuilds the <For> fresh in the correct order, working around
package/src/ui/Modal.tsx CHANGED
@@ -20,41 +20,21 @@ import {
20
20
  parseTimeBlockShortcut,
21
21
  } from "~/store/parsers";
22
22
  import { ATTR, T } from "~/ui/glyphs";
23
+ import { TIMELINE_WIDTH } from "~/views/Dashboard";
23
24
  import type { TuiStore } from "~/store/index";
24
25
  import type { PriorityLevel, TimeBlock } from "~/types";
25
26
 
26
- /** Fixed side-panel width for any modal. Wide enough for Detail / Help
27
- * without being absurd for Edit / Confirm. */
28
- const MODAL_WIDTH = 64;
27
+ /** The modal panel matches the Agenda's width so it can drop into the Agenda's
28
+ * slot (which the Dashboard vacates while a modal is open) with no reflow. */
29
+ const MODAL_WIDTH = TIMELINE_WIDTH;
29
30
 
30
31
  export function ModalLayer(props: { store: TuiStore }) {
31
32
  const modal = createMemo(() => props.store.state.ui.modal);
32
33
  return (
33
34
  <Show when={modal()}>
34
- <box
35
- style={{
36
- flexDirection: "column",
37
- width: MODAL_WIDTH,
38
- minWidth: MODAL_WIDTH,
39
- flexGrow: 0,
40
- flexShrink: 0,
41
- // Stretch to the parent's height so the modal panel matches
42
- // the timeline / board zones it sits next to. Same contract as
43
- // every other zone — fixed cross-axis size, full-height fill.
44
- alignSelf: "stretch",
45
- marginLeft: 1,
46
- backgroundColor: T.panelBgActive,
47
- border: true,
48
- borderStyle: "rounded",
49
- borderColor: T.borderActive,
50
- paddingLeft: 1,
51
- paddingRight: 1,
52
- paddingTop: 1,
53
- paddingBottom: 1,
54
- }}
55
- >
56
- <ModalRouter store={props.store} modal={modal()!} />
57
- </box>
35
+ {/* Each modal's DialogShell IS the panel box (border + title + slot
36
+ dimensions), so it drops into the Agenda's slot directly. */}
37
+ <ModalRouter store={props.store} modal={modal()!} />
58
38
  </Show>
59
39
  );
60
40
  }
@@ -87,11 +67,31 @@ interface DialogShellProps {
87
67
  function DialogShell(props: DialogShellProps) {
88
68
  void props.width;
89
69
  return (
90
- <box style={{ flexDirection: "column" }}>
91
- <text>
92
- <span style={{ fg: T.accent, attributes: ATTR.bold }}>{props.title}</span>
93
- </text>
94
- <box style={{ height: 1 }} />
70
+ <box
71
+ style={{
72
+ // The modal panel: same width + marginLeft as the Agenda so it occupies
73
+ // the Agenda's slot (the Dashboard hides the Agenda while a modal is
74
+ // open) with no reflow. The title rides in the top border, exactly like
75
+ // the board columns and the other zones.
76
+ flexDirection: "column",
77
+ width: MODAL_WIDTH,
78
+ minWidth: MODAL_WIDTH,
79
+ flexGrow: 0,
80
+ flexShrink: 0,
81
+ alignSelf: "stretch",
82
+ marginLeft: 1,
83
+ backgroundColor: T.panelBgActive,
84
+ border: true,
85
+ borderStyle: "rounded",
86
+ borderColor: T.borderActive,
87
+ paddingLeft: 1,
88
+ paddingRight: 1,
89
+ paddingTop: 1,
90
+ paddingBottom: 1,
91
+ }}
92
+ title={`┤ ${props.title} ├`}
93
+ titleAlignment="left"
94
+ >
95
95
  {props.children}
96
96
  <Show when={props.hint}>
97
97
  <text>
@@ -207,11 +207,7 @@ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiS
207
207
 
208
208
  return (
209
209
  <DialogShell
210
- title={
211
- markedCount > 1
212
- ? `Schedule ${markedCount} selected tasks`
213
- : `Schedule: ${task?.displayTitle.slice(0, 50) ?? ""}`
214
- }
210
+ title={markedCount > 1 ? `Schedule · ${markedCount} tasks` : "Schedule"}
215
211
  hint="t = today · tm = tomorrow · +3 = in 3 days · lun = next Monday · 2026-06-15 · empty/-clear · Esc to cancel"
216
212
  width={70}
217
213
  >
@@ -256,11 +252,7 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
256
252
 
257
253
  return (
258
254
  <DialogShell
259
- title={
260
- markedCount > 1
261
- ? `Time block ${markedCount} selected tasks`
262
- : `Time block: ${task?.displayTitle.slice(0, 50) ?? ""}`
263
- }
255
+ title={markedCount > 1 ? `Time block · ${markedCount} tasks` : "Time block"}
264
256
  hint="n = now+30 · 9:00 · 9-11 · 09:30-10:45 · - to clear · Esc to cancel"
265
257
  width={70}
266
258
  >
@@ -588,12 +580,12 @@ function HelpModal(props: { store: TuiStore }) {
588
580
  <span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
589
581
  <span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
590
582
  <span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
591
- <span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow virtual panel focus\n"}</span>
592
- <span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (virtual → board → timeline → agents)\n"}</span>
593
- <span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Virtual / Timeline / Agents zones\n"}</span>
583
+ <span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow planner panel focus\n"}</span>
584
+ <span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (planner → board → timeline → agents)\n"}</span>
585
+ <span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Planner / Timeline / Agents zones\n"}</span>
594
586
  <span style={{ fg: T.text }}>{" z Zoom active zone (or column) to full screen\n"}</span>
595
587
  <span style={{ fg: T.text }}>{" r Refresh everything (boards from disk, agents, agenda calendar)\n"}</span>
596
- <span style={{ fg: T.textDim }}>{"\nTask actions (work in board, virtual, AND timeline zones)\n"}</span>
588
+ <span style={{ fg: T.textDim }}>{"\nTask actions (work in board, planner, AND timeline zones)\n"}</span>
597
589
  <span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
598
590
  <span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
599
591
  <span style={{ fg: T.text }}>{" e Edit task text\n"}</span>