tuiboard 0.13.3 → 0.14.1

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,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.1] - 2026-09-28
11
+
12
+ ### Changed
13
+ - **Shift+T clears the time blocks it carries to today** (#79). A time block
14
+ is a slot on a particular day, so moving every overdue task to today used to
15
+ keep theirs: tasks landed in today's agenda at hours nobody chose, stacked on
16
+ whatever was already there. Now each moved task drops its block and waits in
17
+ today's planner to be placed again (two keys, with arm mode). The banner
18
+ says how many were cleared. `t` / `m` on a single task and the headless
19
+ `tuiboard task defer` are unchanged.
20
+
21
+ ## [0.14.0] - 2026-09-21
22
+
23
+ ### Added
24
+ - **Status file viewer** (#67): point `status_file:` at a markdown file — a
25
+ morning digest, a handover note, whatever you or an agent write there — and
26
+ `i` shows it in a dialog, read-only. The markdown is rendered rather than
27
+ shown as source: headings and bold as weight, links and wikilinks by their
28
+ label, list and checkbox glyphs, frontmatter hidden. `j`/`k`, arrows and
29
+ PgUp/PgDn scroll it. It re-reads itself while open when the file changes,
30
+ and `tuiboard summary` reports its path and mtime (never its body) when
31
+ configured.
32
+ - **Overdue tasks show how late they are** (#67): a task overdue for days is
33
+ painted in a louder red than one that slipped yesterday. No day count
34
+ anywhere — just two bands.
35
+ - **Agent interface documentation** (#69): `docs/agent-interface.md` spells
36
+ out `tuiboard summary` and `tuiboard task` as a stable contract for agents
37
+ and scripts — the JSON shape, title matching, exit codes and the rules that
38
+ keep an agent from corrupting a board.
39
+
40
+ ### Changed
41
+ - **Time blocking in arm mode takes fewer round trips** (#73). A task placed
42
+ on the agenda stays armed, so `+`/`-` and `j`/`k` size and move it straight
43
+ away. `Enter` keeps it and returns to where `c` was pressed (usually
44
+ Today/Tomorrow — in single-pane, without passing through the agents panel);
45
+ `Esc` puts the task back as it was and returns the same way.
46
+
47
+ ### Fixed
48
+ - **Keypad `+` grows an armed time block** (#71) instead of opening the "new
49
+ board" dialog.
50
+ - Arming a second task no longer rewrites the first task's reference in place
51
+ (#73), a latent bug surfaced by the new arm-mode tests.
52
+
10
53
  ## [0.13.3] - 2026-09-19
11
54
 
12
55
  ### Fixed
@@ -512,6 +555,8 @@ First public release on npm. This entry captures the full feature set at launch.
512
555
 
513
556
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
514
557
 
558
+ [0.14.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.14.1
559
+ [0.14.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.14.0
515
560
  [0.13.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.3
516
561
  [0.13.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.2
517
562
  [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
 
@@ -499,13 +509,19 @@ session (until the next terminal resize).
499
509
  | Key | Action |
500
510
  |---|---|
501
511
  | `n` | New task in current column (quick-add syntax) |
502
- | `Shift-T` | Reset ALL overdue tasks (any board) to today |
512
+ | `Shift-T` | Reset ALL overdue tasks (any board) to today — their time blocks are cleared, since a slot chosen for another day means nothing 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.1",
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) {
@@ -186,8 +200,9 @@ export function handleKey(
186
200
 
187
201
  // Bulk: reset all overdue across all boards → today (Shift+T).
188
202
  if (key.name === "t" && key.shift) {
189
- const n = store.resetAllOverdueToToday();
190
- store.flashBanner("info", n > 0 ? `Reset ${n} overdue → today` : "No overdue tasks");
203
+ const { tasks, blocks } = store.resetAllOverdueToToday();
204
+ const cleared = blocks > 0 ? ` (${blocks} time block${blocks > 1 ? "s" : ""} cleared)` : "";
205
+ store.flashBanner("info", tasks > 0 ? `Reset ${tasks} overdue → today${cleared}` : "No overdue tasks");
191
206
  return;
192
207
  }
193
208
 
@@ -212,9 +227,10 @@ export function handleKey(
212
227
  return;
213
228
  }
214
229
 
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)) {
230
+ // New board — the `+` chip in the top bar, and its key. Except on the
231
+ // agenda with a block armed: there `+` grows the block (handleTimelineZone),
232
+ // and this shortcut running first used to swallow it (#71).
233
+ if (isPlusKey(key) && !(ui.armedTimelineRef && ui.activeZone === "timeline")) {
218
234
  store.openBoardNew();
219
235
  return;
220
236
  }
@@ -270,6 +286,14 @@ export function handleKey(
270
286
  return;
271
287
  }
272
288
 
289
+ // The status file: a markdown file read once in a while (a morning digest,
290
+ // a handover note), shown in a dialog. `i` for "info"; `u` and `w` were the
291
+ // other free letters, both easier to misread as undo / write.
292
+ if (key.name === "i") {
293
+ store.openModal({ kind: "status-file" });
294
+ return;
295
+ }
296
+
273
297
  // Zoom toggle: focus the active panel (board column or planner panel)
274
298
  // at full width.
275
299
  if (key.name === "z") {
@@ -320,7 +344,7 @@ export function handleKey(
320
344
  }
321
345
 
322
346
  if (ui.activeZone === "timeline") {
323
- handleTimelineZone(store, key, openLater);
347
+ handleTimelineZone(store, key, plannerCount, openLater);
324
348
  return;
325
349
  }
326
350
 
@@ -373,9 +397,22 @@ function handlePlannerZone(
373
397
  }
374
398
  }
375
399
 
400
+ /** Put the cursor back where `c` started arm mode. */
401
+ function restoreArmOrigin(store: TuiStore, origin: ArmOrigin | undefined, plannerCount = Infinity): boolean {
402
+ if (!origin) return false;
403
+ if (origin.boardIndex !== store.state.ui.activeBoardIndex) store.setActiveBoard(origin.boardIndex);
404
+ store.setActiveZone(origin.zone);
405
+ // Placing a task can reorder the planner (it joins the time-blocked
406
+ // bucket); the row stays, clamped to what's there.
407
+ const row = origin.zone === "planner" ? Math.min(origin.row, Math.max(0, plannerCount - 1)) : origin.row;
408
+ store.setCursor(origin.col, row);
409
+ return true;
410
+ }
411
+
376
412
  function handleTimelineZone(
377
413
  store: TuiStore,
378
414
  key: KeyEvent,
415
+ plannerCount: number,
379
416
  openLater: (m: ModalKind) => void,
380
417
  ): void {
381
418
  const ui = store.state.ui;
@@ -427,6 +464,16 @@ function handleTimelineZone(
427
464
  )
428
465
  : undefined;
429
466
 
467
+ // Enter confirms whatever is armed — placed or not — and goes back to where
468
+ // `c` started; a task armed straight from its band still jumps to its card.
469
+ if (armedRef && (key.name === "enter" || key.name === "return")) {
470
+ const origin = store.leaveArmMode(true);
471
+ if (!restoreArmOrigin(store, origin, plannerCount)) jumpToKanban(store, armedRef);
472
+ const t = store.getTask(armedRef);
473
+ store.flashBanner("info", t?.timeBlock ? `✓ ${fmtHm(t.timeBlock.startMin)}-${fmtHm(t.timeBlock.endMin)}` : "Arm mode off");
474
+ return;
475
+ }
476
+
430
477
  if (armed) {
431
478
  const NUDGE = 15; // minutes
432
479
  if (key.name === "j" || key.name === "down") {
@@ -443,24 +490,18 @@ function handleTimelineZone(
443
490
  store.flashBanner("info", `✋ ${fmtHm(newStart)}-${fmtHm(newEnd)}`);
444
491
  return;
445
492
  }
446
- if (key.name === "+" || key.name === "=" || key.sequence === "+") {
493
+ if (isPlusKey(key) || key.name === "=") {
447
494
  const newEnd = Math.min(24 * 60 - 1, armed.endMin + NUDGE);
448
495
  store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
449
496
  store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
450
497
  return;
451
498
  }
452
- if (key.name === "-" || key.name === "_" || key.sequence === "-") {
499
+ if (isMinusKey(key) || key.name === "_") {
453
500
  const newEnd = Math.max(armed.startMin + 15, armed.endMin - NUDGE);
454
501
  store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
455
502
  store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
456
503
  return;
457
504
  }
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
505
  // Fall through for other keys (Esc handled globally, task actions below).
465
506
  }
466
507
 
@@ -784,13 +825,12 @@ function dispatchTaskAction(
784
825
  // slot, repeat. `c` again or `Esc` exits. Works from any zone.
785
826
  if (key.name === "c" && !key.shift) {
786
827
  if (store.state.ui.armMode) {
787
- store.setArmMode(false);
788
- store.armTimeline(undefined);
828
+ // `c` again keeps what was placed, like Enter.
829
+ restoreArmOrigin(store, store.leaveArmMode(true));
789
830
  store.flashBanner("info", "Arm mode off");
790
831
  return true;
791
832
  }
792
- store.setArmMode(true);
793
- store.armTimeline(ref);
833
+ store.startArmMode(ref);
794
834
  store.setZoneVisible("timeline", true);
795
835
  store.setActiveZone("timeline");
796
836
  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;
@@ -1514,9 +1641,15 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1514
1641
 
1515
1642
  // ─── Bulk: reset all overdue across all boards to today ──────────────────
1516
1643
 
1517
- function resetAllOverdueToToday(): number {
1644
+ /**
1645
+ * Every overdue task → today, and its time block goes with the day it was
1646
+ * planned for: a slot chosen for last Tuesday means nothing on today's
1647
+ * agenda, and re-placing it is two keys with arm mode (#79).
1648
+ */
1649
+ function resetAllOverdueToToday(): { tasks: number; blocks: number } {
1518
1650
  const today = isoToday();
1519
- let count = 0;
1651
+ let tasks = 0;
1652
+ let blocks = 0;
1520
1653
  for (const lb of state.boards) {
1521
1654
  const board = lb.board;
1522
1655
  for (let ci = 0; ci < board.columns.length; ci++) {
@@ -1525,22 +1658,25 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1525
1658
  for (const child of col.children) {
1526
1659
  if (!isTask(child)) continue;
1527
1660
  if (!child.done && child.scheduled && child.scheduled < today) {
1528
- setScheduled(
1529
- { boardPath: board.filepath, columnIndex: ci, taskIndex: ti },
1530
- today,
1531
- );
1532
- count++;
1661
+ const ref = { boardPath: board.filepath, columnIndex: ci, taskIndex: ti };
1662
+ if (child.timeBlock) {
1663
+ setTimeBlock(ref, undefined);
1664
+ blocks++;
1665
+ }
1666
+ setScheduled(ref, today);
1667
+ tasks++;
1533
1668
  }
1534
1669
  ti++;
1535
1670
  }
1536
1671
  }
1537
1672
  }
1538
- return count;
1673
+ return { tasks, blocks };
1539
1674
  }
1540
1675
 
1541
1676
  function openModal(m: ModalKind): void {
1542
1677
  // Help always opens scrolled to the top.
1543
1678
  if (m.kind === "help") setState("ui", "helpScroll", 0);
1679
+ if (m.kind === "status-file") setState("ui", "statusScroll", 0);
1544
1680
  setState("ui", "modal", m);
1545
1681
  }
1546
1682
 
@@ -1603,6 +1739,11 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1603
1739
  function setHelpScroll(n: number): void {
1604
1740
  setState("ui", "helpScroll", Math.max(0, n));
1605
1741
  }
1742
+ /** Scroll the status file (rows, lower-clamped at 0; StatusFileModal writes
1743
+ * back the upper bound once it knows how tall the file renders). */
1744
+ function setStatusScroll(n: number): void {
1745
+ setState("ui", "statusScroll", Math.max(0, n));
1746
+ }
1606
1747
 
1607
1748
  /** Move the step-2 calendar selection (wraps). */
1608
1749
  function setEventSel(n: number): void {
@@ -1753,6 +1894,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1753
1894
  moveTaskWithinBoard,
1754
1895
  // notes
1755
1896
  taskNote,
1897
+ statusFile,
1756
1898
  // boards
1757
1899
  addBoard,
1758
1900
  openBoardNew,
@@ -1779,6 +1921,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1779
1921
  exitGrab,
1780
1922
  armTimeline,
1781
1923
  setArmMode,
1924
+ startArmMode,
1925
+ leaveArmMode,
1782
1926
  agendaDate,
1783
1927
  shiftAgendaDay,
1784
1928
  resetAgendaDay,
@@ -1798,6 +1942,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1798
1942
  openEventModal,
1799
1943
  advanceEventToStep2,
1800
1944
  setHelpScroll,
1945
+ setStatusScroll,
1801
1946
  setEventSel,
1802
1947
  confirmEventPicker,
1803
1948
  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
  {
@@ -1166,13 +1307,14 @@ const HELP_SECTIONS: HelpSection[] = [
1166
1307
  {
1167
1308
  emoji: "📦",
1168
1309
  title: "Bulk",
1169
- rows: [["T", "Reset ALL overdue tasks (any board) to today"]],
1310
+ rows: [["T", "Reset ALL overdue tasks (any board) to today — clears their time blocks"]],
1170
1311
  },
1171
1312
  {
1172
1313
  emoji: "🌐",
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
+ }