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.
- package/.tuiboard/config.example.yaml +17 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/config/loader.ts +14 -0
- package/src/input/handleKey.ts +120 -30
- package/src/store/index.ts +14 -0
- package/src/store/timeline.test.ts +4 -2
- package/src/store/timeline.ts +7 -3
- package/src/ui/AgentsBar.tsx +57 -49
- package/src/ui/BoardView.tsx +6 -0
- package/src/ui/Chrome.tsx +8 -2
- package/src/ui/Modal.tsx +9 -5
- package/src/ui/TaskRow.tsx +5 -2
- package/src/ui/TimelineView.tsx +58 -8
- package/src/ui/VirtualPanel.tsx +9 -0
- package/src/ui/glyphs.test.ts +31 -0
- package/src/ui/glyphs.ts +23 -0
|
@@ -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
package/src/config/loader.ts
CHANGED
|
@@ -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
|
|
package/src/input/handleKey.ts
CHANGED
|
@@ -74,11 +74,14 @@ export function handleKey(
|
|
|
74
74
|
return;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// Escape priority: timeline arm → grab mode → marks. Most
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
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
|
-
?
|
|
576
|
-
: "
|
|
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
|
package/src/store/index.ts
CHANGED
|
@@ -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("
|
|
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
|
-
|
|
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", () => {
|
package/src/store/timeline.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/src/ui/AgentsBar.tsx
CHANGED
|
@@ -1,62 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compact agent status strip for the dashboard.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
<
|
|
87
|
-
{(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
);
|
package/src/ui/BoardView.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 }}>
|
|
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 }}>{"
|
|
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 }}>{"
|
|
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>
|
package/src/ui/TaskRow.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/ui/TimelineView.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
560
|
-
{
|
|
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
|
|
package/src/ui/VirtualPanel.tsx
CHANGED
|
@@ -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
|