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