tuiboard 0.5.0 → 0.5.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.
@@ -30,3 +30,20 @@ done_column: Done
30
30
  # Name of the column used by the archive action (Shift-X). If the column
31
31
  # doesn't exist in a board, tuiboard creates it on the fly.
32
32
  archive_column: Archive
33
+
34
+ # Optional: override what Enter does in the Agents zone ("open the selected
35
+ # Claude Code session"). An argv array — the tokens {cwd} and {sessionId} are
36
+ # substituted, then it's run directly (no shell). Point it at your own script
37
+ # to spawn a custom terminal layout. When omitted, tuiboard just opens a new
38
+ # WezTerm tab and runs `claude --resume <sessionId>`.
39
+ #
40
+ # The first element must be a DIRECTLY EXECUTABLE program — a real binary on
41
+ # PATH or an absolute path. It is NOT run through a shell, so shell builtins
42
+ # and Windows App Execution Aliases (e.g. a Store-installed `pwsh`) won't
43
+ # resolve; use a real binary like `nu`, `python`, or a full path instead.
44
+ #
45
+ # resume_command:
46
+ # - nu
47
+ # - C:/Users/you/.config/tuiboard/code-resume.nu
48
+ # - "{cwd}"
49
+ # - "{sessionId}"
package/README.md CHANGED
@@ -78,6 +78,12 @@ boards:
78
78
  assignees: [Alice, Bob]
79
79
  done_column: Done
80
80
  archive_column: Archive
81
+
82
+ # Optional: override Enter in the Agents zone. argv array, {cwd}/{sessionId}
83
+ # substituted, run directly (no shell — element 0 must be a real binary/abs
84
+ # path, NOT a shell builtin or Windows App Execution Alias). Defaults to
85
+ # opening a WezTerm tab with `claude --resume <id>`. For a custom layout:
86
+ # resume_command: ["nu", "C:/Users/you/.config/tuiboard/code-resume.nu", "{cwd}", "{sessionId}"]
81
87
  ```
82
88
 
83
89
  ## Markdown board format
@@ -176,6 +182,8 @@ session (until the next terminal resize).
176
182
  | `b` | Set time block modal |
177
183
  | `p` | Cycle priority (none → 🔺 → ⏫ → 🔼 → 🔽 → ⏬ → none) |
178
184
  | `a` | Set assignee |
185
+ | `c` | Toggle calendar **arm mode** — then click a task, click a timeline slot, repeat |
186
+ | `Shift-C` | Copy task to clipboard (markdown line — paste as context for Claude Code) |
179
187
  | `d` | Delete task (with confirm) |
180
188
  | `Shift-X` | Archive task → moves to Archive column |
181
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Terminal dashboard for markdown task boards. Kanban + Today/Tomorrow + 24h timeline + Claude Code agent view, all in one TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,6 +34,15 @@ export interface Config {
34
34
  assignees: string[];
35
35
  doneColumn: string;
36
36
  archiveColumn: string;
37
+ /**
38
+ * Optional override for "open the selected agent session" (Enter in the
39
+ * agents zone). An argv array; the tokens `{cwd}` and `{sessionId}` are
40
+ * substituted, then it's spawned directly (no shell). Point it at your own
41
+ * script to launch a custom terminal layout — e.g.
42
+ * ["pwsh", "-NoProfile", "-File", "C:/.../code-resume.ps1", "{cwd}", "{sessionId}"]
43
+ * When unset, tuiboard falls back to opening a tab + `claude --resume <id>`.
44
+ */
45
+ resumeCommand?: string[];
37
46
  }
38
47
 
39
48
  export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
@@ -81,6 +90,7 @@ interface RawConfig {
81
90
  assignees: string[];
82
91
  done_column: string;
83
92
  archive_column: string;
93
+ resume_command: string[];
84
94
  }
85
95
 
86
96
  interface FoundConfig {
@@ -144,6 +154,10 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
144
154
  assignees: raw.assignees ?? DEFAULT_CONFIG.assignees,
145
155
  doneColumn: raw.done_column ?? DEFAULT_CONFIG.doneColumn,
146
156
  archiveColumn: raw.archive_column ?? DEFAULT_CONFIG.archiveColumn,
157
+ resumeCommand:
158
+ Array.isArray(raw.resume_command) && raw.resume_command.length > 0
159
+ ? raw.resume_command.map(String)
160
+ : undefined,
147
161
  };
148
162
  }
149
163
 
@@ -74,11 +74,14 @@ export function handleKey(
74
74
  return;
75
75
  }
76
76
 
77
- // Escape priority: timeline arm → grab mode → marks. Most disruptive first.
77
+ // Escape priority: arm mode / timeline arm → grab mode → marks. Most
78
+ // disruptive first.
78
79
  if (key.name === "escape") {
79
- if (ui.armedTimelineRef) {
80
+ if (ui.armMode || ui.armedTimelineRef) {
81
+ const wasMode = ui.armMode;
82
+ store.setArmMode(false);
80
83
  store.armTimeline(undefined);
81
- store.flashBanner("info", "Disarmed");
84
+ store.flashBanner("info", wasMode ? "Arm mode off" : "Disarmed");
82
85
  return;
83
86
  }
84
87
  if (ui.grabbing) {
@@ -330,11 +333,12 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
330
333
  store.setCursor(0, Math.min(sessions.length - 1, ui.row + 1));
331
334
  } else if (key.name === "k" || key.name === "up") {
332
335
  store.setCursor(0, Math.max(0, ui.row - 1));
333
- } else if (
334
- key.name === "enter" ||
335
- key.name === "return" ||
336
- key.name === "o"
337
- ) {
336
+ } else if (key.name === "enter" || key.name === "return") {
337
+ // Open (resume) the selected session in a new WezTerm tab.
338
+ const target = sessions[ui.row];
339
+ if (target) void openSessionInWezterm(store, target.cwd, target.sessionId);
340
+ } else if (key.name === "o") {
341
+ // Inspect the session in the detail modal.
338
342
  const target = sessions[ui.row];
339
343
  if (target) {
340
344
  setTimeout(
@@ -559,12 +563,34 @@ function dispatchTaskAction(
559
563
  return true;
560
564
  }
561
565
 
562
- // Calendar-arm (Shift+C): arm this task for the timeline and jump focus
563
- // there so the user can immediately click a slot to place it. Works from
564
- // the board, the virtual panel, or the timeline wherever the cursor is.
565
- // Replaces the removed sticky 'Unscheduled' list: instead of duplicating
566
- // today's tasks at the top of the timeline, arm one in place and drop it.
566
+ // Copy task as a markdown line to the system clipboard (Shift+C). Mirrors
567
+ // Python kanban `action_copy_context`. Single-task only. Moved to Shift+C so
568
+ // lowercase `c` is free for calendar arm mode (below); Ctrl+C can't be used
569
+ // (it quits / is terminal-reserved), so Shift+C is the copy combo.
567
570
  if (key.name === "C" || (key.name === "c" && key.shift)) {
571
+ const t = store.getTask(ref);
572
+ if (t) {
573
+ copyToClipboard(t.rawLine).then(
574
+ () => store.flashBanner("info", "📋 Copied task"),
575
+ (err) => store.flashBanner("error", `Copy failed: ${err}`),
576
+ );
577
+ }
578
+ return true;
579
+ }
580
+
581
+ // Calendar arm mode (lowercase c): toggle a persistent mode for batch
582
+ // scheduling onto the timeline. Entering also arms the cursor task and
583
+ // focuses the timeline so you can immediately click a slot. While the mode
584
+ // is on, clicking ANY task (board / virtual) arms it — click a task, click a
585
+ // slot, repeat. `c` again or `Esc` exits. Works from any zone.
586
+ if (key.name === "c" && !key.shift) {
587
+ if (store.state.ui.armMode) {
588
+ store.setArmMode(false);
589
+ store.armTimeline(undefined);
590
+ store.flashBanner("info", "Arm mode off");
591
+ return true;
592
+ }
593
+ store.setArmMode(true);
568
594
  store.armTimeline(ref);
569
595
  store.setZoneVisible("timeline", true);
570
596
  store.setActiveZone("timeline");
@@ -572,27 +598,12 @@ function dispatchTaskAction(
572
598
  store.flashBanner(
573
599
  "info",
574
600
  t
575
- ? `⤤ Armed "${t.displayTitle.slice(0, 32)}" click a timeline slot to place`
576
- : " Armed — click a timeline slot to place",
601
+ ? `◉ Arm mode — armed "${t.displayTitle.slice(0, 28)}". Click a task, then a slot. Esc to exit.`
602
+ : " Arm mode — click a task, then a slot. Esc to exit.",
577
603
  );
578
604
  return true;
579
605
  }
580
606
 
581
- // Copy task as a markdown line to the system clipboard. Mirrors Python
582
- // kanban `action_copy_context`. Single-task only (multi-select would
583
- // require deciding how to join lines). Explicitly non-shift so Shift+C
584
- // (calendar-arm, above) doesn't also trigger a clipboard copy.
585
- if (key.name === "c" && !key.shift) {
586
- const t = store.getTask(ref);
587
- if (t) {
588
- copyToClipboard(t.rawLine).then(
589
- () => store.flashBanner("info", "📋 Copied task"),
590
- (err) => store.flashBanner("error", `Copy failed: ${err}`),
591
- );
592
- }
593
- return true;
594
- }
595
-
596
607
  // Toggle priority — cycle: none → highest → high → medium → low → lowest → none.
597
608
  // Mirrors Python kanban `action_toggle_priority`.
598
609
  if (key.name === "p") {
@@ -682,6 +693,85 @@ function fmtHm(m: number): string {
682
693
  return `${h.toString().padStart(2, "0")}:${mm.toString().padStart(2, "0")}`;
683
694
  }
684
695
 
696
+ /**
697
+ * Open (resume) a Claude Code session in a new WezTerm tab.
698
+ *
699
+ * Two steps:
700
+ * 1. `wezterm cli spawn --cwd <cwd>` opens a new tab running your DEFAULT
701
+ * shell in the session's directory (prints the new pane id).
702
+ * 2. `wezterm cli send-text` types `claude --resume <id>` + Enter into it.
703
+ *
704
+ * Running it through the interactive shell (rather than `spawn -- claude …`
705
+ * directly) means `claude` gets your full shell environment — PATH, env vars,
706
+ * any wrapper — which is why the direct form exited 1. And if `claude` still
707
+ * errors, you're left at a live prompt that shows it instead of a vanishing
708
+ * tab. Failures (not inside WezTerm, `wezterm` off PATH) surface as a banner.
709
+ */
710
+ async function openSessionInWezterm(
711
+ store: TuiStore,
712
+ cwd: string,
713
+ sessionId: string,
714
+ ): Promise<void> {
715
+ const { spawn, spawnSync } = await import("node:child_process");
716
+
717
+ // Custom override (config `resume_command`): an argv array with {cwd} /
718
+ // {sessionId} placeholders, spawned directly (no shell). Lets you launch a
719
+ // personal terminal layout without baking it into the distributed tool.
720
+ const custom = store.config.resumeCommand;
721
+ if (custom && custom.length > 0) {
722
+ const argv = custom.map((arg) =>
723
+ arg.replaceAll("{cwd}", cwd).replaceAll("{sessionId}", sessionId),
724
+ );
725
+ const [cmd, ...rest] = argv;
726
+ try {
727
+ // windowsHide suppresses the transient console window the orchestrator
728
+ // process would otherwise flash on Windows (the equivalent of Python's
729
+ // CREATE_NO_WINDOW).
730
+ const child = spawn(cmd!, rest, {
731
+ detached: true,
732
+ stdio: "ignore",
733
+ windowsHide: true,
734
+ });
735
+ child.on("error", (e: NodeJS.ErrnoException) =>
736
+ store.flashBanner("error", `resume_command failed: ${e.message}`),
737
+ );
738
+ child.unref();
739
+ store.flashBanner("info", `↗ Opening session (${sessionId.slice(0, 8)})`);
740
+ } catch (e) {
741
+ store.flashBanner("error", `resume_command failed: ${String(e)}`);
742
+ }
743
+ return;
744
+ }
745
+
746
+ try {
747
+ const spawned = spawnSync("wezterm", ["cli", "spawn", "--cwd", cwd], {
748
+ encoding: "utf8",
749
+ windowsHide: true,
750
+ });
751
+ if (spawned.error) {
752
+ store.flashBanner("error", `WezTerm launch failed: ${spawned.error.message}`);
753
+ return;
754
+ }
755
+ if (spawned.status !== 0) {
756
+ store.flashBanner(
757
+ "error",
758
+ `WezTerm spawn failed: ${(spawned.stderr || "").trim() || `exit ${spawned.status}`}`,
759
+ );
760
+ return;
761
+ }
762
+ const paneId = spawned.stdout.trim();
763
+ // Type the resume command into the fresh pane (\r submits, like Enter).
764
+ spawnSync(
765
+ "wezterm",
766
+ ["cli", "send-text", "--pane-id", paneId, "--no-paste"],
767
+ { input: `claude --resume ${sessionId}\r`, encoding: "utf8", windowsHide: true },
768
+ );
769
+ store.flashBanner("info", `↗ Opened session in WezTerm (${sessionId.slice(0, 8)})`);
770
+ } catch (e) {
771
+ store.flashBanner("error", `WezTerm launch failed: ${String(e)}`);
772
+ }
773
+ }
774
+
685
775
  /**
686
776
  * Cross-platform clipboard copy. Picks the host's native cli tool:
687
777
  * Windows → clip
@@ -106,6 +106,14 @@ export interface UIState {
106
106
  * cancels.
107
107
  */
108
108
  armedTimelineRef?: TaskRef;
109
+ /**
110
+ * Persistent calendar "arm mode" (toggled with `c`). While on, clicking any
111
+ * task in the board / virtual panel arms it for the timeline, so you can
112
+ * schedule several tasks in a row — click a task, click a slot, repeat —
113
+ * without re-pressing `c`. `Esc` (or `c` again) exits. Distinct from
114
+ * `armedTimelineRef`, which is the single task currently armed.
115
+ */
116
+ armMode: boolean;
109
117
  view: ViewMode;
110
118
  /**
111
119
  * Tasks marked for bulk ops (`Space`). Key format:
@@ -154,6 +162,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
154
162
  row: 0,
155
163
  zoomed: false,
156
164
  grabbing: false,
165
+ armMode: false,
157
166
  view: "kanban",
158
167
  marked: {},
159
168
  filter: "all",
@@ -738,6 +747,10 @@ export function createTuiStore({ config }: CreateStoreOptions) {
738
747
  setState("ui", "armedTimelineRef", ref);
739
748
  }
740
749
 
750
+ function setArmMode(on: boolean): void {
751
+ setState("ui", "armMode", on);
752
+ }
753
+
741
754
  // ─── Multi-select ────────────────────────────────────────────────────────
742
755
 
743
756
  function markKey(ref: TaskRef): string {
@@ -914,6 +927,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
914
927
  toggleGrab,
915
928
  exitGrab,
916
929
  armTimeline,
930
+ setArmMode,
917
931
  setFilter,
918
932
  applyBoardFilter,
919
933
  setZoomed,
@@ -73,7 +73,7 @@ describe("buildTimelineEntries", () => {
73
73
  expect(entries[0]!.endRow).toBe(14);
74
74
  });
75
75
 
76
- it("excludes done tasks", () => {
76
+ it("includes done tasks (timeline doubles as a done-log)", () => {
77
77
  const t = makeTask({
78
78
  displayTitle: "done deal",
79
79
  done: true,
@@ -81,7 +81,9 @@ describe("buildTimelineEntries", () => {
81
81
  timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
82
82
  });
83
83
  const board = makeBoard("R3PLICA", "r3.md", [t]);
84
- expect(buildTimelineEntries([board], today)).toEqual([]);
84
+ const entries = buildTimelineEntries([board], today);
85
+ expect(entries).toHaveLength(1);
86
+ expect(entries[0]!.task.done).toBe(true);
85
87
  });
86
88
 
87
89
  it("excludes tasks scheduled for a different date", () => {
@@ -70,12 +70,15 @@ export interface BuildRowMapResult {
70
70
 
71
71
  /**
72
72
  * Build the flat list of time-blocked tasks for the given ISO date.
73
- * Tasks must be:
74
- * - non-done
73
+ * Tasks must:
75
74
  * - have task.timeBlock
76
75
  * - have task.scheduled === date (we ignore `due` for now; calendar-style
77
76
  * time-blocking is always scheduled, not due)
78
77
  *
78
+ * Completed tasks ARE included (rendered green + checked) so the timeline
79
+ * doubles as a record of what actually got done in each slot, not just what's
80
+ * still pending.
81
+ *
79
82
  * Sorted ascending by startMin so head/body/fill rendering can claim rows
80
83
  * in chronological order.
81
84
  */
@@ -93,7 +96,8 @@ export function buildTimelineEntries(
93
96
  if (!isTask(child)) continue;
94
97
  const idx = taskIndex++;
95
98
  const t = child;
96
- if (t.done) continue;
99
+ // Done tasks stay on the timeline (shown green + checked) — the day's
100
+ // schedule is also a log of what got done.
97
101
  if (!t.timeBlock) continue;
98
102
  if (t.scheduled !== date) continue;
99
103
 
@@ -1,62 +1,55 @@
1
1
  /**
2
- * Compact agent status strip for the dashboard. Renders a windowed slice
3
- * of the non-archived sessions list so the cursor stays inside the view
4
- * even when the underlying list is far larger than the visible rows.
2
+ * Compact agent status strip for the dashboard. The non-archived sessions
3
+ * render inside a vertical scrollbox so the list scrolls with the mouse wheel
4
+ * (like the board / virtual / timeline zones) AND follows the keyboard cursor
5
+ * via scrollChildIntoView — same pattern as BoardView's columns.
5
6
  *
6
- * The border color reflects activeZone === "agents" so the cursor
7
- * ring is visible. Clicking a row sets activeZone + agent cursor.
7
+ * The border color reflects activeZone === "agents" so the cursor ring is
8
+ * visible. Clicking a row sets activeZone + agent cursor.
8
9
  */
9
10
 
10
- import { For, Show, createMemo } from "solid-js";
11
+ import { For, Show, createEffect, createMemo } from "solid-js";
11
12
 
12
13
  import { AgentRow } from "~/ui/AgentRow";
13
14
  import { T } from "~/ui/glyphs";
14
15
  import type { TuiStore } from "~/store/index";
15
16
 
17
+ interface ScrollBoxLike {
18
+ scrollChildIntoView(id: string): void;
19
+ }
20
+
21
+ const AGENT_ROW_PREFIX = "tuiboard-agent-row-";
22
+ const agentRowId = (index: number) => `${AGENT_ROW_PREFIX}${index}`;
23
+
16
24
  interface AgentsBarProps {
17
25
  store: TuiStore;
18
26
  /** Fixed row height in the dashboard layout. */
19
27
  height?: number;
20
- /** Max sessions to show in the compact strip. Default 5 (fits in height=7). */
21
- maxVisible?: number;
22
- }
23
-
24
- interface WindowedEntry {
25
- index: number;
26
- // We don't import AgentSession here; carrying through the For element is fine.
27
- // Solid will infer the type.
28
- session: ReturnType<TuiStore["agents"]["sessions"]>[number];
29
28
  }
30
29
 
31
30
  export function AgentsBar(props: AgentsBarProps) {
32
31
  const isActive = () => props.store.state.ui.activeZone === "agents";
33
32
  const agentRow = () => props.store.state.ui.row;
34
- const maxVisible = () => props.maxVisible ?? 5;
35
33
 
36
- /** All visible (non-archived) sessions — full list. */
34
+ /** All visible (non-archived) sessions. */
37
35
  const allShown = createMemo(() =>
38
36
  props.store.agents.sessions().filter((s) => s.status !== "archived"),
39
37
  );
40
38
 
41
- /**
42
- * Windowed slice that follows the cursor. When the cursor falls outside
43
- * the current window, we slide so it sits in the middle (when possible).
44
- */
45
- const windowed = createMemo<WindowedEntry[]>(() => {
46
- const all = allShown();
47
- const max = maxVisible();
48
- if (all.length <= max) {
49
- return all.map((session, index) => ({ index, session }));
50
- }
51
- const cursor = isActive() ? agentRow() : 0;
52
- const halfWin = Math.floor(max / 2);
53
- let start = Math.max(0, cursor - halfWin);
54
- let end = Math.min(all.length, start + max);
55
- if (end - start < max) start = Math.max(0, end - max);
56
- return all.slice(start, end).map((session, i) => ({
57
- index: start + i,
58
- session,
59
- }));
39
+ let scrollBoxRef: ScrollBoxLike | undefined;
40
+
41
+ // Keep the cursor row visible as j/k moves it (mouse wheel scrolls freely
42
+ // via the scrollbox itself). setTimeout(0) waits for layout to commit.
43
+ createEffect(() => {
44
+ const row = agentRow();
45
+ if (!isActive() || !scrollBoxRef) return;
46
+ setTimeout(() => {
47
+ try {
48
+ scrollBoxRef?.scrollChildIntoView(agentRowId(row));
49
+ } catch {
50
+ // Child not mounted yet — harmless.
51
+ }
52
+ }, 0);
60
53
  });
61
54
 
62
55
  return (
@@ -83,19 +76,34 @@ export function AgentsBar(props: AgentsBarProps) {
83
76
  </text>
84
77
  }
85
78
  >
86
- <For each={windowed()}>
87
- {(entry) => (
88
- <AgentRow
89
- session={entry.session}
90
- cursor={isActive() && entry.index === agentRow()}
91
- nameMaxChars={48}
92
- onClick={() => {
93
- props.store.setActiveZone("agents");
94
- props.store.setCursor(0, entry.index);
95
- }}
96
- />
97
- )}
98
- </For>
79
+ <scrollbox
80
+ ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
81
+ style={{
82
+ width: "100%",
83
+ flexGrow: 1,
84
+ scrollX: false,
85
+ scrollY: true,
86
+ rootOptions: {},
87
+ contentOptions: {},
88
+ scrollbarOptions: { visible: false },
89
+ }}
90
+ >
91
+ <For each={allShown()}>
92
+ {(session, i) => (
93
+ <box id={agentRowId(i())}>
94
+ <AgentRow
95
+ session={session}
96
+ cursor={isActive() && i() === agentRow()}
97
+ nameMaxChars={48}
98
+ onClick={() => {
99
+ props.store.setActiveZone("agents");
100
+ props.store.setCursor(0, i());
101
+ }}
102
+ />
103
+ </box>
104
+ )}
105
+ </For>
106
+ </scrollbox>
99
107
  </Show>
100
108
  </box>
101
109
  );
@@ -306,6 +306,12 @@ function ColumnView(props: ColumnViewProps) {
306
306
  onClick={() => {
307
307
  props.store.setActiveZone("board");
308
308
  props.store.setCursor(props.columnIndex, ri());
309
+ // In calendar arm mode, a click also arms the task so the
310
+ // user can immediately drop it on a timeline slot.
311
+ if (props.store.state.ui.armMode) {
312
+ props.store.armTimeline(ref);
313
+ props.store.setZoneVisible("timeline", true);
314
+ }
309
315
  }}
310
316
  />
311
317
  </box>
package/src/ui/Chrome.tsx CHANGED
@@ -111,9 +111,15 @@ export function BottomBar(props: { store: TuiStore }) {
111
111
  </Show>
112
112
  </box>
113
113
  <box style={{ height: 1, flexDirection: "row" }}>
114
- <text>
114
+ {/*
115
+ Curated cheat-sheet: only the keys that keep you unstuck (move,
116
+ switch zone/board, help, quit) plus the highest-frequency, on-brand
117
+ actions (done, new, schedule). Everything else — zoom, toggles,
118
+ multi-select, edit/assign/archive/delete, undo — lives in `?`.
119
+ */}
120
+ <text wrapMode="none" truncate>
115
121
  <span style={{ fg: T.textDim }}>
116
- {"hjkl move · Tab/1-9 board · S-Tab zone · F1/F2/F3 toggle · v panel · z zoom · Space mark · ⏎ done · o detail · n/e/s/b/a/X act · d del · ⌃Z undo · ? help · q quit"}
122
+ {"hjkl move · Tab board · Tab zone · ⏎ done · n new · c schedule · ? help · q quit"}
117
123
  </span>
118
124
  </text>
119
125
  </box>
package/src/ui/Modal.tsx CHANGED
@@ -525,7 +525,9 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
525
525
  </Show>
526
526
  <box style={{ height: 1 }} />
527
527
  <text>
528
- <span style={{ fg: T.textDim }}>resume command (copy by hand for now):</span>
528
+ <span style={{ fg: T.textDim }}>
529
+ resume — press Enter in the agents list to open this in WezTerm:
530
+ </span>
529
531
  </text>
530
532
  <text wrapMode="word">
531
533
  <span style={{ fg: T.scheduled }}>
@@ -573,22 +575,24 @@ function HelpModal(props: { store: TuiStore }) {
573
575
  <span style={{ fg: T.text }}>{" a Set assignee\n"}</span>
574
576
  <span style={{ fg: T.text }}>{" d Delete task (with confirm)\n"}</span>
575
577
  <span style={{ fg: T.text }}>{" X Archive task → moves to Archive column\n"}</span>
576
- <span style={{ fg: T.text }}>{" c Copy task to clipboard (markdown line)\n"}</span>
577
- <span style={{ fg: T.text }}>{" C Calendar-arm — arm this task + jump to the timeline\n"}</span>
578
+ <span style={{ fg: T.text }}>{" C Copy task to clipboard (markdown line)\n"}</span>
578
579
  <span style={{ fg: T.textDim }}>{"\nTimeline scheduling\n"}</span>
579
- <span style={{ fg: T.text }}>{" C (any zone) Arm the cursor task, then click a timeline slot to place it\n"}</span>
580
+ <span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE then click a task, click a slot, repeat\n"}</span>
580
581
  <span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
581
582
  <span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
582
583
  <span style={{ fg: T.text }}>{" shift+click row While armed (existing block): resize end to that row\n"}</span>
583
584
  <span style={{ fg: T.text }}>{" j / k While armed: nudge block ±15 min\n"}</span>
584
585
  <span style={{ fg: T.text }}>{" + / - While armed: resize block end ±15 min\n"}</span>
585
586
  <span style={{ fg: T.text }}>{" Enter While armed: commit + jump to source task\n"}</span>
586
- <span style={{ fg: T.text }}>{" Esc Disarm\n"}</span>
587
+ <span style={{ fg: T.text }}>{" Esc Disarm / exit arm mode\n"}</span>
587
588
  <span style={{ fg: T.textDim }}>{"\nBoard-only actions\n"}</span>
588
589
  <span style={{ fg: T.text }}>{" n New task in current column (quick-add syntax)\n"}</span>
589
590
  <span style={{ fg: T.text }}>{" g Grab task — h/l then moves it between columns; g/Esc to drop\n"}</span>
590
591
  <span style={{ fg: T.text }}>{" f Cycle board filter: all → today → overdue → tomorrow → followup\n"}</span>
591
592
  <span style={{ fg: T.text }}>{" / Search task titles — jumps cursor to first match\n"}</span>
593
+ <span style={{ fg: T.textDim }}>{"\nAgents zone\n"}</span>
594
+ <span style={{ fg: T.text }}>{" Enter Open (resume) the selected session in a new WezTerm tab\n"}</span>
595
+ <span style={{ fg: T.text }}>{" o Session detail (cwd, branch, last prompts, resume cmd)\n"}</span>
592
596
  <span style={{ fg: T.textDim }}>{"\nMulti-select\n"}</span>
593
597
  <span style={{ fg: T.text }}>{" Space Mark / unmark task — single-task actions then\n"}</span>
594
598
  <span style={{ fg: T.text }}>{" apply to ALL marked instead of just the cursor\n"}</span>
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { Show, createMemo } from "solid-js";
14
14
 
15
- import { PRIORITY_COLOR, PRIORITY_GLYPH, T, fmtMin } from "~/ui/glyphs";
15
+ import { PRIORITY_COLOR, PRIORITY_GLYPH, T, cellWidth, fmtMin } from "~/ui/glyphs";
16
16
  import { isoToday, isoTomorrow } from "~/store/index";
17
17
  import type { Task } from "~/types";
18
18
 
@@ -71,7 +71,10 @@ export function TaskRow(props: TaskRowProps) {
71
71
  if (props.task.priority !== "none") overhead += 3; // emoji 2 cells + space
72
72
  if (props.contextTag) overhead += props.contextTag.length + 3; // " [" + tag + "]"
73
73
  const sfx = suffix();
74
- if (sfx) overhead += sfx.length + 1; // leading space + suffix
74
+ // cellWidth (not .length) so the time-block glyph 2 cells but 1 code
75
+ // unit — is counted correctly. +1 leading space, +1 safety so the row can
76
+ // never exact-fit-overflow into OpenTUI's middle-ellipsis truncation.
77
+ if (sfx) overhead += cellWidth(sfx) + 2;
75
78
  const computed = props.availableWidth - overhead;
76
79
  return Math.max(6, Math.min(hardCap, computed));
77
80
  });
@@ -83,6 +83,7 @@ export function TimelineView(props: TimelineViewProps) {
83
83
  const isActive = () => props.store.state.ui.activeZone === "timeline";
84
84
  const cursor = () => props.store.state.ui.row;
85
85
  const armedRef = () => props.store.state.ui.armedTimelineRef;
86
+ const armMode = () => props.store.state.ui.armMode;
86
87
 
87
88
  const entries = createMemo(() =>
88
89
  buildTimelineEntries(
@@ -215,6 +216,10 @@ export function TimelineView(props: TimelineViewProps) {
215
216
  if (!armed.timeBlock) {
216
217
  const startMin = Math.max(0, targetMin);
217
218
  const endMin = Math.min(24 * 60 - 1, startMin + DEFAULT_BLOCK_MIN);
219
+ // A time block only renders on the timeline when the task is also
220
+ // scheduled for today — so arming a task from ANY board and dropping it
221
+ // here pins it to today (otherwise it'd vanish: block set, wrong date).
222
+ props.store.setScheduled(ref, isoToday());
218
223
  props.store.setTimeBlock(ref, { startMin, endMin });
219
224
  props.store.flashBanner(
220
225
  "info",
@@ -262,13 +267,29 @@ export function TimelineView(props: TimelineViewProps) {
262
267
  marginLeft: 1,
263
268
  border: true,
264
269
  borderStyle: "rounded",
265
- borderColor: isActive() ? T.borderActive : T.border,
270
+ // Arm mode paints the border warm so the special scheduling mode is
271
+ // unmistakable, even when the keyboard focus is elsewhere.
272
+ borderColor: armMode()
273
+ ? T.warmActive
274
+ : isActive()
275
+ ? T.borderActive
276
+ : T.border,
266
277
  paddingLeft: 1,
267
278
  paddingRight: 1,
268
279
  }}
269
- title={`┤ Timeline · ${entries().length} ├`}
280
+ title={`┤ Timeline · ${entries().length}${armMode() ? " ◉ ARM" : ""} ├`}
270
281
  titleAlignment="left"
271
282
  >
283
+ <Show when={armMode()}>
284
+ <text wrapMode="none">
285
+ <span style={{ fg: T.warmActive, attributes: ATTR.bold }}>
286
+ {"◉ ARM MODE "}
287
+ </span>
288
+ <span style={{ fg: T.textDim }}>
289
+ {"click a task → click a slot · Esc to exit"}
290
+ </span>
291
+ </text>
292
+ </Show>
272
293
  <Show when={armedTask()}>
273
294
  <text wrapMode="none">
274
295
  <span style={{ fg: T.warm, attributes: ATTR.bold }}>
@@ -315,6 +336,7 @@ export function TimelineView(props: TimelineViewProps) {
315
336
  rowIndex={i()}
316
337
  cursorEntry={isActive() ? cursorEntry() : undefined}
317
338
  armedEntry={armedEntry()}
339
+ innerWidth={props.width ? props.width - 4 : undefined}
318
340
  onBlockClick={onBlockClick}
319
341
  onEmptyRowClick={onEmptyRowClick}
320
342
  />
@@ -361,6 +383,8 @@ interface TimelineRowProps {
361
383
  cursorEntry: TimelineEntry | undefined;
362
384
  /** When set, the armed entry — used to tint its rows warm. */
363
385
  armedEntry: TimelineEntry | undefined;
386
+ /** Panel content width (border+padding already removed). Undefined = fullscreen. */
387
+ innerWidth?: number;
364
388
  onBlockClick: (entry: TimelineEntry, event: MouseEventLike) => void;
365
389
  onEmptyRowClick: (rowIndex: number, event: MouseEventLike) => void;
366
390
  }
@@ -391,6 +415,15 @@ function TimelineRow(props: TimelineRowProps) {
391
415
  const rightIsArmed = () =>
392
416
  !!props.armedEntry && right().entry === props.armedEntry;
393
417
 
418
+ const leftIsDone = () => !!left().entry?.task.done;
419
+ const rightIsDone = () => !!right().entry?.task.done;
420
+
421
+ // Cell budget per lane, so RowContent can tail-truncate the title (keeping
422
+ // the head readable) instead of leaning on OpenTUI's middle-ellipsis.
423
+ const innerW = () => props.innerWidth ?? 200;
424
+ const splitLeftW = () => Math.floor((innerW() - 1) / 2);
425
+ const splitRightW = () => innerW() - 1 - splitLeftW();
426
+
394
427
  /** Mouse handler factory for a lane cell. */
395
428
  const cellMouseDown = (cellEntry: TimelineEntry | undefined) => {
396
429
  return (event: MouseEventLike) => {
@@ -416,12 +449,13 @@ function TimelineRow(props: TimelineRowProps) {
416
449
  leftIsCursor(),
417
450
  leftIsArmed(),
418
451
  leftIsBlock(),
452
+ leftIsDone(),
419
453
  ),
420
454
  }}
421
455
  onMouseDown={cellMouseDown(left().entry)}
422
456
  >
423
457
  <text wrapMode="none" truncate style={{ flexGrow: 1 }}>
424
- <RowContent row={left()} rowIndex={props.rowIndex} />
458
+ <RowContent row={left()} rowIndex={props.rowIndex} laneWidth={innerW()} />
425
459
  </text>
426
460
  </box>
427
461
  }
@@ -443,12 +477,13 @@ function TimelineRow(props: TimelineRowProps) {
443
477
  leftIsCursor(),
444
478
  leftIsArmed(),
445
479
  leftIsBlock(),
480
+ leftIsDone(),
446
481
  ),
447
482
  }}
448
483
  onMouseDown={cellMouseDown(left().entry)}
449
484
  >
450
485
  <text wrapMode="none" truncate style={{ flexGrow: 1 }}>
451
- <RowContent row={left()} rowIndex={props.rowIndex} />
486
+ <RowContent row={left()} rowIndex={props.rowIndex} laneWidth={splitLeftW()} />
452
487
  </text>
453
488
  </box>
454
489
  <text style={{ width: 1, flexShrink: 0 }} wrapMode="none">
@@ -464,13 +499,14 @@ function TimelineRow(props: TimelineRowProps) {
464
499
  rightIsCursor(),
465
500
  rightIsArmed(),
466
501
  rightIsBlock(),
502
+ rightIsDone(),
467
503
  ),
468
504
  }}
469
505
  onMouseDown={cellMouseDown(right().entry)}
470
506
  >
471
507
  <text wrapMode="none" truncate style={{ flexGrow: 1 }}>
472
508
  {/* Right lane skips the 3-char hour prefix that's already on the row. */}
473
- <RowContent row={right()} rowIndex={props.rowIndex} skipPrefix />
509
+ <RowContent row={right()} rowIndex={props.rowIndex} laneWidth={splitRightW()} skipPrefix />
474
510
  </text>
475
511
  </box>
476
512
  </box>
@@ -483,6 +519,8 @@ interface RowContentProps {
483
519
  rowIndex: number;
484
520
  /** When true, omit the leading 3-char hour-gutter spacer. */
485
521
  skipPrefix?: boolean;
522
+ /** Cell budget for this lane — used to tail-truncate the block title. */
523
+ laneWidth?: number;
486
524
  }
487
525
 
488
526
  function RowContent(props: RowContentProps) {
@@ -552,12 +590,23 @@ function RowContent(props: RowContentProps) {
552
590
  if (r.kind === "body" && r.entry) {
553
591
  const e = r.entry;
554
592
  const bColor = boardColor(e.boardIndex);
593
+ // Tail-truncate so the START of the title stays readable (the head/tail
594
+ // ellipsis OpenTUI does otherwise chops the middle). Budget = lane width
595
+ // minus the prefix, the "│ " gutter, and the done check.
596
+ const avail = props.laneWidth ?? 200;
597
+ const budget = Math.max(
598
+ 6,
599
+ avail - (props.skipPrefix ? 0 : 3) - 2 - (e.task.done ? 2 : 0),
600
+ );
555
601
  return (
556
602
  <>
557
603
  <span style={{ fg: T.textDim }}>{prefix}</span>
558
604
  <span style={{ fg: bColor }}>{"│ "}</span>
559
- <span style={{ fg: e.task.done ? T.textDone : T.text }}>
560
- {e.task.displayTitle}
605
+ <Show when={e.task.done}>
606
+ <span style={{ fg: T.done }}>{"✓ "}</span>
607
+ </Show>
608
+ <span style={{ fg: e.task.done ? T.done : T.text }}>
609
+ {tailTruncate(e.task.displayTitle, budget)}
561
610
  </span>
562
611
  </>
563
612
  );
@@ -614,10 +663,11 @@ function laneBg(
614
663
  isCursor: boolean,
615
664
  isArmed: boolean,
616
665
  isBlock: boolean,
666
+ isDone: boolean,
617
667
  ): string | undefined {
618
668
  if (isArmed) return T.warmDim;
619
669
  if (isCursor) return T.cardBgCursor;
620
- if (isBlock) return T.cardBlockBg;
670
+ if (isBlock) return isDone ? T.cardBlockBgDone : T.cardBlockBg;
621
671
  return undefined;
622
672
  }
623
673
 
@@ -119,6 +119,15 @@ export function VirtualPanel(props: { store: TuiStore }) {
119
119
  onClickItem={(flatIndex) => {
120
120
  props.store.setActiveZone("virtual");
121
121
  props.store.setCursor(0, flatIndex);
122
+ // In calendar arm mode, a click also arms the task for the
123
+ // timeline (click a task, then a slot to place it).
124
+ if (props.store.state.ui.armMode) {
125
+ const item = items().find((it) => it.flatIndex === flatIndex);
126
+ if (item) {
127
+ props.store.armTimeline(item.ref);
128
+ props.store.setZoneVisible("timeline", true);
129
+ }
130
+ }
122
131
  }}
123
132
  />
124
133
  </scrollbox>
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { cellWidth } from "~/ui/glyphs";
4
+
5
+ describe("cellWidth", () => {
6
+ test("ASCII counts one cell per char", () => {
7
+ expect(cellWidth("09:00")).toBe(5);
8
+ expect(cellWidth("Costruire routine")).toBe(17);
9
+ expect(cellWidth("")).toBe(0);
10
+ });
11
+
12
+ test("the ⌚ time-block glyph is 2 cells (the bug that broke truncation)", () => {
13
+ expect(cellWidth("⌚")).toBe(2);
14
+ // The actual suffix on a time-blocked row: ⌚ + "09:00" = 2 + 5 = 7,
15
+ // not the 6 that String.length reports.
16
+ expect(cellWidth("⌚09:00")).toBe(7);
17
+ expect("⌚09:00".length).toBe(6); // proves the undercount we corrected
18
+ });
19
+
20
+ test("priority + clock emoji are 2 cells", () => {
21
+ expect(cellWidth("🔺")).toBe(2); // U+1F53A
22
+ expect(cellWidth("⏰")).toBe(2); // U+23F0
23
+ expect(cellWidth("⏫")).toBe(2); // U+23EB
24
+ });
25
+
26
+ test("narrow symbols used in rows stay 1 cell", () => {
27
+ expect(cellWidth("✓")).toBe(1); // done check
28
+ expect(cellWidth("●")).toBe(1); // marked dot
29
+ expect(cellWidth("→")).toBe(1); // tomorrow arrow
30
+ });
31
+ });
package/src/ui/glyphs.ts CHANGED
@@ -46,6 +46,9 @@ export const T = {
46
46
  // dotted/empty gutters around them. Just barely darker-than-cursor; on a
47
47
  // typical dark terminal it reads as "filled card", not as "highlighted".
48
48
  cardBlockBg: "#1c2030",
49
+ // Same idea but tinted faintly green for COMPLETED time blocks, matching
50
+ // the done-green title. Reads as "done" at a glance without shouting.
51
+ cardBlockBgDone: "#1a2a1d",
49
52
 
50
53
  // Foreground neutrals — readable mid-grays so dim chrome doesn't disappear
51
54
  text: undefined as string | undefined, // terminal default fg
@@ -110,6 +113,26 @@ export function fmtMin(m: number): string {
110
113
  return `${h}:${mm}`;
111
114
  }
112
115
 
116
+ // Symbols in the Misc-Technical block that render 2 cells wide despite being
117
+ // a single JS code unit (so `.length` undercounts them): ⌚ ⏰ ⏫ ⏬ ⏩ ⏪.
118
+ const WIDE_CODEPOINTS = new Set([0x231a, 0x231b, 0x23eb, 0x23ec, 0x23e9, 0x23ea, 0x23f0, 0x23f3]);
119
+
120
+ /**
121
+ * Terminal display width of a string in cells, counting emoji / wide glyphs
122
+ * as 2. `.length` undercounts these (e.g. `⌚` is 1 code unit but 2 cells),
123
+ * which throws off truncation budgets and makes OpenTUI re-truncate a row
124
+ * with its middle-ellipsis. Use this wherever a layout budget must match what
125
+ * the terminal actually paints.
126
+ */
127
+ export function cellWidth(s: string): number {
128
+ let w = 0;
129
+ for (const ch of s) {
130
+ const cp = ch.codePointAt(0)!;
131
+ w += cp >= 0x1f000 || WIDE_CODEPOINTS.has(cp) ? 2 : 1;
132
+ }
133
+ return w;
134
+ }
135
+
113
136
  /**
114
137
  * Per-board accent palette. Used by the virtual panel to color-code the
115
138
  * source-board tag on priority/agenda items, so the user can recognize