tuiboard 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tuiboard/config.example.yaml +14 -0
- package/README.md +51 -15
- package/package.json +1 -1
- package/src/app.tsx +19 -14
- package/src/config/loader.ts +51 -0
- package/src/input/handleKey.ts +23 -22
- package/src/store/index.test.ts +76 -10
- package/src/store/index.ts +112 -19
- package/src/store/{virtual-panel.ts → planner-panel.ts} +0 -0
- package/src/ui/AgentsBar.tsx +1 -1
- package/src/ui/BoardView.tsx +5 -5
- package/src/ui/Modal.tsx +4 -4
- package/src/ui/{VirtualPanel.tsx → PlannerPanel.tsx} +13 -13
- package/src/ui/TimelineView.tsx +1 -1
- package/src/ui/glyphs.ts +2 -2
- package/src/views/BoardOnly.tsx +5 -5
- package/src/views/Dashboard.tsx +6 -6
|
@@ -31,6 +31,20 @@ done_column: Done
|
|
|
31
31
|
# doesn't exist in a board, tuiboard creates it on the fly.
|
|
32
32
|
archive_column: Archive
|
|
33
33
|
|
|
34
|
+
# Optional: pick which zones you want. The board is always on; the other three
|
|
35
|
+
# are yours to turn off. Each takes one of:
|
|
36
|
+
# on enabled and shown at launch (the default)
|
|
37
|
+
# off disabled entirely — never rendered, skipped by Shift-Tab, its F-key
|
|
38
|
+
# is inert, and its background work never runs (no calendar fetch, no
|
|
39
|
+
# ~/.claude reads). Use this for a pure kanban, or kanban + one panel.
|
|
40
|
+
# hidden enabled but collapsed at launch — reveal it any time with its F-key
|
|
41
|
+
# Want just a kanban? Set agenda and agents to off. (true/false also work.)
|
|
42
|
+
#
|
|
43
|
+
# zones:
|
|
44
|
+
# planner: on # Today/Tomorrow cross-board panel (F1)
|
|
45
|
+
# agenda: on # 24h agenda + calendar overlay (F2)
|
|
46
|
+
# agents: on # live Claude Code session view (F3)
|
|
47
|
+
|
|
34
48
|
# Optional: override what Enter does in the Agents zone ("open the selected
|
|
35
49
|
# Claude Code session"). An argv array — the tokens {cwd} and {sessionId} are
|
|
36
50
|
# substituted, then it's run directly (no shell). Point it at your own script
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tuiboard
|
|
2
2
|
|
|
3
|
-
A terminal dashboard that unifies **kanban**, a **Today/Tomorrow
|
|
3
|
+
A terminal dashboard that unifies **kanban**, a **Today/Tomorrow planner
|
|
4
4
|
panel**, a **24-hour agenda** (with a read-only Google / Microsoft 365
|
|
5
5
|
calendar overlay), and a **live agent view** for Claude Code sessions — all
|
|
6
6
|
on top of plain markdown task files.
|
|
@@ -98,12 +98,17 @@ https://github.com/NazzarenoGiannelli/tuiboard). Set it up for me from scratch:
|
|
|
98
98
|
3. Create a global config at `~/.config/tuiboard/config.yaml` (resolve the real
|
|
99
99
|
home path) with a `boards:` list pointing at those files by ABSOLUTE path,
|
|
100
100
|
plus `assignees: [...]`, `done_column: Done`, `archive_column: Archive`.
|
|
101
|
-
4.
|
|
101
|
+
4. Ask me which zones I want besides the kanban board: the Today/Tomorrow
|
|
102
|
+
planner, the 24h agenda, and the live Claude Code agents view. For any I
|
|
103
|
+
don't want, add a `zones:` block setting it to `off` (e.g. someone who
|
|
104
|
+
doesn't use Claude Code would set `agents: off`). If I want them all, omit
|
|
105
|
+
the block. Do NOT configure the agents view beyond on/off — it reads
|
|
106
|
+
`~/.claude` automatically when enabled.
|
|
102
107
|
5. Ask me whether I want to overlay my Google Calendar or Microsoft 365 events
|
|
103
|
-
on the Agenda. If yes, tell me to run
|
|
104
|
-
`microsoft`) — it interviews me, opens
|
|
105
|
-
`calendars:` YAML block to add. Don't try
|
|
106
|
-
skip it (it's optional and can be added later).
|
|
108
|
+
on the Agenda (skip this if I turned the agenda off). If yes, tell me to run
|
|
109
|
+
`tuiboard calendar-setup google` (or `microsoft`) — it interviews me, opens
|
|
110
|
+
the browser, and prints the exact `calendars:` YAML block to add. Don't try
|
|
111
|
+
to do the OAuth yourself. If no, skip it (it's optional and can be added later).
|
|
107
112
|
6. Show me the final config, then tell me to run `tuiboard`, and how to add a
|
|
108
113
|
board later (create a new `.md` and append it to the `boards:` list).
|
|
109
114
|
|
|
@@ -143,6 +148,34 @@ archive_column: Archive
|
|
|
143
148
|
# resume_command: ["nu", "C:/Users/you/.config/tuiboard/code-resume.nu", "{cwd}", "{sessionId}"]
|
|
144
149
|
```
|
|
145
150
|
|
|
151
|
+
## Zones
|
|
152
|
+
|
|
153
|
+
tuiboard is four zones — **board** (kanban), **planner** (Today/Tomorrow across
|
|
154
|
+
all boards), **agenda** (24h timeline + calendar overlay), and **agents** (live
|
|
155
|
+
Claude Code sessions). Only want some of them? The board is always on; the other
|
|
156
|
+
three are yours to configure:
|
|
157
|
+
|
|
158
|
+
```yaml
|
|
159
|
+
zones:
|
|
160
|
+
planner: on # Today/Tomorrow panel (toggle at runtime with F1)
|
|
161
|
+
agenda: on # 24h agenda + calendars (F2)
|
|
162
|
+
agents: off # live Claude Code view (F3)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Each zone takes one of:
|
|
166
|
+
|
|
167
|
+
| Value | Behavior |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `on` | Enabled and shown at launch (the default). |
|
|
170
|
+
| `off` | **Disabled entirely** — never rendered, skipped by `Shift-Tab`, its F-key is inert, and its background work never starts (no calendar fetch, no `~/.claude` reads). |
|
|
171
|
+
| `hidden` | Enabled but **collapsed at launch** — reveal it any time with its F-key. |
|
|
172
|
+
|
|
173
|
+
`true`/`false` work as aliases for `on`/`off`. So a pure kanban is just
|
|
174
|
+
`agenda: off` and `agents: off`; kanban + calendar is `agents: off`. The
|
|
175
|
+
difference between `off` and the F-key hide: `off` means the feature never runs
|
|
176
|
+
at all — handy if you don't use Claude Code and don't want tuiboard reading
|
|
177
|
+
`~/.claude`.
|
|
178
|
+
|
|
146
179
|
## Calendars (Agenda overlay)
|
|
147
180
|
|
|
148
181
|
The **Agenda** zone (the 24h timeline) can overlay read-only events from Google
|
|
@@ -241,7 +274,7 @@ Launch `tuiboard` with no flag for the default 4-zone dashboard.
|
|
|
241
274
|
| Flag | View | Use case |
|
|
242
275
|
|---|---|---|
|
|
243
276
|
| (none) | **Dashboard** — all 4 zones | Default; everything in one terminal |
|
|
244
|
-
| `--view=board` | Kanban +
|
|
277
|
+
| `--view=board` | Kanban + planner panel only | Focus mode, or a single WezTerm pane |
|
|
245
278
|
| `--view=timeline` | Timeline fullscreen | Wall-mounted "what's now" |
|
|
246
279
|
| `--view=agents` | Agent view fullscreen | Cross-machine session monitor |
|
|
247
280
|
|
|
@@ -249,9 +282,9 @@ The dashboard auto-collapses optional zones on narrow terminals:
|
|
|
249
282
|
|
|
250
283
|
| Terminal width | Default zones visible |
|
|
251
284
|
|---|---|
|
|
252
|
-
| ≥ 150 cols |
|
|
253
|
-
| 120–149 |
|
|
254
|
-
| 100–119 |
|
|
285
|
+
| ≥ 150 cols | planner + board + timeline + agents |
|
|
286
|
+
| 120–149 | planner + board + agents |
|
|
287
|
+
| 100–119 | planner + board |
|
|
255
288
|
| < 100 | board only |
|
|
256
289
|
|
|
257
290
|
`F1` / `F2` / `F3` toggles override the auto-collapse for the current
|
|
@@ -266,9 +299,9 @@ session (until the next terminal resize).
|
|
|
266
299
|
| `h j k l` / arrows | Move cursor inside the active zone |
|
|
267
300
|
| `Tab` | Cycle to next board |
|
|
268
301
|
| `1`..`9` | Jump to board N |
|
|
269
|
-
| `v` | Toggle Today/Tomorrow
|
|
270
|
-
| `Shift-Tab` | Cycle active zone (
|
|
271
|
-
| `F1` / `F2` / `F3` | Toggle visibility of
|
|
302
|
+
| `v` | Toggle Today/Tomorrow planner panel focus |
|
|
303
|
+
| `Shift-Tab` | Cycle active zone (planner → board → timeline → agents) |
|
|
304
|
+
| `F1` / `F2` / `F3` | Toggle visibility of Planner / Timeline / Agents zones |
|
|
272
305
|
| `z` | Zoom active zone to full screen |
|
|
273
306
|
| `r` | Refresh everything — reload boards from disk, rescan agents, force-refetch the agenda calendar (bypasses the 30-min cache) |
|
|
274
307
|
|
|
@@ -282,7 +315,7 @@ session (until the next terminal resize).
|
|
|
282
315
|
| `j` / `k` | While armed: nudge the block ±15 min |
|
|
283
316
|
| `+` / `-` | While armed: resize the block's end ±15 min |
|
|
284
317
|
|
|
285
|
-
### Task actions (work in board,
|
|
318
|
+
### Task actions (work in board, planner, AND timeline zones)
|
|
286
319
|
|
|
287
320
|
| Key | Action |
|
|
288
321
|
|---|---|
|
|
@@ -320,10 +353,13 @@ session (until the next terminal resize).
|
|
|
320
353
|
|
|
321
354
|
## Status
|
|
322
355
|
|
|
356
|
+
- **v0.7** — configurable zones: turn the planner, agenda, or agents view off
|
|
357
|
+
(or start it collapsed) via the `zones:` config, so tuiboard can be a pure
|
|
358
|
+
kanban, kanban + calendar, or any mix.
|
|
323
359
|
- **v0.6** — adds the Agenda calendar overlay (Google + Microsoft 365,
|
|
324
360
|
read-only, BYO credentials), day-navigation (`[` / `]` / `\`) to page tasks
|
|
325
361
|
and events across days, and a manual full-refresh key (`r`).
|
|
326
|
-
- **v0.5** — daily-driver ready. Kanban +
|
|
362
|
+
- **v0.5** — daily-driver ready. Kanban + planner + timeline + agents
|
|
327
363
|
all functional, multi-select, undo, atomic file roundtrip, mouse click,
|
|
328
364
|
responsive layout. Tested on Windows with WezTerm; Linux/macOS should
|
|
329
365
|
work via the same OpenTUI binaries (untested).
|
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Loads config, builds the reactive store, parses argv, and dispatches
|
|
5
5
|
* to one of four root views:
|
|
6
6
|
* - undefined → Dashboard (all 4 zones)
|
|
7
|
-
* - "board" → BoardOnly (kanban +
|
|
7
|
+
* - "board" → BoardOnly (kanban + planner fullscreen)
|
|
8
8
|
* - "timeline"→ TimelineOnly
|
|
9
9
|
* - "agents" → AgentsOnly
|
|
10
10
|
*
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
createTuiStore,
|
|
23
23
|
type TuiStore,
|
|
24
24
|
} from "~/store/index";
|
|
25
|
-
import {
|
|
25
|
+
import { buildPlannerItems } from "~/store/planner-panel";
|
|
26
26
|
import { T } from "~/ui/glyphs";
|
|
27
27
|
import { TopBar, BottomBar } from "~/ui/Chrome";
|
|
28
28
|
import { ModalLayer } from "~/ui/Modal";
|
|
@@ -62,16 +62,21 @@ process.on("SIGTERM", () => {
|
|
|
62
62
|
// ≥ 150 col → all four zones
|
|
63
63
|
// 120–149 → hide timeline
|
|
64
64
|
// 100–119 → hide agents too
|
|
65
|
-
// < 100 → hide
|
|
65
|
+
// < 100 → hide planner too (board is non-hideable)
|
|
66
66
|
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
67
|
+
// This only reports what FITS. The store combines it with each zone's enabled
|
|
68
|
+
// flag and the user's desired visibility, so F1/F2/F3 toggles persist across
|
|
69
|
+
// resizes and a disabled/hidden zone is never force-shown.
|
|
70
70
|
function applyResponsiveLayout(): void {
|
|
71
71
|
const width = process.stdout.columns ?? 200;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// Report which zones FIT at this width. The store ANDs this with each zone's
|
|
73
|
+
// enabled flag and the user's desired visibility, so a disabled or
|
|
74
|
+
// intentionally-hidden zone is never force-shown just because there's room.
|
|
75
|
+
store.applyResponsiveFits({
|
|
76
|
+
planner: width >= 100,
|
|
77
|
+
timeline: width >= 150,
|
|
78
|
+
agents: width >= 120,
|
|
79
|
+
});
|
|
75
80
|
}
|
|
76
81
|
applyResponsiveLayout();
|
|
77
82
|
process.stdout.on("resize", applyResponsiveLayout);
|
|
@@ -79,8 +84,8 @@ process.stdout.on("resize", applyResponsiveLayout);
|
|
|
79
84
|
// Land on the Today/Tomorrow panel by default — for a daily-planning tool the
|
|
80
85
|
// first question is "what's on my plate today", and that panel answers it. On
|
|
81
86
|
// a narrow terminal where the panel auto-hides, fall back to the board.
|
|
82
|
-
if (store.state.ui.visibleZones.
|
|
83
|
-
store.setActiveZone("
|
|
87
|
+
if (store.state.ui.visibleZones.planner) {
|
|
88
|
+
store.setActiveZone("planner");
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
const { view } = parseArgs(process.argv.slice(2));
|
|
@@ -97,11 +102,11 @@ function rootViewFor(v: ViewKind | undefined, s: TuiStore) {
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
function App() {
|
|
100
|
-
const
|
|
101
|
-
|
|
105
|
+
const plannerItems = createMemo(() =>
|
|
106
|
+
buildPlannerItems(store.state.boards.map((b) => b.board)),
|
|
102
107
|
);
|
|
103
108
|
|
|
104
|
-
useKeyboard((key) => handleKey(store, key,
|
|
109
|
+
useKeyboard((key) => handleKey(store, key, plannerItems().length));
|
|
105
110
|
|
|
106
111
|
return (
|
|
107
112
|
<box
|
package/src/config/loader.ts
CHANGED
|
@@ -49,6 +49,30 @@ export interface Config {
|
|
|
49
49
|
* Tokens are produced by `tuiboard calendar-setup`.
|
|
50
50
|
*/
|
|
51
51
|
calendars?: CalendarsConfig;
|
|
52
|
+
/**
|
|
53
|
+
* Which optional zones are enabled and how they start. The board zone is
|
|
54
|
+
* always on (load-bearing) and not configurable here.
|
|
55
|
+
*/
|
|
56
|
+
zones: ZonesConfig;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-zone startup mode:
|
|
61
|
+
* - "on" → enabled and visible at launch (default)
|
|
62
|
+
* - "off" → disabled entirely: never rendered, skipped by Shift-Tab, its
|
|
63
|
+
* F-key is inert, and its background work (calendar fetch /
|
|
64
|
+
* `~/.claude` watcher) never starts
|
|
65
|
+
* - "hidden" → enabled but collapsed at launch; reveal it with its F-key
|
|
66
|
+
*/
|
|
67
|
+
export type ZoneMode = "on" | "off" | "hidden";
|
|
68
|
+
|
|
69
|
+
export interface ZonesConfig {
|
|
70
|
+
/** Today/Tomorrow cross-board panel. */
|
|
71
|
+
planner: ZoneMode;
|
|
72
|
+
/** 24h agenda + calendar overlay (internally the "timeline" zone). */
|
|
73
|
+
agenda: ZoneMode;
|
|
74
|
+
/** Live Claude Code session view. */
|
|
75
|
+
agents: ZoneMode;
|
|
52
76
|
}
|
|
53
77
|
|
|
54
78
|
export interface GoogleCalendarConfig {
|
|
@@ -79,6 +103,7 @@ export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
|
|
|
79
103
|
assignees: [],
|
|
80
104
|
doneColumn: "Done",
|
|
81
105
|
archiveColumn: "Archive",
|
|
106
|
+
zones: { planner: "on", agenda: "on", agents: "on" },
|
|
82
107
|
};
|
|
83
108
|
|
|
84
109
|
/**
|
|
@@ -135,6 +160,13 @@ interface RawConfig {
|
|
|
135
160
|
color?: string;
|
|
136
161
|
};
|
|
137
162
|
};
|
|
163
|
+
// Values may be boolean (js-yaml parses on/off/yes/no → boolean) or the
|
|
164
|
+
// string "hidden"/"collapsed". Anything else falls back to "on".
|
|
165
|
+
zones: {
|
|
166
|
+
planner?: boolean | string;
|
|
167
|
+
agenda?: boolean | string;
|
|
168
|
+
agents?: boolean | string;
|
|
169
|
+
};
|
|
138
170
|
}
|
|
139
171
|
|
|
140
172
|
/** Expand `~` to the home dir; resolve relative paths against the config dir. */
|
|
@@ -173,6 +205,24 @@ function normalizeCalendars(
|
|
|
173
205
|
return out.google || out.microsoft ? out : undefined;
|
|
174
206
|
}
|
|
175
207
|
|
|
208
|
+
/** Normalize one zone's raw value to a ZoneMode. Default (undefined) → "on". */
|
|
209
|
+
function normalizeZoneMode(v: boolean | string | undefined): ZoneMode {
|
|
210
|
+
if (v === undefined || v === true) return "on";
|
|
211
|
+
if (v === false) return "off";
|
|
212
|
+
const s = String(v).toLowerCase();
|
|
213
|
+
if (s === "off" || s === "false" || s === "no" || s === "none") return "off";
|
|
214
|
+
if (s === "hidden" || s === "collapsed" || s === "closed") return "hidden";
|
|
215
|
+
return "on";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeZones(raw: RawConfig["zones"] | undefined): ZonesConfig {
|
|
219
|
+
return {
|
|
220
|
+
planner: normalizeZoneMode(raw?.planner),
|
|
221
|
+
agenda: normalizeZoneMode(raw?.agenda),
|
|
222
|
+
agents: normalizeZoneMode(raw?.agents),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
176
226
|
interface FoundConfig {
|
|
177
227
|
path: string;
|
|
178
228
|
dir: string;
|
|
@@ -239,6 +289,7 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
|
|
|
239
289
|
? raw.resume_command.map(String)
|
|
240
290
|
: undefined,
|
|
241
291
|
calendars: normalizeCalendars(raw.calendars, root),
|
|
292
|
+
zones: normalizeZones(raw.zones),
|
|
242
293
|
};
|
|
243
294
|
}
|
|
244
295
|
|
package/src/input/handleKey.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* Centralized keyboard input handler. Dispatches based on:
|
|
3
3
|
* 1. modal state (modal eats most keys)
|
|
4
4
|
* 2. global keys (quit, help, undo, board switch, escape, zone cycle)
|
|
5
|
-
* 3. active zone (
|
|
5
|
+
* 3. active zone (planner / board / timeline / agents)
|
|
6
6
|
*
|
|
7
7
|
* Task-level actions (`t`/`m`/`s`/`b`/`a`/`o`/`Space`/`X`/`d`/`p`/`.`/`Enter`)
|
|
8
8
|
* all route through `dispatchTaskAction`, so they work identically on a
|
|
9
|
-
* cursor task whether you reach it via the kanban board, the
|
|
9
|
+
* cursor task whether you reach it via the kanban board, the planner
|
|
10
10
|
* panel, or a timeline block.
|
|
11
11
|
*
|
|
12
12
|
* Extracted from app.tsx so every root view (Dashboard, BoardOnly, etc.)
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "~/store/index";
|
|
25
25
|
import type { Board, PriorityLevel } from "~/types";
|
|
26
26
|
import { buildTimelineEntries, formatAgendaDay } from "~/store/timeline";
|
|
27
|
-
import {
|
|
27
|
+
import { buildPlannerItems } from "~/store/planner-panel";
|
|
28
28
|
import { jumpToKanban } from "~/ui/TimelineView";
|
|
29
29
|
|
|
30
30
|
interface KeyEvent {
|
|
@@ -37,7 +37,7 @@ interface KeyEvent {
|
|
|
37
37
|
export function handleKey(
|
|
38
38
|
store: TuiStore,
|
|
39
39
|
key: KeyEvent,
|
|
40
|
-
|
|
40
|
+
plannerCount: number,
|
|
41
41
|
): void {
|
|
42
42
|
const ui = store.state.ui;
|
|
43
43
|
const board = store.state.boards[ui.activeBoardIndex]?.board;
|
|
@@ -125,17 +125,18 @@ export function handleKey(
|
|
|
125
125
|
return;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// F1/F2/F3 toggle zone visibility. Board cannot be hidden
|
|
128
|
+
// F1/F2/F3 toggle zone visibility. Board cannot be hidden; a disabled zone's
|
|
129
|
+
// key is inert (toggleZoneDesired no-ops).
|
|
129
130
|
if (key.name === "f1") {
|
|
130
|
-
store.
|
|
131
|
+
store.toggleZoneDesired("planner");
|
|
131
132
|
return;
|
|
132
133
|
}
|
|
133
134
|
if (key.name === "f2") {
|
|
134
|
-
store.
|
|
135
|
+
store.toggleZoneDesired("timeline");
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
137
138
|
if (key.name === "f3") {
|
|
138
|
-
store.
|
|
139
|
+
store.toggleZoneDesired("agents");
|
|
139
140
|
return;
|
|
140
141
|
}
|
|
141
142
|
|
|
@@ -150,9 +151,9 @@ export function handleKey(
|
|
|
150
151
|
return;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
// Switch in/out of
|
|
154
|
+
// Switch in/out of planner panel with `v`
|
|
154
155
|
if (key.name === "v") {
|
|
155
|
-
store.setActiveZone(ui.activeZone === "
|
|
156
|
+
store.setActiveZone(ui.activeZone === "planner" ? "board" : "planner");
|
|
156
157
|
return;
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -183,7 +184,7 @@ export function handleKey(
|
|
|
183
184
|
return;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
// Zoom toggle: focus the active panel (board column or
|
|
187
|
+
// Zoom toggle: focus the active panel (board column or planner panel)
|
|
187
188
|
// at full width.
|
|
188
189
|
if (key.name === "z") {
|
|
189
190
|
store.toggleZoom();
|
|
@@ -227,8 +228,8 @@ export function handleKey(
|
|
|
227
228
|
|
|
228
229
|
// ─── Per-zone dispatching ───────────────────────────────────────────────
|
|
229
230
|
|
|
230
|
-
if (ui.activeZone === "
|
|
231
|
-
|
|
231
|
+
if (ui.activeZone === "planner") {
|
|
232
|
+
handlePlannerZone(store, key, plannerCount, openLater);
|
|
232
233
|
return;
|
|
233
234
|
}
|
|
234
235
|
|
|
@@ -249,19 +250,19 @@ export function handleKey(
|
|
|
249
250
|
|
|
250
251
|
// ─── Zone handlers ──────────────────────────────────────────────────────────
|
|
251
252
|
|
|
252
|
-
function
|
|
253
|
+
function handlePlannerZone(
|
|
253
254
|
store: TuiStore,
|
|
254
255
|
key: KeyEvent,
|
|
255
|
-
|
|
256
|
+
plannerCount: number,
|
|
256
257
|
openLater: (m: ModalKind) => void,
|
|
257
258
|
): void {
|
|
258
259
|
const ui = store.state.ui;
|
|
259
|
-
const items =
|
|
260
|
+
const items = buildPlannerItems(store.state.boards.map((b) => b.board));
|
|
260
261
|
const target = items[ui.row];
|
|
261
262
|
|
|
262
263
|
// Navigation
|
|
263
264
|
if (key.name === "j" || key.name === "down") {
|
|
264
|
-
store.setCursor(ui.col, Math.min(
|
|
265
|
+
store.setCursor(ui.col, Math.min(plannerCount - 1, ui.row + 1));
|
|
265
266
|
return;
|
|
266
267
|
}
|
|
267
268
|
if (key.name === "k" || key.name === "up") {
|
|
@@ -273,7 +274,7 @@ function handleVirtualZone(
|
|
|
273
274
|
return;
|
|
274
275
|
}
|
|
275
276
|
|
|
276
|
-
// Task actions on the
|
|
277
|
+
// Task actions on the planner cursor's target (works cross-board).
|
|
277
278
|
if (target) {
|
|
278
279
|
dispatchTaskAction(store, key, target.ref, openLater);
|
|
279
280
|
}
|
|
@@ -501,7 +502,7 @@ function handleBoardZone(
|
|
|
501
502
|
// unrendered, unscrollable column.
|
|
502
503
|
const prev = adjacentVisibleColumn(store, board, ui.col, -1);
|
|
503
504
|
if (prev === undefined) {
|
|
504
|
-
store.setActiveZone("
|
|
505
|
+
store.setActiveZone("planner");
|
|
505
506
|
} else {
|
|
506
507
|
store.setCursor(prev, 0);
|
|
507
508
|
}
|
|
@@ -551,7 +552,7 @@ function handleBoardZone(
|
|
|
551
552
|
|
|
552
553
|
/**
|
|
553
554
|
* Apply a task-level action to the given cursorRef. Used by every zone
|
|
554
|
-
* (board /
|
|
555
|
+
* (board / planner / timeline) so the keys feel identical wherever the
|
|
555
556
|
* cursor lives. Multi-select aware: when there are marked tasks, the
|
|
556
557
|
* action operates on all of them via `applyToMarkedOr`.
|
|
557
558
|
*
|
|
@@ -565,7 +566,7 @@ function dispatchTaskAction(
|
|
|
565
566
|
ref: TaskRef,
|
|
566
567
|
openLater: (m: ModalKind) => void,
|
|
567
568
|
): boolean {
|
|
568
|
-
// Toggle done (Enter). Only meaningful for board/
|
|
569
|
+
// Toggle done (Enter). Only meaningful for board/planner; timeline has
|
|
569
570
|
// its own Enter behavior (jump to kanban) which is handled earlier.
|
|
570
571
|
if (key.name === "enter" || key.name === "return") {
|
|
571
572
|
const n = store.applyToMarkedOr(ref, (r) => store.toggleDone(r));
|
|
@@ -623,7 +624,7 @@ function dispatchTaskAction(
|
|
|
623
624
|
// Calendar arm mode (lowercase c): toggle a persistent mode for batch
|
|
624
625
|
// scheduling onto the timeline. Entering also arms the cursor task and
|
|
625
626
|
// focuses the timeline so you can immediately click a slot. While the mode
|
|
626
|
-
// is on, clicking ANY task (board /
|
|
627
|
+
// is on, clicking ANY task (board / planner) arms it — click a task, click a
|
|
627
628
|
// slot, repeat. `c` again or `Esc` exits. Works from any zone.
|
|
628
629
|
if (key.name === "c" && !key.shift) {
|
|
629
630
|
if (store.state.ui.armMode) {
|
package/src/store/index.test.ts
CHANGED
|
@@ -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("
|
|
33
|
-
expect(store.state.ui.activeZone).toBe("
|
|
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('
|
|
40
|
+
it("setActiveZone('planner') resets row to 0", () => {
|
|
39
41
|
const store = createTuiStore({ config: emptyConfig() });
|
|
40
42
|
store.setCursor(0, 7);
|
|
41
|
-
store.setActiveZone("
|
|
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
|
-
|
|
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.
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
+
});
|
package/src/store/index.ts
CHANGED
|
@@ -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 /
|
|
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 = "
|
|
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[] = ["
|
|
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
|
-
/**
|
|
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 /
|
|
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,
|
|
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:
|
|
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
|
-
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
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 (
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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) {
|
|
Binary file
|
package/src/ui/AgentsBar.tsx
CHANGED
|
@@ -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 /
|
|
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
|
package/src/ui/BoardView.tsx
CHANGED
|
@@ -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
|
|
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
|
|
@@ -88,7 +88,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
88
88
|
* (Python kanban `z`).
|
|
89
89
|
*/
|
|
90
90
|
const renderedColumns = createMemo(() => {
|
|
91
|
-
if (!ui().zoomed || ui().activeZone === "
|
|
91
|
+
if (!ui().zoomed || ui().activeZone === "planner") return visibleColumns();
|
|
92
92
|
const cols = visibleColumns();
|
|
93
93
|
// ui.col is a board.columns index (carries Archive); map it to the
|
|
94
94
|
// rendered list so zoom focuses the column actually under the cursor.
|
|
@@ -111,7 +111,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
111
111
|
// geometry used everywhere (COL_WIDTH + COL_GAP).
|
|
112
112
|
createEffect(() => {
|
|
113
113
|
const colIdx = ui().col;
|
|
114
|
-
if (ui().zoomed || ui().activeZone === "
|
|
114
|
+
if (ui().zoomed || ui().activeZone === "planner") {
|
|
115
115
|
setScrollX(0);
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
@@ -153,7 +153,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
153
153
|
};
|
|
154
154
|
|
|
155
155
|
// Measure the viewport width once at mount, regardless of the active zone.
|
|
156
|
-
// Otherwise — since tuiboard now starts focused on the
|
|
156
|
+
// Otherwise — since tuiboard now starts focused on the planner panel — the
|
|
157
157
|
// scroll effect early-returns and viewportW stays 0, so partly-clipped
|
|
158
158
|
// columns briefly show their task rows until the board is first touched.
|
|
159
159
|
onMount(() => {
|
|
@@ -328,7 +328,7 @@ function ColumnView(props: ColumnViewProps) {
|
|
|
328
328
|
flexShrink: 0,
|
|
329
329
|
// No explicit height — Yoga stretches us along the row's cross
|
|
330
330
|
// axis, so the column always fills the full board height. Same
|
|
331
|
-
// contract as the Today/Tomorrow
|
|
331
|
+
// contract as the Today/Tomorrow planner panel next door.
|
|
332
332
|
marginRight: COL_GAP,
|
|
333
333
|
border: true,
|
|
334
334
|
borderStyle: "rounded",
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -588,12 +588,12 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
588
588
|
<span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
|
|
589
589
|
<span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
|
|
590
590
|
<span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
|
|
591
|
-
<span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow
|
|
592
|
-
<span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (
|
|
593
|
-
<span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of
|
|
591
|
+
<span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow planner panel focus\n"}</span>
|
|
592
|
+
<span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (planner → board → timeline → agents)\n"}</span>
|
|
593
|
+
<span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Planner / Timeline / Agents zones\n"}</span>
|
|
594
594
|
<span style={{ fg: T.text }}>{" z Zoom active zone (or column) to full screen\n"}</span>
|
|
595
595
|
<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,
|
|
596
|
+
<span style={{ fg: T.textDim }}>{"\nTask actions (work in board, planner, AND timeline zones)\n"}</span>
|
|
597
597
|
<span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
|
|
598
598
|
<span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
|
|
599
599
|
<span style={{ fg: T.text }}>{" e Edit task text\n"}</span>
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/** The fixed Today/Tomorrow
|
|
1
|
+
/** The fixed Today/Tomorrow planner panel — always on the left. */
|
|
2
2
|
|
|
3
3
|
import { For, Show, createEffect, createMemo } from "solid-js";
|
|
4
4
|
|
|
5
5
|
import { ATTR, T } from "~/ui/glyphs";
|
|
6
6
|
import { TaskRow } from "~/ui/TaskRow";
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
type
|
|
11
|
-
} from "~/store/
|
|
8
|
+
buildPlannerItems,
|
|
9
|
+
groupPlannerItems,
|
|
10
|
+
type PlannerGroup,
|
|
11
|
+
} from "~/store/planner-panel";
|
|
12
12
|
import type { TuiStore } from "~/store/index";
|
|
13
13
|
|
|
14
14
|
interface ScrollBoxLike {
|
|
@@ -32,15 +32,15 @@ const BUCKET_HEADER: Record<string, { label: string; color: string }> = {
|
|
|
32
32
|
priority: { label: "🔺 Priority", color: T.today },
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
export function
|
|
35
|
+
export function PlannerPanel(props: { store: TuiStore }) {
|
|
36
36
|
const items = createMemo(() => {
|
|
37
37
|
props.store.state.rev; // recompute on any board mutation
|
|
38
|
-
return
|
|
38
|
+
return buildPlannerItems(props.store.state.boards.map((b) => b.board));
|
|
39
39
|
});
|
|
40
|
-
const groups = createMemo(() =>
|
|
41
|
-
const isActive = createMemo(() => props.store.state.ui.activeZone === "
|
|
40
|
+
const groups = createMemo(() => groupPlannerItems(items()));
|
|
41
|
+
const isActive = createMemo(() => props.store.state.ui.activeZone === "planner");
|
|
42
42
|
const isZoomed = createMemo(
|
|
43
|
-
() => props.store.state.ui.zoomed && props.store.state.ui.activeZone === "
|
|
43
|
+
() => props.store.state.ui.zoomed && props.store.state.ui.activeZone === "planner",
|
|
44
44
|
);
|
|
45
45
|
const cursorRow = createMemo(() => props.store.state.ui.row);
|
|
46
46
|
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
@@ -121,7 +121,7 @@ export function VirtualPanel(props: { store: TuiStore }) {
|
|
|
121
121
|
availableWidth={isZoomed() ? 100 : 36}
|
|
122
122
|
isMarkedFn={(r) => props.store.isMarked(r)}
|
|
123
123
|
onClickItem={(flatIndex) => {
|
|
124
|
-
props.store.setActiveZone("
|
|
124
|
+
props.store.setActiveZone("planner");
|
|
125
125
|
props.store.setCursor(0, flatIndex);
|
|
126
126
|
// In calendar arm mode, a click also arms the task for the
|
|
127
127
|
// timeline (click a task, then a slot to place it).
|
|
@@ -141,7 +141,7 @@ export function VirtualPanel(props: { store: TuiStore }) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
function RenderGroups(props: {
|
|
144
|
-
groups:
|
|
144
|
+
groups: PlannerGroup[];
|
|
145
145
|
isActive: boolean;
|
|
146
146
|
cursorRow: number;
|
|
147
147
|
availableWidth: number;
|
|
@@ -258,7 +258,7 @@ function RenderGroups(props: {
|
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
function isFirstOfSection(
|
|
261
|
-
groups:
|
|
261
|
+
groups: PlannerGroup[],
|
|
262
262
|
idx: number,
|
|
263
263
|
): boolean {
|
|
264
264
|
if (idx === 0) return true;
|
package/src/ui/TimelineView.tsx
CHANGED
|
@@ -354,7 +354,7 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
354
354
|
</Show>
|
|
355
355
|
|
|
356
356
|
{/* The 24h grid now owns the whole panel — no sticky section above
|
|
357
|
-
it. Tasks are armed for scheduling from the board /
|
|
357
|
+
it. Tasks are armed for scheduling from the board / planner panel
|
|
358
358
|
via the `C` shortcut, then placed by clicking a slot here. */}
|
|
359
359
|
<scrollbox
|
|
360
360
|
ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
|
package/src/ui/glyphs.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const ATTR = {
|
|
|
29
29
|
* instead of raw ANSI names like "red"/"yellow" — those tend to render
|
|
30
30
|
* as fully-saturated traffic-light colors in most themes and clash
|
|
31
31
|
* with everything around them.
|
|
32
|
-
* - The Today/Tomorrow
|
|
32
|
+
* - The Today/Tomorrow planner panel has its own *warm* identity
|
|
33
33
|
* (peach/orange) so the user instantly knows "this is the time zone";
|
|
34
34
|
* everything else uses a *cool* identity (soft cyan) for active focus.
|
|
35
35
|
* - Cursor row highlight is the only opaque paint outside the modal.
|
|
@@ -137,7 +137,7 @@ export function cellWidth(s: string): number {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
|
-
* Per-board accent palette. Used by the
|
|
140
|
+
* Per-board accent palette. Used by the planner panel to color-code the
|
|
141
141
|
* source-board tag on priority/agenda items, so the user can recognize
|
|
142
142
|
* which board a cross-cutting item came from at a glance.
|
|
143
143
|
*
|
package/src/views/BoardOnly.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Standalone fullscreen view for the kanban board (with
|
|
2
|
+
* Standalone fullscreen view for the kanban board (with planner panel).
|
|
3
3
|
* Mounted when the user launches `tuiboard --view=board`, AND used inside
|
|
4
4
|
* the dashboard via composition.
|
|
5
5
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { Show, createMemo } from "solid-js";
|
|
14
14
|
|
|
15
15
|
import { BoardView } from "~/ui/BoardView";
|
|
16
|
-
import {
|
|
16
|
+
import { PlannerPanel } from "~/ui/PlannerPanel";
|
|
17
17
|
import type { TuiStore } from "~/store/index";
|
|
18
18
|
|
|
19
19
|
export function BoardOnly(props: { store: TuiStore }) {
|
|
@@ -24,10 +24,10 @@ export function BoardOnly(props: { store: TuiStore }) {
|
|
|
24
24
|
|
|
25
25
|
return (
|
|
26
26
|
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
27
|
-
<Show when={!ui().zoomed || ui().activeZone === "
|
|
28
|
-
<
|
|
27
|
+
<Show when={!ui().zoomed || ui().activeZone === "planner"}>
|
|
28
|
+
<PlannerPanel store={props.store} />
|
|
29
29
|
</Show>
|
|
30
|
-
<Show when={(!ui().zoomed || ui().activeZone !== "
|
|
30
|
+
<Show when={(!ui().zoomed || ui().activeZone !== "planner") && activeBoard()}>
|
|
31
31
|
<BoardView store={props.store} board={activeBoard()!} />
|
|
32
32
|
</Show>
|
|
33
33
|
</box>
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The default tuiboard view: a 4-zone dashboard.
|
|
3
3
|
*
|
|
4
|
-
* ┌─
|
|
4
|
+
* ┌─Planner─┬─Board────────┬─Timeline─┐
|
|
5
5
|
* │ │ │ │
|
|
6
6
|
* │ │ │ │
|
|
7
7
|
* │ ├──────────────┤ │
|
|
@@ -24,7 +24,7 @@ import { Show, createMemo } from "solid-js";
|
|
|
24
24
|
import { AgentsBar } from "~/ui/AgentsBar";
|
|
25
25
|
import { BoardView } from "~/ui/BoardView";
|
|
26
26
|
import { TimelineView } from "~/ui/TimelineView";
|
|
27
|
-
import {
|
|
27
|
+
import { PlannerPanel } from "~/ui/PlannerPanel";
|
|
28
28
|
import { AgentsOnly } from "~/views/AgentsOnly";
|
|
29
29
|
import { BoardOnly } from "~/views/BoardOnly";
|
|
30
30
|
import { TimelineOnly } from "~/views/TimelineOnly";
|
|
@@ -53,7 +53,7 @@ export function Dashboard(props: { store: TuiStore }) {
|
|
|
53
53
|
* standalone view wrappers so a `z` press in the dashboard produces
|
|
54
54
|
* the same visual as launching `tuiboard --view=<zone>`.
|
|
55
55
|
*
|
|
56
|
-
* Board +
|
|
56
|
+
* Board + planner share BoardOnly because both live in the top-left
|
|
57
57
|
* zone of the normal layout and BoardOnly already respects ui.zoomed
|
|
58
58
|
* to render only the active panel between them.
|
|
59
59
|
*/
|
|
@@ -80,12 +80,12 @@ function FourZoneLayout(props: { store: TuiStore }) {
|
|
|
80
80
|
|
|
81
81
|
return (
|
|
82
82
|
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
83
|
-
{/* Left column:
|
|
83
|
+
{/* Left column: planner + board on top, agents on bottom */}
|
|
84
84
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
85
85
|
{/* Top zone */}
|
|
86
86
|
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
87
|
-
<Show when={visible().
|
|
88
|
-
<
|
|
87
|
+
<Show when={visible().planner}>
|
|
88
|
+
<PlannerPanel store={props.store} />
|
|
89
89
|
</Show>
|
|
90
90
|
<Show when={visible().board && activeBoard()}>
|
|
91
91
|
<BoardView store={props.store} board={activeBoard()!} />
|