tuiboard 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tuiboard/config.example.yaml +32 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bin/tuiboard.ts +28 -0
- package/package.json +62 -0
- package/src/app.tsx +129 -0
- package/src/cli/args.test.ts +40 -0
- package/src/cli/args.ts +41 -0
- package/src/config/loader.ts +169 -0
- package/src/input/handleKey.ts +733 -0
- package/src/io/watcher.ts +85 -0
- package/src/io/writer.ts +92 -0
- package/src/parser/markdown.ts +351 -0
- package/src/parser/serialize.ts +97 -0
- package/src/scripts/agents-check.ts +24 -0
- package/src/scripts/parse-check.ts +124 -0
- package/src/scripts/roundtrip-check.ts +79 -0
- package/src/store/agents.test.ts +181 -0
- package/src/store/agents.ts +435 -0
- package/src/store/index.test.ts +110 -0
- package/src/store/index.ts +972 -0
- package/src/store/parsers.ts +243 -0
- package/src/store/timeline.test.ts +279 -0
- package/src/store/timeline.ts +279 -0
- package/src/store/virtual-panel.ts +0 -0
- package/src/types.ts +116 -0
- package/src/ui/AgentRow.tsx +79 -0
- package/src/ui/AgentsBar.tsx +102 -0
- package/src/ui/BoardView.tsx +333 -0
- package/src/ui/Chrome.tsx +122 -0
- package/src/ui/Modal.tsx +613 -0
- package/src/ui/TaskRow.tsx +240 -0
- package/src/ui/TimelineView.tsx +643 -0
- package/src/ui/VirtualPanel.tsx +237 -0
- package/src/ui/board-scroll.test.ts +63 -0
- package/src/ui/board-scroll.ts +49 -0
- package/src/ui/glyphs.ts +129 -0
- package/src/views/AgentsOnly.tsx +103 -0
- package/src/views/BoardOnly.tsx +35 -0
- package/src/views/Dashboard.tsx +106 -0
- package/src/views/TimelineOnly.tsx +12 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI smoke-test for the markdown parser.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* bun run src/scripts/parse-check.ts # uses config-discovered boards
|
|
6
|
+
* bun run src/scripts/parse-check.ts <file.md>... # parses specific files
|
|
7
|
+
*
|
|
8
|
+
* Prints per-board summary: columns, task counts, metadata coverage,
|
|
9
|
+
* diagnostics, and a sample of parsed tasks so we can eyeball correctness.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { loadConfig } from "~/config/loader";
|
|
14
|
+
import { isTask, parseBoard } from "~/parser/markdown";
|
|
15
|
+
import type { Task } from "~/types";
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const files: string[] = [];
|
|
19
|
+
|
|
20
|
+
if (args.length > 0) {
|
|
21
|
+
files.push(...args);
|
|
22
|
+
} else {
|
|
23
|
+
const cfg = loadConfig();
|
|
24
|
+
if (!cfg.loaded) {
|
|
25
|
+
console.error(
|
|
26
|
+
`No .tuiboard/config.yaml found from ${cfg.root}. Using fallback scan of cwd.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (cfg.boards.length === 0) {
|
|
30
|
+
console.error("No boards configured and none found via fallback scan.");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
files.push(...cfg.boards.map((b) => b.path));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let totalTasks = 0;
|
|
37
|
+
let totalDone = 0;
|
|
38
|
+
let totalWithSched = 0;
|
|
39
|
+
let totalWithTimeBlock = 0;
|
|
40
|
+
let totalLegacyTimeBlock = 0;
|
|
41
|
+
let totalWithPriority = 0;
|
|
42
|
+
let totalDiagnostics = 0;
|
|
43
|
+
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
let content: string;
|
|
46
|
+
try {
|
|
47
|
+
content = readFileSync(file, "utf-8");
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error(`✗ Cannot read ${file}: ${(e as Error).message}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { board, diagnostics } = parseBoard(content, { filepath: file });
|
|
54
|
+
const tasks: Task[] = [];
|
|
55
|
+
for (const col of board.columns) {
|
|
56
|
+
for (const child of col.children) {
|
|
57
|
+
if (isTask(child)) tasks.push(child);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const done = tasks.filter((t) => t.done).length;
|
|
61
|
+
const withSched = tasks.filter((t) => t.scheduled).length;
|
|
62
|
+
const withTime = tasks.filter((t) => t.timeBlock).length;
|
|
63
|
+
const legacyTime = tasks.filter((t) => t.timeBlockSource === "legacy-prefix").length;
|
|
64
|
+
const withPrio = tasks.filter((t) => t.priority !== "none").length;
|
|
65
|
+
|
|
66
|
+
totalTasks += tasks.length;
|
|
67
|
+
totalDone += done;
|
|
68
|
+
totalWithSched += withSched;
|
|
69
|
+
totalWithTimeBlock += withTime;
|
|
70
|
+
totalLegacyTimeBlock += legacyTime;
|
|
71
|
+
totalWithPriority += withPrio;
|
|
72
|
+
totalDiagnostics += diagnostics.length;
|
|
73
|
+
|
|
74
|
+
console.log(`\n━━━ ${board.name} ━━━`);
|
|
75
|
+
console.log(` file: ${file}`);
|
|
76
|
+
console.log(` frontmatter: ${board.frontmatter ? "yes" : "no"}`);
|
|
77
|
+
console.log(` trailer: ${board.trailer ? "yes" : "no"}`);
|
|
78
|
+
console.log(` columns: ${board.columns.length} — ${board.columns.map((c) => c.name).join(" │ ")}`);
|
|
79
|
+
console.log(` tasks total: ${tasks.length} (${done} done, ${tasks.length - done} open)`);
|
|
80
|
+
console.log(` scheduled: ${withSched}`);
|
|
81
|
+
console.log(` time blocks: ${withTime} (${legacyTime} legacy prefix, ${withTime - legacyTime} ⌚)`);
|
|
82
|
+
console.log(` priority: ${withPrio}`);
|
|
83
|
+
console.log(` diagnostics: ${diagnostics.length}`);
|
|
84
|
+
|
|
85
|
+
// Show first 3 diagnostics
|
|
86
|
+
for (const d of diagnostics.slice(0, 3)) {
|
|
87
|
+
console.log(` [L${d.line}] ${d.level}: ${d.message}`);
|
|
88
|
+
}
|
|
89
|
+
if (diagnostics.length > 3) console.log(` … and ${diagnostics.length - 3} more`);
|
|
90
|
+
|
|
91
|
+
// Show first 5 parsed open tasks for eyeballing
|
|
92
|
+
const sample = tasks.filter((t) => !t.done).slice(0, 5);
|
|
93
|
+
if (sample.length > 0) {
|
|
94
|
+
console.log("\n sample tasks:");
|
|
95
|
+
for (const t of sample) {
|
|
96
|
+
const tb = t.timeBlock
|
|
97
|
+
? ` ⌚${fmtMin(t.timeBlock.startMin)}-${fmtMin(t.timeBlock.endMin)}`
|
|
98
|
+
: "";
|
|
99
|
+
const sched = t.scheduled ? ` ⏳${t.scheduled}` : "";
|
|
100
|
+
const prio = t.priority !== "none" ? ` [${t.priority}]` : "";
|
|
101
|
+
const assignee = t.assignee ? ` @${t.assignee}` : "";
|
|
102
|
+
const tags = t.tags.length ? ` ${t.tags.map((x) => "#" + x).join(" ")}` : "";
|
|
103
|
+
console.log(` • ${truncate(t.displayTitle, 60)}${prio}${assignee}${sched}${tb}${tags}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log("\n━━━ TOTALS ━━━");
|
|
109
|
+
console.log(` files: ${files.length}`);
|
|
110
|
+
console.log(` tasks: ${totalTasks} (${totalDone} done)`);
|
|
111
|
+
console.log(` scheduled: ${totalWithSched}`);
|
|
112
|
+
console.log(` time blocks: ${totalWithTimeBlock} (${totalLegacyTimeBlock} legacy, ${totalWithTimeBlock - totalLegacyTimeBlock} ⌚)`);
|
|
113
|
+
console.log(` priority: ${totalWithPriority}`);
|
|
114
|
+
console.log(` diagnostics: ${totalDiagnostics}`);
|
|
115
|
+
|
|
116
|
+
function fmtMin(m: number): string {
|
|
117
|
+
const h = Math.floor(m / 60).toString().padStart(2, "0");
|
|
118
|
+
const mm = (m % 60).toString().padStart(2, "0");
|
|
119
|
+
return `${h}:${mm}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function truncate(s: string, n: number): string {
|
|
123
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
124
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roundtrip integrity check.
|
|
3
|
+
*
|
|
4
|
+
* Parses each board, serializes it back, and verifies bit-for-bit equality
|
|
5
|
+
* with the original (no tasks are dirty since we haven't mutated anything,
|
|
6
|
+
* so `serializeTask` falls through to `rawLine`). Any diff indicates a bug
|
|
7
|
+
* in the parser or serializer's handling of structural elements (frontmatter,
|
|
8
|
+
* headings, section breaks, trailer).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* bun run src/scripts/roundtrip-check.ts [file.md ...]
|
|
12
|
+
*
|
|
13
|
+
* Exit code 0 on success, 1 on any mismatch.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { loadConfig } from "~/config/loader";
|
|
18
|
+
import { parseBoard } from "~/parser/markdown";
|
|
19
|
+
import { serializeBoard } from "~/parser/serialize";
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const files = args.length > 0 ? args : loadConfig().boards.map((b) => b.path);
|
|
23
|
+
|
|
24
|
+
if (files.length === 0) {
|
|
25
|
+
console.error("No boards to check.");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let failures = 0;
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
let original: string;
|
|
33
|
+
try {
|
|
34
|
+
original = readFileSync(file, "utf-8");
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`✗ ${file}: ${(e as Error).message}`);
|
|
37
|
+
failures++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { board } = parseBoard(original, { filepath: file });
|
|
42
|
+
const serialized = serializeBoard(board);
|
|
43
|
+
|
|
44
|
+
if (serialized === original) {
|
|
45
|
+
console.log(`✓ ${file} (${original.length} bytes, ${board.columns.length} cols)`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Find the first difference for diagnostics.
|
|
50
|
+
const minLen = Math.min(serialized.length, original.length);
|
|
51
|
+
let diffAt = -1;
|
|
52
|
+
for (let i = 0; i < minLen; i++) {
|
|
53
|
+
if (serialized[i] !== original[i]) {
|
|
54
|
+
diffAt = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (diffAt === -1) diffAt = minLen;
|
|
59
|
+
|
|
60
|
+
const ctx = (s: string, at: number) => {
|
|
61
|
+
const from = Math.max(0, at - 40);
|
|
62
|
+
const to = Math.min(s.length, at + 40);
|
|
63
|
+
return JSON.stringify(s.slice(from, to));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
console.error(`✗ ${file}: roundtrip differs at offset ${diffAt}`);
|
|
67
|
+
console.error(` original size: ${original.length}`);
|
|
68
|
+
console.error(` serialized size: ${serialized.length}`);
|
|
69
|
+
console.error(` original @ ${diffAt}: ${ctx(original, diffAt)}`);
|
|
70
|
+
console.error(` serialized @ ${diffAt}: ${ctx(serialized, diffAt)}`);
|
|
71
|
+
failures++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (failures > 0) {
|
|
75
|
+
console.error(`\n${failures} file(s) failed roundtrip.`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log(`\nAll ${files.length} board(s) passed roundtrip.`);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
classifyStatus,
|
|
4
|
+
cwdFromSlug,
|
|
5
|
+
cwdShort,
|
|
6
|
+
formatAge,
|
|
7
|
+
parseTranscript,
|
|
8
|
+
type LivePidRecord,
|
|
9
|
+
} from "./agents";
|
|
10
|
+
|
|
11
|
+
describe("cwdFromSlug", () => {
|
|
12
|
+
it("decodes a Windows drive-letter slug", () => {
|
|
13
|
+
expect(cwdFromSlug("C--Users-nazza-Documents-Repos-Blits")).toBe(
|
|
14
|
+
"C:\\Users\\nazza\\Documents\\Repos\\Blits",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("decodes a POSIX absolute path slug", () => {
|
|
19
|
+
expect(cwdFromSlug("-home-foo-projects-myrepo")).toBe(
|
|
20
|
+
"/home/foo/projects/myrepo",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("decodes a macOS user directory slug", () => {
|
|
25
|
+
expect(cwdFromSlug("-Users-foo-code-blits")).toBe("/Users/foo/code/blits");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("falls back to host separator for ambiguous bare slugs", () => {
|
|
29
|
+
// No leading dash and no drive letter — host OS picks the separator.
|
|
30
|
+
const sep = process.platform === "win32" ? "\\" : "/";
|
|
31
|
+
expect(cwdFromSlug("workdir-x")).toBe(`workdir${sep}x`);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("cwdShort", () => {
|
|
36
|
+
it("returns the last 3 parts prefixed with ellipsis when path is long", () => {
|
|
37
|
+
expect(cwdShort("C:\\Users\\nazza\\Documents\\Repos\\Blits")).toBe(
|
|
38
|
+
"…Documents\\Repos\\Blits",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns the full path when 3 or fewer parts", () => {
|
|
43
|
+
expect(cwdShort("C:\\Users\\nazza")).toBe("C:\\Users\\nazza");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("classifyStatus", () => {
|
|
48
|
+
const now = 1_700_000_000_000; // fixed instant
|
|
49
|
+
const minutes = (n: number) => n * 60_000;
|
|
50
|
+
const days = (n: number) => n * 86_400_000;
|
|
51
|
+
|
|
52
|
+
it("returns live-busy when PID record fresh AND status busy", () => {
|
|
53
|
+
const live: LivePidRecord = { mtimeMs: now - minutes(1), status: "busy" };
|
|
54
|
+
expect(classifyStatus(now, now, live)).toBe("live-busy");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns live-idle when PID record fresh AND status idle/missing", () => {
|
|
58
|
+
const live: LivePidRecord = { mtimeMs: now - minutes(1) };
|
|
59
|
+
expect(classifyStatus(now, now, live)).toBe("live-idle");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns stale-pid when PID record older than 5min", () => {
|
|
63
|
+
const live: LivePidRecord = { mtimeMs: now - minutes(10), status: "busy" };
|
|
64
|
+
expect(classifyStatus(now, now, live)).toBe("stale-pid");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns dormant when no PID and jsonl mtime within 7 days", () => {
|
|
68
|
+
expect(classifyStatus(now, now - days(2), undefined)).toBe("dormant");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns archived when jsonl mtime older than 7 days and no PID", () => {
|
|
72
|
+
expect(classifyStatus(now, now - days(10), undefined)).toBe("archived");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("formatAge", () => {
|
|
77
|
+
const now = 1_700_000_000_000;
|
|
78
|
+
|
|
79
|
+
it("formats seconds", () => {
|
|
80
|
+
expect(formatAge(now - 30_000, now)).toBe("30s");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("formats minutes", () => {
|
|
84
|
+
expect(formatAge(now - 5 * 60_000, now)).toBe("5m");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("formats hours", () => {
|
|
88
|
+
expect(formatAge(now - 3 * 3_600_000, now)).toBe("3h");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("formats days", () => {
|
|
92
|
+
expect(formatAge(now - 2 * 86_400_000, now)).toBe("2d");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns dash for zero", () => {
|
|
96
|
+
expect(formatAge(0, now)).toBe("—");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("parseTranscript", () => {
|
|
101
|
+
const SAMPLE_JSONL = [
|
|
102
|
+
JSON.stringify({ type: "user", message: { role: "user", content: "Ciao" }, gitBranch: "main" }),
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
type: "assistant",
|
|
105
|
+
message: {
|
|
106
|
+
role: "assistant",
|
|
107
|
+
content: [
|
|
108
|
+
{ type: "text", text: "Hello" },
|
|
109
|
+
{ type: "tool_use", name: "Read" },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
JSON.stringify({ type: "custom-title", customTitle: "Refactor store" }),
|
|
114
|
+
].join("\n");
|
|
115
|
+
|
|
116
|
+
it("extracts title, last messages, counts, branch", () => {
|
|
117
|
+
const result = parseTranscript(SAMPLE_JSONL);
|
|
118
|
+
expect(result.customTitle).toBe("Refactor store");
|
|
119
|
+
expect(result.lastUser).toBe("Ciao");
|
|
120
|
+
expect(result.firstHumanUser).toBe("Ciao");
|
|
121
|
+
expect(result.lastAssistant).toBe("Hello");
|
|
122
|
+
expect(result.messageCount).toBe(2);
|
|
123
|
+
expect(result.toolCount).toBe(1);
|
|
124
|
+
expect(result.gitBranch).toBe("main");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("tolerates malformed lines", () => {
|
|
128
|
+
const broken = SAMPLE_JSONL + "\n{this is not json\n";
|
|
129
|
+
const result = parseTranscript(broken);
|
|
130
|
+
expect(result.lastUser).toBe("Ciao"); // still got the good lines
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("handles empty input", () => {
|
|
134
|
+
const result = parseTranscript("");
|
|
135
|
+
expect(result.messageCount).toBe(0);
|
|
136
|
+
expect(result.customTitle).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("skips skill-bootstrap and system-tag user messages when picking firstHumanUser", () => {
|
|
140
|
+
const lines = [
|
|
141
|
+
// Synthetic skill loader injected by Claude Code on /morning
|
|
142
|
+
JSON.stringify({
|
|
143
|
+
type: "user",
|
|
144
|
+
message: {
|
|
145
|
+
role: "user",
|
|
146
|
+
content: "Base directory for this skill: C:\\Users\\nazza\\.claude\\skills\\morning",
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
// System-injected reminder tag
|
|
150
|
+
JSON.stringify({
|
|
151
|
+
type: "user",
|
|
152
|
+
message: { role: "user", content: "<system-reminder>do the thing</system-reminder>" },
|
|
153
|
+
}),
|
|
154
|
+
// Real human prompt
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
type: "user",
|
|
157
|
+
message: { role: "user", content: "Davvero buongiorno, partiamo dal recap di ieri" },
|
|
158
|
+
}),
|
|
159
|
+
].join("\n");
|
|
160
|
+
const result = parseTranscript(lines);
|
|
161
|
+
expect(result.firstHumanUser).toBe("Davvero buongiorno, partiamo dal recap di ieri");
|
|
162
|
+
// lastUser still tracks the literal last message regardless
|
|
163
|
+
expect(result.lastUser).toBe("Davvero buongiorno, partiamo dal recap di ieri");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns undefined firstHumanUser when every user message is synthetic", () => {
|
|
167
|
+
const lines = [
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
type: "user",
|
|
170
|
+
message: { role: "user", content: "Base directory for this skill: X" },
|
|
171
|
+
}),
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
type: "user",
|
|
174
|
+
message: { role: "user", content: "<task-notification>noisy</task-notification>" },
|
|
175
|
+
}),
|
|
176
|
+
].join("\n");
|
|
177
|
+
const result = parseTranscript(lines);
|
|
178
|
+
expect(result.firstHumanUser).toBeUndefined();
|
|
179
|
+
expect(result.lastUser).toBe("<task-notification>noisy</task-notification>");
|
|
180
|
+
});
|
|
181
|
+
});
|