bertrand 0.14.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.
@@ -0,0 +1,1021 @@
1
+ // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+
17
+ // src/tui/run-screen.tsx
18
+ import { writeFileSync } from "fs";
19
+ import { render } from "@orchetron/storm";
20
+
21
+ // src/tui/screens/launch/index.tsx
22
+ import { useCallback as useCallback2 } from "react";
23
+ import { Box as Box5, useTui as useTui2 } from "@orchetron/storm";
24
+
25
+ // src/tui/components/app-details.tsx
26
+ import { Box, Text } from "@orchetron/storm";
27
+ import { jsxDEV } from "react/jsx-dev-runtime";
28
+ var AppDetails = () => /* @__PURE__ */ jsxDEV(Box, {
29
+ "data-slot": "app-details",
30
+ flexDirection: "column",
31
+ children: [
32
+ /* @__PURE__ */ jsxDEV(Text, {
33
+ dim: true,
34
+ children: "Multi-session workflow manager for Claude Code"
35
+ }, undefined, false, undefined, this),
36
+ /* @__PURE__ */ jsxDEV(Text, {
37
+ dim: true,
38
+ children: "by uiid.systems \xB7 https://github.com/uiid-systems/bertrand"
39
+ }, undefined, false, undefined, this)
40
+ ]
41
+ }, undefined, true, undefined, this);
42
+ // src/tui/components/ascii-logos.tsx
43
+ import { Box as Box2, Text as Text2 } from "@orchetron/storm";
44
+ import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime";
45
+ var greenGradient = [
46
+ "#87d787",
47
+ "#87d787",
48
+ "#5faf5f",
49
+ "#5faf87",
50
+ "#00af00",
51
+ "#008700",
52
+ "#005f00"
53
+ ];
54
+ var logoLines = [
55
+ ` .o8 . .o8 `,
56
+ `"888 .o8 "888 `,
57
+ ` 888oooo. .ooooo. oooo d8b .o888oo oooo d8b .oooo. ooo. .oo. .oooo888 `,
58
+ ' d88\' `88b d88\' `88b `888""8P 888 `888""8P `P )88b `888P"Y88b d88\' `888 ',
59
+ ` 888 888 888ooo888 888 888 888 .oP"888 888 888 888 888 `,
60
+ ` 888 888 888 .o 888 888 . 888 d8( 888 888 888 888 888 `,
61
+ ' `Y8bod8P\' `Y8bod8P\' d888b "888" d888b `Y888""8o o888o o888o `Y8bod88P"'
62
+ ];
63
+ function Logo() {
64
+ return /* @__PURE__ */ jsxDEV2(Box2, {
65
+ flexDirection: "column",
66
+ children: logoLines.map((line, i) => /* @__PURE__ */ jsxDEV2(Text2, {
67
+ color: greenGradient[i],
68
+ children: line
69
+ }, i, false, undefined, this))
70
+ }, undefined, false, undefined, this);
71
+ }
72
+ // src/tui/components/picker.tsx
73
+ import { useMemo, useState } from "react";
74
+ import { Box as Box3, Text as Text3, TextInput, useInput } from "@orchetron/storm";
75
+ import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime";
76
+ var NEW_KEY = "__new__";
77
+ function Picker(props) {
78
+ const {
79
+ items,
80
+ isFocused,
81
+ placeholder,
82
+ allowCreate = true,
83
+ emptyHint,
84
+ maxVisible = 8
85
+ } = props;
86
+ const [filter, setFilter] = useState("");
87
+ const [cursor, setCursor] = useState(0);
88
+ const filtered = useMemo(() => {
89
+ const q = filter.trim().toLowerCase();
90
+ if (!q)
91
+ return items;
92
+ return items.filter((it) => it.label.toLowerCase().includes(q));
93
+ }, [filter, items]);
94
+ const exactMatch = useMemo(() => filtered.some((it) => it.label.toLowerCase() === filter.trim().toLowerCase()), [filter, filtered]);
95
+ const showCreate = allowCreate && filter.trim().length > 0 && !exactMatch;
96
+ const visibleRows = useMemo(() => showCreate ? [...filtered, { value: NEW_KEY }] : filtered, [filtered, showCreate]);
97
+ if (cursor >= visibleRows.length) {
98
+ setTimeout(() => setCursor(Math.max(0, visibleRows.length - 1)), 0);
99
+ }
100
+ useInput((e) => {
101
+ if (!isFocused)
102
+ return;
103
+ if (e.key === "escape" && filter.length > 0) {
104
+ setFilter("");
105
+ setCursor(0);
106
+ return;
107
+ }
108
+ if (visibleRows.length === 0)
109
+ return;
110
+ if (e.key === "up") {
111
+ setCursor((c) => Math.max(0, c - 1));
112
+ } else if (e.key === "down") {
113
+ setCursor((c) => Math.min(visibleRows.length - 1, c + 1));
114
+ } else if (e.key === "tab" && props.mode === "multi") {
115
+ props.onDone();
116
+ }
117
+ }, { isActive: isFocused });
118
+ const submitCursor = () => {
119
+ const row = visibleRows[cursor];
120
+ const value = row && row.value === NEW_KEY ? filter.trim() : row?.value ?? "";
121
+ if (!value)
122
+ return;
123
+ if (props.mode === "single") {
124
+ props.onSubmit(value);
125
+ setFilter("");
126
+ setCursor(0);
127
+ } else {
128
+ props.onToggle(value);
129
+ setFilter("");
130
+ setCursor(0);
131
+ }
132
+ };
133
+ const handleSubmit = (text) => {
134
+ if (visibleRows.length === 0) {
135
+ if (allowCreate && text.trim() && props.mode === "single") {
136
+ props.onSubmit(text.trim());
137
+ setFilter("");
138
+ }
139
+ return;
140
+ }
141
+ submitCursor();
142
+ };
143
+ const visibleStart = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), visibleRows.length - maxVisible));
144
+ const visibleEnd = Math.min(visibleRows.length, visibleStart + maxVisible);
145
+ const slice = visibleRows.slice(visibleStart, visibleEnd);
146
+ const selectedSet = props.mode === "multi" ? new Set(props.selected) : new Set;
147
+ return /* @__PURE__ */ jsxDEV3(Box3, {
148
+ flexDirection: "column",
149
+ gap: 0,
150
+ width: "100%",
151
+ children: [
152
+ props.mode === "multi" && props.selected.length > 0 && /* @__PURE__ */ jsxDEV3(Box3, {
153
+ flexDirection: "row",
154
+ gap: 1,
155
+ children: [
156
+ /* @__PURE__ */ jsxDEV3(Text3, {
157
+ dim: true,
158
+ children: "Selected:"
159
+ }, undefined, false, undefined, this),
160
+ props.selected.map((v) => {
161
+ const item = items.find((i) => i.value === v);
162
+ return /* @__PURE__ */ jsxDEV3(Text3, {
163
+ color: item?.color ?? "cyan",
164
+ children: item?.label ?? v
165
+ }, v, false, undefined, this);
166
+ })
167
+ ]
168
+ }, undefined, true, undefined, this),
169
+ /* @__PURE__ */ jsxDEV3(Box3, {
170
+ borderStyle: "round",
171
+ borderColor: isFocused ? "green" : undefined,
172
+ borderDimColor: !isFocused,
173
+ paddingX: 1,
174
+ children: /* @__PURE__ */ jsxDEV3(TextInput, {
175
+ value: filter,
176
+ onChange: setFilter,
177
+ onSubmit: handleSubmit,
178
+ placeholder,
179
+ color: "green",
180
+ placeholderColor: "gray",
181
+ isFocused
182
+ }, undefined, false, undefined, this)
183
+ }, undefined, false, undefined, this),
184
+ /* @__PURE__ */ jsxDEV3(Box3, {
185
+ flexDirection: "column",
186
+ borderStyle: "round",
187
+ borderDimColor: true,
188
+ paddingX: 1,
189
+ children: [
190
+ visibleStart > 0 && /* @__PURE__ */ jsxDEV3(Text3, {
191
+ dim: true,
192
+ children: [
193
+ "\u25B2 ",
194
+ visibleStart,
195
+ " more above"
196
+ ]
197
+ }, undefined, true, undefined, this),
198
+ slice.length === 0 && emptyHint && /* @__PURE__ */ jsxDEV3(Text3, {
199
+ dim: true,
200
+ children: emptyHint
201
+ }, undefined, false, undefined, this),
202
+ slice.map((row, i) => {
203
+ const idx = visibleStart + i;
204
+ const isCursor = idx === cursor && isFocused;
205
+ const isNew = row.value === NEW_KEY;
206
+ const showDivider = isNew && filtered.length > 0 && i > 0;
207
+ if (isNew) {
208
+ return /* @__PURE__ */ jsxDEV3(Box3, {
209
+ flexDirection: "column",
210
+ children: [
211
+ showDivider && /* @__PURE__ */ jsxDEV3(Text3, {
212
+ dim: true,
213
+ children: "\u2500".repeat(Math.min(20, filter.length + 12))
214
+ }, undefined, false, undefined, this),
215
+ /* @__PURE__ */ jsxDEV3(Box3, {
216
+ flexDirection: "row",
217
+ gap: 1,
218
+ backgroundColor: isCursor ? "green" : undefined,
219
+ children: /* @__PURE__ */ jsxDEV3(Text3, {
220
+ color: isCursor ? "black" : "cyan",
221
+ bold: true,
222
+ children: [
223
+ "\u271A create \u201C",
224
+ filter.trim(),
225
+ "\u201D"
226
+ ]
227
+ }, undefined, true, undefined, this)
228
+ }, undefined, false, undefined, this)
229
+ ]
230
+ }, "__new__", true, undefined, this);
231
+ }
232
+ const item = row;
233
+ const isSelected = selectedSet.has(item.value);
234
+ const marker = props.mode === "multi" ? isSelected ? "\u2713 " : " " : "";
235
+ return /* @__PURE__ */ jsxDEV3(Box3, {
236
+ flexDirection: "row",
237
+ justifyContent: "space-between",
238
+ gap: 1,
239
+ backgroundColor: isCursor ? "green" : undefined,
240
+ children: [
241
+ /* @__PURE__ */ jsxDEV3(Text3, {
242
+ color: isCursor ? "black" : item.color ?? undefined,
243
+ bold: isCursor,
244
+ children: [
245
+ marker,
246
+ item.label
247
+ ]
248
+ }, undefined, true, undefined, this),
249
+ item.meta && /* @__PURE__ */ jsxDEV3(Box3, {
250
+ paddingX: 1,
251
+ backgroundColor: isCursor ? "black" : "#3a3a3a",
252
+ children: /* @__PURE__ */ jsxDEV3(Text3, {
253
+ color: isCursor ? "green" : "white",
254
+ bold: true,
255
+ children: item.meta
256
+ }, undefined, false, undefined, this)
257
+ }, undefined, false, undefined, this)
258
+ ]
259
+ }, item.value, true, undefined, this);
260
+ }),
261
+ visibleEnd < visibleRows.length && /* @__PURE__ */ jsxDEV3(Text3, {
262
+ dim: true,
263
+ children: [
264
+ "\u25BC ",
265
+ visibleRows.length - visibleEnd,
266
+ " more below"
267
+ ]
268
+ }, undefined, true, undefined, this)
269
+ ]
270
+ }, undefined, true, undefined, this)
271
+ ]
272
+ }, undefined, true, undefined, this);
273
+ }
274
+ // src/tui/components/status-dot.tsx
275
+ import { Text as Text4 } from "@orchetron/storm";
276
+ import { jsxDEV as jsxDEV4 } from "react/jsx-dev-runtime";
277
+ var STATUS_COLORS = {
278
+ active: "orange",
279
+ waiting: "red",
280
+ paused: "gold",
281
+ archived: "purple"
282
+ };
283
+ function StatusDot({ status }) {
284
+ const color = STATUS_COLORS[status] ?? "#6B7280";
285
+ return /* @__PURE__ */ jsxDEV4(Text4, {
286
+ color,
287
+ children: "\u25CF"
288
+ }, undefined, false, undefined, this);
289
+ }
290
+ // src/tui/screens/launch/create-screen.tsx
291
+ import { useState as useState2, useMemo as useMemo2, useCallback, useEffect, useRef } from "react";
292
+ import { Box as Box4, Text as Text5, TextInput as TextInput2, useInput as useInput2, useTui } from "@orchetron/storm";
293
+
294
+ // src/db/queries/groups.ts
295
+ import { eq, like, or, isNull } from "drizzle-orm";
296
+
297
+ // src/db/client.ts
298
+ import { Database } from "bun:sqlite";
299
+ import { drizzle } from "drizzle-orm/bun-sqlite";
300
+ import { mkdirSync } from "fs";
301
+ import { dirname } from "path";
302
+
303
+ // src/lib/paths.ts
304
+ import { homedir } from "os";
305
+ import { join } from "path";
306
+ var BERTRAND_DIR = ".bertrand";
307
+ var paths = {
308
+ root: join(homedir(), BERTRAND_DIR),
309
+ db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
310
+ hooks: join(homedir(), BERTRAND_DIR, "hooks"),
311
+ sessions: join(homedir(), BERTRAND_DIR, "sessions")
312
+ };
313
+
314
+ // src/db/schema.ts
315
+ var exports_schema = {};
316
+ __export(exports_schema, {
317
+ worktreeAssociations: () => worktreeAssociations,
318
+ sessions: () => sessions,
319
+ sessionStats: () => sessionStats,
320
+ sessionLabels: () => sessionLabels,
321
+ labels: () => labels,
322
+ groups: () => groups,
323
+ events: () => events,
324
+ conversations: () => conversations
325
+ });
326
+ import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
327
+ import { sql } from "drizzle-orm";
328
+ var groups = sqliteTable("groups", {
329
+ id: text("id").primaryKey(),
330
+ parentId: text("parent_id").references(() => groups.id, {
331
+ onDelete: "cascade"
332
+ }),
333
+ slug: text("slug").notNull(),
334
+ name: text("name").notNull(),
335
+ path: text("path").notNull(),
336
+ depth: integer("depth").notNull().default(0),
337
+ color: text("color"),
338
+ createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
339
+ }, (t) => [
340
+ uniqueIndex("groups_parent_slug").on(t.parentId, t.slug),
341
+ index("groups_path").on(t.path)
342
+ ]);
343
+ var labels = sqliteTable("labels", {
344
+ id: text("id").primaryKey(),
345
+ name: text("name").notNull().unique(),
346
+ color: text("color"),
347
+ createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
348
+ });
349
+ var sessions = sqliteTable("sessions", {
350
+ id: text("id").primaryKey(),
351
+ groupId: text("group_id").notNull().references(() => groups.id, { onDelete: "cascade" }),
352
+ slug: text("slug").notNull(),
353
+ name: text("name").notNull(),
354
+ status: text("status", {
355
+ enum: ["active", "waiting", "paused", "archived"]
356
+ }).notNull().default("paused"),
357
+ summary: text("summary"),
358
+ pid: integer("pid"),
359
+ startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
360
+ endedAt: text("ended_at"),
361
+ createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
362
+ updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
363
+ }, (t) => [
364
+ uniqueIndex("sessions_group_slug").on(t.groupId, t.slug),
365
+ index("sessions_status").on(t.status),
366
+ index("sessions_started").on(t.startedAt)
367
+ ]);
368
+ var sessionLabels = sqliteTable("session_labels", {
369
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
370
+ labelId: text("label_id").notNull().references(() => labels.id, { onDelete: "cascade" })
371
+ }, (t) => [
372
+ uniqueIndex("sl_pk").on(t.sessionId, t.labelId),
373
+ index("sl_label").on(t.labelId)
374
+ ]);
375
+ var conversations = sqliteTable("conversations", {
376
+ id: text("id").primaryKey(),
377
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
378
+ startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
379
+ endedAt: text("ended_at"),
380
+ discarded: integer("discarded", { mode: "boolean" }).notNull().default(false),
381
+ lastQuestion: text("last_question"),
382
+ eventCount: integer("event_count").notNull().default(0)
383
+ }, (t) => [index("conv_session").on(t.sessionId)]);
384
+ var events = sqliteTable("events", {
385
+ id: integer("id").primaryKey({ autoIncrement: true }),
386
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
387
+ conversationId: text("conversation_id").references(() => conversations.id),
388
+ event: text("event").notNull(),
389
+ summary: text("summary"),
390
+ meta: text("meta", { mode: "json" }),
391
+ createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
392
+ }, (t) => [
393
+ index("ev_session").on(t.sessionId),
394
+ index("ev_session_event").on(t.sessionId, t.event),
395
+ index("ev_event_created").on(t.event, t.createdAt),
396
+ index("ev_conversation").on(t.conversationId)
397
+ ]);
398
+ var worktreeAssociations = sqliteTable("worktree_associations", {
399
+ id: text("id").primaryKey(),
400
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
401
+ branch: text("branch").notNull(),
402
+ worktreePath: text("worktree_path"),
403
+ active: integer("active", { mode: "boolean" }).notNull().default(true),
404
+ enteredAt: text("entered_at").notNull().default(sql`(datetime('now'))`),
405
+ exitedAt: text("exited_at")
406
+ }, (t) => [
407
+ index("wt_session").on(t.sessionId),
408
+ index("wt_active").on(t.active)
409
+ ]);
410
+ var sessionStats = sqliteTable("session_stats", {
411
+ sessionId: text("session_id").primaryKey().references(() => sessions.id, { onDelete: "cascade" }),
412
+ eventCount: integer("event_count").notNull().default(0),
413
+ conversationCount: integer("conversation_count").notNull().default(0),
414
+ interactionCount: integer("interaction_count").notNull().default(0),
415
+ prCount: integer("pr_count").notNull().default(0),
416
+ claudeWorkS: integer("claude_work_s").notNull().default(0),
417
+ userWaitS: integer("user_wait_s").notNull().default(0),
418
+ activePct: integer("active_pct").notNull().default(0),
419
+ durationS: integer("duration_s").notNull().default(0),
420
+ linesAdded: integer("lines_added").notNull().default(0),
421
+ linesRemoved: integer("lines_removed").notNull().default(0),
422
+ filesTouched: integer("files_touched").notNull().default(0),
423
+ updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
424
+ });
425
+
426
+ // src/db/client.ts
427
+ var _db = null;
428
+ function getDb() {
429
+ if (_db)
430
+ return _db;
431
+ mkdirSync(dirname(paths.db), { recursive: true });
432
+ const sqlite = new Database(paths.db);
433
+ sqlite.exec("PRAGMA journal_mode = WAL");
434
+ sqlite.exec("PRAGMA foreign_keys = ON");
435
+ sqlite.exec("PRAGMA synchronous = NORMAL");
436
+ sqlite.exec("PRAGMA cache_size = -8000");
437
+ sqlite.exec("PRAGMA temp_store = MEMORY");
438
+ _db = drizzle(sqlite, { schema: exports_schema });
439
+ return _db;
440
+ }
441
+
442
+ // src/lib/id.ts
443
+ import { nanoid } from "nanoid";
444
+
445
+ // src/db/queries/groups.ts
446
+ function getAllGroups() {
447
+ return getDb().select().from(groups).all();
448
+ }
449
+
450
+ // src/db/queries/sessions.ts
451
+ import { eq as eq2, and, inArray, sql as sql2 } from "drizzle-orm";
452
+ function getSession(id) {
453
+ return getDb().select().from(sessions).where(eq2(sessions.id, id)).get();
454
+ }
455
+ function getAllSessions(opts) {
456
+ const db = getDb();
457
+ const query = db.select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq2(sessions.groupId, groups.id));
458
+ if (opts?.excludeArchived) {
459
+ return query.where(inArray(sessions.status, [
460
+ "active",
461
+ "waiting",
462
+ "paused"
463
+ ])).all();
464
+ }
465
+ return query.all();
466
+ }
467
+
468
+ // src/tui/screens/launch/create-screen.tsx
469
+ import { jsxDEV as jsxDEV5 } from "react/jsx-dev-runtime";
470
+ function CreateScreen({
471
+ isFocused,
472
+ onSubmit,
473
+ onQuit
474
+ }) {
475
+ const { clear } = useTui();
476
+ const [step, setStep] = useState2("group");
477
+ const [groupPath, setGroupPath] = useState2(null);
478
+ const [slug, setSlug] = useState2("");
479
+ const [error, setError] = useState2(null);
480
+ const stepRef = useRef(step);
481
+ stepRef.current = step;
482
+ useEffect(() => {
483
+ clear();
484
+ }, [step, clear]);
485
+ const groupItems = useMemo2(() => {
486
+ const activeSessions = getAllSessions({ excludeArchived: true });
487
+ const counts = new Map;
488
+ for (const s of activeSessions) {
489
+ counts.set(s.groupPath, (counts.get(s.groupPath) ?? 0) + 1);
490
+ }
491
+ return getAllGroups().filter((g) => counts.has(g.path)).sort((a, b) => a.path.localeCompare(b.path)).map((g) => ({
492
+ value: g.path,
493
+ label: g.path,
494
+ color: g.color,
495
+ meta: `${counts.get(g.path)}`
496
+ }));
497
+ }, []);
498
+ const handleGroupPicked = useCallback((value) => {
499
+ setGroupPath(value);
500
+ setError(null);
501
+ setStep("slug");
502
+ }, []);
503
+ const handleSlugSubmit = useCallback((value) => {
504
+ if (stepRef.current !== "slug")
505
+ return;
506
+ const trimmed = value.trim();
507
+ if (!trimmed) {
508
+ setError("Name cannot be empty.");
509
+ return;
510
+ }
511
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(trimmed)) {
512
+ setError("Name must start alphanumeric; letters, digits, dots, underscores, dashes only.");
513
+ return;
514
+ }
515
+ if (!groupPath)
516
+ return;
517
+ onSubmit({ groupPath, slug: trimmed });
518
+ }, [groupPath, onSubmit]);
519
+ useInput2((e) => {
520
+ if (!isFocused)
521
+ return;
522
+ if (e.key === "c" && e.ctrl) {
523
+ onQuit();
524
+ return;
525
+ }
526
+ if (e.key === "escape" && step === "slug") {
527
+ setStep("group");
528
+ setSlug("");
529
+ setError(null);
530
+ }
531
+ }, { isActive: isFocused });
532
+ return /* @__PURE__ */ jsxDEV5(Box4, {
533
+ flexDirection: "column",
534
+ gap: 1,
535
+ children: [
536
+ /* @__PURE__ */ jsxDEV5(Box4, {
537
+ flexDirection: "row",
538
+ gap: 1,
539
+ children: [
540
+ /* @__PURE__ */ jsxDEV5(Text5, {
541
+ bold: true,
542
+ children: "New session"
543
+ }, undefined, false, undefined, this),
544
+ /* @__PURE__ */ jsxDEV5(Text5, {
545
+ dim: true,
546
+ children: "\xB7"
547
+ }, undefined, false, undefined, this),
548
+ /* @__PURE__ */ jsxDEV5(Text5, {
549
+ dim: true,
550
+ children: stepLabel(step)
551
+ }, undefined, false, undefined, this)
552
+ ]
553
+ }, undefined, true, undefined, this),
554
+ groupPath && step === "slug" && /* @__PURE__ */ jsxDEV5(Box4, {
555
+ flexDirection: "row",
556
+ gap: 1,
557
+ children: [
558
+ /* @__PURE__ */ jsxDEV5(Text5, {
559
+ dim: true,
560
+ children: "Group:"
561
+ }, undefined, false, undefined, this),
562
+ /* @__PURE__ */ jsxDEV5(Text5, {
563
+ color: "green",
564
+ children: groupPath
565
+ }, undefined, false, undefined, this)
566
+ ]
567
+ }, undefined, true, undefined, this),
568
+ /* @__PURE__ */ jsxDEV5(Box4, {
569
+ flexDirection: "column",
570
+ height: step === "group" ? undefined : 0,
571
+ overflow: "hidden",
572
+ children: /* @__PURE__ */ jsxDEV5(Picker, {
573
+ mode: "single",
574
+ items: groupItems,
575
+ isFocused: isFocused && step === "group",
576
+ placeholder: "Pick or type a new group path\u2026",
577
+ onSubmit: handleGroupPicked,
578
+ emptyHint: "No active groups. Type a name to create one."
579
+ }, undefined, false, undefined, this)
580
+ }, undefined, false, undefined, this),
581
+ /* @__PURE__ */ jsxDEV5(Box4, {
582
+ flexDirection: "column",
583
+ gap: 0,
584
+ height: step === "slug" ? undefined : 0,
585
+ overflow: "hidden",
586
+ children: [
587
+ /* @__PURE__ */ jsxDEV5(Text5, {
588
+ dim: true,
589
+ children: "Name"
590
+ }, undefined, false, undefined, this),
591
+ /* @__PURE__ */ jsxDEV5(Box4, {
592
+ borderStyle: "round",
593
+ borderColor: isFocused ? "green" : undefined,
594
+ borderDimColor: !isFocused,
595
+ paddingX: 1,
596
+ children: /* @__PURE__ */ jsxDEV5(TextInput2, {
597
+ value: slug,
598
+ onChange: (v) => {
599
+ setSlug(v);
600
+ setError(null);
601
+ },
602
+ onSubmit: handleSlugSubmit,
603
+ placeholder: "fix-auth-bug",
604
+ color: "green",
605
+ placeholderColor: "gray",
606
+ isFocused: isFocused && step === "slug"
607
+ }, undefined, false, undefined, this)
608
+ }, undefined, false, undefined, this)
609
+ ]
610
+ }, undefined, true, undefined, this),
611
+ error && /* @__PURE__ */ jsxDEV5(Text5, {
612
+ color: "red",
613
+ children: error
614
+ }, undefined, false, undefined, this),
615
+ /* @__PURE__ */ jsxDEV5(Text5, {
616
+ dim: true,
617
+ children: footer(step)
618
+ }, undefined, false, undefined, this)
619
+ ]
620
+ }, undefined, true, undefined, this);
621
+ }
622
+ function stepLabel(step) {
623
+ return step === "group" ? "1/2 Group" : "2/2 Name";
624
+ }
625
+ function footer(step) {
626
+ if (step === "group") {
627
+ return "\u2191\u2193 navigate \xB7 enter pick \xB7 ctrl+c quit";
628
+ }
629
+ return "enter create \xB7 esc back \xB7 ctrl+c quit";
630
+ }
631
+
632
+ // src/tui/screens/launch/index.tsx
633
+ import { jsxDEV as jsxDEV6 } from "react/jsx-dev-runtime";
634
+ function Launch({ onSelect }) {
635
+ const { exit } = useTui2();
636
+ const select = useCallback2((selection) => {
637
+ onSelect(selection);
638
+ exit();
639
+ }, [onSelect, exit]);
640
+ return /* @__PURE__ */ jsxDEV6(Box5, {
641
+ flexDirection: "column",
642
+ paddingY: 1,
643
+ gap: 1,
644
+ children: [
645
+ /* @__PURE__ */ jsxDEV6(Box5, {
646
+ marginX: 1,
647
+ children: /* @__PURE__ */ jsxDEV6(Logo, {}, undefined, false, undefined, this)
648
+ }, undefined, false, undefined, this),
649
+ /* @__PURE__ */ jsxDEV6(Box5, {
650
+ flexDirection: "column",
651
+ marginX: 2,
652
+ gap: 1,
653
+ children: [
654
+ /* @__PURE__ */ jsxDEV6(AppDetails, {}, undefined, false, undefined, this),
655
+ /* @__PURE__ */ jsxDEV6(CreateScreen, {
656
+ isFocused: true,
657
+ onSubmit: (payload) => select({ type: "create", ...payload }),
658
+ onQuit: () => select({ type: "quit" })
659
+ }, undefined, false, undefined, this)
660
+ ]
661
+ }, undefined, true, undefined, this)
662
+ ]
663
+ }, undefined, true, undefined, this);
664
+ }
665
+
666
+ // src/tui/screens/Exit.tsx
667
+ import { useState as useState3 } from "react";
668
+ import { Box as Box6, Text as Text6, useInput as useInput3, useTui as useTui3 } from "@orchetron/storm";
669
+
670
+ // src/db/queries/conversations.ts
671
+ import { eq as eq3, and as and2, desc, sql as sql3 } from "drizzle-orm";
672
+ function getConversationsBySession(sessionId) {
673
+ return getDb().select().from(conversations).where(and2(eq3(conversations.sessionId, sessionId), eq3(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
674
+ }
675
+
676
+ // src/lib/format.ts
677
+ var SECOND = 1000;
678
+ var MINUTE = 60 * SECOND;
679
+ var HOUR = 60 * MINUTE;
680
+ var DAY = 24 * HOUR;
681
+ function formatDuration(ms) {
682
+ if (ms < MINUTE)
683
+ return `${Math.round(ms / SECOND)}s`;
684
+ const days = Math.floor(ms / DAY);
685
+ const hours = Math.floor(ms % DAY / HOUR);
686
+ const minutes = Math.floor(ms % HOUR / MINUTE);
687
+ if (days > 0)
688
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
689
+ if (hours > 0)
690
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
691
+ return `${minutes}m`;
692
+ }
693
+ function formatAgo(isoOrDate) {
694
+ const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
695
+ const ms = Date.now() - date.getTime();
696
+ if (ms < MINUTE)
697
+ return "just now";
698
+ if (ms < HOUR)
699
+ return `${Math.floor(ms / MINUTE)}m ago`;
700
+ if (ms < DAY)
701
+ return `${Math.floor(ms / HOUR)}h ago`;
702
+ if (ms < 2 * DAY)
703
+ return "yesterday";
704
+ if (ms < 7 * DAY)
705
+ return `${Math.floor(ms / DAY)}d ago`;
706
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
707
+ }
708
+
709
+ // src/tui/screens/Exit.tsx
710
+ import { jsxDEV as jsxDEV7 } from "react/jsx-dev-runtime";
711
+ var OPTIONS = [
712
+ { action: "save", label: "Save", hint: "Keep session paused for later" },
713
+ {
714
+ action: "archive",
715
+ label: "Archive",
716
+ hint: "Mark as done, hide from active view"
717
+ },
718
+ {
719
+ action: "discard",
720
+ label: "Discard",
721
+ hint: "Delete this session permanently"
722
+ },
723
+ {
724
+ action: "resume",
725
+ label: "Resume",
726
+ hint: "Start a new conversation in this session"
727
+ }
728
+ ];
729
+ function Exit({ sessionId, onAction }) {
730
+ const { exit } = useTui3();
731
+ const [cursor, setCursor] = useState3(0);
732
+ const session = getSession(sessionId);
733
+ const conversations2 = session ? getConversationsBySession(session.id) : [];
734
+ useInput3((e) => {
735
+ if (e.key === "c" && e.ctrl)
736
+ exit();
737
+ if (e.key === "up" || e.key === "k") {
738
+ setCursor((c) => Math.max(0, c - 1));
739
+ } else if (e.key === "down" || e.key === "j") {
740
+ setCursor((c) => Math.min(OPTIONS.length - 1, c + 1));
741
+ } else if (e.key === "return") {
742
+ const selected = OPTIONS[cursor];
743
+ exit();
744
+ onAction(selected.action, sessionId);
745
+ } else if (e.key === "q") {
746
+ exit();
747
+ onAction("save", sessionId);
748
+ }
749
+ });
750
+ if (!session) {
751
+ return /* @__PURE__ */ jsxDEV7(Text6, {
752
+ color: "red",
753
+ children: "Session not found"
754
+ }, undefined, false, undefined, this);
755
+ }
756
+ const duration = session.endedAt && session.startedAt ? formatDuration(new Date(session.endedAt).getTime() - new Date(session.startedAt).getTime()) : null;
757
+ return /* @__PURE__ */ jsxDEV7(Box6, {
758
+ flexDirection: "column",
759
+ padding: 1,
760
+ gap: 1,
761
+ children: [
762
+ /* @__PURE__ */ jsxDEV7(Text6, {
763
+ bold: true,
764
+ color: "#82AAFF",
765
+ children: "Session ended"
766
+ }, undefined, false, undefined, this),
767
+ /* @__PURE__ */ jsxDEV7(Box6, {
768
+ flexDirection: "column",
769
+ children: [
770
+ /* @__PURE__ */ jsxDEV7(Box6, {
771
+ flexDirection: "row",
772
+ gap: 1,
773
+ children: [
774
+ /* @__PURE__ */ jsxDEV7(StatusDot, {
775
+ status: session.status
776
+ }, undefined, false, undefined, this),
777
+ /* @__PURE__ */ jsxDEV7(Text6, {
778
+ bold: true,
779
+ children: session.name
780
+ }, undefined, false, undefined, this),
781
+ duration && /* @__PURE__ */ jsxDEV7(Text6, {
782
+ dim: true,
783
+ children: [
784
+ "(",
785
+ duration,
786
+ ")"
787
+ ]
788
+ }, undefined, true, undefined, this)
789
+ ]
790
+ }, undefined, true, undefined, this),
791
+ /* @__PURE__ */ jsxDEV7(Text6, {
792
+ dim: true,
793
+ children: [
794
+ conversations2.length,
795
+ " conversation",
796
+ conversations2.length !== 1 ? "s" : ""
797
+ ]
798
+ }, undefined, true, undefined, this)
799
+ ]
800
+ }, undefined, true, undefined, this),
801
+ /* @__PURE__ */ jsxDEV7(Box6, {
802
+ flexDirection: "column",
803
+ children: OPTIONS.map((opt, i) => /* @__PURE__ */ jsxDEV7(Box6, {
804
+ flexDirection: "row",
805
+ gap: 1,
806
+ children: [
807
+ /* @__PURE__ */ jsxDEV7(Text6, {
808
+ children: i === cursor ? "\u276F" : " "
809
+ }, undefined, false, undefined, this),
810
+ /* @__PURE__ */ jsxDEV7(Text6, {
811
+ bold: i === cursor,
812
+ children: opt.label
813
+ }, undefined, false, undefined, this),
814
+ /* @__PURE__ */ jsxDEV7(Text6, {
815
+ dim: true,
816
+ children: opt.hint
817
+ }, undefined, false, undefined, this)
818
+ ]
819
+ }, opt.action, true, undefined, this))
820
+ }, undefined, false, undefined, this),
821
+ /* @__PURE__ */ jsxDEV7(Box6, {
822
+ children: /* @__PURE__ */ jsxDEV7(Text6, {
823
+ dim: true,
824
+ children: "\u2191\u2193 navigate \xB7 enter select \xB7 q save & quit"
825
+ }, undefined, false, undefined, this)
826
+ }, undefined, false, undefined, this)
827
+ ]
828
+ }, undefined, true, undefined, this);
829
+ }
830
+
831
+ // src/tui/screens/Resume.tsx
832
+ import { useState as useState4 } from "react";
833
+ import { Box as Box7, Text as Text7, useInput as useInput4, useTui as useTui4 } from "@orchetron/storm";
834
+ import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
835
+ function Resume({ sessionId, onSelect }) {
836
+ const { exit } = useTui4();
837
+ const [cursor, setCursor] = useState4(0);
838
+ const session = getSession(sessionId);
839
+ const conversations2 = getConversationsBySession(sessionId);
840
+ const totalOptions = conversations2.length + 1;
841
+ const select = (selection) => {
842
+ onSelect(selection);
843
+ exit();
844
+ };
845
+ useInput4((e) => {
846
+ if (e.key === "c" && e.ctrl)
847
+ select({ type: "back" });
848
+ if (e.key === "up" || e.key === "k") {
849
+ setCursor((c) => Math.max(0, c - 1));
850
+ } else if (e.key === "down" || e.key === "j") {
851
+ setCursor((c) => Math.min(totalOptions - 1, c + 1));
852
+ } else if (e.key === "return") {
853
+ if (cursor === 0) {
854
+ select({ type: "new" });
855
+ } else {
856
+ const conv = conversations2[cursor - 1];
857
+ select({ type: "conversation", conversationId: conv.id });
858
+ }
859
+ } else if (e.key === "escape" || e.key === "q") {
860
+ select({ type: "back" });
861
+ }
862
+ });
863
+ if (!session) {
864
+ return /* @__PURE__ */ jsxDEV8(Text7, {
865
+ color: "red",
866
+ children: "Session not found"
867
+ }, undefined, false, undefined, this);
868
+ }
869
+ return /* @__PURE__ */ jsxDEV8(Box7, {
870
+ flexDirection: "column",
871
+ padding: 1,
872
+ gap: 1,
873
+ children: [
874
+ /* @__PURE__ */ jsxDEV8(Text7, {
875
+ bold: true,
876
+ color: "#82AAFF",
877
+ children: [
878
+ "Resume: ",
879
+ session.name
880
+ ]
881
+ }, undefined, true, undefined, this),
882
+ /* @__PURE__ */ jsxDEV8(Box7, {
883
+ flexDirection: "column",
884
+ children: [
885
+ /* @__PURE__ */ jsxDEV8(Box7, {
886
+ flexDirection: "row",
887
+ gap: 1,
888
+ children: [
889
+ /* @__PURE__ */ jsxDEV8(Text7, {
890
+ children: cursor === 0 ? "\u276F" : " "
891
+ }, undefined, false, undefined, this),
892
+ /* @__PURE__ */ jsxDEV8(Text7, {
893
+ bold: cursor === 0,
894
+ color: "#34D399",
895
+ children: "+ New conversation"
896
+ }, undefined, false, undefined, this)
897
+ ]
898
+ }, undefined, true, undefined, this),
899
+ conversations2.map((conv, i) => {
900
+ const idx = i + 1;
901
+ const isSelected = cursor === idx;
902
+ const duration = conv.endedAt ? formatDuration(new Date(conv.endedAt).getTime() - new Date(conv.startedAt).getTime()) : "active";
903
+ const ago = formatAgo(conv.startedAt);
904
+ return /* @__PURE__ */ jsxDEV8(Box7, {
905
+ flexDirection: "row",
906
+ gap: 1,
907
+ children: [
908
+ /* @__PURE__ */ jsxDEV8(Text7, {
909
+ children: isSelected ? "\u276F" : " "
910
+ }, undefined, false, undefined, this),
911
+ /* @__PURE__ */ jsxDEV8(Text7, {
912
+ bold: isSelected,
913
+ dim: conv.discarded,
914
+ children: conv.id.slice(0, 8)
915
+ }, undefined, false, undefined, this),
916
+ /* @__PURE__ */ jsxDEV8(Text7, {
917
+ dim: true,
918
+ children: ago
919
+ }, undefined, false, undefined, this),
920
+ /* @__PURE__ */ jsxDEV8(Text7, {
921
+ dim: true,
922
+ children: "\xB7"
923
+ }, undefined, false, undefined, this),
924
+ /* @__PURE__ */ jsxDEV8(Text7, {
925
+ dim: true,
926
+ children: [
927
+ conv.eventCount,
928
+ " events"
929
+ ]
930
+ }, undefined, true, undefined, this),
931
+ /* @__PURE__ */ jsxDEV8(Text7, {
932
+ dim: true,
933
+ children: "\xB7"
934
+ }, undefined, false, undefined, this),
935
+ /* @__PURE__ */ jsxDEV8(Text7, {
936
+ dim: true,
937
+ children: duration
938
+ }, undefined, false, undefined, this),
939
+ conv.discarded && /* @__PURE__ */ jsxDEV8(Text7, {
940
+ color: "red",
941
+ dim: true,
942
+ children: "(discarded)"
943
+ }, undefined, false, undefined, this)
944
+ ]
945
+ }, conv.id, true, undefined, this);
946
+ })
947
+ ]
948
+ }, undefined, true, undefined, this),
949
+ /* @__PURE__ */ jsxDEV8(Box7, {
950
+ children: /* @__PURE__ */ jsxDEV8(Text7, {
951
+ dim: true,
952
+ children: "\u2191\u2193 navigate \xB7 enter select \xB7 q back"
953
+ }, undefined, false, undefined, this)
954
+ }, undefined, false, undefined, this)
955
+ ]
956
+ }, undefined, true, undefined, this);
957
+ }
958
+
959
+ // src/tui/run-screen.tsx
960
+ import { jsxDEV as jsxDEV9 } from "react/jsx-dev-runtime";
961
+ var [, , screen, outputPath, ...args] = process.argv;
962
+ if (!screen || !outputPath) {
963
+ console.error("Usage: run-screen <screen> <outputPath> [args...]");
964
+ process.exit(1);
965
+ }
966
+ var result;
967
+ switch (screen) {
968
+ case "launch": {
969
+ let selection = { type: "quit" };
970
+ const app = render(/* @__PURE__ */ jsxDEV9(Launch, {
971
+ onSelect: (s) => {
972
+ selection = s;
973
+ }
974
+ }, undefined, false, undefined, this), { alternateScreen: true, patchConsole: true });
975
+ await app.waitUntilExit();
976
+ app.unmount();
977
+ result = selection;
978
+ break;
979
+ }
980
+ case "exit": {
981
+ const sessionId = args[0];
982
+ if (!sessionId) {
983
+ console.error("exit requires sessionId");
984
+ process.exit(1);
985
+ }
986
+ let action = "save";
987
+ const app = render(/* @__PURE__ */ jsxDEV9(Exit, {
988
+ sessionId,
989
+ onAction: (a) => {
990
+ action = a;
991
+ }
992
+ }, undefined, false, undefined, this), { alternateScreen: true, patchConsole: true });
993
+ await app.waitUntilExit();
994
+ app.unmount();
995
+ result = action;
996
+ break;
997
+ }
998
+ case "resume": {
999
+ const sessionId = args[0];
1000
+ if (!sessionId) {
1001
+ console.error("resume requires sessionId");
1002
+ process.exit(1);
1003
+ }
1004
+ let selection = { type: "back" };
1005
+ const app = render(/* @__PURE__ */ jsxDEV9(Resume, {
1006
+ sessionId,
1007
+ onSelect: (s) => {
1008
+ selection = s;
1009
+ }
1010
+ }, undefined, false, undefined, this), { alternateScreen: true, patchConsole: true });
1011
+ await app.waitUntilExit();
1012
+ app.unmount();
1013
+ result = selection;
1014
+ break;
1015
+ }
1016
+ default:
1017
+ console.error(`Unknown screen: ${screen}`);
1018
+ process.exit(1);
1019
+ }
1020
+ writeFileSync(outputPath, JSON.stringify(result));
1021
+ process.exit(0);