tuiboard 0.10.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -5,7 +5,35 @@ All notable changes to **tuiboard** are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.11.0] - 2026-09-16
9
+
10
+ ### Fixed
11
+ - **Zoomed column titles no longer truncate short of the available width.**
12
+ The zoomed `<TaskRow>` read a hardcoded `availableWidth` of `100` regardless
13
+ of the terminal's real size, and a separate 60-character cap on the title
14
+ never lifted for zoomed columns — together they clipped titles well before
15
+ the column's actual right edge, sometimes with a garbled double-truncation
16
+ (`walk t...gh backgro…`). The real measured viewport width now drives the
17
+ budget, the cap lifts when zoomed, and the width is re-measured on
18
+ zoom-toggle and terminal resize. Thanks @ArjunAnil2000.
19
+ - **A board's configured display `name` no longer reverts to the filename on
20
+ an external edit.** `loadAll()` applied `config.boards[].name` on startup,
21
+ but the file-watcher's reload path re-parsed the board from disk without
22
+ reapplying it, so any edit made outside tuiboard (another editor, a sync
23
+ tool, a script) silently renamed the board back to its filename-derived
24
+ default. The reload path now reapplies the configured name, matched by
25
+ filepath, the same way the initial load already did. Thanks @ArjunAnil2000.
26
+
27
+ ### Changed
28
+ - **Keyboard reference (`?`) restyle + scroll.** The help modal now groups
29
+ shortcuts under emoji section headers, color-codes the key (accent) vs. its
30
+ description (dim) with a dotted leader between them, and wraps long
31
+ descriptions into their own aligned column (no more continuation text running
32
+ under the keys). The whole reference now lives in a scrollbox so it never
33
+ clips on short terminals — `j`/`k` (or arrows) scroll it. Shortcut text is
34
+ unchanged, except that two entries missing from the rewrite (`+` New board,
35
+ `c` copy resume command in the Agents zone — both real, both still bound)
36
+ were restored during review. Thanks @k0-ba.
9
37
 
10
38
  ## [0.10.0] - 2026-09-09
11
39
 
@@ -77,6 +105,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
105
  that folder's sync and versioning; the fallback is `~/.local/share/tuiboard/boards/`.
78
106
 
79
107
  ### Fixed
108
+ - **A fresh install could crash on the `~/*` path alias**
109
+ (`Cannot find module '~/ui/splash-boot'`) — `tsconfig.json` wasn't in the
110
+ published `files` list yet. Fixed as a side effect of the packaging cleanup
111
+ below, but never called out on its own at the time; three users hit it on
112
+ 0.8.3/0.8.4 and filed independent issues before this line existed. Noted
113
+ retroactively on 2026-09-16.
80
114
  - **The key hints no longer truncate mid-word on a narrow terminal.** The
81
115
  bottom bar is a 130-character line that truncates rather than wraps, so at 60
82
116
  columns it read `⏎ don…schedule`. In single-pane it keeps only the keys that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live Claude Code agent view. Use only the panels you want.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -104,6 +104,12 @@ export function handleKey(
104
104
  store.closeModal();
105
105
  return;
106
106
  }
107
+ // Help modal: j/k (or arrows) scroll its keyboard reference.
108
+ if (ui.modal.kind === "help") {
109
+ if (key.name === "j" || key.name === "down") { store.setHelpScroll(ui.helpScroll + 1); return; }
110
+ if (key.name === "k" || key.name === "up") { store.setHelpScroll(ui.helpScroll - 1); return; }
111
+ return;
112
+ }
107
113
  // New-event modal: step 1 typing goes to the <input>; step 2 (no input
108
114
  // focused) is the calendar picker, driven here.
109
115
  if (ui.modal.kind === "event") {
@@ -243,6 +243,12 @@ export interface UIState {
243
243
  * entries and the calendar overlay. Changed with `[` / `]`; `\` resets to 0.
244
244
  */
245
245
  agendaOffset: number;
246
+ /**
247
+ * Vertical scroll position of the help modal, as a block index (0 = top).
248
+ * `?` opens the help at 0; `j`/`k` step it. The HelpModal component clamps
249
+ * the upper bound (it knows the block count) and scrolls that block into view.
250
+ */
251
+ helpScroll: number;
246
252
  view: ViewMode;
247
253
  /**
248
254
  * Tasks marked for bulk ops (`Space`). Key format:
@@ -339,6 +345,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
339
345
  grabbing: false,
340
346
  armMode: false,
341
347
  agendaOffset: 0,
348
+ helpScroll: 0,
342
349
  view: "kanban",
343
350
  marked: {},
344
351
  filter: "all",
@@ -379,6 +386,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
379
386
  // change — and spuriously flash "Reloaded after external edit". Skip.
380
387
  if (lastWrittenContent.get(filepath) === content) return;
381
388
  const { board } = parseBoard(content, { filepath });
389
+ const boardConfig = config.boards.find((cb) => cb.path === filepath);
390
+ if (boardConfig?.name) board.name = boardConfig.name;
382
391
  const mtimeMs = statMtime(filepath);
383
392
  setState(
384
393
  "boards",
@@ -1494,6 +1503,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1494
1503
  }
1495
1504
 
1496
1505
  function openModal(m: ModalKind): void {
1506
+ // Help always opens scrolled to the top.
1507
+ if (m.kind === "help") setState("ui", "helpScroll", 0);
1497
1508
  setState("ui", "modal", m);
1498
1509
  }
1499
1510
 
@@ -1551,6 +1562,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1551
1562
  if (cals.length === 1) void confirmEventPicker();
1552
1563
  }
1553
1564
 
1565
+ /** Scroll the help modal by setting its block index (lower-clamped at 0; the
1566
+ * HelpModal component enforces the upper bound against its block count). */
1567
+ function setHelpScroll(n: number): void {
1568
+ setState("ui", "helpScroll", Math.max(0, n));
1569
+ }
1570
+
1554
1571
  /** Move the step-2 calendar selection (wraps). */
1555
1572
  function setEventSel(n: number): void {
1556
1573
  setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
@@ -1740,6 +1757,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1740
1757
  closeModal,
1741
1758
  openEventModal,
1742
1759
  advanceEventToStep2,
1760
+ setHelpScroll,
1743
1761
  setEventSel,
1744
1762
  confirmEventPicker,
1745
1763
  selectCalEvent,
@@ -8,7 +8,7 @@
8
8
  * board width.
9
9
  */
10
10
 
11
- import { For, Show, createEffect, createMemo, createSignal, onMount } from "solid-js";
11
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";
12
12
 
13
13
  import { isHiddenColumn } from "~/config/loader";
14
14
  import { isTask } from "~/parser/markdown";
@@ -122,6 +122,11 @@ export function BoardView(props: BoardViewProps) {
122
122
  const colIdx = ui().col;
123
123
  if (singlePane() || ui().activeZone === "planner") {
124
124
  setScrollX(0);
125
+ // Refresh viewportW too - the zoomed title budget depends on it.
126
+ setTimeout(() => {
127
+ const vw = viewportRef?.width ?? 0;
128
+ if (vw > 0) setViewportW(vw);
129
+ }, 0);
125
130
  return;
126
131
  }
127
132
  const cols = props.board.columns;
@@ -178,6 +183,18 @@ export function BoardView(props: BoardViewProps) {
178
183
  }, 0);
179
184
  });
180
185
 
186
+ // Re-measure on resize while zoomed (the scroll effect above only
187
+ // reruns on col/zoomed/activeZone changes, not plain resizes).
188
+ const onResize = () => {
189
+ // 50ms, not 0ms: give OpenTUI's layout pass time to catch up.
190
+ setTimeout(() => {
191
+ const vw = viewportRef?.width ?? 0;
192
+ if (vw > 0) setViewportW(vw);
193
+ }, 50);
194
+ };
195
+ process.stdout.on("resize", onResize);
196
+ onCleanup(() => process.stdout.off("resize", onResize));
197
+
181
198
  return (
182
199
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
183
200
  {/* Clipping viewport: fills the board zone, hides overflow. */}
@@ -216,6 +233,7 @@ export function BoardView(props: BoardViewProps) {
216
233
  zoomed={singlePane() && isActive()}
217
234
  tasksVisible={columnTasksVisible(i())}
218
235
  boxId={columnId(props.board.filepath, originalIndex)}
236
+ viewportWidth={viewportW()}
219
237
  />
220
238
  );
221
239
  }}
@@ -245,6 +263,9 @@ interface ColumnViewProps {
245
263
  tasksVisible?: boolean;
246
264
  /** Stable DOM-equivalent id used by `scrollChildIntoView`. */
247
265
  boxId: string;
266
+ /** Real measured board viewport width in cells, used to size the
267
+ * title budget for a zoomed column instead of a hardcoded guess. */
268
+ viewportWidth?: number;
248
269
  }
249
270
 
250
271
  /** Stable id for a task row inside a column, used by scrollChildIntoView. */
@@ -395,9 +416,19 @@ function ColumnView(props: ColumnViewProps) {
395
416
  }
396
417
  // Column inner cell width for a TaskRow: COL_WIDTH 42 −
397
418
  // border 2 − col padding 2 − TaskRow padding 2 = 36 cols
398
- // (when not zoomed). Zoomed → column grows to fill, so
399
- // ~terminal width − some chrome.
400
- availableWidth={props.zoomed ? 100 : 36}
419
+ // (when not zoomed). Zoomed: use the real measured
420
+ // viewport width, same 6-cell overhead, plus a 2-cell
421
+ // safety margin for width-measurement drift (fixes a
422
+ // double-truncation glitch, e.g. "walk t...gh backgro...").
423
+ availableWidth={
424
+ props.zoomed
425
+ ? Math.max(6, (props.viewportWidth ?? 42) - 8)
426
+ : 36
427
+ }
428
+ // Default hardCap (60 chars) was clipping titles well
429
+ // short of the zoomed column's real width. Let
430
+ // availableWidth alone govern the budget when zoomed.
431
+ titleMaxChars={props.zoomed ? 1000 : undefined}
401
432
  onClick={() => {
402
433
  props.store.setActiveZone("board");
403
434
  props.store.setCursor(props.columnIndex, ri());
package/src/ui/Modal.tsx CHANGED
@@ -11,7 +11,7 @@
11
11
  * checks `ui.modal` first and bails if set (only Escape passes through).
12
12
  */
13
13
 
14
- import { For, Show, createContext, createMemo, createSignal, useContext } from "solid-js";
14
+ import { For, Show, createContext, createEffect, createMemo, createSignal, useContext } from "solid-js";
15
15
 
16
16
  import { isTask } from "~/parser/markdown";
17
17
  import {
@@ -19,7 +19,7 @@ import {
19
19
  parseQuickAdd,
20
20
  parseTimeBlockShortcut,
21
21
  } from "~/store/parsers";
22
- import { ATTR, T } from "~/ui/glyphs";
22
+ import { ATTR, T, cellWidth } from "~/ui/glyphs";
23
23
  import { AGENDA_WIDTH } from "~/ui/layout";
24
24
  import { formatHm } from "~/store/timeline";
25
25
  import type { TuiStore } from "~/store/index";
@@ -1001,72 +1001,233 @@ function truncate(s: string, n: number): string {
1001
1001
 
1002
1002
  // ─── Help ────────────────────────────────────────────────────────────────────
1003
1003
 
1004
+ /**
1005
+ * Width (cells) of the key column. The longest key label is 15 cells
1006
+ * ("shift+click row"), so 16 leaves room for a one-cell separator and keeps
1007
+ * every description starting at the same column. `cellWidth` (not `.length`)
1008
+ * is used so wide arrow/emoji glyphs are budgeted correctly.
1009
+ */
1010
+ const HELP_KEYW = 16;
1011
+
1012
+ interface HelpSection {
1013
+ emoji: string;
1014
+ title: string;
1015
+ /** Scope note shown dim under the header (kept verbatim from the original). */
1016
+ note?: string;
1017
+ /** [keyLabel, description] pairs — descriptions are verbatim. */
1018
+ rows: [string, string][];
1019
+ }
1020
+
1021
+ const HELP_SECTIONS: HelpSection[] = [
1022
+ {
1023
+ emoji: "🧭",
1024
+ title: "Navigation",
1025
+ rows: [
1026
+ ["h j k l ←↑↓→", "Move cursor inside the active zone"],
1027
+ ["Tab", "Next board (kanban zone)"],
1028
+ ["+", "New board — create one, or adopt files you have"],
1029
+ ["1..9", "Jump to board N"],
1030
+ ["v", "Toggle Today/Tomorrow planner panel focus"],
1031
+ ["Shift-Tab", "Cycle active zone (planner → board → timeline → agents)"],
1032
+ ["F1 / F2 / F3", "Toggle visibility of Planner / Timeline / Agents zones"],
1033
+ ["z", "Zoom active zone (or column) to full screen"],
1034
+ ["r", "Refresh everything (boards from disk, agents, agenda calendar)"],
1035
+ ],
1036
+ },
1037
+ {
1038
+ emoji: "⚙️",
1039
+ title: "Task actions",
1040
+ note: "work in board, planner, AND timeline zones",
1041
+ rows: [
1042
+ ["Enter", "Toggle done"],
1043
+ ["o", "Open detail view"],
1044
+ ["e", "Edit task text"],
1045
+ ["s", "Schedule date modal (t/m/+N/lun/YYYY-MM-DD — same t/m as the board)"],
1046
+ ["t", "Set scheduled = today"],
1047
+ ["m", "Set scheduled = tomorrow"],
1048
+ [".", "Schedule now — time block at next 15-min slot (30min)"],
1049
+ ["b", "Set time block modal"],
1050
+ ["p", "Cycle priority (none → 🔺 → ⏫ → 🔼 → 🔽 → ⏬ → none)"],
1051
+ ["a", "Set assignee"],
1052
+ ["d", "Delete task (with confirm)"],
1053
+ ["X", "Archive task → moves to Archive column"],
1054
+ ["C", "Copy task to clipboard (markdown line)"],
1055
+ ],
1056
+ },
1057
+ {
1058
+ emoji: "📅",
1059
+ title: "Agenda — day navigation",
1060
+ note: "works from any zone",
1061
+ rows: [
1062
+ ["[ / ]", "Previous / next day (tasks + calendar events)"],
1063
+ ["\\", "Jump back to today"],
1064
+ ],
1065
+ },
1066
+ {
1067
+ emoji: "⏰",
1068
+ title: "Agenda — scheduling",
1069
+ rows: [
1070
+ ["n / click slot", "New Google Calendar event (needs: calendar-setup google --write) append date+time: Lunch tomorrow 12-13 · Review 2026-06-10 15-16 · Holiday 25-12 allday"],
1071
+ ["click an event", "Select an editable Google event — then e edit · d delete · Esc"],
1072
+ ["c (any zone)", "Toggle ARM MODE — then click a task, click a slot, repeat"],
1073
+ ["click empty row", "Place the armed task here (30-min block, or move if it has one)"],
1074
+ ["click band", "Arm an existing block (or place the armed task at its start)"],
1075
+ ["shift+click row", "While armed (existing block): resize end to that row"],
1076
+ ["j / k", "While armed: nudge block ±15 min"],
1077
+ ["+ / -", "While armed: resize block end ±15 min"],
1078
+ ["Enter", "While armed: commit + jump to source task"],
1079
+ ["Esc", "Disarm / exit arm mode"],
1080
+ ],
1081
+ },
1082
+ {
1083
+ emoji: "📋",
1084
+ title: "Board-only actions",
1085
+ rows: [
1086
+ ["n", "New task in current column (quick-add syntax)"],
1087
+ ["g", "Grab task — h/l then moves it between columns; g/Esc to drop"],
1088
+ ["f", "Cycle board filter: all → today → overdue → tomorrow → followup"],
1089
+ ["/", "Search task titles — jumps cursor to first match"],
1090
+ ],
1091
+ },
1092
+ {
1093
+ emoji: "🤖",
1094
+ title: "Agents zone",
1095
+ rows: [
1096
+ ["Enter", "Open (resume) the selected session in a new WezTerm tab"],
1097
+ ["c", "Copy a 'cd + claude --resume' command for the session"],
1098
+ ["o", "Session detail (cwd, branch, last prompts, resume cmd)"],
1099
+ ],
1100
+ },
1101
+ {
1102
+ emoji: "☑️",
1103
+ title: "Multi-select",
1104
+ rows: [
1105
+ ["Space", "Mark / unmark task (cursor stays — mark in any order) Every task action (done/schedule/time block/assign/priority/archive/delete) then applies to ALL marked"],
1106
+ ["Esc", "Clear the selection (when no modal is open)"],
1107
+ ],
1108
+ },
1109
+ {
1110
+ emoji: "📦",
1111
+ title: "Bulk",
1112
+ rows: [["T", "Reset ALL overdue tasks (any board) to today"]],
1113
+ },
1114
+ {
1115
+ emoji: "🌐",
1116
+ title: "Global",
1117
+ rows: [
1118
+ ["Ctrl-Z", "Undo last mutation"],
1119
+ ["?", "This help"],
1120
+ ["q · Ctrl-C", "Quit"],
1121
+ ],
1122
+ },
1123
+ ];
1124
+
1125
+ /**
1126
+ * Flattened render+scroll units: one "header" block per section (carrying its
1127
+ * optional note) followed by one "row" block per shortcut. `ui.helpScroll` is
1128
+ * an index into this list; `j`/`k` step it and the matching `<box id>` is
1129
+ * scrolled into view.
1130
+ */
1131
+ type HelpBlock =
1132
+ | { kind: "header"; emoji: string; title: string; note?: string }
1133
+ | { kind: "row"; keyLabel: string; desc: string };
1134
+
1135
+ const HELP_BLOCKS: HelpBlock[] = HELP_SECTIONS.flatMap((s) => [
1136
+ { kind: "header", emoji: s.emoji, title: s.title, note: s.note } as HelpBlock,
1137
+ ...s.rows.map(([keyLabel, desc]) => ({ kind: "row", keyLabel, desc }) as HelpBlock),
1138
+ ]);
1139
+
1140
+ /** Dim dot leader that pads `key` out to the fixed key column (with one leading
1141
+ * separator space). Empty for keys that already fill the column. */
1142
+ function helpDots(key: string): string {
1143
+ const n = HELP_KEYW - cellWidth(key) - 1;
1144
+ return n > 0 ? "·".repeat(n) : "";
1145
+ }
1146
+
1147
+ /** Minimal structural view of OpenTUI's <scrollbox> ref (mirrors TimelineView). */
1148
+ interface ScrollBoxLike {
1149
+ scrollChildIntoView(id: string): void;
1150
+ }
1151
+
1152
+ /**
1153
+ * Keyboard reference.
1154
+ *
1155
+ * Each shortcut is a SINGLE full-width `<text wrapMode="word">` (key + dim dot
1156
+ * leader + description as spans). A single full-width text manages its own
1157
+ * wrapped height correctly; a wrapping `<text>` placed as a flex *sibling* in a
1158
+ * row does NOT (the next row overpaints it — the "bleed-into-neighbor" glitch
1159
+ * documented in TaskRow). When a description wraps it returns to column 0 in the
1160
+ * dim description color, visually distinct from the accent key.
1161
+ *
1162
+ * The whole list lives in a `<scrollbox>` so it never clips on short terminals;
1163
+ * `j`/`k` move `ui.helpScroll` and the focused block is scrolled into view.
1164
+ */
1004
1165
  function HelpModal(props: { store: TuiStore }) {
1005
- void props;
1166
+ let scrollBoxRef: ScrollBoxLike | undefined;
1167
+
1168
+ createEffect(() => {
1169
+ const raw = props.store.state.ui.helpScroll;
1170
+ const clamped = Math.max(0, Math.min(raw, HELP_BLOCKS.length - 1));
1171
+ if (clamped !== raw) {
1172
+ props.store.setHelpScroll(clamped);
1173
+ return; // effect re-runs with the corrected value
1174
+ }
1175
+ scrollBoxRef?.scrollChildIntoView(`help-b${clamped}`);
1176
+ });
1177
+
1006
1178
  return (
1007
- <DialogShell title="tuiboard — keyboard reference" hint="Esc/? to close" width={92}>
1008
- <text>
1009
- <span style={{ fg: T.textDim }}>{"Navigation\n"}</span>
1010
- <span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
1011
- <span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
1012
- <span style={{ fg: T.text }}>{" + New board — create one, or adopt files you have\n"}</span>
1013
- <span style={{ fg: T.text }}>{" z Focus one pane (automatic below 100 columns)\n"}</span>
1014
- <span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
1015
- <span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow planner panel focus\n"}</span>
1016
- <span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (planner → board → timeline → agents)\n"}</span>
1017
- <span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Planner / Timeline / Agents zones\n"}</span>
1018
- <span style={{ fg: T.text }}>{" z Zoom active zone (or column) to full screen\n"}</span>
1019
- <span style={{ fg: T.text }}>{" r Refresh everything (boards from disk, agents, agenda calendar)\n"}</span>
1020
- <span style={{ fg: T.textDim }}>{"\nTask actions (work in board, planner, AND timeline zones)\n"}</span>
1021
- <span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
1022
- <span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
1023
- <span style={{ fg: T.text }}>{" e Edit task text\n"}</span>
1024
- <span style={{ fg: T.text }}>{" s Schedule date modal (t/m/+N/lun/YYYY-MM-DD — same t/m as the board)\n"}</span>
1025
- <span style={{ fg: T.text }}>{" t Set scheduled = today\n"}</span>
1026
- <span style={{ fg: T.text }}>{" m Set scheduled = tomorrow\n"}</span>
1027
- <span style={{ fg: T.text }}>{" . Schedule now — time block at next 15-min slot (30min)\n"}</span>
1028
- <span style={{ fg: T.text }}>{" b Set time block modal\n"}</span>
1029
- <span style={{ fg: T.text }}>{" p Cycle priority (none → 🔺 → ⏫ → 🔼 → 🔽 → ⏬ → none)\n"}</span>
1030
- <span style={{ fg: T.text }}>{" a Set assignee\n"}</span>
1031
- <span style={{ fg: T.text }}>{" d Delete task (with confirm)\n"}</span>
1032
- <span style={{ fg: T.text }}>{" X Archive task → moves to Archive column\n"}</span>
1033
- <span style={{ fg: T.text }}>{" C Copy task to clipboard (markdown line)\n"}</span>
1034
- <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) day navigation — works from any zone\n"}</span>
1035
- <span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
1036
- <span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
1037
- <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
1038
- <span style={{ fg: T.text }}>{" n / click slot New Google Calendar event (needs: calendar-setup google --write)\n"}</span>
1039
- <span style={{ fg: T.text }}>{" append date+time: Lunch tomorrow 12-13 · Review 2026-06-10 15-16 · Holiday 25-12 allday\n"}</span>
1040
- <span style={{ fg: T.text }}>{" click an event Select an editable Google event — then e edit · d delete · Esc\n"}</span>
1041
- <span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
1042
- <span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
1043
- <span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
1044
- <span style={{ fg: T.text }}>{" shift+click row While armed (existing block): resize end to that row\n"}</span>
1045
- <span style={{ fg: T.text }}>{" j / k While armed: nudge block ±15 min\n"}</span>
1046
- <span style={{ fg: T.text }}>{" + / - While armed: resize block end ±15 min\n"}</span>
1047
- <span style={{ fg: T.text }}>{" Enter While armed: commit + jump to source task\n"}</span>
1048
- <span style={{ fg: T.text }}>{" Esc Disarm / exit arm mode\n"}</span>
1049
- <span style={{ fg: T.textDim }}>{"\nBoard-only actions\n"}</span>
1050
- <span style={{ fg: T.text }}>{" n New task in current column (quick-add syntax)\n"}</span>
1051
- <span style={{ fg: T.text }}>{" g Grab task — h/l then moves it between columns; g/Esc to drop\n"}</span>
1052
- <span style={{ fg: T.text }}>{" f Cycle board filter: all → today → overdue → tomorrow → followup\n"}</span>
1053
- <span style={{ fg: T.text }}>{" / Search task titles — jumps cursor to first match\n"}</span>
1054
- <span style={{ fg: T.textDim }}>{"\nAgents zone\n"}</span>
1055
- <span style={{ fg: T.text }}>{" Enter Open (resume) the selected session in a new WezTerm tab\n"}</span>
1056
- <span style={{ fg: T.text }}>{" c Copy a 'cd + claude --resume' command for the session\n"}</span>
1057
- <span style={{ fg: T.text }}>{" o Session detail (cwd, branch, last prompts, resume cmd)\n"}</span>
1058
- <span style={{ fg: T.textDim }}>{"\nMulti-select\n"}</span>
1059
- <span style={{ fg: T.text }}>{" Space Mark / unmark task (cursor stays — mark in any order)\n"}</span>
1060
- <span style={{ fg: T.text }}>{" Every task action (done/schedule/time block/assign/\n"}</span>
1061
- <span style={{ fg: T.text }}>{" priority/archive/delete) then applies to ALL marked\n"}</span>
1062
- <span style={{ fg: T.text }}>{" Esc Clear the selection (when no modal is open)\n"}</span>
1063
- <span style={{ fg: T.textDim }}>{"\nBulk\n"}</span>
1064
- <span style={{ fg: T.text }}>{" T Reset ALL overdue tasks (any board) to today\n"}</span>
1065
- <span style={{ fg: T.textDim }}>{"\nGlobal\n"}</span>
1066
- <span style={{ fg: T.text }}>{" Ctrl-Z Undo last mutation\n"}</span>
1067
- <span style={{ fg: T.text }}>{" ? This help\n"}</span>
1068
- <span style={{ fg: T.text }}>{" q · Ctrl-C Quit\n"}</span>
1069
- </text>
1179
+ <DialogShell title="tuiboard — keyboard reference" hint="Esc/? close · j/k scroll" width={92}>
1180
+ <scrollbox
1181
+ ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
1182
+ style={{
1183
+ width: "100%",
1184
+ flexGrow: 1,
1185
+ scrollX: false,
1186
+ scrollY: true,
1187
+ rootOptions: {},
1188
+ contentOptions: {},
1189
+ scrollbarOptions: { visible: false },
1190
+ }}
1191
+ >
1192
+ <For each={HELP_BLOCKS}>
1193
+ {(block, i) => (
1194
+ <box id={`help-b${i()}`} style={{ flexDirection: "column" }}>
1195
+ <Show when={block.kind === "header"}>
1196
+ {/* blank line above every section header except the first */}
1197
+ <Show when={i() > 0}>
1198
+ <box style={{ height: 1 }} />
1199
+ </Show>
1200
+ <text wrapMode="none" truncate>
1201
+ <span style={{ fg: T.warm, attributes: ATTR.bold }}>
1202
+ {`${(block as { emoji: string }).emoji} ${(block as { title: string }).title}`}
1203
+ </span>
1204
+ </text>
1205
+ {/* scope note on its own wrapping line, kept verbatim */}
1206
+ <Show when={(block as { note?: string }).note}>
1207
+ <text wrapMode="word">
1208
+ <span style={{ fg: T.textDone, attributes: ATTR.italic }}>
1209
+ {(block as { note: string }).note}
1210
+ </span>
1211
+ </text>
1212
+ </Show>
1213
+ </Show>
1214
+ <Show when={block.kind === "row"}>
1215
+ <text wrapMode="word">
1216
+ <span style={{ fg: T.accent, attributes: ATTR.bold }}>
1217
+ {(block as { keyLabel: string }).keyLabel}
1218
+ </span>
1219
+ <span style={{ fg: T.border }}>
1220
+ {` ${helpDots((block as { keyLabel: string }).keyLabel)}`}
1221
+ </span>
1222
+ <span style={{ fg: T.textDim }}>
1223
+ {` ${(block as { desc: string }).desc}`}
1224
+ </span>
1225
+ </text>
1226
+ </Show>
1227
+ </box>
1228
+ )}
1229
+ </For>
1230
+ </scrollbox>
1070
1231
  </DialogShell>
1071
1232
  );
1072
1233
  }