@bastani/atomic 0.5.5-0 → 0.5.6-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.
Files changed (37) hide show
  1. package/README.md +60 -34
  2. package/dist/sdk/components/compact-switcher.d.ts +10 -0
  3. package/dist/sdk/components/compact-switcher.d.ts.map +1 -0
  4. package/dist/sdk/components/orchestrator-panel-store.d.ts +21 -1
  5. package/dist/sdk/components/orchestrator-panel-store.d.ts.map +1 -1
  6. package/dist/sdk/components/orchestrator-panel-types.d.ts +1 -0
  7. package/dist/sdk/components/orchestrator-panel-types.d.ts.map +1 -1
  8. package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
  9. package/dist/sdk/components/statusline.d.ts.map +1 -1
  10. package/dist/sdk/runtime/executor.d.ts +3 -2
  11. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  12. package/dist/sdk/runtime/tmux.d.ts +82 -2
  13. package/dist/sdk/runtime/tmux.d.ts.map +1 -1
  14. package/dist/sdk/workflows/index.d.ts +2 -2
  15. package/dist/sdk/workflows/index.d.ts.map +1 -1
  16. package/package.json +4 -2
  17. package/src/cli.ts +150 -27
  18. package/src/commands/cli/chat/index.ts +25 -14
  19. package/src/commands/cli/completions.ts +24 -0
  20. package/src/commands/cli/session.test.ts +491 -0
  21. package/src/commands/cli/session.ts +265 -0
  22. package/src/commands/cli/workflow.ts +1 -1
  23. package/src/completions/bash.ts +107 -0
  24. package/src/completions/fish.ts +126 -0
  25. package/src/completions/index.ts +7 -0
  26. package/src/completions/powershell.ts +184 -0
  27. package/src/completions/zsh.ts +144 -0
  28. package/src/sdk/components/compact-switcher.tsx +73 -0
  29. package/src/sdk/components/orchestrator-panel-store.test.ts +124 -0
  30. package/src/sdk/components/orchestrator-panel-store.ts +36 -1
  31. package/src/sdk/components/orchestrator-panel-types.ts +2 -0
  32. package/src/sdk/components/session-graph-panel.tsx +138 -10
  33. package/src/sdk/components/statusline.tsx +13 -8
  34. package/src/sdk/runtime/executor.ts +18 -27
  35. package/src/sdk/runtime/tmux.conf +18 -0
  36. package/src/sdk/runtime/tmux.ts +198 -24
  37. package/src/sdk/workflows/index.ts +7 -1
@@ -0,0 +1,491 @@
1
+ import { describe, test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
2
+ import {
3
+ renderSessionList,
4
+ filterByAgent,
5
+ filterByScope,
6
+ sessionListCommand,
7
+ sessionConnectCommand,
8
+ sessionPickerCommand,
9
+ } from "./session.ts";
10
+ import type { SessionDeps } from "./session.ts";
11
+ import type { TmuxSession } from "../../sdk/runtime/tmux.ts";
12
+
13
+ // Force plain-text output so assertions match readable substrings.
14
+ let originalNoColor: string | undefined;
15
+ beforeAll(() => {
16
+ originalNoColor = process.env.NO_COLOR;
17
+ process.env.NO_COLOR = "1";
18
+ });
19
+ afterAll(() => {
20
+ if (originalNoColor === undefined) delete process.env.NO_COLOR;
21
+ else process.env.NO_COLOR = originalNoColor;
22
+ });
23
+
24
+ // ─── renderSessionList ─────────────────────────────────────────────────────
25
+
26
+ describe("renderSessionList", () => {
27
+ test("empty state teaches user how to start a session", () => {
28
+ const output = renderSessionList([]);
29
+ expect(output).toContain("no sessions running");
30
+ expect(output).toContain("atomic chat -a <agent>");
31
+ expect(output).toContain("atomic workflow -n <name> -a <agent>");
32
+ });
33
+
34
+ test("renders a single session with name and status", () => {
35
+ const sessions: TmuxSession[] = [
36
+ {
37
+ name: "atomic-chat-claude-abc12345",
38
+ windows: 1,
39
+ created: new Date().toISOString(),
40
+ attached: false,
41
+ type: "chat",
42
+ agent: "claude",
43
+ },
44
+ ];
45
+ const output = renderSessionList(sessions);
46
+ expect(output).toContain("1 session");
47
+ expect(output).toContain("atomic-chat-claude-abc12345");
48
+ expect(output).toContain("○"); // unattached indicator
49
+ });
50
+
51
+ test("renders agent badge when agent field is present", () => {
52
+ const sessions: TmuxSession[] = [
53
+ {
54
+ name: "atomic-chat-claude-abc12345",
55
+ windows: 1,
56
+ created: new Date().toISOString(),
57
+ attached: false,
58
+ type: "chat",
59
+ agent: "claude",
60
+ },
61
+ ];
62
+ const output = renderSessionList(sessions);
63
+ expect(output).toContain("[claude]");
64
+ });
65
+
66
+ test("omits agent badge when agent field is undefined", () => {
67
+ const sessions: TmuxSession[] = [
68
+ {
69
+ name: "atomic-chat-abc12345",
70
+ windows: 1,
71
+ created: new Date().toISOString(),
72
+ attached: false,
73
+ },
74
+ ];
75
+ const output = renderSessionList(sessions);
76
+ expect(output).not.toMatch(/\[.*\]/);
77
+ });
78
+
79
+ test("renders attached sessions with the filled indicator", () => {
80
+ const sessions: TmuxSession[] = [
81
+ {
82
+ name: "my-session",
83
+ windows: 2,
84
+ created: new Date().toISOString(),
85
+ attached: true,
86
+ },
87
+ ];
88
+ const output = renderSessionList(sessions);
89
+ expect(output).toContain("●"); // attached indicator
90
+ expect(output).toContain("attached");
91
+ });
92
+
93
+ test("pluralises 'sessions' for multiple entries", () => {
94
+ const sessions: TmuxSession[] = [
95
+ { name: "a", windows: 1, created: new Date().toISOString(), attached: false },
96
+ { name: "b", windows: 1, created: new Date().toISOString(), attached: false },
97
+ ];
98
+ const output = renderSessionList(sessions);
99
+ expect(output).toContain("2 sessions");
100
+ });
101
+
102
+ test("shows relative age for recent sessions", () => {
103
+ const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
104
+ const sessions: TmuxSession[] = [
105
+ { name: "recent", windows: 1, created: fiveMinAgo, attached: false },
106
+ ];
107
+ const output = renderSessionList(sessions);
108
+ expect(output).toContain("5m ago");
109
+ });
110
+
111
+ test("shows connect hint in footer", () => {
112
+ const sessions: TmuxSession[] = [
113
+ { name: "s", windows: 1, created: new Date().toISOString(), attached: false },
114
+ ];
115
+ const output = renderSessionList(sessions);
116
+ expect(output).toContain("atomic session connect");
117
+ });
118
+ });
119
+
120
+ // ─── filterByScope ────────────────────────────────────────────────────────
121
+
122
+ describe("filterByScope", () => {
123
+ const now = new Date().toISOString();
124
+ const sessions: TmuxSession[] = [
125
+ { name: "atomic-chat-claude-aaa11111", windows: 1, created: now, attached: false, type: "chat", agent: "claude" },
126
+ { name: "atomic-chat-copilot-bbb22222", windows: 1, created: now, attached: false, type: "chat", agent: "copilot" },
127
+ { name: "atomic-wf-claude-ralph-ccc33333", windows: 3, created: now, attached: false, type: "workflow", agent: "claude" },
128
+ { name: "atomic-wf-opencode-gen-spec-ddd44444", windows: 2, created: now, attached: false, type: "workflow", agent: "opencode" },
129
+ { name: "unrelated-session", windows: 1, created: now, attached: false }, // no type
130
+ ];
131
+
132
+ test("returns all sessions when scope is 'all'", () => {
133
+ expect(filterByScope(sessions, "all")).toEqual(sessions);
134
+ });
135
+
136
+ test("filters to chat sessions only", () => {
137
+ const result = filterByScope(sessions, "chat");
138
+ expect(result).toHaveLength(2);
139
+ expect(result.every((s) => s.type === "chat")).toBe(true);
140
+ });
141
+
142
+ test("filters to workflow sessions only", () => {
143
+ const result = filterByScope(sessions, "workflow");
144
+ expect(result).toHaveLength(2);
145
+ expect(result.every((s) => s.type === "workflow")).toBe(true);
146
+ });
147
+
148
+ test("excludes sessions with no type when scope is chat", () => {
149
+ const result = filterByScope(sessions, "chat");
150
+ expect(result.find((s) => s.name === "unrelated-session")).toBeUndefined();
151
+ });
152
+
153
+ test("excludes sessions with no type when scope is workflow", () => {
154
+ const result = filterByScope(sessions, "workflow");
155
+ expect(result.find((s) => s.name === "unrelated-session")).toBeUndefined();
156
+ });
157
+ });
158
+
159
+ // ─── filterByAgent ────────────────────────────────────────────────────────
160
+
161
+ describe("filterByAgent", () => {
162
+ const now = new Date().toISOString();
163
+ const sessions: TmuxSession[] = [
164
+ { name: "atomic-chat-claude-aaa11111", windows: 1, created: now, attached: false, type: "chat", agent: "claude" },
165
+ { name: "atomic-chat-copilot-bbb22222", windows: 1, created: now, attached: false, type: "chat", agent: "copilot" },
166
+ { name: "atomic-wf-opencode-ralph-ccc33333", windows: 1, created: now, attached: false, type: "workflow", agent: "opencode" },
167
+ { name: "unrelated-session", windows: 1, created: now, attached: false }, // no agent
168
+ ];
169
+
170
+ test("returns all sessions when agents array is empty", () => {
171
+ expect(filterByAgent(sessions, [])).toEqual(sessions);
172
+ });
173
+
174
+ test("filters to a single agent", () => {
175
+ const result = filterByAgent(sessions, ["claude"]);
176
+ expect(result).toHaveLength(1);
177
+ expect(result[0]!.agent).toBe("claude");
178
+ });
179
+
180
+ test("filters to multiple agents", () => {
181
+ const result = filterByAgent(sessions, ["copilot", "opencode"]);
182
+ expect(result).toHaveLength(2);
183
+ expect(result.map((s) => s.agent)).toEqual(["copilot", "opencode"]);
184
+ });
185
+
186
+ test("matching is case-insensitive", () => {
187
+ const result = filterByAgent(sessions, ["CLAUDE"]);
188
+ expect(result).toHaveLength(1);
189
+ expect(result[0]!.agent).toBe("claude");
190
+ });
191
+
192
+ test("excludes sessions with no agent field", () => {
193
+ const result = filterByAgent(sessions, ["claude", "copilot", "opencode"]);
194
+ expect(result).toHaveLength(3);
195
+ expect(result.every((s) => s.agent !== undefined)).toBe(true);
196
+ });
197
+
198
+ test("returns empty array when no agents match", () => {
199
+ expect(filterByAgent(sessions, ["nonexistent"])).toEqual([]);
200
+ });
201
+ });
202
+
203
+ // ─── renderSessionList — formatAge branches ─────────────────────────────
204
+
205
+ describe("renderSessionList — formatAge edge cases", () => {
206
+ test("shows hours-ago for sessions older than 60 minutes", () => {
207
+ const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
208
+ const sessions: TmuxSession[] = [
209
+ { name: "old-session", windows: 1, created: threeHoursAgo, attached: false },
210
+ ];
211
+ const output = renderSessionList(sessions);
212
+ expect(output).toContain("3h ago");
213
+ });
214
+
215
+ test("shows days-ago for sessions older than 24 hours", () => {
216
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
217
+ const sessions: TmuxSession[] = [
218
+ { name: "ancient-session", windows: 1, created: twoDaysAgo, attached: false },
219
+ ];
220
+ const output = renderSessionList(sessions);
221
+ expect(output).toContain("2d ago");
222
+ });
223
+
224
+ test("shows raw string for unparseable dates", () => {
225
+ const sessions: TmuxSession[] = [
226
+ { name: "bad-date", windows: 1, created: "not-a-date", attached: false },
227
+ ];
228
+ const output = renderSessionList(sessions);
229
+ expect(output).toContain("not-a-date");
230
+ });
231
+
232
+ test("shows 'just now' for future timestamps", () => {
233
+ const future = new Date(Date.now() + 60_000).toISOString();
234
+ const sessions: TmuxSession[] = [
235
+ { name: "future-session", windows: 1, created: future, attached: false },
236
+ ];
237
+ const output = renderSessionList(sessions);
238
+ expect(output).toContain("just now");
239
+ });
240
+ });
241
+
242
+ // ─── filterByScope + filterByAgent combined ───────────────────────────────
243
+
244
+ describe("filterByScope + filterByAgent combined", () => {
245
+ const now = new Date().toISOString();
246
+ const sessions: TmuxSession[] = [
247
+ { name: "atomic-chat-claude-aaa11111", windows: 1, created: now, attached: false, type: "chat", agent: "claude" },
248
+ { name: "atomic-chat-copilot-bbb22222", windows: 1, created: now, attached: false, type: "chat", agent: "copilot" },
249
+ { name: "atomic-wf-claude-ralph-ccc33333", windows: 3, created: now, attached: false, type: "workflow", agent: "claude" },
250
+ { name: "atomic-wf-opencode-gen-spec-ddd44444", windows: 2, created: now, attached: false, type: "workflow", agent: "opencode" },
251
+ ];
252
+
253
+ test("scope=chat + agent=claude returns only claude chat sessions", () => {
254
+ const result = filterByAgent(filterByScope(sessions, "chat"), ["claude"]);
255
+ expect(result).toHaveLength(1);
256
+ expect(result[0]!.name).toBe("atomic-chat-claude-aaa11111");
257
+ });
258
+
259
+ test("scope=workflow + agent=claude returns only claude workflow sessions", () => {
260
+ const result = filterByAgent(filterByScope(sessions, "workflow"), ["claude"]);
261
+ expect(result).toHaveLength(1);
262
+ expect(result[0]!.name).toBe("atomic-wf-claude-ralph-ccc33333");
263
+ });
264
+
265
+ test("scope=all + agent=claude returns both chat and workflow claude sessions", () => {
266
+ const result = filterByAgent(filterByScope(sessions, "all"), ["claude"]);
267
+ expect(result).toHaveLength(2);
268
+ });
269
+ });
270
+
271
+ // ─── Command functions (dependency-injected mocks) ──────────────────────────
272
+ //
273
+ // Instead of mock.module (which leaks across test files in Bun — see
274
+ // https://github.com/oven-sh/bun/issues/12823), each command function
275
+ // receives its tmux/prompt dependencies via a `SessionDeps` parameter.
276
+ // This keeps the mocks scoped to these tests without polluting the
277
+ // module registry for other test files that import from tmux.ts.
278
+
279
+ const tmuxMocks = {
280
+ isTmuxInstalled: mock<() => boolean>(() => true),
281
+ sessionExists: mock<(name: string) => boolean>(() => true),
282
+ listSessions: mock<() => TmuxSession[]>(() => []),
283
+ isInsideAtomicSocket: mock<() => boolean>(() => false),
284
+ isInsideTmux: mock<() => boolean>(() => false),
285
+ switchClient: mock<(name: string) => void>(() => {}),
286
+ detachAndAttachAtomic: mock<(name: string) => void>(() => {}),
287
+ spawnMuxAttach: mock(() => ({ exited: Promise.resolve(0) }) as never),
288
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
+ select: mock<(...args: any[]) => Promise<string | symbol>>(() => Promise.resolve("my-session")),
290
+ isCancel: ((v: unknown) => typeof v === "symbol") as SessionDeps["isCancel"],
291
+ };
292
+
293
+ /** Build a deps object from the current mock state. */
294
+ function makeDeps(): SessionDeps {
295
+ return tmuxMocks as unknown as SessionDeps;
296
+ }
297
+
298
+ function resetTmuxMocks(): void {
299
+ tmuxMocks.isTmuxInstalled.mockReset().mockReturnValue(true);
300
+ tmuxMocks.sessionExists.mockReset().mockReturnValue(true);
301
+ tmuxMocks.listSessions.mockReset().mockReturnValue([]);
302
+ tmuxMocks.isInsideAtomicSocket.mockReset().mockReturnValue(false);
303
+ tmuxMocks.isInsideTmux.mockReset().mockReturnValue(false);
304
+ tmuxMocks.switchClient.mockReset();
305
+ tmuxMocks.detachAndAttachAtomic.mockReset();
306
+ tmuxMocks.spawnMuxAttach.mockReset().mockReturnValue({ exited: Promise.resolve(0) } as never);
307
+ tmuxMocks.select.mockReset().mockResolvedValue("my-session");
308
+ }
309
+
310
+ // ─── sessionListCommand ─────────────────────────────────────────────────
311
+
312
+ describe("sessionListCommand", () => {
313
+ beforeEach(resetTmuxMocks);
314
+
315
+ test("returns 0 and prints 'no sessions' when tmux is not installed", async () => {
316
+ tmuxMocks.isTmuxInstalled.mockReturnValue(false);
317
+ const chunks: string[] = [];
318
+ const origWrite = process.stdout.write;
319
+ process.stdout.write = ((c: string) => { chunks.push(c); return true; }) as typeof process.stdout.write;
320
+ try {
321
+ const code = await sessionListCommand([], "all", makeDeps());
322
+ expect(code).toBe(0);
323
+ const output = chunks.join("");
324
+ expect(output).toContain("no sessions running");
325
+ expect(output).toContain("tmux is not installed");
326
+ } finally {
327
+ process.stdout.write = origWrite;
328
+ }
329
+ });
330
+
331
+ test("returns 0 and prints session list when tmux is installed", async () => {
332
+ const now = new Date().toISOString();
333
+ tmuxMocks.listSessions.mockReturnValue([
334
+ { name: "atomic-chat-claude-aaa11111", windows: 1, created: now, attached: false, type: "chat" as const, agent: "claude" },
335
+ ]);
336
+ const chunks: string[] = [];
337
+ const origWrite = process.stdout.write;
338
+ process.stdout.write = ((c: string) => { chunks.push(c); return true; }) as typeof process.stdout.write;
339
+ try {
340
+ const code = await sessionListCommand([], "all", makeDeps());
341
+ expect(code).toBe(0);
342
+ const output = chunks.join("");
343
+ expect(output).toContain("1 session");
344
+ expect(output).toContain("atomic-chat-claude-aaa11111");
345
+ } finally {
346
+ process.stdout.write = origWrite;
347
+ }
348
+ });
349
+
350
+ test("filters by scope and agent", async () => {
351
+ const now = new Date().toISOString();
352
+ tmuxMocks.listSessions.mockReturnValue([
353
+ { name: "chat-1", windows: 1, created: now, attached: false, type: "chat" as const, agent: "claude" },
354
+ { name: "wf-1", windows: 1, created: now, attached: false, type: "workflow" as const, agent: "opencode" },
355
+ ]);
356
+ const chunks: string[] = [];
357
+ const origWrite = process.stdout.write;
358
+ process.stdout.write = ((c: string) => { chunks.push(c); return true; }) as typeof process.stdout.write;
359
+ try {
360
+ const code = await sessionListCommand(["claude"], "chat", makeDeps());
361
+ expect(code).toBe(0);
362
+ const output = chunks.join("");
363
+ expect(output).toContain("chat-1");
364
+ expect(output).not.toContain("wf-1");
365
+ } finally {
366
+ process.stdout.write = origWrite;
367
+ }
368
+ });
369
+ });
370
+
371
+ // ─── sessionConnectCommand ──────────────────────────────────────────────
372
+
373
+ describe("sessionConnectCommand", () => {
374
+ beforeEach(resetTmuxMocks);
375
+
376
+ test("returns 1 when tmux is not installed", async () => {
377
+ tmuxMocks.isTmuxInstalled.mockReturnValue(false);
378
+ const origWrite = process.stderr.write;
379
+ process.stderr.write = (() => true) as typeof process.stderr.write;
380
+ try {
381
+ const code = await sessionConnectCommand("my-session", makeDeps());
382
+ expect(code).toBe(1);
383
+ } finally {
384
+ process.stderr.write = origWrite;
385
+ }
386
+ });
387
+
388
+ test("returns 1 when session does not exist", async () => {
389
+ tmuxMocks.sessionExists.mockReturnValue(false);
390
+ const origWrite = process.stderr.write;
391
+ process.stderr.write = (() => true) as typeof process.stderr.write;
392
+ try {
393
+ const code = await sessionConnectCommand("missing", makeDeps());
394
+ expect(code).toBe(1);
395
+ } finally {
396
+ process.stderr.write = origWrite;
397
+ }
398
+ });
399
+
400
+ test("lists available sessions when target not found", async () => {
401
+ tmuxMocks.sessionExists.mockReturnValue(false);
402
+ const now = new Date().toISOString();
403
+ tmuxMocks.listSessions.mockReturnValue([
404
+ { name: "existing", windows: 1, created: now, attached: false },
405
+ ]);
406
+ const chunks: string[] = [];
407
+ const origWrite = process.stderr.write;
408
+ process.stderr.write = ((c: string) => { chunks.push(c); return true; }) as typeof process.stderr.write;
409
+ try {
410
+ await sessionConnectCommand("missing", makeDeps());
411
+ const output = chunks.join("");
412
+ expect(output).toContain("existing");
413
+ } finally {
414
+ process.stderr.write = origWrite;
415
+ }
416
+ });
417
+
418
+ test("uses switch-client when inside atomic socket", async () => {
419
+ tmuxMocks.isInsideAtomicSocket.mockReturnValue(true);
420
+ const code = await sessionConnectCommand("my-session", makeDeps());
421
+ expect(code).toBe(0);
422
+ expect(tmuxMocks.switchClient).toHaveBeenCalledWith("my-session");
423
+ });
424
+
425
+ test("uses detach-and-attach when inside non-atomic tmux", async () => {
426
+ tmuxMocks.isInsideTmux.mockReturnValue(true);
427
+ const code = await sessionConnectCommand("my-session", makeDeps());
428
+ expect(code).toBe(0);
429
+ expect(tmuxMocks.detachAndAttachAtomic).toHaveBeenCalledWith("my-session");
430
+ });
431
+
432
+ test("spawns attach when outside tmux", async () => {
433
+ const code = await sessionConnectCommand("my-session", makeDeps());
434
+ expect(code).toBe(0);
435
+ expect(tmuxMocks.spawnMuxAttach).toHaveBeenCalledWith("my-session");
436
+ });
437
+ });
438
+
439
+ // ─── sessionPickerCommand ──────────────────────────────────────────────
440
+
441
+ describe("sessionPickerCommand", () => {
442
+ beforeEach(resetTmuxMocks);
443
+
444
+ test("returns 1 when tmux is not installed", async () => {
445
+ tmuxMocks.isTmuxInstalled.mockReturnValue(false);
446
+ const origWrite = process.stderr.write;
447
+ process.stderr.write = (() => true) as typeof process.stderr.write;
448
+ try {
449
+ const code = await sessionPickerCommand([], "all", makeDeps());
450
+ expect(code).toBe(1);
451
+ } finally {
452
+ process.stderr.write = origWrite;
453
+ }
454
+ });
455
+
456
+ test("prints empty state and returns 0 when no sessions exist", async () => {
457
+ const chunks: string[] = [];
458
+ const origWrite = process.stdout.write;
459
+ process.stdout.write = ((c: string) => { chunks.push(c); return true; }) as typeof process.stdout.write;
460
+ try {
461
+ const code = await sessionPickerCommand([], "all", makeDeps());
462
+ expect(code).toBe(0);
463
+ const output = chunks.join("");
464
+ expect(output).toContain("no sessions running");
465
+ } finally {
466
+ process.stdout.write = origWrite;
467
+ }
468
+ });
469
+
470
+ test("shows picker and connects to selected session", async () => {
471
+ const now = new Date().toISOString();
472
+ tmuxMocks.listSessions.mockReturnValue([
473
+ { name: "my-session", windows: 1, created: now, attached: false },
474
+ ]);
475
+ tmuxMocks.select.mockResolvedValue("my-session");
476
+ const code = await sessionPickerCommand([], "all", makeDeps());
477
+ expect(code).toBe(0);
478
+ expect(tmuxMocks.spawnMuxAttach).toHaveBeenCalledWith("my-session");
479
+ });
480
+
481
+ test("returns 0 when user cancels picker", async () => {
482
+ const now = new Date().toISOString();
483
+ tmuxMocks.listSessions.mockReturnValue([
484
+ { name: "a-session", windows: 1, created: now, attached: false },
485
+ ]);
486
+ tmuxMocks.select.mockResolvedValue(Symbol("cancel"));
487
+ const code = await sessionPickerCommand([], "all", makeDeps());
488
+ expect(code).toBe(0);
489
+ expect(tmuxMocks.spawnMuxAttach).not.toHaveBeenCalled();
490
+ });
491
+ });