tuiboard 0.13.3 → 0.14.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.
@@ -95,6 +95,12 @@ archive_column: Archive
95
95
  # Nushell users typically want `;` instead of `&&`:
96
96
  # copy_resume_command: 'cd "{cwd}"; {resume}'
97
97
 
98
+ # Optional: a markdown file to read from inside tuiboard with `i` — a morning
99
+ # digest, a handover note, whatever you (or an agent) write there. Shown as it
100
+ # is: never parsed, never written to. Relative paths resolve against this file,
101
+ # `~` works. tuiboard picks up changes while the dialog is open.
102
+ # status_file: ~/vault/Home.md
103
+
98
104
  # Optional: overlay read-only calendar events on the Agenda (the 24h timeline).
99
105
  # Connect a provider with `tuiboard calendar-setup google` / `... microsoft`,
100
106
  # which opens the auth flow and prints the exact block to paste here. Bring your
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.0] - 2026-09-21
11
+
12
+ ### Added
13
+ - **Status file viewer** (#67): point `status_file:` at a markdown file — a
14
+ morning digest, a handover note, whatever you or an agent write there — and
15
+ `i` shows it in a dialog, read-only. The markdown is rendered rather than
16
+ shown as source: headings and bold as weight, links and wikilinks by their
17
+ label, list and checkbox glyphs, frontmatter hidden. `j`/`k`, arrows and
18
+ PgUp/PgDn scroll it. It re-reads itself while open when the file changes,
19
+ and `tuiboard summary` reports its path and mtime (never its body) when
20
+ configured.
21
+ - **Overdue tasks show how late they are** (#67): a task overdue for days is
22
+ painted in a louder red than one that slipped yesterday. No day count
23
+ anywhere — just two bands.
24
+ - **Agent interface documentation** (#69): `docs/agent-interface.md` spells
25
+ out `tuiboard summary` and `tuiboard task` as a stable contract for agents
26
+ and scripts — the JSON shape, title matching, exit codes and the rules that
27
+ keep an agent from corrupting a board.
28
+
29
+ ### Changed
30
+ - **Time blocking in arm mode takes fewer round trips** (#73). A task placed
31
+ on the agenda stays armed, so `+`/`-` and `j`/`k` size and move it straight
32
+ away. `Enter` keeps it and returns to where `c` was pressed (usually
33
+ Today/Tomorrow — in single-pane, without passing through the agents panel);
34
+ `Esc` puts the task back as it was and returns the same way.
35
+
36
+ ### Fixed
37
+ - **Keypad `+` grows an armed time block** (#71) instead of opening the "new
38
+ board" dialog.
39
+ - Arming a second task no longer rewrites the first task's reference in place
40
+ (#73), a latent bug surfaced by the new arm-mode tests.
41
+
10
42
  ## [0.13.3] - 2026-09-19
11
43
 
12
44
  ### Fixed
@@ -512,6 +544,7 @@ First public release on npm. This entry captures the full feature set at launch.
512
544
 
513
545
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
514
546
 
547
+ [0.14.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.14.0
515
548
  [0.13.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.3
516
549
  [0.13.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.2
517
550
  [0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
package/README.md CHANGED
@@ -204,6 +204,12 @@ archive_column: Archive
204
204
  # yourself in any tab/pane (no WezTerm needed). {cwd}/{sessionId}/{resume}
205
205
  # substituted. Default: 'cd "{cwd}" && {resume}'. Nushell users:
206
206
  # copy_resume_command: 'cd "{cwd}"; {resume}'
207
+
208
+ # Optional: a markdown file to read from inside tuiboard with `i` — a morning
209
+ # digest, a handover note, whatever you (or an agent) write there. Shown as it
210
+ # is: never parsed, never written to. `~` works, relative paths resolve against
211
+ # the config file. Unconfigured, `i` just explains how to set it.
212
+ # status_file: ~/vault/Home.md
207
213
  ```
208
214
 
209
215
  ## Zones
@@ -456,6 +462,10 @@ session (until the next terminal resize).
456
462
  | `c` | Arm mode: click a task, then click a slot to schedule (works from any zone) |
457
463
  | `j` / `k` | While armed: nudge the block ±15 min |
458
464
  | `+` / `-` | While armed: resize the block's end ±15 min |
465
+ | `Enter` | While armed: keep the placement and go back to where `c` was pressed |
466
+ | `Esc` | While armed: undo the placement and go back to where `c` was pressed |
467
+
468
+ A placed task stays armed, so it can be sized and moved straight away.
459
469
 
460
470
  ### Agents (agents zone)
461
471
 
@@ -501,11 +511,17 @@ session (until the next terminal resize).
501
511
  | `n` | New task in current column (quick-add syntax) |
502
512
  | `Shift-T` | Reset ALL overdue tasks (any board) to today |
503
513
  | `Ctrl-Z` | Undo last mutation |
514
+ | `i` | Status file — the markdown file set as `status_file`, shown read-only |
504
515
  | `?` | Help modal with the full reference |
505
516
  | `q` · `Ctrl-C` | Quit |
506
517
 
507
518
  ## Headless commands
508
519
 
520
+ > Driving tuiboard from Claude Code, Codex or any other agent?
521
+ > [docs/agent-interface.md](docs/agent-interface.md) documents these two
522
+ > commands as an API: the JSON shape, the matching rules, the exit codes, and
523
+ > the handful of rules that keep an agent from corrupting a board.
524
+
509
525
  Two subcommands run without the TUI, for status bars, widgets and scripts.
510
526
  Both reuse the same config loader and parser as the dashboard, so they can
511
527
  never disagree with it about what is on your board.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.13.3",
3
+ "version": "0.14.0",
4
4
  "description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live view of your coding-agent sessions (Claude Code, Codex, OpenCode, Pi). Use only the panels you want.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@
12
12
  * dashboard shows — the parser stays the single source of truth.
13
13
  */
14
14
 
15
- import { readFileSync } from "node:fs";
15
+ import { readFileSync, statSync } from "node:fs";
16
16
 
17
17
  import { isHiddenColumn, loadConfig } from "~/config/loader";
18
18
  import { isTask, parseBoard } from "~/parser/markdown";
@@ -92,6 +92,22 @@ export interface Summary {
92
92
  * widget and the dashboard can never disagree about what is due.
93
93
  */
94
94
  planner: Record<PlannerSection, PlannerEntry[]>;
95
+ /**
96
+ * The configured status file, when there is one: mtime only, never the
97
+ * body. A bar widget wants to know *that* it changed — laying out a page of
98
+ * prose is the TUI's job. Absent when unconfigured, so every existing
99
+ * consumer sees the shape it already knows.
100
+ */
101
+ statusFile?: { path: string; updatedAt: string | null };
102
+ }
103
+
104
+ /** A file's mtime as ISO, or null when it isn't there. */
105
+ function statMtimeIso(path: string): string | null {
106
+ try {
107
+ return new Date(statSync(path).mtimeMs).toISOString();
108
+ } catch {
109
+ return null;
110
+ }
95
111
  }
96
112
 
97
113
  /** Local calendar date as YYYY-MM-DD — never UTC, or "today" flips at the wrong hour. */
@@ -253,7 +269,22 @@ export function buildSummary(options: { next?: number; today?: string } = {}): S
253
269
  });
254
270
  }
255
271
 
256
- return { generatedAt: new Date().toISOString(), totals, boards, planner };
272
+ const statusFile = config.statusFilePath
273
+ ? {
274
+ path: config.statusFilePath,
275
+ // null, not absent: configured but not written yet is a state a widget
276
+ // may want to show, and it differs from "no status file at all".
277
+ updatedAt: statMtimeIso(config.statusFilePath),
278
+ }
279
+ : undefined;
280
+
281
+ return {
282
+ generatedAt: new Date().toISOString(),
283
+ totals,
284
+ boards,
285
+ planner,
286
+ ...(statusFile ? { statusFile } : {}),
287
+ };
257
288
  }
258
289
 
259
290
  export async function runSummary(argv: readonly string[]): Promise<number> {
@@ -59,6 +59,13 @@ export interface Config {
59
59
  * Windows, `$SHELL` elsewhere). Ignored when `resumeCommand` is set.
60
60
  */
61
61
  resumeShell: "auto" | Shell;
62
+ /**
63
+ * A markdown file to read from inside tuiboard with `i` (config
64
+ * `status_file`) — a morning digest, a handover note, whatever is written
65
+ * there. Read-only and never parsed: tuiboard shows it, nothing else.
66
+ * Unset means the key does nothing.
67
+ */
68
+ statusFilePath?: string;
62
69
  /**
63
70
  * Agent status glyphs (config `status_indicators`): `symbols` (default —
64
71
  * the same × ◐ ✓ ○ · herdr uses) or `dots` (● ○ ·).
@@ -223,6 +230,7 @@ interface RawConfig {
223
230
  resume_terminal: string;
224
231
  resume_shell: string;
225
232
  status_indicators: string;
233
+ status_file: string;
226
234
  copy_resume_command: string;
227
235
  calendars: {
228
236
  google?: {
@@ -374,6 +382,10 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
374
382
  resumeShell: (SHELLS as readonly string[]).includes(raw.resume_shell ?? "")
375
383
  ? (raw.resume_shell as Shell)
376
384
  : "auto",
385
+ statusFilePath:
386
+ typeof raw.status_file === "string" && raw.status_file.trim()
387
+ ? expandPath(raw.status_file.trim(), root)
388
+ : undefined,
377
389
  statusIndicators: raw.status_indicators === "dots" ? "dots" : "symbols",
378
390
  copyResumeCommand:
379
391
  typeof raw.copy_resume_command === "string" &&
@@ -24,6 +24,7 @@ import {
24
24
  systemLaunchEnv,
25
25
  } from "~/input/open-session";
26
26
  import { planHerdrFocus, planHerdrResume } from "~/input/herdr-open";
27
+ import { isMinusKey, isPlusKey } from "~/input/keys";
27
28
  import { HARNESS, type AgentSession } from "~/store/agents";
28
29
  import { herdrBin, herdrPlace } from "~/store/herdr";
29
30
  import { googleTokenCanWrite } from "~/store/calendar";
@@ -31,6 +32,7 @@ import { isTask } from "~/parser/markdown";
31
32
  import {
32
33
  isoToday,
33
34
  isoTomorrow,
35
+ type ArmOrigin,
34
36
  type ModalKind,
35
37
  type TaskRef,
36
38
  type TuiStore,
@@ -116,6 +118,17 @@ export function handleKey(
116
118
  store.closeModal();
117
119
  return;
118
120
  }
121
+ // The status file closes on its own key too, like `o` closes the detail.
122
+ if (ui.modal.kind === "status-file") {
123
+ if (key.name === "i") { store.closeModal(); return; }
124
+ const step =
125
+ key.name === "j" || key.name === "down" ? 1 :
126
+ key.name === "k" || key.name === "up" ? -1 :
127
+ key.name === "pagedown" || key.name === "space" ? 10 :
128
+ key.name === "pageup" ? -10 : 0;
129
+ if (step) store.setStatusScroll(ui.statusScroll + step);
130
+ return;
131
+ }
119
132
  // Help modal: j/k (or arrows) scroll its keyboard reference.
120
133
  if (ui.modal.kind === "help") {
121
134
  if (key.name === "j" || key.name === "down") { store.setHelpScroll(ui.helpScroll + 1); return; }
@@ -146,10 +159,11 @@ export function handleKey(
146
159
  // disruptive first.
147
160
  if (key.name === "escape") {
148
161
  if (ui.armMode || ui.armedTimelineRef) {
149
- const wasMode = ui.armMode;
150
- store.setArmMode(false);
151
- store.armTimeline(undefined);
152
- store.flashBanner("info", wasMode ? "Arm mode off" : "Disarmed");
162
+ // Cancel: the armed task goes back to how it was, the cursor to where
163
+ // `c` was pressed (#73).
164
+ const hadTask = !!ui.armedTimelineRef;
165
+ restoreArmOrigin(store, store.leaveArmMode(false), plannerCount);
166
+ store.flashBanner("info", hadTask ? "Cancelled" : "Arm mode off");
153
167
  return;
154
168
  }
155
169
  if (ui.selectedCalEvent) {
@@ -212,9 +226,10 @@ export function handleKey(
212
226
  return;
213
227
  }
214
228
 
215
- // New board — the `+` chip in the top bar, and its key. Free at this level:
216
- // `+` is otherwise only used inside the timeline's duration sub-mode.
217
- if (key.name === "+" || key.sequence === "+" || (key.name === "=" && key.shift)) {
229
+ // New board — the `+` chip in the top bar, and its key. Except on the
230
+ // agenda with a block armed: there `+` grows the block (handleTimelineZone),
231
+ // and this shortcut running first used to swallow it (#71).
232
+ if (isPlusKey(key) && !(ui.armedTimelineRef && ui.activeZone === "timeline")) {
218
233
  store.openBoardNew();
219
234
  return;
220
235
  }
@@ -270,6 +285,14 @@ export function handleKey(
270
285
  return;
271
286
  }
272
287
 
288
+ // The status file: a markdown file read once in a while (a morning digest,
289
+ // a handover note), shown in a dialog. `i` for "info"; `u` and `w` were the
290
+ // other free letters, both easier to misread as undo / write.
291
+ if (key.name === "i") {
292
+ store.openModal({ kind: "status-file" });
293
+ return;
294
+ }
295
+
273
296
  // Zoom toggle: focus the active panel (board column or planner panel)
274
297
  // at full width.
275
298
  if (key.name === "z") {
@@ -320,7 +343,7 @@ export function handleKey(
320
343
  }
321
344
 
322
345
  if (ui.activeZone === "timeline") {
323
- handleTimelineZone(store, key, openLater);
346
+ handleTimelineZone(store, key, plannerCount, openLater);
324
347
  return;
325
348
  }
326
349
 
@@ -373,9 +396,22 @@ function handlePlannerZone(
373
396
  }
374
397
  }
375
398
 
399
+ /** Put the cursor back where `c` started arm mode. */
400
+ function restoreArmOrigin(store: TuiStore, origin: ArmOrigin | undefined, plannerCount = Infinity): boolean {
401
+ if (!origin) return false;
402
+ if (origin.boardIndex !== store.state.ui.activeBoardIndex) store.setActiveBoard(origin.boardIndex);
403
+ store.setActiveZone(origin.zone);
404
+ // Placing a task can reorder the planner (it joins the time-blocked
405
+ // bucket); the row stays, clamped to what's there.
406
+ const row = origin.zone === "planner" ? Math.min(origin.row, Math.max(0, plannerCount - 1)) : origin.row;
407
+ store.setCursor(origin.col, row);
408
+ return true;
409
+ }
410
+
376
411
  function handleTimelineZone(
377
412
  store: TuiStore,
378
413
  key: KeyEvent,
414
+ plannerCount: number,
379
415
  openLater: (m: ModalKind) => void,
380
416
  ): void {
381
417
  const ui = store.state.ui;
@@ -427,6 +463,16 @@ function handleTimelineZone(
427
463
  )
428
464
  : undefined;
429
465
 
466
+ // Enter confirms whatever is armed — placed or not — and goes back to where
467
+ // `c` started; a task armed straight from its band still jumps to its card.
468
+ if (armedRef && (key.name === "enter" || key.name === "return")) {
469
+ const origin = store.leaveArmMode(true);
470
+ if (!restoreArmOrigin(store, origin, plannerCount)) jumpToKanban(store, armedRef);
471
+ const t = store.getTask(armedRef);
472
+ store.flashBanner("info", t?.timeBlock ? `✓ ${fmtHm(t.timeBlock.startMin)}-${fmtHm(t.timeBlock.endMin)}` : "Arm mode off");
473
+ return;
474
+ }
475
+
430
476
  if (armed) {
431
477
  const NUDGE = 15; // minutes
432
478
  if (key.name === "j" || key.name === "down") {
@@ -443,24 +489,18 @@ function handleTimelineZone(
443
489
  store.flashBanner("info", `✋ ${fmtHm(newStart)}-${fmtHm(newEnd)}`);
444
490
  return;
445
491
  }
446
- if (key.name === "+" || key.name === "=" || key.sequence === "+") {
492
+ if (isPlusKey(key) || key.name === "=") {
447
493
  const newEnd = Math.min(24 * 60 - 1, armed.endMin + NUDGE);
448
494
  store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
449
495
  store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
450
496
  return;
451
497
  }
452
- if (key.name === "-" || key.name === "_" || key.sequence === "-") {
498
+ if (isMinusKey(key) || key.name === "_") {
453
499
  const newEnd = Math.max(armed.startMin + 15, armed.endMin - NUDGE);
454
500
  store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
455
501
  store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
456
502
  return;
457
503
  }
458
- if (key.name === "enter" || key.name === "return") {
459
- // Commit + jump to kanban + disarm.
460
- store.armTimeline(undefined);
461
- jumpToKanban(store, armed.ref);
462
- return;
463
- }
464
504
  // Fall through for other keys (Esc handled globally, task actions below).
465
505
  }
466
506
 
@@ -784,13 +824,12 @@ function dispatchTaskAction(
784
824
  // slot, repeat. `c` again or `Esc` exits. Works from any zone.
785
825
  if (key.name === "c" && !key.shift) {
786
826
  if (store.state.ui.armMode) {
787
- store.setArmMode(false);
788
- store.armTimeline(undefined);
827
+ // `c` again keeps what was placed, like Enter.
828
+ restoreArmOrigin(store, store.leaveArmMode(true));
789
829
  store.flashBanner("info", "Arm mode off");
790
830
  return true;
791
831
  }
792
- store.setArmMode(true);
793
- store.armTimeline(ref);
832
+ store.startArmMode(ref);
794
833
  store.setZoneVisible("timeline", true);
795
834
  store.setActiveZone("timeline");
796
835
  const t = store.getTask(ref);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Keys that more than one handler cares about, recognised in one place so the
3
+ * handlers can't disagree about what "+" is.
4
+ *
5
+ * A `+` arrives in several shapes: the main-row key (name "+", or "=" with
6
+ * shift on US layouts), the keypad key (name "kpplus" under the kitty
7
+ * keyboard protocol, a plain "+" sequence elsewhere).
8
+ */
9
+
10
+ export interface KeyLike {
11
+ name?: string;
12
+ sequence?: string;
13
+ shift?: boolean;
14
+ }
15
+
16
+ export function isPlusKey(key: KeyLike): boolean {
17
+ return key.name === "+" || key.name === "kpplus" || key.sequence === "+" || (key.name === "=" && !!key.shift);
18
+ }
19
+
20
+ export function isMinusKey(key: KeyLike): boolean {
21
+ return key.name === "-" || key.name === "kpminus" || key.sequence === "-";
22
+ }
@@ -18,7 +18,7 @@
18
18
  * 4. Schedule a debounced write to disk (writer + watcher self-mark).
19
19
  */
20
20
 
21
- import { readFileSync } from "node:fs";
21
+ import { readFileSync, statSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { createMemo } from "solid-js";
@@ -76,6 +76,14 @@ export interface TaskRef {
76
76
  taskIndex: number;
77
77
  }
78
78
 
79
+ function sameRef(a: TaskRef, b: TaskRef): boolean {
80
+ return a.boardPath === b.boardPath && a.columnIndex === b.columnIndex && a.taskIndex === b.taskIndex;
81
+ }
82
+
83
+ function sameBlock(a: TimeBlock | undefined, b: TimeBlock | undefined): boolean {
84
+ return a?.startMin === b?.startMin && a?.endMin === b?.endMin;
85
+ }
86
+
79
87
  export interface LoadedBoard {
80
88
  board: Board;
81
89
  /** mtime in ms at the moment of the last successful read or write. */
@@ -98,6 +106,7 @@ export type ModalKind =
98
106
  | { kind: "confirm-delete-event" }
99
107
  | { kind: "search" }
100
108
  | { kind: "board-new" }
109
+ | { kind: "status-file" }
101
110
  | { kind: "help" };
102
111
 
103
112
  /**
@@ -133,6 +142,24 @@ export interface BoardNew {
133
142
  }
134
143
 
135
144
  /** What the detail view needs to show a task's note, or to explain its absence. */
145
+ /** The configured status file, as the modal shows it. */
146
+ export interface StatusFileView {
147
+ /** Path as configured, for the dialog subtitle. */
148
+ path: string;
149
+ body?: string;
150
+ /** Last write, ISO — undefined when the file isn't there. */
151
+ updatedAt?: string;
152
+ /** The configured path doesn't exist. */
153
+ missing?: string;
154
+ /** It exists but couldn't be read. */
155
+ error?: string;
156
+ /** Shown truncated: bigger than STATUS_FILE_MAX_BYTES. */
157
+ truncated?: boolean;
158
+ }
159
+
160
+ /** A status file is prose, not a database: past this it's shown cut. */
161
+ export const STATUS_FILE_MAX_BYTES = 64 * 1024;
162
+
136
163
  export interface TaskNoteView {
137
164
  path?: string;
138
165
  body?: string;
@@ -177,6 +204,14 @@ export interface SelectedCalEvent {
177
204
  export type ActiveZone = "planner" | "board" | "timeline" | "agents";
178
205
 
179
206
  /** Fixed cycling order for Shift+Tab navigation. */
207
+ /** Where `c` was pressed, so leaving arm mode can put the cursor back. */
208
+ export interface ArmOrigin {
209
+ zone: ActiveZone;
210
+ boardIndex: number;
211
+ col: number;
212
+ row: number;
213
+ }
214
+
180
215
  const ZONE_ORDER: readonly ActiveZone[] = ["planner", "board", "timeline", "agents"];
181
216
 
182
217
  export interface UIState {
@@ -246,6 +281,8 @@ export interface UIState {
246
281
  * `armedTimelineRef`, which is the single task currently armed.
247
282
  */
248
283
  armMode: boolean;
284
+ /** Set when `c` starts arm mode; `Enter`/`Esc` return here (#73). */
285
+ armOrigin?: ArmOrigin;
249
286
  /**
250
287
  * Which day the Agenda (timeline) zone is showing, as a signed offset from
251
288
  * today (0 = today, +1 = tomorrow, -1 = yesterday). Drives both the task
@@ -258,6 +295,10 @@ export interface UIState {
258
295
  * the upper bound (it knows the block count) and scrolls that block into view.
259
296
  */
260
297
  helpScroll: number;
298
+ /** Bumped when the status file changes on disk, so an open modal re-reads. */
299
+ statusFileRev: number;
300
+ /** Status-file modal scroll, in rows. The modal clamps the upper bound. */
301
+ statusScroll: number;
261
302
  view: ViewMode;
262
303
  /**
263
304
  * Tasks marked for bulk ops (`Space`). Key format:
@@ -359,6 +400,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
359
400
  armMode: false,
360
401
  agendaOffset: 0,
361
402
  helpScroll: 0,
403
+ statusFileRev: 0,
404
+ statusScroll: 0,
362
405
  view: "kanban",
363
406
  marked: {},
364
407
  filter: "all",
@@ -372,9 +415,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
372
415
  // Last content tuiboard itself wrote per board path — used by the watcher's
373
416
  // self-write guard to ignore our own writes echoed back by the OS / sync.
374
417
  const lastWrittenContent = new Map<string, string>();
375
- const watcher: BoardWatcher = createBoardWatcher(
376
- initialBoards.map((b) => b.board.filepath),
377
- );
418
+ const watcher: BoardWatcher = createBoardWatcher([
419
+ ...initialBoards.map((b) => b.board.filepath),
420
+ // Watched from the start, even when it doesn't exist yet: chokidar picks
421
+ // up a file created later as long as its directory exists (verified).
422
+ ...(config.statusFilePath ? [config.statusFilePath] : []),
423
+ ]);
378
424
 
379
425
  // Agents store has its own lifecycle (chokidar watcher on each agent CLI's
380
426
  // session dirs). Shared dispose() boundary below so SIGINT cleans both. When
@@ -389,6 +435,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
389
435
  ? createCalendarStore(config.calendars, isoToday)
390
436
  : noopCalendarStore();
391
437
  watcher.onChange((filepath) => {
438
+ // The status file is not a board: nothing to parse, just tell an open
439
+ // modal to read it again.
440
+ if (config.statusFilePath && filepath === config.statusFilePath) {
441
+ setState("ui", "statusFileRev", (n: number) => n + 1);
442
+ return;
443
+ }
392
444
  // External edit. Re-read this board from disk.
393
445
  try {
394
446
  const content = readFileSync(filepath, "utf-8");
@@ -1308,14 +1360,58 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1308
1360
  setState("ui", "grabbing", false);
1309
1361
  }
1310
1362
 
1363
+ /**
1364
+ * The armed task as it was when it was armed, so `Esc` can put it back.
1365
+ * Not UI state: nothing renders it, and it only means something while the
1366
+ * same ref is still armed.
1367
+ */
1368
+ let armSnapshot: { ref: TaskRef; scheduled?: string; timeBlock?: TimeBlock } | undefined;
1369
+
1311
1370
  function armTimeline(ref: TaskRef | undefined): void {
1312
- setState("ui", "armedTimelineRef", ref);
1371
+ // A copy: handed an object, setState merges it into the one already
1372
+ // stored, so arming a second task used to rewrite the first task's ref
1373
+ // object in place — wherever a caller still held it.
1374
+ const own = ref && { ...ref };
1375
+ setState("ui", "armedTimelineRef", own);
1376
+ const t = own && getTask(own);
1377
+ armSnapshot =
1378
+ own && t
1379
+ ? { ref: own, scheduled: t.scheduled, timeBlock: t.timeBlock && { ...t.timeBlock } }
1380
+ : undefined;
1313
1381
  }
1314
1382
 
1315
1383
  function setArmMode(on: boolean): void {
1316
1384
  setState("ui", "armMode", on);
1317
1385
  }
1318
1386
 
1387
+ /** Start arm mode from the cursor, remembering where it started. */
1388
+ function startArmMode(ref: TaskRef): void {
1389
+ const ui = state.ui;
1390
+ setState("ui", "armOrigin", { zone: ui.activeZone, boardIndex: ui.activeBoardIndex, col: ui.col, row: ui.row });
1391
+ setArmMode(true);
1392
+ armTimeline(ref);
1393
+ }
1394
+
1395
+ /**
1396
+ * Leave arm mode. `confirm` keeps whatever was placed; otherwise the armed
1397
+ * task goes back to how it was when armed. Returns the origin to restore,
1398
+ * if arm mode was started with `c`.
1399
+ */
1400
+ function leaveArmMode(confirm: boolean): ArmOrigin | undefined {
1401
+ const ref = state.ui.armedTimelineRef;
1402
+ const snap = armSnapshot;
1403
+ if (!confirm && ref && snap && sameRef(ref, snap.ref)) {
1404
+ const t = getTask(ref);
1405
+ if (t && !sameBlock(t.timeBlock, snap.timeBlock)) setTimeBlock(ref, snap.timeBlock);
1406
+ if (t && t.scheduled !== snap.scheduled) setScheduled(ref, snap.scheduled);
1407
+ }
1408
+ const origin = state.ui.armOrigin;
1409
+ armTimeline(undefined);
1410
+ setArmMode(false);
1411
+ setState("ui", "armOrigin", undefined);
1412
+ return origin;
1413
+ }
1414
+
1319
1415
  /** ISO date the Agenda is currently showing (today + offset). Reactive. */
1320
1416
  function agendaDate(): string {
1321
1417
  return isoAddDays(isoToday(), state.ui.agendaOffset);
@@ -1370,6 +1466,37 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1370
1466
  * from, or why it could not be read. `undefined` means the task has no note —
1371
1467
  * which is most tasks, and must produce no message at all.
1372
1468
  */
1469
+ /**
1470
+ * The configured status file, read fresh. No parsing and no caching: it is
1471
+ * read when the modal opens and again whenever the watcher says it changed,
1472
+ * which is rare enough that a cache would only be a way to show stale text.
1473
+ */
1474
+ function statusFile(): StatusFileView | undefined {
1475
+ const path = config.statusFilePath;
1476
+ if (!path) return undefined;
1477
+ let size: number;
1478
+ let mtimeMs: number;
1479
+ try {
1480
+ const st = statSync(path);
1481
+ size = st.size;
1482
+ mtimeMs = st.mtimeMs;
1483
+ } catch {
1484
+ return { path, missing: path };
1485
+ }
1486
+ try {
1487
+ const body = readNoteBody(path);
1488
+ const truncated = size > STATUS_FILE_MAX_BYTES;
1489
+ return {
1490
+ path,
1491
+ updatedAt: new Date(mtimeMs).toISOString(),
1492
+ body: truncated ? body.slice(0, STATUS_FILE_MAX_BYTES) : body,
1493
+ ...(truncated ? { truncated: true } : {}),
1494
+ };
1495
+ } catch (e) {
1496
+ return { path, updatedAt: new Date(mtimeMs).toISOString(), error: (e as Error).message };
1497
+ }
1498
+ }
1499
+
1373
1500
  function taskNote(ref: TaskRef): TaskNoteView | undefined {
1374
1501
  const task = getTask(ref);
1375
1502
  if (!task?.note) return undefined;
@@ -1541,6 +1668,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1541
1668
  function openModal(m: ModalKind): void {
1542
1669
  // Help always opens scrolled to the top.
1543
1670
  if (m.kind === "help") setState("ui", "helpScroll", 0);
1671
+ if (m.kind === "status-file") setState("ui", "statusScroll", 0);
1544
1672
  setState("ui", "modal", m);
1545
1673
  }
1546
1674
 
@@ -1603,6 +1731,11 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1603
1731
  function setHelpScroll(n: number): void {
1604
1732
  setState("ui", "helpScroll", Math.max(0, n));
1605
1733
  }
1734
+ /** Scroll the status file (rows, lower-clamped at 0; StatusFileModal writes
1735
+ * back the upper bound once it knows how tall the file renders). */
1736
+ function setStatusScroll(n: number): void {
1737
+ setState("ui", "statusScroll", Math.max(0, n));
1738
+ }
1606
1739
 
1607
1740
  /** Move the step-2 calendar selection (wraps). */
1608
1741
  function setEventSel(n: number): void {
@@ -1753,6 +1886,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1753
1886
  moveTaskWithinBoard,
1754
1887
  // notes
1755
1888
  taskNote,
1889
+ statusFile,
1756
1890
  // boards
1757
1891
  addBoard,
1758
1892
  openBoardNew,
@@ -1779,6 +1913,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1779
1913
  exitGrab,
1780
1914
  armTimeline,
1781
1915
  setArmMode,
1916
+ startArmMode,
1917
+ leaveArmMode,
1782
1918
  agendaDate,
1783
1919
  shiftAgendaDay,
1784
1920
  resetAgendaDay,
@@ -1798,6 +1934,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1798
1934
  openEventModal,
1799
1935
  advanceEventToStep2,
1800
1936
  setHelpScroll,
1937
+ setStatusScroll,
1801
1938
  setEventSel,
1802
1939
  confirmEventPicker,
1803
1940
  selectCalEvent,
package/src/ui/Modal.tsx CHANGED
@@ -22,9 +22,10 @@ import {
22
22
  import { ATTR, T, cellWidth } from "~/ui/glyphs";
23
23
  import { AGENDA_WIDTH } from "~/ui/layout";
24
24
  import { formatHm } from "~/store/timeline";
25
- import { HARNESS } from "~/store/agents";
25
+ import { HARNESS, formatAge } from "~/store/agents";
26
26
  import { HARNESS_COLOR } from "~/ui/AgentRow";
27
27
  import { herdrPlace } from "~/store/herdr";
28
+ import { markdownLines, type MdLine, type MdStyle } from "~/ui/markdown-lines";
28
29
  import type { TuiStore } from "~/store/index";
29
30
  import type { PriorityLevel, TimeBlock } from "~/types";
30
31
 
@@ -68,6 +69,7 @@ function ModalRouter(props: { store: TuiStore; modal: NonNullable<TuiStore["stat
68
69
  case "confirm-delete-event": return <ConfirmDeleteEventModal store={props.store} />;
69
70
  case "search": return <SearchModal store={props.store} />;
70
71
  case "board-new": return <BoardNewModal store={props.store} />;
72
+ case "status-file": return <StatusFileModal store={props.store} />;
71
73
  case "help": return <HelpModal store={props.store} />;
72
74
  }
73
75
  }
@@ -258,7 +260,7 @@ function DialogShell(props: DialogShellProps) {
258
260
  >
259
261
  {props.children}
260
262
  <Show when={props.hint}>
261
- <text>
263
+ <text style={{ flexShrink: 0 }}>
262
264
  <span style={{ fg: T.textDim }}>{props.hint}</span>
263
265
  </text>
264
266
  </Show>
@@ -837,6 +839,145 @@ function DetailModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiSto
837
839
  );
838
840
  }
839
841
 
842
+ /**
843
+ * The configured status file (`status_file`), shown and nothing else: no
844
+ * writing, and no parsing beyond the light markdown rendering of
845
+ * markdown-lines.ts — markers become colour and weight, link targets go away.
846
+ * Same shape as a task's note — a dialog with a scrollable body — because
847
+ * from tuiboard's side it is the same object.
848
+ */
849
+ /** "updated now" / "updated 5m ago" — same vocabulary the agents rows use. */
850
+ function updatedLabel(iso: string): string {
851
+ const age = formatAge(Date.parse(iso), Date.now());
852
+ return age === "now" ? "updated just now" : `updated ${age} ago`;
853
+ }
854
+
855
+ const MD_SPAN: Record<MdStyle, { fg: string | undefined; attributes?: number }> = {
856
+ text: { fg: T.text },
857
+ bold: { fg: T.text, attributes: ATTR.bold },
858
+ italic: { fg: T.text, attributes: ATTR.italic },
859
+ code: { fg: T.tag },
860
+ link: { fg: T.accent },
861
+ wikilink: { fg: T.accent },
862
+ dim: { fg: T.textDim },
863
+ };
864
+
865
+ /** One markdown line as a word-wrapped row; blank lines keep their height. */
866
+ function MarkdownLine(props: { line: MdLine }) {
867
+ const l = () => props.line;
868
+ const heading = () => l().kind === "heading";
869
+ const spanStyle = (style: MdStyle) =>
870
+ heading()
871
+ ? { fg: l().level! <= 2 ? T.accent : T.warm, attributes: ATTR.bold }
872
+ : l().kind === "quote" && style === "text"
873
+ ? MD_SPAN.dim
874
+ : MD_SPAN[style];
875
+ return (
876
+ <Show when={l().kind !== "blank"} fallback={<box style={{ height: 1 }} />}>
877
+ <Show
878
+ when={l().kind !== "rule"}
879
+ fallback={
880
+ <text wrapMode="none">
881
+ <span style={{ fg: T.textDim }}>{"─".repeat(40)}</span>
882
+ </text>
883
+ }
884
+ >
885
+ <text wrapMode="word">
886
+ <Show when={l().prefix}>
887
+ <span style={{ fg: T.textDim }}>{l().prefix}</span>
888
+ </Show>
889
+ <For each={l().spans}>
890
+ {(s) => <span style={spanStyle(s.style)}>{s.text}</span>}
891
+ </For>
892
+ </text>
893
+ </Show>
894
+ </Show>
895
+ );
896
+ }
897
+
898
+ /** The bits of OpenTUI's <scrollbox> the status file needs. */
899
+ interface ScrollTopLike {
900
+ scrollTop: number;
901
+ }
902
+
903
+ function StatusFileModal(props: { store: TuiStore }) {
904
+ let scroller: ScrollTopLike | undefined;
905
+ // j/k drive `ui.statusScroll`; the scrollbox clamps it to what it can
906
+ // show, and the clamped value is written back so `k` answers at once after
907
+ // overshooting the end.
908
+ createEffect(() => {
909
+ const want = props.store.state.ui.statusScroll;
910
+ if (!scroller) return;
911
+ scroller.scrollTop = want;
912
+ if (scroller.scrollTop !== want) props.store.setStatusScroll(scroller.scrollTop);
913
+ });
914
+ const view = createMemo(() => {
915
+ props.store.state.ui.statusFileRev; // re-read when the file changes on disk
916
+ return props.store.statusFile();
917
+ });
918
+ return (
919
+ <Show
920
+ when={view()}
921
+ fallback={
922
+ <DialogShell title="Status file" hint="Esc to close" width={70}>
923
+ <text wrapMode="word">
924
+ <span style={{ fg: T.textDim }}>
925
+ No status file configured. Set `status_file:` in your config to the markdown
926
+ file you want to read here.
927
+ </span>
928
+ </text>
929
+ </DialogShell>
930
+ }
931
+ >
932
+ {(v: () => NonNullable<ReturnType<typeof view>>) => (
933
+ <DialogShell title="Status file" hint="j/k scroll · Esc/i to close" width={90}>
934
+ <text wrapMode="word">
935
+ <span style={{ fg: T.tag }}>{v().path}</span>
936
+ <Show when={v().updatedAt}>
937
+ <span style={{ fg: T.textDim }}>{" " + updatedLabel(v().updatedAt!)}</span>
938
+ </Show>
939
+ </text>
940
+ <Show when={v().missing}>
941
+ <box style={{ height: 1 }} />
942
+ <text wrapMode="word">
943
+ <span style={{ fg: T.overdue }}>{"File not found: " + v().missing}</span>
944
+ </text>
945
+ </Show>
946
+ <Show when={v().error}>
947
+ <box style={{ height: 1 }} />
948
+ <text wrapMode="word">
949
+ <span style={{ fg: T.overdue }}>{"File unreadable: " + v().error}</span>
950
+ </text>
951
+ </Show>
952
+ <Show when={v().body !== undefined}>
953
+ <box style={{ height: 1 }} />
954
+ <scrollbox
955
+ ref={(r: ScrollTopLike) => (scroller = r)}
956
+ style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0, minHeight: 0 }}
957
+ >
958
+ <Show
959
+ when={v().body !== ""}
960
+ fallback={
961
+ <text wrapMode="word">
962
+ <span style={{ fg: T.textDim }}>(the status file is empty)</span>
963
+ </text>
964
+ }
965
+ >
966
+ <For each={markdownLines(v().body!)}>{(line) => <MarkdownLine line={line} />}</For>
967
+ </Show>
968
+ </scrollbox>
969
+ <Show when={v().truncated}>
970
+ <text wrapMode="word">
971
+ <span style={{ fg: T.textDim }}>… shown truncated (the file is large)</span>
972
+ </text>
973
+ </Show>
974
+ </Show>
975
+ </DialogShell>
976
+ )}
977
+ </Show>
978
+ );
979
+ }
980
+
840
981
  // ─── Search ──────────────────────────────────────────────────────────────────
841
982
 
842
983
  function SearchModal(props: { store: TuiStore }) {
@@ -1125,13 +1266,13 @@ const HELP_SECTIONS: HelpSection[] = [
1125
1266
  ["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"],
1126
1267
  ["click an event", "Select an editable Google event — then e edit · d delete · Esc"],
1127
1268
  ["c (any zone)", "Toggle ARM MODE — then click a task, click a slot, repeat"],
1128
- ["click empty row", "Place the armed task here (30-min block, or move if it has one)"],
1269
+ ["click empty row", "Place the armed task here (30-min block, or move if it has one) — it stays armed"],
1129
1270
  ["click band", "Arm an existing block (or place the armed task at its start)"],
1130
1271
  ["shift+click row", "While armed (existing block): resize end to that row"],
1131
1272
  ["j / k", "While armed: nudge block ±15 min"],
1132
1273
  ["+ / -", "While armed: resize block end ±15 min"],
1133
- ["Enter", "While armed: commit + jump to source task"],
1134
- ["Esc", "Disarm / exit arm mode"],
1274
+ ["Enter", "While armed: keep it, exit arm mode, back to where c was pressed"],
1275
+ ["Esc", "While armed: undo the placement, exit arm mode, back to where c was pressed"],
1135
1276
  ],
1136
1277
  },
1137
1278
  {
@@ -1173,6 +1314,7 @@ const HELP_SECTIONS: HelpSection[] = [
1173
1314
  title: "Global",
1174
1315
  rows: [
1175
1316
  ["Ctrl-Z", "Undo last mutation"],
1317
+ ["i", "Status file (the markdown file set as `status_file`)"],
1176
1318
  ["?", "This help"],
1177
1319
  ["q · Ctrl-C", "Quit"],
1178
1320
  ],
@@ -14,6 +14,12 @@ import { Show, createMemo } from "solid-js";
14
14
 
15
15
  import { PRIORITY_COLOR, PRIORITY_GLYPH, T, cellWidth, fmtMin } from "~/ui/glyphs";
16
16
  import { isoToday, isoTomorrow } from "~/store/index";
17
+ import {
18
+ statusOf,
19
+ suffixColorFor,
20
+ titleColorFor,
21
+ type TaskStatus,
22
+ } from "~/ui/task-status";
17
23
  import type { Task } from "~/types";
18
24
 
19
25
  interface TaskRowProps {
@@ -149,40 +155,6 @@ export function TaskRow(props: TaskRowProps) {
149
155
  );
150
156
  }
151
157
 
152
- type TaskStatus =
153
- | "done"
154
- | "overdue"
155
- | "today"
156
- | "tomorrow"
157
- | "future"
158
- | "unscheduled";
159
-
160
- function statusOf(t: Task): TaskStatus {
161
- if (t.done) return "done";
162
- const d = t.scheduled ?? t.due;
163
- if (!d) return "unscheduled";
164
- if (d < isoToday()) return "overdue";
165
- if (d === isoToday()) return "today";
166
- if (d === isoTomorrow()) return "tomorrow";
167
- return "future";
168
- }
169
-
170
- function titleColorFor(task: Task, status: TaskStatus): string | undefined {
171
- // Precedence: done (green) > overdue (red) > priority (orange) > today
172
- // (pale yellow) > tomorrow (grey) > default. The orange now *means*
173
- // "priority flag" — only tasks with a priority get it; everything scheduled
174
- // today is the calm pale yellow instead.
175
- if (status === "done") return T.done;
176
- if (status === "overdue") return T.overdue;
177
- // Tomorrow is uniformly grey — even priority tasks — so everything set for
178
- // tomorrow reads consistently as "later, de-emphasized".
179
- if (status === "tomorrow") return T.textDim;
180
- if (task.priority !== "none") return T.today;
181
- if (status === "today") return T.todayPale;
182
- // future / unscheduled: terminal default fg (looks right on any theme).
183
- return T.text;
184
- }
185
-
186
158
  /**
187
159
  * Build the compact right-side suffix shown on a task row.
188
160
  *
@@ -211,16 +183,6 @@ function buildSuffix(task: Task, hideDate?: boolean): string | undefined {
211
183
  return parts.join(" ");
212
184
  }
213
185
 
214
- function suffixColorFor(task: Task, status: TaskStatus): string | undefined {
215
- if (status === "done") return T.textDone;
216
- if (status === "overdue") return T.overdue;
217
- if (status === "today") return T.todayPale;
218
- if (status === "tomorrow") return T.textDim;
219
- if (status === "future") return T.scheduled;
220
- return T.textDim;
221
- void task;
222
- }
223
-
224
186
  /**
225
187
  * Truncate to `max` chars, preserving as much of the head as possible.
226
188
  * When the string fits, return it untouched. Otherwise show the first
@@ -20,7 +20,8 @@
20
20
  * Enter → bounce kanban cursor to the underlying task
21
21
  * j/k while armed → nudge armed block ±15 min (move)
22
22
  * +/- while armed → resize armed block end ±15 min
23
- * Esc → disarm
23
+ * Enter while armed → keep, leave arm mode, back to where `c` started
24
+ * Esc while armed → undo the placement, then the same way back
24
25
  *
25
26
  * Each timeline row is exactly 1 terminal line tall, so row index maps
26
27
  * 1:1 to MINS_PER_ROW (15) minute offsets from DAY_START_HOUR.
@@ -258,7 +259,7 @@ export function TimelineView(props: TimelineViewProps) {
258
259
  props.store.armTimeline(entry.ref);
259
260
  props.store.flashBanner(
260
261
  "info",
261
- `Armed ⌚${formatHm(entry.startMin)}-${formatHm(entry.endMin)} · click empty row to move, shift+click to resize, Esc to cancel`,
262
+ `Armed ⌚${formatHm(entry.startMin)}-${formatHm(entry.endMin)} · click a row to move, shift+click to resize · Enter done · Esc cancel`,
262
263
  );
263
264
  }
264
265
  };
@@ -298,11 +299,10 @@ export function TimelineView(props: TimelineViewProps) {
298
299
  props.store.setTimeBlock(ref, { startMin, endMin });
299
300
  props.store.flashBanner(
300
301
  "info",
301
- `⌚ Scheduled → ${formatHm(startMin)}-${formatHm(endMin)}`,
302
+ `⌚ ${formatHm(startMin)}-${formatHm(endMin)} · +/- length · j/k move · Enter done · Esc cancel`,
302
303
  );
303
- // Auto-disarm: the task now has a block and will appear as a band;
304
- // the user can re-click on that band to keep adjusting.
305
- props.store.armTimeline(undefined);
304
+ // Stays armed (#73): the default length is rarely the right one, so
305
+ // +/- and j/k apply straight away, without re-clicking the band.
306
306
  return;
307
307
  }
308
308
 
@@ -361,7 +361,7 @@ export function TimelineView(props: TimelineViewProps) {
361
361
  {"◉ ARM MODE "}
362
362
  </span>
363
363
  <span style={{ fg: T.textDim }}>
364
- {"click a task → click a slot · Esc to exit"}
364
+ {"click a task → click a slot · Enter done · Esc cancel"}
365
365
  </span>
366
366
  </text>
367
367
  </Show>
@@ -376,7 +376,7 @@ export function TimelineView(props: TimelineViewProps) {
376
376
  <span style={{ fg: T.textDim }}>
377
377
  {armedIsUnscheduled()
378
378
  ? " click row to place · Esc to cancel"
379
- : " click row to move · shift+click to resize · Esc"}
379
+ : " +/- length · j/k move · Enter done · Esc cancel"}
380
380
  </span>
381
381
  </text>
382
382
  </Show>
package/src/ui/glyphs.ts CHANGED
@@ -74,6 +74,9 @@ export const T = {
74
74
 
75
75
  // Status-based row colors — kept clearly distinct in hue + brightness
76
76
  overdue: "#e26a6a", // hue 0°, sat 65%, light 65% — clearly red
77
+ // Same hue, turned up: a task that has been late for days reads louder than
78
+ // one that slipped yesterday, without introducing a second meaning.
79
+ overdueHeavy: "#ff5555", // hue 0°, sat 100%, light 67%
77
80
  today: "#e8a05c", // warm orange — now the PRIORITY accent (flagged tasks)
78
81
  // Today/Tomorrow identity + today-scheduled task titles: a soft pale yellow.
79
82
  todayPale: "#eaf6ad", // bright, calm — the Today/Tomorrow zone accent
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Just enough markdown for reading a note in a dialog: the file is shown, not
3
+ * edited, so the markers that only matter to an editor go away (`**`, `#`,
4
+ * link targets, `[[ ]]`) and what they meant becomes colour and weight.
5
+ *
6
+ * Deliberately line-based and forgiving — anything it doesn't recognise is
7
+ * printed as written, never dropped. Pure so the rules can be tested without
8
+ * a renderer.
9
+ */
10
+
11
+ export type MdStyle = "text" | "bold" | "italic" | "code" | "link" | "wikilink" | "dim";
12
+
13
+ export interface MdSpan {
14
+ text: string;
15
+ style: MdStyle;
16
+ }
17
+
18
+ export type MdLineKind = "heading" | "text" | "quote" | "code" | "rule" | "blank";
19
+
20
+ export interface MdLine {
21
+ kind: MdLineKind;
22
+ /** Heading depth (1–6); only set on headings. */
23
+ level?: number;
24
+ /** Leading glyph + indent for list items and quotes, e.g. " • ". */
25
+ prefix?: string;
26
+ spans: MdSpan[];
27
+ }
28
+
29
+ // Order matters: code first (its content is literal), then links, then emphasis.
30
+ const INLINE =
31
+ /(`[^`]+`)|(\[\[[^\]]+\]\])|(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\s][^*]*\*|(?<![\w])_[^_\s][^_]*_(?![\w]))/g;
32
+
33
+ export function parseInline(src: string): MdSpan[] {
34
+ const out: MdSpan[] = [];
35
+ let last = 0;
36
+ const push = (text: string, style: MdStyle) => {
37
+ if (!text) return;
38
+ const prev = out[out.length - 1];
39
+ if (prev && prev.style === style) prev.text += text;
40
+ else out.push({ text, style });
41
+ };
42
+ for (const m of src.matchAll(INLINE)) {
43
+ push(src.slice(last, m.index), "text");
44
+ const [tok, code, wiki, link, bold] = m;
45
+ if (code) push(tok.slice(1, -1), "code");
46
+ else if (wiki) {
47
+ const inner = tok.slice(2, -2);
48
+ const bar = inner.indexOf("|");
49
+ push(bar >= 0 ? inner.slice(bar + 1) : inner.replace(/#.*$/, ""), "wikilink");
50
+ } else if (link) push(tok.slice(1, tok.indexOf("](")), "link");
51
+ else if (bold) {
52
+ // Links inside bold keep their own colour; the rest is bold.
53
+ for (const s of parseInline(tok.slice(2, -2))) push(s.text, s.style === "text" ? "bold" : s.style);
54
+ } else push(tok.slice(1, -1), "italic");
55
+ last = m.index! + tok.length;
56
+ }
57
+ push(src.slice(last), "text");
58
+ return out;
59
+ }
60
+
61
+ export function markdownLines(body: string): MdLine[] {
62
+ const lines: MdLine[] = [];
63
+ let fence: string | undefined;
64
+ let inFrontmatter = false;
65
+ const src = body.replace(/\r\n?/g, "\n").split("\n");
66
+
67
+ src.forEach((raw, i) => {
68
+ // YAML frontmatter is metadata for the editor, not something to read.
69
+ if (i === 0 && raw.trim() === "---") return void (inFrontmatter = true);
70
+ if (inFrontmatter) {
71
+ if (raw.trim() === "---") inFrontmatter = false;
72
+ return;
73
+ }
74
+
75
+ const fenceMatch = raw.match(/^\s*(```|~~~)/);
76
+ if (fenceMatch) {
77
+ fence = fence ? undefined : fenceMatch[1];
78
+ return;
79
+ }
80
+ if (fence) return void lines.push({ kind: "code", spans: [{ text: raw, style: "code" }] });
81
+
82
+ if (raw.trim() === "") return void lines.push({ kind: "blank", spans: [] });
83
+
84
+ const heading = raw.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
85
+ if (heading) {
86
+ return void lines.push({ kind: "heading", level: heading[1]!.length, spans: parseInline(heading[2]!) });
87
+ }
88
+
89
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(raw)) return void lines.push({ kind: "rule", spans: [] });
90
+
91
+ const quote = raw.match(/^\s*>\s?(.*)$/);
92
+ if (quote) return void lines.push({ kind: "quote", prefix: "│ ", spans: parseInline(quote[1]!) });
93
+
94
+ const item = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(?:\[( |x|X)\]\s+)?(.*)$/);
95
+ if (item) {
96
+ const indent = " ".repeat(Math.floor(item[1]!.replace(/\t/g, " ").length / 2) * 2);
97
+ const box = item[3];
98
+ const glyph = box === undefined ? (/\d/.test(item[2]!) ? item[2]! : "•") : box === " " ? "○" : "✓";
99
+ return void lines.push({ kind: "text", prefix: `${indent}${glyph} `, spans: parseInline(item[4]!) });
100
+ }
101
+
102
+ lines.push({ kind: "text", spans: parseInline(raw.trim()) });
103
+ });
104
+
105
+ // Collapse runs of blank lines, and trim them at both ends.
106
+ const out = lines.filter((l, i, all) => l.kind !== "blank" || all[i - 1]?.kind !== "blank");
107
+ while (out[0]?.kind === "blank") out.shift();
108
+ while (out[out.length - 1]?.kind === "blank") out.pop();
109
+ return out;
110
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * What a task *is* right now (done, overdue, today…) and what color says so.
3
+ *
4
+ * Pulled out of TaskRow.tsx so the rules can be tested directly: they encode
5
+ * every precedence decision the board and planner rows rely on, and a
6
+ * component is an awkward place to assert them from.
7
+ */
8
+
9
+ import { isoToday, isoTomorrow } from "~/store/index";
10
+ import { T } from "~/ui/glyphs";
11
+ import type { Task } from "~/types";
12
+
13
+ export type TaskStatus =
14
+ | "done"
15
+ | "overdue"
16
+ | "today"
17
+ | "tomorrow"
18
+ | "future"
19
+ | "unscheduled";
20
+
21
+ /**
22
+ * Calendar days from `from` to `to` (both `YYYY-MM-DD`). UTC arithmetic, so
23
+ * a DST change can't turn a day into 23 or 25 hours and round the wrong way.
24
+ */
25
+ export function daysBetweenIso(from: string, to: string): number {
26
+ const ms = (iso: string) => {
27
+ const [y, m, d] = iso.split("-").map(Number);
28
+ return Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1);
29
+ };
30
+ return Math.round((ms(to) - ms(from)) / 86_400_000);
31
+ }
32
+
33
+ /**
34
+ * How late is late. Nazz resets most tasks to Today each morning, so being a
35
+ * day or two past is ordinary drift — a Friday task looked at again on Monday
36
+ * is already 3 days old without anyone ignoring it. Five days means the task
37
+ * has survived a weekend *and* working days of deliberate skipping, which is
38
+ * the thing worth seeing from across the board.
39
+ *
40
+ * Measured against the real boards when this landed (117 open tasks, 13
41
+ * overdue): 3 days would have painted 8 of the 13 — the whole weekend —
42
+ * while the spec's placeholder of 7 would have painted none.
43
+ */
44
+ export const OVERDUE_HEAVY_AFTER_DAYS = 5;
45
+
46
+ /** Two bands, not a gradient: "a little" vs "a lot". */
47
+ export function overdueBand(scheduled: string, today: string): "light" | "heavy" {
48
+ return daysBetweenIso(scheduled, today) >= OVERDUE_HEAVY_AFTER_DAYS ? "heavy" : "light";
49
+ }
50
+
51
+ export function statusOf(t: Task, today = isoToday(), tomorrow = isoTomorrow()): TaskStatus {
52
+ if (t.done) return "done";
53
+ const d = t.scheduled ?? t.due;
54
+ if (!d) return "unscheduled";
55
+ if (d < today) return "overdue";
56
+ if (d === today) return "today";
57
+ if (d === tomorrow) return "tomorrow";
58
+ return "future";
59
+ }
60
+
61
+ /** The overdue color for this task: louder once it's been late a while. */
62
+ function overdueColor(task: Task, today: string): string {
63
+ const d = task.scheduled ?? task.due;
64
+ return d && overdueBand(d, today) === "heavy" ? T.overdueHeavy : T.overdue;
65
+ }
66
+
67
+ export function titleColorFor(
68
+ task: Task,
69
+ status: TaskStatus,
70
+ today = isoToday(),
71
+ ): string | undefined {
72
+ // Precedence: done (green) > overdue (red) > priority (orange) > today
73
+ // (pale yellow) > tomorrow (grey) > default. The orange now *means*
74
+ // "priority flag" — only tasks with a priority get it; everything scheduled
75
+ // today is the calm pale yellow instead.
76
+ if (status === "done") return T.done;
77
+ if (status === "overdue") return overdueColor(task, today);
78
+ // Tomorrow is uniformly grey — even priority tasks — so everything set for
79
+ // tomorrow reads consistently as "later, de-emphasized".
80
+ if (status === "tomorrow") return T.textDim;
81
+ if (task.priority !== "none") return T.today;
82
+ if (status === "today") return T.todayPale;
83
+ // future / unscheduled: terminal default fg (looks right on any theme).
84
+ return T.text;
85
+ }
86
+
87
+ export function suffixColorFor(
88
+ task: Task,
89
+ status: TaskStatus,
90
+ today = isoToday(),
91
+ ): string | undefined {
92
+ if (status === "done") return T.textDone;
93
+ // The date suffix carries the same band as the title, so a row doesn't read
94
+ // as two different degrees of late at once.
95
+ if (status === "overdue") return overdueColor(task, today);
96
+ if (status === "today") return T.todayPale;
97
+ if (status === "tomorrow") return T.textDim;
98
+ if (status === "future") return T.scheduled;
99
+ return T.textDim;
100
+ }