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 +54 -1
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/input/handleKey.ts +6 -0
- package/src/notes/index.ts +144 -0
- package/src/parser/markdown.ts +51 -0
- package/src/store/index.ts +71 -0
- package/src/types.ts +14 -0
- package/src/ui/BoardView.tsx +35 -4
- package/src/ui/Modal.tsx +271 -67
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
|
-
## [
|
|
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.
|
|
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",
|
package/src/input/handleKey.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/parser/markdown.ts
CHANGED
|
@@ -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]!;
|
package/src/store/index.ts
CHANGED
|
@@ -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;
|
package/src/ui/BoardView.tsx
CHANGED
|
@@ -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
|
|
399
|
-
//
|
|
400
|
-
|
|
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
|
-
|
|
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/?
|
|
965
|
-
<
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
<
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
}
|