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,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery + reactive store for local Claude Code sessions.
|
|
3
|
+
*
|
|
4
|
+
* Reads:
|
|
5
|
+
* ~/.claude/projects/<slug>/<sessionId>.jsonl — transcripts
|
|
6
|
+
* ~/.claude/sessions/<sessionId>.json — live PID records
|
|
7
|
+
*
|
|
8
|
+
* Watches both with chokidar; re-parses the changed jsonl on update.
|
|
9
|
+
* Eager initial scan (1-2s for ~80 sessions) is acceptable startup cost.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import chokidar from "chokidar";
|
|
16
|
+
import { createSignal } from "solid-js";
|
|
17
|
+
|
|
18
|
+
const CLAUDE_HOME = join(homedir(), ".claude");
|
|
19
|
+
const PROJECTS_DIR = join(CLAUDE_HOME, "projects");
|
|
20
|
+
const SESSIONS_DIR = join(CLAUDE_HOME, "sessions");
|
|
21
|
+
|
|
22
|
+
/** Threshold: PID record older than this means the Claude process likely crashed. */
|
|
23
|
+
const LIVE_STALE_AFTER_MS = 5 * 60 * 1000;
|
|
24
|
+
/** Threshold: jsonl untouched longer than this is "archived" (won't show in compact list). */
|
|
25
|
+
const DORMANT_AFTER_MS = 7 * 86_400 * 1000;
|
|
26
|
+
|
|
27
|
+
export type AgentStatus =
|
|
28
|
+
| "live-busy"
|
|
29
|
+
| "live-idle"
|
|
30
|
+
| "stale-pid"
|
|
31
|
+
| "dormant"
|
|
32
|
+
| "archived";
|
|
33
|
+
|
|
34
|
+
export interface LivePidRecord {
|
|
35
|
+
mtimeMs: number;
|
|
36
|
+
/** "busy" | "idle" | undefined */
|
|
37
|
+
status?: string;
|
|
38
|
+
pid?: number;
|
|
39
|
+
version?: string;
|
|
40
|
+
cwd?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AgentSession {
|
|
44
|
+
sessionId: string;
|
|
45
|
+
jsonlPath: string;
|
|
46
|
+
cwd: string;
|
|
47
|
+
cwdShort: string;
|
|
48
|
+
status: AgentStatus;
|
|
49
|
+
lastActivityMs: number;
|
|
50
|
+
customTitle?: string;
|
|
51
|
+
aiTitle?: string;
|
|
52
|
+
displayName: string;
|
|
53
|
+
messageCount: number;
|
|
54
|
+
toolCount: number;
|
|
55
|
+
lastUser?: string;
|
|
56
|
+
lastAssistant?: string;
|
|
57
|
+
gitBranch?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reverse Claude Code's path-to-slug encoding (lossy on case).
|
|
62
|
+
*
|
|
63
|
+
* Claude Code encodes the cwd by replacing every `:`, `\` and `/` with `-`.
|
|
64
|
+
*
|
|
65
|
+
* Windows "C:\Users\foo" → "C--Users-foo"
|
|
66
|
+
* POSIX "/home/foo" → "-home-foo"
|
|
67
|
+
*
|
|
68
|
+
* We can recognize a Windows-shaped slug by the `<letter>--` prefix
|
|
69
|
+
* (drive letter followed by colon → two leading dashes). Anything else
|
|
70
|
+
* is assumed POSIX. This works regardless of `process.platform`, so
|
|
71
|
+
* decoding remote-shape paths (sessions originated on a different OS)
|
|
72
|
+
* still produces something sensible.
|
|
73
|
+
*/
|
|
74
|
+
export function cwdFromSlug(slug: string): string {
|
|
75
|
+
// Windows drive letter shape: "C--Users-foo" → "C:\Users\foo".
|
|
76
|
+
if (slug.length >= 3 && /^[A-Za-z]--/.test(slug)) {
|
|
77
|
+
return slug[0] + ":\\" + slug.slice(3).replaceAll("-", "\\");
|
|
78
|
+
}
|
|
79
|
+
// POSIX shape: "-home-foo" → "/home/foo".
|
|
80
|
+
if (slug.startsWith("-")) {
|
|
81
|
+
return "/" + slug.slice(1).replaceAll("-", "/");
|
|
82
|
+
}
|
|
83
|
+
// Fallback: bare directory name. Pick the separator from the host OS so
|
|
84
|
+
// the result at least concatenates correctly when the user copies it.
|
|
85
|
+
const sep = process.platform === "win32" ? "\\" : "/";
|
|
86
|
+
return slug.replaceAll("-", sep);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Last 3 path parts with leading ellipsis when path is long. */
|
|
90
|
+
export function cwdShort(cwd: string): string {
|
|
91
|
+
const parts = cwd.split(/[\\/]/).filter((p) => p.length > 0);
|
|
92
|
+
if (parts.length >= 4) {
|
|
93
|
+
return "…" + parts.slice(-3).join("\\");
|
|
94
|
+
}
|
|
95
|
+
return cwd;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function classifyStatus(
|
|
99
|
+
now: number,
|
|
100
|
+
jsonlMtimeMs: number,
|
|
101
|
+
live: LivePidRecord | undefined,
|
|
102
|
+
): AgentStatus {
|
|
103
|
+
if (live) {
|
|
104
|
+
if (now - live.mtimeMs > LIVE_STALE_AFTER_MS) return "stale-pid";
|
|
105
|
+
return live.status === "busy" ? "live-busy" : "live-idle";
|
|
106
|
+
}
|
|
107
|
+
const age = now - jsonlMtimeMs;
|
|
108
|
+
if (age > DORMANT_AFTER_MS) return "archived";
|
|
109
|
+
return "dormant";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Compact human-readable age. Mirrors av.py `_fmt_age`. */
|
|
113
|
+
export function formatAge(ts: number, now: number): string {
|
|
114
|
+
if (!ts) return "—";
|
|
115
|
+
const delta = (now - ts) / 1000;
|
|
116
|
+
if (delta < 60) return `${Math.floor(delta)}s`;
|
|
117
|
+
if (delta < 3600) return `${Math.floor(delta / 60)}m`;
|
|
118
|
+
if (delta < 86_400) return `${Math.floor(delta / 3600)}h`;
|
|
119
|
+
return `${Math.floor(delta / 86_400)}d`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface TranscriptParseResult {
|
|
123
|
+
customTitle?: string;
|
|
124
|
+
aiTitle?: string;
|
|
125
|
+
/**
|
|
126
|
+
* First user message that looks like a real human prompt — skill loaders,
|
|
127
|
+
* system tags, and bare slash-command invocations are filtered. Used as
|
|
128
|
+
* the displayName fallback when there is no custom/ai title.
|
|
129
|
+
*/
|
|
130
|
+
firstHumanUser?: string;
|
|
131
|
+
lastUser?: string;
|
|
132
|
+
lastAssistant?: string;
|
|
133
|
+
messageCount: number;
|
|
134
|
+
toolCount: number;
|
|
135
|
+
gitBranch?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Heuristic: is this user "message" actually a skill/system bootstrap that
|
|
140
|
+
* Claude Code injected on session start? Used to skip these when picking
|
|
141
|
+
* a fallback displayName — otherwise N sessions opened by the same skill
|
|
142
|
+
* all look identical in the list.
|
|
143
|
+
*/
|
|
144
|
+
function looksSyntheticUser(text: string): boolean {
|
|
145
|
+
const t = text.trimStart();
|
|
146
|
+
if (!t) return true;
|
|
147
|
+
// Skill bootstrap: "Base directory for this skill: ..."
|
|
148
|
+
if (t.startsWith("Base directory for this skill")) return true;
|
|
149
|
+
// System-injected tags: <command-name>, <task-notification>, <system-reminder>, <local-command-stdout>
|
|
150
|
+
if (/^<[a-z][a-z0-9-]*>/i.test(t)) return true;
|
|
151
|
+
// Bare slash-command invocation (the literal "/morning", "/log", etc.)
|
|
152
|
+
if (/^\/[a-z][a-z0-9-]*\s*$/i.test(t)) return true;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Lightweight pass over a jsonl transcript. Defensive: malformed lines
|
|
158
|
+
* are skipped silently because the format is internal to Claude Code
|
|
159
|
+
* and may drift between versions.
|
|
160
|
+
*/
|
|
161
|
+
export function parseTranscript(content: string): TranscriptParseResult {
|
|
162
|
+
let customTitle: string | undefined;
|
|
163
|
+
let aiTitle: string | undefined;
|
|
164
|
+
let firstHumanUser: string | undefined;
|
|
165
|
+
let lastUser: string | undefined;
|
|
166
|
+
let lastAssistant: string | undefined;
|
|
167
|
+
let gitBranch: string | undefined;
|
|
168
|
+
let messageCount = 0;
|
|
169
|
+
let toolCount = 0;
|
|
170
|
+
|
|
171
|
+
const recordUserText = (text: string) => {
|
|
172
|
+
lastUser = text;
|
|
173
|
+
if (firstHumanUser === undefined && !looksSyntheticUser(text)) {
|
|
174
|
+
firstHumanUser = text;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (const line of content.split("\n")) {
|
|
179
|
+
if (!line.trim()) continue;
|
|
180
|
+
let obj: any;
|
|
181
|
+
try {
|
|
182
|
+
obj = JSON.parse(line);
|
|
183
|
+
} catch {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (obj.gitBranch) gitBranch = obj.gitBranch;
|
|
187
|
+
const t = obj.type;
|
|
188
|
+
if (t === "custom-title") {
|
|
189
|
+
customTitle = obj.customTitle ?? obj.title ?? customTitle;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (t === "ai-title") {
|
|
193
|
+
aiTitle = obj.aiTitle ?? obj.title ?? aiTitle;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const msg = obj.message ?? {};
|
|
197
|
+
const role = msg.role;
|
|
198
|
+
if (role === "user") {
|
|
199
|
+
messageCount++;
|
|
200
|
+
const content = msg.content;
|
|
201
|
+
if (typeof content === "string") {
|
|
202
|
+
recordUserText(content);
|
|
203
|
+
} else if (Array.isArray(content)) {
|
|
204
|
+
for (const part of content) {
|
|
205
|
+
if (
|
|
206
|
+
part &&
|
|
207
|
+
typeof part === "object" &&
|
|
208
|
+
part.type === "text" &&
|
|
209
|
+
typeof part.text === "string"
|
|
210
|
+
) {
|
|
211
|
+
recordUserText(part.text);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} else if (role === "assistant") {
|
|
216
|
+
messageCount++;
|
|
217
|
+
const content = msg.content;
|
|
218
|
+
if (Array.isArray(content)) {
|
|
219
|
+
for (const part of content) {
|
|
220
|
+
if (!part || typeof part !== "object") continue;
|
|
221
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
222
|
+
lastAssistant = part.text;
|
|
223
|
+
} else if (part.type === "tool_use") {
|
|
224
|
+
toolCount++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
customTitle,
|
|
233
|
+
aiTitle,
|
|
234
|
+
firstHumanUser,
|
|
235
|
+
lastUser,
|
|
236
|
+
lastAssistant,
|
|
237
|
+
messageCount,
|
|
238
|
+
toolCount,
|
|
239
|
+
gitBranch,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── Discovery ──────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
function discoverLivePids(): Map<string, LivePidRecord> {
|
|
246
|
+
const out = new Map<string, LivePidRecord>();
|
|
247
|
+
if (!existsSync(SESSIONS_DIR)) return out;
|
|
248
|
+
let entries: string[];
|
|
249
|
+
try {
|
|
250
|
+
entries = readdirSync(SESSIONS_DIR);
|
|
251
|
+
} catch {
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
for (const f of entries) {
|
|
255
|
+
if (!f.endsWith(".json")) continue;
|
|
256
|
+
const path = join(SESSIONS_DIR, f);
|
|
257
|
+
try {
|
|
258
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
259
|
+
const sid = raw.sessionId;
|
|
260
|
+
if (!sid) continue;
|
|
261
|
+
const stat = statSync(path);
|
|
262
|
+
out.set(sid, {
|
|
263
|
+
mtimeMs: stat.mtimeMs,
|
|
264
|
+
status: raw.status?.toLowerCase(),
|
|
265
|
+
pid: raw.pid,
|
|
266
|
+
version: raw.version,
|
|
267
|
+
cwd: raw.cwd,
|
|
268
|
+
});
|
|
269
|
+
} catch {
|
|
270
|
+
// ignore — malformed PID files happen during writes
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
interface JsonlEntry {
|
|
277
|
+
slug: string;
|
|
278
|
+
sessionId: string;
|
|
279
|
+
path: string;
|
|
280
|
+
mtimeMs: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function discoverJsonlFiles(): JsonlEntry[] {
|
|
284
|
+
const out: JsonlEntry[] = [];
|
|
285
|
+
if (!existsSync(PROJECTS_DIR)) return out;
|
|
286
|
+
let slugs: string[];
|
|
287
|
+
try {
|
|
288
|
+
slugs = readdirSync(PROJECTS_DIR);
|
|
289
|
+
} catch {
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
for (const slug of slugs) {
|
|
293
|
+
const slugDir = join(PROJECTS_DIR, slug);
|
|
294
|
+
let slugStat;
|
|
295
|
+
try {
|
|
296
|
+
slugStat = statSync(slugDir);
|
|
297
|
+
} catch {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!slugStat.isDirectory()) continue;
|
|
301
|
+
let inner: string[];
|
|
302
|
+
try {
|
|
303
|
+
inner = readdirSync(slugDir);
|
|
304
|
+
} catch {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
for (const f of inner) {
|
|
308
|
+
// Skip subagent transcripts — they're addressed by their parent session.
|
|
309
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
310
|
+
const path = join(slugDir, f);
|
|
311
|
+
try {
|
|
312
|
+
const stat = statSync(path);
|
|
313
|
+
if (!stat.isFile()) continue;
|
|
314
|
+
out.push({
|
|
315
|
+
slug,
|
|
316
|
+
sessionId: f.slice(0, -".jsonl".length),
|
|
317
|
+
path,
|
|
318
|
+
mtimeMs: stat.mtimeMs,
|
|
319
|
+
});
|
|
320
|
+
} catch {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function buildSession(
|
|
329
|
+
jsonl: JsonlEntry,
|
|
330
|
+
live: LivePidRecord | undefined,
|
|
331
|
+
now: number,
|
|
332
|
+
): AgentSession {
|
|
333
|
+
let parsed: TranscriptParseResult;
|
|
334
|
+
try {
|
|
335
|
+
parsed = parseTranscript(readFileSync(jsonl.path, "utf-8"));
|
|
336
|
+
} catch {
|
|
337
|
+
parsed = { messageCount: 0, toolCount: 0 };
|
|
338
|
+
}
|
|
339
|
+
const cwd = live?.cwd ?? cwdFromSlug(jsonl.slug);
|
|
340
|
+
const hasRealTitle = Boolean(parsed.customTitle || parsed.aiTitle);
|
|
341
|
+
const fallbackText =
|
|
342
|
+
parsed.firstHumanUser?.split("\n").find((l) => l.trim().length > 0)?.slice(0, 60) ??
|
|
343
|
+
parsed.lastUser?.split("\n").find((l) => l.trim().length > 0)?.slice(0, 60);
|
|
344
|
+
const uuid8 = jsonl.sessionId.slice(0, 8);
|
|
345
|
+
// When no human-authored title is available, suffix the uuid8 so visually
|
|
346
|
+
// identical fallback titles (e.g. many sessions started by the same /skill)
|
|
347
|
+
// still produce distinct rows.
|
|
348
|
+
const displayName = hasRealTitle
|
|
349
|
+
? (parsed.customTitle ?? parsed.aiTitle)!.slice(0, 60)
|
|
350
|
+
: fallbackText
|
|
351
|
+
? `${fallbackText} · ${uuid8}`
|
|
352
|
+
: uuid8;
|
|
353
|
+
return {
|
|
354
|
+
sessionId: jsonl.sessionId,
|
|
355
|
+
jsonlPath: jsonl.path,
|
|
356
|
+
cwd,
|
|
357
|
+
cwdShort: cwdShort(cwd),
|
|
358
|
+
status: classifyStatus(now, jsonl.mtimeMs, live),
|
|
359
|
+
lastActivityMs: jsonl.mtimeMs,
|
|
360
|
+
customTitle: parsed.customTitle,
|
|
361
|
+
aiTitle: parsed.aiTitle,
|
|
362
|
+
displayName,
|
|
363
|
+
messageCount: parsed.messageCount,
|
|
364
|
+
toolCount: parsed.toolCount,
|
|
365
|
+
lastUser: parsed.lastUser,
|
|
366
|
+
lastAssistant: parsed.lastAssistant,
|
|
367
|
+
gitBranch: parsed.gitBranch,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const STATUS_RANK: Record<AgentStatus, number> = {
|
|
372
|
+
"live-busy": 0,
|
|
373
|
+
"live-idle": 1,
|
|
374
|
+
"stale-pid": 2,
|
|
375
|
+
"dormant": 3,
|
|
376
|
+
"archived": 4,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
380
|
+
return arr.slice().sort((a, b) => {
|
|
381
|
+
const r = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
382
|
+
if (r !== 0) return r;
|
|
383
|
+
return b.lastActivityMs - a.lastActivityMs;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Reactive store ─────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
export interface AgentsStore {
|
|
390
|
+
sessions: () => AgentSession[];
|
|
391
|
+
refresh: () => void;
|
|
392
|
+
dispose: () => Promise<void>;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Reactive store of local Claude Code sessions. Watches the .claude
|
|
397
|
+
* projects + sessions directories and refreshes on any change with a
|
|
398
|
+
* short debounce. Initial scan is eager.
|
|
399
|
+
*/
|
|
400
|
+
export function createAgentsStore(): AgentsStore {
|
|
401
|
+
const [sessions, setSessions] = createSignal<AgentSession[]>([]);
|
|
402
|
+
|
|
403
|
+
function refresh(): void {
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
const live = discoverLivePids();
|
|
406
|
+
const jsonlFiles = discoverJsonlFiles();
|
|
407
|
+
const built = jsonlFiles.map((j) =>
|
|
408
|
+
buildSession(j, live.get(j.sessionId), now),
|
|
409
|
+
);
|
|
410
|
+
setSessions(sortSessions(built));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
refresh();
|
|
414
|
+
|
|
415
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
416
|
+
const onChange = () => {
|
|
417
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
418
|
+
debounceTimer = setTimeout(refresh, 200);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const watcher = chokidar.watch([PROJECTS_DIR, SESSIONS_DIR], {
|
|
422
|
+
ignoreInitial: true,
|
|
423
|
+
depth: 3,
|
|
424
|
+
});
|
|
425
|
+
watcher.on("add", onChange);
|
|
426
|
+
watcher.on("change", onChange);
|
|
427
|
+
watcher.on("unlink", onChange);
|
|
428
|
+
|
|
429
|
+
async function dispose() {
|
|
430
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
431
|
+
await watcher.close();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return { sessions, refresh, dispose };
|
|
435
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { Config } from "~/config/loader";
|
|
4
|
+
import { createTuiStore } from "./index";
|
|
5
|
+
|
|
6
|
+
describe("test runner smoke", () => {
|
|
7
|
+
it("can run a trivial assertion", () => {
|
|
8
|
+
expect(1 + 1).toBe(2);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/** Builds a minimal config with no boards — enough to exercise pure UI actions. */
|
|
13
|
+
function emptyConfig(): Config {
|
|
14
|
+
return {
|
|
15
|
+
root: process.cwd(),
|
|
16
|
+
loaded: false,
|
|
17
|
+
boards: [],
|
|
18
|
+
assignees: [],
|
|
19
|
+
doneColumn: "Done",
|
|
20
|
+
archiveColumn: "Archive",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("UI activeZone", () => {
|
|
25
|
+
it("defaults to 'board' on a fresh store", () => {
|
|
26
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
27
|
+
expect(store.state.ui.activeZone).toBe("board");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("setActiveZone updates the zone", () => {
|
|
31
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
32
|
+
store.setActiveZone("virtual");
|
|
33
|
+
expect(store.state.ui.activeZone).toBe("virtual");
|
|
34
|
+
store.setActiveZone("timeline");
|
|
35
|
+
expect(store.state.ui.activeZone).toBe("timeline");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("setActiveZone('virtual') resets row to 0", () => {
|
|
39
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
40
|
+
store.setCursor(0, 7);
|
|
41
|
+
store.setActiveZone("virtual");
|
|
42
|
+
expect(store.state.ui.row).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("UI visibleZones", () => {
|
|
47
|
+
it("defaults to all four zones visible", () => {
|
|
48
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
49
|
+
expect(store.state.ui.visibleZones).toEqual({
|
|
50
|
+
virtual: true,
|
|
51
|
+
board: true,
|
|
52
|
+
timeline: true,
|
|
53
|
+
agents: true,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("setZoneVisible flips one zone without touching others", () => {
|
|
58
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
59
|
+
store.setZoneVisible("timeline", false);
|
|
60
|
+
expect(store.state.ui.visibleZones.timeline).toBe(false);
|
|
61
|
+
expect(store.state.ui.visibleZones.virtual).toBe(true);
|
|
62
|
+
expect(store.state.ui.visibleZones.board).toBe(true);
|
|
63
|
+
expect(store.state.ui.visibleZones.agents).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("setZoneVisible('board', false) is ignored — board is load-bearing", () => {
|
|
67
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
68
|
+
store.setZoneVisible("board", false);
|
|
69
|
+
expect(store.state.ui.visibleZones.board).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("hiding the active zone moves activeZone to 'board'", () => {
|
|
73
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
74
|
+
store.setActiveZone("timeline");
|
|
75
|
+
store.setZoneVisible("timeline", false);
|
|
76
|
+
expect(store.state.ui.activeZone).toBe("board");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("UI cycleActiveZone", () => {
|
|
81
|
+
it("cycles through all visible zones in fixed order", () => {
|
|
82
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
83
|
+
store.setActiveZone("virtual");
|
|
84
|
+
store.cycleActiveZone();
|
|
85
|
+
expect(store.state.ui.activeZone).toBe("board");
|
|
86
|
+
store.cycleActiveZone();
|
|
87
|
+
expect(store.state.ui.activeZone).toBe("timeline");
|
|
88
|
+
store.cycleActiveZone();
|
|
89
|
+
expect(store.state.ui.activeZone).toBe("agents");
|
|
90
|
+
store.cycleActiveZone();
|
|
91
|
+
expect(store.state.ui.activeZone).toBe("virtual"); // wrap
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("skips hidden zones", () => {
|
|
95
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
96
|
+
store.setZoneVisible("timeline", false);
|
|
97
|
+
store.setActiveZone("board");
|
|
98
|
+
store.cycleActiveZone();
|
|
99
|
+
expect(store.state.ui.activeZone).toBe("agents"); // timeline skipped
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("is a no-op when only one zone is visible (board only)", () => {
|
|
103
|
+
const store = createTuiStore({ config: emptyConfig() });
|
|
104
|
+
store.setZoneVisible("virtual", false);
|
|
105
|
+
store.setZoneVisible("timeline", false);
|
|
106
|
+
store.setZoneVisible("agents", false);
|
|
107
|
+
store.cycleActiveZone();
|
|
108
|
+
expect(store.state.ui.activeZone).toBe("board");
|
|
109
|
+
});
|
|
110
|
+
});
|