tuiboard 0.9.2 → 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,53 @@ 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.
37
+
38
+ ## [0.10.0] - 2026-09-09
39
+
40
+ ### Added
41
+ - **A task's note is readable inside tuiboard.** When a task's title is a link —
42
+ `[[Nome nota|Titolo]]` or `[Titolo](Tasks/Nome.md)` — the detail view (`o`)
43
+ shows that note's text, scrollable, instead of pointing at Obsidian. Both link
44
+ forms work, so the convention stands on its own: a board written in plain
45
+ markdown gets the feature too. Links *inside* a sentence stay mentions and are
46
+ listed as before — a task that mentions a person is not documented by that
47
+ person's page. A missing note says which name it looked for; two notes sharing
48
+ a name resolve to the nearest and the shadowed one is named.
49
+
50
+ ### Fixed
51
+ - **Markdown links no longer show as raw syntax in task titles.** `displayTitle`
52
+ stripped wikilinks but not `[text](path.md)`, so a board written without
53
+ Obsidian showed the whole link as its title — in the board, the planner and
54
+ the bar widget.
9
55
 
10
56
  ## [0.9.2] - 2026-09-08
11
57
 
@@ -59,6 +105,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
59
105
  that folder's sync and versioning; the fallback is `~/.local/share/tuiboard/boards/`.
60
106
 
61
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.
62
114
  - **The key hints no longer truncate mid-word on a narrow terminal.** The
63
115
  bottom bar is a 130-character line that truncates rather than wraps, so at 60
64
116
  columns it read `⏎ don…schedule`. In single-pane it keeps only the keys that
@@ -298,6 +350,7 @@ First public release on npm. This entry captures the full feature set at launch.
298
350
 
299
351
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
300
352
 
353
+ [0.10.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.10.0
301
354
  [0.9.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.2
302
355
  [0.9.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.1
303
356
  [0.9.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.0
package/README.md CHANGED
@@ -534,6 +534,9 @@ clobber an edit made in the TUI or another editor in the meantime.
534
534
 
535
535
  See [CHANGELOG.md](CHANGELOG.md) for the full release history.
536
536
 
537
+ - **v0.10** — a task's note, read inside tuiboard: when a task's title is a
538
+ link, `o` shows that note's text instead of pointing at Obsidian. Works with
539
+ plain markdown links too, so the convention needs no vault.
537
540
  - **v0.9** — tuiboard makes its own boards: a `+` that creates or adopts them,
538
541
  onboarding on first run instead of an error, `tuiboard board` headless, and
539
542
  single-pane mode so a narrow vertical panel shows one zone at a time instead
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.9.2",
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") {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Task notes: finding the file a task's link points at, and reading it.
3
+ *
4
+ * The convention is tuiboard's own, not Obsidian's: a task's note is the link
5
+ * that wraps its title, written either way —
6
+ *
7
+ * - [ ] [[Nome nota|Titolo mostrato]] wiki style
8
+ * - [ ] [Titolo mostrato](Tasks/Nome.md) plain markdown
9
+ *
10
+ * The second form is how any markdown document links another, so a board that
11
+ * has never heard of wikilinks gets the feature too. The parser decides which
12
+ * link qualifies (see `titleNoteLink`); this module only turns it into a file.
13
+ *
14
+ * Pure except for the filesystem reads it exists to perform: no store, no
15
+ * renderer, testable against a temp directory.
16
+ */
17
+
18
+ import { readFileSync, readdirSync, statSync } from "node:fs";
19
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
20
+
21
+ import type { TaskNoteLink } from "~/types";
22
+
23
+ /** Directories never worth walking for notes. */
24
+ const SKIP_DIRS = new Set([".git", ".obsidian", "node_modules", ".trash", ".stversions"]);
25
+
26
+ /** How deep the scan goes. Deeper than any sane note layout, shallow enough to end. */
27
+ const MAX_DEPTH = 8;
28
+
29
+ /** Note name (lowercased, without extension) → every file carrying that name. */
30
+ export type NoteIndex = Record<string, string[]>;
31
+
32
+ export interface ResolvedNote {
33
+ path: string;
34
+ /** Same-named files that lost to the winner, nearest-first. Absent when unique. */
35
+ shadowed?: string[];
36
+ }
37
+
38
+ export interface MissingNote {
39
+ missing: string;
40
+ }
41
+
42
+ /**
43
+ * Index every markdown file under `root`, by name.
44
+ *
45
+ * Obsidian resolves `[[Name]]` by name rather than by path, and notes written
46
+ * by hand rely on that, so tuiboard does the same instead of demanding paths.
47
+ * A missing or unreadable directory yields an empty index rather than an
48
+ * error: a broken note link must never stop the board from opening.
49
+ */
50
+ export function buildNoteIndex(root: string): NoteIndex {
51
+ const index: NoteIndex = {};
52
+
53
+ const walk = (dir: string, depth: number): void => {
54
+ if (depth > MAX_DEPTH) return;
55
+ let entries: string[];
56
+ try {
57
+ entries = readdirSync(dir);
58
+ } catch {
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ if (entry.startsWith(".") && SKIP_DIRS.has(entry)) continue;
63
+ if (SKIP_DIRS.has(entry)) continue;
64
+ const full = join(dir, entry);
65
+ let isDir = false;
66
+ try {
67
+ isDir = statSync(full).isDirectory();
68
+ } catch {
69
+ continue;
70
+ }
71
+ if (isDir) {
72
+ walk(full, depth + 1);
73
+ } else if (extname(entry).toLowerCase() === ".md") {
74
+ const key = noteKey(entry);
75
+ (index[key] ??= []).push(full);
76
+ }
77
+ }
78
+ };
79
+
80
+ walk(resolve(root), 0);
81
+ return index;
82
+ }
83
+
84
+ /** The name a link and a filename are compared by: no extension, no case. */
85
+ function noteKey(name: string): string {
86
+ return basename(name, extname(name)).trim().toLowerCase();
87
+ }
88
+
89
+ /**
90
+ * Turn a task's link into a file.
91
+ *
92
+ * A `path` link is a real path and resolves against the board that carries the
93
+ * task — the same way a relative link works in any markdown document. A
94
+ * `wikilink` is only a name, so it goes through the index.
95
+ *
96
+ * When several files share a name the nearest to the board wins, and the ones
97
+ * it shadowed are returned: proximity is a guess, and a guess should be
98
+ * visible rather than silently applied.
99
+ */
100
+ export function resolveNote(
101
+ link: TaskNoteLink,
102
+ { index, boardPath }: { index: NoteIndex; boardPath: string },
103
+ ): ResolvedNote | MissingNote {
104
+ const boardDir = dirname(resolve(boardPath));
105
+
106
+ if (link.kind === "path") {
107
+ const path = resolve(boardDir, link.target);
108
+ try {
109
+ if (statSync(path).isFile()) return { path };
110
+ } catch {
111
+ /* fall through to missing */
112
+ }
113
+ return { missing: link.target };
114
+ }
115
+
116
+ const hits = index[noteKey(link.target)];
117
+ if (!hits || hits.length === 0) return { missing: link.target };
118
+
119
+ const ranked = [...hits].sort((a, b) => distanceFrom(boardDir, a) - distanceFrom(boardDir, b));
120
+ const [winner, ...rest] = ranked;
121
+ return rest.length > 0 ? { path: winner!, shadowed: rest } : { path: winner! };
122
+ }
123
+
124
+ /** How far a file sits from a directory, in path segments. Lower is nearer. */
125
+ function distanceFrom(dir: string, file: string): number {
126
+ const from = dir.split(sep).filter(Boolean);
127
+ const to = dirname(file).split(sep).filter(Boolean);
128
+ let common = 0;
129
+ while (common < from.length && common < to.length && from[common] === to[common]) common++;
130
+ return from.length - common + (to.length - common);
131
+ }
132
+
133
+ /**
134
+ * The note's text, without its frontmatter — that is configuration, and the
135
+ * detail view is showing context.
136
+ *
137
+ * Throws when the file cannot be read: the caller has a frame to put the
138
+ * message in, and swallowing it would show an empty note instead of a reason.
139
+ */
140
+ export function readNoteBody(path: string): string {
141
+ const raw = readFileSync(path, "utf-8");
142
+ const withoutFrontmatter = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
143
+ return withoutFrontmatter.trim();
144
+ }
@@ -26,6 +26,7 @@ import type {
26
26
  RawOther,
27
27
  SectionBreak,
28
28
  Task,
29
+ TaskNoteLink,
29
30
  TimeBlock,
30
31
  TimeBlockSource,
31
32
  } from "~/types";
@@ -268,6 +269,12 @@ function parseTask(input: ParseTaskInput): Task {
268
269
  // Wikilinks — capture displayed text (alias if present, target otherwise)
269
270
  const wikilinks = Array.from(body.matchAll(RE_WIKILINK), (m) => m[2] ?? m[1]!);
270
271
 
272
+ // The task's note: a link that WRAPS the title, i.e. opens the line. A link
273
+ // in the middle of a sentence is a mention — `Mandare materiale a [[Lisa]]`
274
+ // is not a task documented by Lisa's page. Trailing metadata (dates, tags)
275
+ // does not disqualify it, only text before the link does.
276
+ const note = titleNoteLink(body);
277
+
271
278
  // Priority
272
279
  let priority: PriorityLevel = "none";
273
280
  for (const [emoji, level] of PRIORITY_EMOJI) {
@@ -290,6 +297,7 @@ function parseTask(input: ParseTaskInput): Task {
290
297
  assignee: am?.[1],
291
298
  tags,
292
299
  wikilinks,
300
+ note,
293
301
  scheduled: sched?.[1],
294
302
  due: due?.[1],
295
303
  start: start?.[1],
@@ -324,6 +332,10 @@ function buildDisplayTitle(
324
332
  t = t.replace(RE_TAG, "");
325
333
  // Replace wikilinks with their displayed text (alias or target)
326
334
  t = t.replace(RE_WIKILINK, (_m, target: string, alias?: string) => alias ?? target);
335
+ // Same for markdown links: show the text, drop the target. Without this a
336
+ // board written in plain markdown — the form that needs no Obsidian — shows
337
+ // `[Titolo](Tasks/Nota.md)` as its title everywhere, widget included.
338
+ t = t.replace(RE_MDLINK, (_m, text: string) => text);
327
339
  // Strip priority and decorative emoji
328
340
  for (const [emoji] of PRIORITY_EMOJI) t = t.replaceAll(emoji, "");
329
341
  for (const emoji of DECORATIVE_EMOJI) t = t.replaceAll(emoji, "");
@@ -332,6 +344,45 @@ function buildDisplayTitle(
332
344
  return t;
333
345
  }
334
346
 
347
+ /** `[[Target|shown]]` or `[shown](path.md)` at the very start of the line. */
348
+ /** `[shown](target)` anywhere in the line — for display only. */
349
+ const RE_MDLINK = /\[([^\]]*)\]\(([^)]+)\)/g;
350
+
351
+ const RE_TITLE_WIKILINK = /^\s*\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/;
352
+ const RE_TITLE_MDLINK = /^\s*\[[^\]]*\]\(([^)]+)\)/;
353
+
354
+ /**
355
+ * What can sit between the checkbox and the title without being the title:
356
+ * priority and decorative emoji, and markdown emphasis. `- [ ] 🔥 [[Nota]]`
357
+ * is still a task whose title is a link.
358
+ */
359
+ function stripTitleDecorations(body: string): string {
360
+ let t = body;
361
+ let changed = true;
362
+ while (changed) {
363
+ const before = t;
364
+ t = t.trimStart().replace(/^[*_~]+/, "");
365
+ for (const [emoji] of PRIORITY_EMOJI) if (t.startsWith(emoji)) t = t.slice(emoji.length);
366
+ for (const emoji of DECORATIVE_EMOJI) if (t.startsWith(emoji)) t = t.slice(emoji.length);
367
+ changed = t !== before;
368
+ }
369
+ return t;
370
+ }
371
+
372
+ function titleNoteLink(rawBody: string): TaskNoteLink | undefined {
373
+ const body = stripTitleDecorations(rawBody);
374
+ const wiki = body.match(RE_TITLE_WIKILINK);
375
+ if (wiki) return { target: wiki[1]!.trim(), kind: "wikilink" };
376
+ const md = body.match(RE_TITLE_MDLINK);
377
+ if (md) {
378
+ const target = md[1]!.trim();
379
+ // A URL is not a note. Nothing to read from the filesystem.
380
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return undefined;
381
+ return { target, kind: "path" };
382
+ }
383
+ return undefined;
384
+ }
385
+
335
386
  function extractBoardName(frontmatter: string, filepath: string): string {
336
387
  const m = frontmatter.match(/^name:\s*(.+?)\s*$/m);
337
388
  if (m) return m[1]!;
@@ -46,6 +46,7 @@ import { createBoardFile } from "~/boards/create";
46
46
  import { scanDirectory, type BoardCandidate } from "~/boards/scan";
47
47
  import { suggestBoardsDir } from "~/boards/suggest";
48
48
  import { isTask, parseBoard } from "~/parser/markdown";
49
+ import { buildNoteIndex, readNoteBody, resolveNote, type NoteIndex } from "~/notes/index";
49
50
  import { buildRing, ringPosition, samePane, stepRing, type Pane } from "~/ui/pane-ring";
50
51
  import { serializeBoard } from "~/parser/serialize";
51
52
  import type {
@@ -122,6 +123,18 @@ export interface BoardNew {
122
123
  error?: string;
123
124
  }
124
125
 
126
+ /** What the detail view needs to show a task's note, or to explain its absence. */
127
+ export interface TaskNoteView {
128
+ path?: string;
129
+ body?: string;
130
+ /** Same-named notes that lost to this one. */
131
+ shadowed?: string[];
132
+ /** The link pointed at something that is not there. */
133
+ missing?: string;
134
+ /** The file exists but could not be read. */
135
+ error?: string;
136
+ }
137
+
125
138
  export interface EventPicker {
126
139
  step: 1 | 2;
127
140
  /** Selection index into `cals` (step 2). */
@@ -230,6 +243,12 @@ export interface UIState {
230
243
  * entries and the calendar overlay. Changed with `[` / `]`; `\` resets to 0.
231
244
  */
232
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;
233
252
  view: ViewMode;
234
253
  /**
235
254
  * Tasks marked for bulk ops (`Space`). Key format:
@@ -326,6 +345,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
326
345
  grabbing: false,
327
346
  armMode: false,
328
347
  agendaOffset: 0,
348
+ helpScroll: 0,
329
349
  view: "kanban",
330
350
  marked: {},
331
351
  filter: "all",
@@ -366,6 +386,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
366
386
  // change — and spuriously flash "Reloaded after external edit". Skip.
367
387
  if (lastWrittenContent.get(filepath) === content) return;
368
388
  const { board } = parseBoard(content, { filepath });
389
+ const boardConfig = config.boards.find((cb) => cb.path === filepath);
390
+ if (boardConfig?.name) board.name = boardConfig.name;
369
391
  const mtimeMs = statMtime(filepath);
370
392
  setState(
371
393
  "boards",
@@ -1295,11 +1317,49 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1295
1317
  function refreshAll(): void {
1296
1318
  setState("boards", loadAll(config));
1297
1319
  setState("rev", (r) => r + 1);
1320
+ noteIndex = undefined; // rebuilt on the next note opened
1298
1321
  agentsStore.refresh();
1299
1322
  calendarStore.refresh(true);
1300
1323
  flashBanner("info", "Refreshed boards · agents · agenda");
1301
1324
  }
1302
1325
 
1326
+ // ─── Task notes ──────────────────────────────────────────────────────────
1327
+ // The index is built the first time a note is opened and thrown away by `r`.
1328
+ // Watching the tree instead would cost a second chokidar over a thousand
1329
+ // files to save a keystroke the user already has.
1330
+ let noteIndex: NoteIndex | undefined;
1331
+
1332
+ /**
1333
+ * The note behind the task at `ref`, ready to render: its text, where it came
1334
+ * from, or why it could not be read. `undefined` means the task has no note —
1335
+ * which is most tasks, and must produce no message at all.
1336
+ */
1337
+ function taskNote(ref: TaskRef): TaskNoteView | undefined {
1338
+ const task = getTask(ref);
1339
+ if (!task?.note) return undefined;
1340
+
1341
+ noteIndex ??= buildNoteIndex(suggestBoardsDir(config));
1342
+ const found = resolveNote(task.note, { index: noteIndex, boardPath: ref.boardPath });
1343
+ if ("missing" in found) return { missing: found.missing };
1344
+
1345
+ // Shown from the boards' own root: an absolute path is noise, and the
1346
+ // config's root is the config's directory, not where the notes live.
1347
+ const root = suggestBoardsDir(config);
1348
+ const shown = found.path.startsWith(root)
1349
+ ? found.path.slice(root.length).replace(/^[/\\]/, "")
1350
+ : found.path;
1351
+
1352
+ try {
1353
+ return {
1354
+ path: shown,
1355
+ body: readNoteBody(found.path),
1356
+ shadowed: found.shadowed?.map((p) => (p.startsWith(root) ? p.slice(root.length + 1) : p)),
1357
+ };
1358
+ } catch (e) {
1359
+ return { path: shown, error: (e as Error).message };
1360
+ }
1361
+ }
1362
+
1303
1363
  // ─── Multi-select ────────────────────────────────────────────────────────
1304
1364
 
1305
1365
  function markKey(ref: TaskRef): string {
@@ -1443,6 +1503,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1443
1503
  }
1444
1504
 
1445
1505
  function openModal(m: ModalKind): void {
1506
+ // Help always opens scrolled to the top.
1507
+ if (m.kind === "help") setState("ui", "helpScroll", 0);
1446
1508
  setState("ui", "modal", m);
1447
1509
  }
1448
1510
 
@@ -1500,6 +1562,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1500
1562
  if (cals.length === 1) void confirmEventPicker();
1501
1563
  }
1502
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
+
1503
1571
  /** Move the step-2 calendar selection (wraps). */
1504
1572
  function setEventSel(n: number): void {
1505
1573
  setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
@@ -1643,6 +1711,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1643
1711
  addTask,
1644
1712
  deleteTask,
1645
1713
  moveTaskWithinBoard,
1714
+ // notes
1715
+ taskNote,
1646
1716
  // boards
1647
1717
  addBoard,
1648
1718
  openBoardNew,
@@ -1687,6 +1757,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1687
1757
  closeModal,
1688
1758
  openEventModal,
1689
1759
  advanceEventToStep2,
1760
+ setHelpScroll,
1690
1761
  setEventSel,
1691
1762
  confirmEventPicker,
1692
1763
  selectCalEvent,
package/src/types.ts CHANGED
@@ -20,6 +20,12 @@ export interface TimeBlock {
20
20
  /** Where the time block was found in the source — drives writer behavior. */
21
21
  export type TimeBlockSource = "legacy-prefix" | "watch-emoji";
22
22
 
23
+ /** Where a task's note lives: a note name (wiki style) or a path. */
24
+ export interface TaskNoteLink {
25
+ target: string;
26
+ kind: "wikilink" | "path";
27
+ }
28
+
23
29
  export interface Task {
24
30
  /** Stable identity within a board: `${columnIndex}:${indexInColumn}`. */
25
31
  id: string;
@@ -38,6 +44,14 @@ export interface Task {
38
44
  tags: string[];
39
45
  /** Wikilinks: alias if present, otherwise target. */
40
46
  wikilinks: string[];
47
+ /**
48
+ * The link that wraps the title, when there is one: this task's note.
49
+ *
50
+ * Distinct from `wikilinks`, which lists every link in the line. A link in
51
+ * the middle of a sentence is a mention — showing a person's page as a
52
+ * task's context would be worse than showing nothing.
53
+ */
54
+ note?: TaskNoteLink;
41
55
  scheduled?: ISODate;
42
56
  due?: ISODate;
43
57
  start?: ISODate;
@@ -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";
@@ -720,8 +720,9 @@ function DetailModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiSto
720
720
  if (!task) {
721
721
  return <DialogShell title="Task not found" hint="Esc to close" width={50}><text>{" "}</text></DialogShell>;
722
722
  }
723
+ const note = createMemo(() => props.store.taskNote(props.modal.ref));
723
724
  return (
724
- <DialogShell title="Detail" hint="Esc to close" width={90}>
725
+ <DialogShell title="Detail" hint="j/k scroll the note · Esc to close" width={90}>
725
726
  <text wrapMode="word">
726
727
  <span style={{ fg: T.text, attributes: ATTR.bold }}>{task.displayTitle}</span>
727
728
  </text>
@@ -774,6 +775,48 @@ function DetailModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiSto
774
775
  <span style={{ fg: T.tag }}>{task.tags.map((t) => "#" + t).join(" ")}</span>
775
776
  </text>
776
777
  </Show>
778
+ {/* The task's note, when its title is a link. Most tasks have none, and
779
+ for those nothing is drawn at all: silence is the correct output. */}
780
+ <Show when={note()}>
781
+ {(n: () => NonNullable<ReturnType<typeof note>>) => (
782
+ <box style={{ flexDirection: "column", flexGrow: 1, minHeight: 0 }}>
783
+ <box style={{ height: 1 }} />
784
+ <Show when={n().path}>
785
+ <text wrapMode="word">
786
+ <span style={{ fg: T.textDim }}>Note: </span>
787
+ <span style={{ fg: T.tag }}>{n().path}</span>
788
+ </text>
789
+ </Show>
790
+ <Show when={n().missing}>
791
+ <text wrapMode="word">
792
+ <span style={{ fg: T.overdue }}>{"Note not found: " + n().missing}</span>
793
+ </text>
794
+ </Show>
795
+ <Show when={n().error}>
796
+ <text wrapMode="word">
797
+ <span style={{ fg: T.overdue }}>{"Note unreadable: " + n().error}</span>
798
+ </text>
799
+ </Show>
800
+ <Show when={n().shadowed?.length}>
801
+ <text wrapMode="word">
802
+ <span style={{ fg: T.textDim }}>
803
+ {"Another note shares this name: " + (n().shadowed ?? []).join(", ")}
804
+ </span>
805
+ </text>
806
+ </Show>
807
+ <Show when={n().body !== undefined}>
808
+ <box style={{ height: 1 }} />
809
+ <scrollbox style={{ flexGrow: 1, minHeight: 0 }}>
810
+ <text wrapMode="word">
811
+ <span style={{ fg: T.text }}>
812
+ {n().body === "" ? "(the note is empty)" : n().body}
813
+ </span>
814
+ </text>
815
+ </scrollbox>
816
+ </Show>
817
+ </box>
818
+ )}
819
+ </Show>
777
820
  <Show when={task.wikilinks.length > 0}>
778
821
  <box style={{ height: 1 }} />
779
822
  <text>
@@ -958,72 +1001,233 @@ function truncate(s: string, n: number): string {
958
1001
 
959
1002
  // ─── Help ────────────────────────────────────────────────────────────────────
960
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
+ */
961
1165
  function HelpModal(props: { store: TuiStore }) {
962
- 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
+
963
1178
  return (
964
- <DialogShell title="tuiboard — keyboard reference" hint="Esc/? to close" width={92}>
965
- <text>
966
- <span style={{ fg: T.textDim }}>{"Navigation\n"}</span>
967
- <span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
968
- <span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
969
- <span style={{ fg: T.text }}>{" + New board — create one, or adopt files you have\n"}</span>
970
- <span style={{ fg: T.text }}>{" z Focus one pane (automatic below 100 columns)\n"}</span>
971
- <span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
972
- <span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow planner panel focus\n"}</span>
973
- <span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (planner → board → timeline → agents)\n"}</span>
974
- <span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Planner / Timeline / Agents zones\n"}</span>
975
- <span style={{ fg: T.text }}>{" z Zoom active zone (or column) to full screen\n"}</span>
976
- <span style={{ fg: T.text }}>{" r Refresh everything (boards from disk, agents, agenda calendar)\n"}</span>
977
- <span style={{ fg: T.textDim }}>{"\nTask actions (work in board, planner, AND timeline zones)\n"}</span>
978
- <span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
979
- <span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
980
- <span style={{ fg: T.text }}>{" e Edit task text\n"}</span>
981
- <span style={{ fg: T.text }}>{" s Schedule date modal (t/m/+N/lun/YYYY-MM-DD — same t/m as the board)\n"}</span>
982
- <span style={{ fg: T.text }}>{" t Set scheduled = today\n"}</span>
983
- <span style={{ fg: T.text }}>{" m Set scheduled = tomorrow\n"}</span>
984
- <span style={{ fg: T.text }}>{" . Schedule now — time block at next 15-min slot (30min)\n"}</span>
985
- <span style={{ fg: T.text }}>{" b Set time block modal\n"}</span>
986
- <span style={{ fg: T.text }}>{" p Cycle priority (none → 🔺 → ⏫ → 🔼 → 🔽 → ⏬ → none)\n"}</span>
987
- <span style={{ fg: T.text }}>{" a Set assignee\n"}</span>
988
- <span style={{ fg: T.text }}>{" d Delete task (with confirm)\n"}</span>
989
- <span style={{ fg: T.text }}>{" X Archive task → moves to Archive column\n"}</span>
990
- <span style={{ fg: T.text }}>{" C Copy task to clipboard (markdown line)\n"}</span>
991
- <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) day navigation — works from any zone\n"}</span>
992
- <span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
993
- <span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
994
- <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
995
- <span style={{ fg: T.text }}>{" n / click slot New Google Calendar event (needs: calendar-setup google --write)\n"}</span>
996
- <span style={{ fg: T.text }}>{" append date+time: Lunch tomorrow 12-13 · Review 2026-06-10 15-16 · Holiday 25-12 allday\n"}</span>
997
- <span style={{ fg: T.text }}>{" click an event Select an editable Google event — then e edit · d delete · Esc\n"}</span>
998
- <span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
999
- <span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
1000
- <span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
1001
- <span style={{ fg: T.text }}>{" shift+click row While armed (existing block): resize end to that row\n"}</span>
1002
- <span style={{ fg: T.text }}>{" j / k While armed: nudge block ±15 min\n"}</span>
1003
- <span style={{ fg: T.text }}>{" + / - While armed: resize block end ±15 min\n"}</span>
1004
- <span style={{ fg: T.text }}>{" Enter While armed: commit + jump to source task\n"}</span>
1005
- <span style={{ fg: T.text }}>{" Esc Disarm / exit arm mode\n"}</span>
1006
- <span style={{ fg: T.textDim }}>{"\nBoard-only actions\n"}</span>
1007
- <span style={{ fg: T.text }}>{" n New task in current column (quick-add syntax)\n"}</span>
1008
- <span style={{ fg: T.text }}>{" g Grab task — h/l then moves it between columns; g/Esc to drop\n"}</span>
1009
- <span style={{ fg: T.text }}>{" f Cycle board filter: all → today → overdue → tomorrow → followup\n"}</span>
1010
- <span style={{ fg: T.text }}>{" / Search task titles — jumps cursor to first match\n"}</span>
1011
- <span style={{ fg: T.textDim }}>{"\nAgents zone\n"}</span>
1012
- <span style={{ fg: T.text }}>{" Enter Open (resume) the selected session in a new WezTerm tab\n"}</span>
1013
- <span style={{ fg: T.text }}>{" c Copy a 'cd + claude --resume' command for the session\n"}</span>
1014
- <span style={{ fg: T.text }}>{" o Session detail (cwd, branch, last prompts, resume cmd)\n"}</span>
1015
- <span style={{ fg: T.textDim }}>{"\nMulti-select\n"}</span>
1016
- <span style={{ fg: T.text }}>{" Space Mark / unmark task (cursor stays — mark in any order)\n"}</span>
1017
- <span style={{ fg: T.text }}>{" Every task action (done/schedule/time block/assign/\n"}</span>
1018
- <span style={{ fg: T.text }}>{" priority/archive/delete) then applies to ALL marked\n"}</span>
1019
- <span style={{ fg: T.text }}>{" Esc Clear the selection (when no modal is open)\n"}</span>
1020
- <span style={{ fg: T.textDim }}>{"\nBulk\n"}</span>
1021
- <span style={{ fg: T.text }}>{" T Reset ALL overdue tasks (any board) to today\n"}</span>
1022
- <span style={{ fg: T.textDim }}>{"\nGlobal\n"}</span>
1023
- <span style={{ fg: T.text }}>{" Ctrl-Z Undo last mutation\n"}</span>
1024
- <span style={{ fg: T.text }}>{" ? This help\n"}</span>
1025
- <span style={{ fg: T.text }}>{" q · Ctrl-C Quit\n"}</span>
1026
- </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>
1027
1231
  </DialogShell>
1028
1232
  );
1029
1233
  }