tuiboard 0.9.2 → 0.10.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
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-09-09
11
+
12
+ ### Added
13
+ - **A task's note is readable inside tuiboard.** When a task's title is a link —
14
+ `[[Nome nota|Titolo]]` or `[Titolo](Tasks/Nome.md)` — the detail view (`o`)
15
+ shows that note's text, scrollable, instead of pointing at Obsidian. Both link
16
+ forms work, so the convention stands on its own: a board written in plain
17
+ markdown gets the feature too. Links *inside* a sentence stay mentions and are
18
+ listed as before — a task that mentions a person is not documented by that
19
+ person's page. A missing note says which name it looked for; two notes sharing
20
+ a name resolve to the nearest and the shadowed one is named.
21
+
22
+ ### Fixed
23
+ - **Markdown links no longer show as raw syntax in task titles.** `displayTitle`
24
+ stripped wikilinks but not `[text](path.md)`, so a board written without
25
+ Obsidian showed the whole link as its title — in the board, the planner and
26
+ the bar widget.
27
+
10
28
  ## [0.9.2] - 2026-09-08
11
29
 
12
30
  ### Fixed
@@ -298,6 +316,7 @@ First public release on npm. This entry captures the full feature set at launch.
298
316
 
299
317
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
300
318
 
319
+ [0.10.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.10.0
301
320
  [0.9.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.2
302
321
  [0.9.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.1
303
322
  [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.10.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",
@@ -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). */
@@ -1295,11 +1308,49 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1295
1308
  function refreshAll(): void {
1296
1309
  setState("boards", loadAll(config));
1297
1310
  setState("rev", (r) => r + 1);
1311
+ noteIndex = undefined; // rebuilt on the next note opened
1298
1312
  agentsStore.refresh();
1299
1313
  calendarStore.refresh(true);
1300
1314
  flashBanner("info", "Refreshed boards · agents · agenda");
1301
1315
  }
1302
1316
 
1317
+ // ─── Task notes ──────────────────────────────────────────────────────────
1318
+ // The index is built the first time a note is opened and thrown away by `r`.
1319
+ // Watching the tree instead would cost a second chokidar over a thousand
1320
+ // files to save a keystroke the user already has.
1321
+ let noteIndex: NoteIndex | undefined;
1322
+
1323
+ /**
1324
+ * The note behind the task at `ref`, ready to render: its text, where it came
1325
+ * from, or why it could not be read. `undefined` means the task has no note —
1326
+ * which is most tasks, and must produce no message at all.
1327
+ */
1328
+ function taskNote(ref: TaskRef): TaskNoteView | undefined {
1329
+ const task = getTask(ref);
1330
+ if (!task?.note) return undefined;
1331
+
1332
+ noteIndex ??= buildNoteIndex(suggestBoardsDir(config));
1333
+ const found = resolveNote(task.note, { index: noteIndex, boardPath: ref.boardPath });
1334
+ if ("missing" in found) return { missing: found.missing };
1335
+
1336
+ // Shown from the boards' own root: an absolute path is noise, and the
1337
+ // config's root is the config's directory, not where the notes live.
1338
+ const root = suggestBoardsDir(config);
1339
+ const shown = found.path.startsWith(root)
1340
+ ? found.path.slice(root.length).replace(/^[/\\]/, "")
1341
+ : found.path;
1342
+
1343
+ try {
1344
+ return {
1345
+ path: shown,
1346
+ body: readNoteBody(found.path),
1347
+ shadowed: found.shadowed?.map((p) => (p.startsWith(root) ? p.slice(root.length + 1) : p)),
1348
+ };
1349
+ } catch (e) {
1350
+ return { path: shown, error: (e as Error).message };
1351
+ }
1352
+ }
1353
+
1303
1354
  // ─── Multi-select ────────────────────────────────────────────────────────
1304
1355
 
1305
1356
  function markKey(ref: TaskRef): string {
@@ -1643,6 +1694,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1643
1694
  addTask,
1644
1695
  deleteTask,
1645
1696
  moveTaskWithinBoard,
1697
+ // notes
1698
+ taskNote,
1646
1699
  // boards
1647
1700
  addBoard,
1648
1701
  openBoardNew,
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;
package/src/ui/Modal.tsx CHANGED
@@ -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>