bertrand 0.14.1 → 0.15.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/dist/bertrand.js +114 -53
- package/dist/run-screen.js +357 -285
- package/package.json +1 -1
package/dist/bertrand.js
CHANGED
|
@@ -1151,8 +1151,54 @@ var init_engagement_stats = __esm(() => {
|
|
|
1151
1151
|
init_events();
|
|
1152
1152
|
});
|
|
1153
1153
|
|
|
1154
|
+
// src/lib/session-archive.ts
|
|
1155
|
+
function archiveSession(id) {
|
|
1156
|
+
const session = getSession(id);
|
|
1157
|
+
if (!session)
|
|
1158
|
+
return { ok: false, reason: "not-found" };
|
|
1159
|
+
if (ACTIVE_STATUSES.includes(session.status)) {
|
|
1160
|
+
return { ok: false, reason: "active" };
|
|
1161
|
+
}
|
|
1162
|
+
if (session.status === "archived") {
|
|
1163
|
+
return { ok: false, reason: "already-archived" };
|
|
1164
|
+
}
|
|
1165
|
+
const updated = updateSessionStatus(id, "archived");
|
|
1166
|
+
return { ok: true, session: updated };
|
|
1167
|
+
}
|
|
1168
|
+
function unarchiveSession(id) {
|
|
1169
|
+
const session = getSession(id);
|
|
1170
|
+
if (!session)
|
|
1171
|
+
return { ok: false, reason: "not-found" };
|
|
1172
|
+
if (session.status !== "archived") {
|
|
1173
|
+
return { ok: false, reason: "not-archived" };
|
|
1174
|
+
}
|
|
1175
|
+
const updated = updateSessionStatus(id, "paused");
|
|
1176
|
+
return { ok: true, session: updated };
|
|
1177
|
+
}
|
|
1178
|
+
function archiveAllPaused() {
|
|
1179
|
+
const rows = getAllSessions({ excludeArchived: true });
|
|
1180
|
+
const paused = rows.filter((r) => r.session.status === "paused");
|
|
1181
|
+
const archived = [];
|
|
1182
|
+
for (const row of paused) {
|
|
1183
|
+
const updated = updateSessionStatus(row.session.id, "archived");
|
|
1184
|
+
archived.push({ session: updated, groupPath: row.groupPath });
|
|
1185
|
+
}
|
|
1186
|
+
return { archived };
|
|
1187
|
+
}
|
|
1188
|
+
var ACTIVE_STATUSES;
|
|
1189
|
+
var init_session_archive = __esm(() => {
|
|
1190
|
+
init_sessions();
|
|
1191
|
+
ACTIVE_STATUSES = ["active", "waiting"];
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1154
1194
|
// src/server/index.ts
|
|
1155
1195
|
import { execFile } from "child_process";
|
|
1196
|
+
function archiveResponse(result) {
|
|
1197
|
+
if (result.ok)
|
|
1198
|
+
return Response.json(result.session);
|
|
1199
|
+
const meta = ARCHIVE_ERROR[result.reason] ?? { status: 400, message: "Operation failed" };
|
|
1200
|
+
return Response.json({ error: meta.message, reason: result.reason }, { status: meta.status });
|
|
1201
|
+
}
|
|
1156
1202
|
async function handleOpen(req) {
|
|
1157
1203
|
let body;
|
|
1158
1204
|
try {
|
|
@@ -1210,6 +1256,20 @@ function startServer(port = PORT) {
|
|
|
1210
1256
|
return r;
|
|
1211
1257
|
});
|
|
1212
1258
|
}
|
|
1259
|
+
if (req.method === "POST") {
|
|
1260
|
+
const archiveMatch = /^\/api\/sessions\/([^/]+)\/archive$/.exec(url.pathname);
|
|
1261
|
+
if (archiveMatch) {
|
|
1262
|
+
const response2 = archiveResponse(archiveSession(archiveMatch[1]));
|
|
1263
|
+
response2.headers.set("Access-Control-Allow-Origin", "*");
|
|
1264
|
+
return response2;
|
|
1265
|
+
}
|
|
1266
|
+
const unarchiveMatch = /^\/api\/sessions\/([^/]+)\/unarchive$/.exec(url.pathname);
|
|
1267
|
+
if (unarchiveMatch) {
|
|
1268
|
+
const response2 = archiveResponse(unarchiveSession(unarchiveMatch[1]));
|
|
1269
|
+
response2.headers.set("Access-Control-Allow-Origin", "*");
|
|
1270
|
+
return response2;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1213
1273
|
const response = match(url.pathname, url);
|
|
1214
1274
|
response.headers.set("Access-Control-Allow-Origin", "*");
|
|
1215
1275
|
return response;
|
|
@@ -1218,13 +1278,14 @@ function startServer(port = PORT) {
|
|
|
1218
1278
|
console.log(`bertrand API server listening on http://localhost:${server.port}`);
|
|
1219
1279
|
return server;
|
|
1220
1280
|
}
|
|
1221
|
-
var PORT, routes;
|
|
1281
|
+
var PORT, routes, ARCHIVE_ERROR;
|
|
1222
1282
|
var init_server = __esm(() => {
|
|
1223
1283
|
init_sessions();
|
|
1224
1284
|
init_events();
|
|
1225
1285
|
init_stats();
|
|
1226
1286
|
init_timing();
|
|
1227
1287
|
init_engagement_stats();
|
|
1288
|
+
init_session_archive();
|
|
1228
1289
|
PORT = Number(process.env.BERTRAND_PORT ?? 5200);
|
|
1229
1290
|
routes = [
|
|
1230
1291
|
[/^\/api\/sessions$/, (_params, url) => {
|
|
@@ -1275,6 +1336,12 @@ var init_server = __esm(() => {
|
|
|
1275
1336
|
return getLatestRecaps();
|
|
1276
1337
|
}]
|
|
1277
1338
|
];
|
|
1339
|
+
ARCHIVE_ERROR = {
|
|
1340
|
+
"not-found": { status: 404, message: "Session not found" },
|
|
1341
|
+
active: { status: 409, message: "Cannot archive an active session" },
|
|
1342
|
+
"already-archived": { status: 409, message: "Session is already archived" },
|
|
1343
|
+
"not-archived": { status: 409, message: "Session is not archived" }
|
|
1344
|
+
};
|
|
1278
1345
|
});
|
|
1279
1346
|
|
|
1280
1347
|
// src/cli/commands/serve.ts
|
|
@@ -1361,16 +1428,10 @@ var init_labels = __esm(() => {
|
|
|
1361
1428
|
// src/contract/template.md
|
|
1362
1429
|
var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
|
|
1363
1430
|
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include a "Done for now" option so the user can exit the loop when ready. The description for "Done for now" must be a 1-2 sentence summary of what was accomplished so far. Describe outcomes (what was built, fixed, decided), not process.
|
|
1431
|
+
After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include an option labeled exactly \`Done for now\` (this exact wording is required \u2014 the session-exit hook greps for it). The description for "Done for now" must be a 1-2 sentence summary of what was accomplished so far. Describe outcomes (what was built, fixed, decided), not process.
|
|
1367
1432
|
|
|
1368
1433
|
If the user's most recent answer to AskUserQuestion was "Done for now" (or contains it), this turn is the FINAL turn. Respond briefly to acknowledge and do NOT call AskUserQuestion again \u2014 the loop is over.
|
|
1369
1434
|
|
|
1370
|
-
Each question must encapsulate the moment \u2014 name the specific decision, tradeoff, or next step being asked about. Avoid generic phrasing like "What next?", "How should we proceed?", or "What would you like to do?". A reader who sees only the question (no recap, no preceding text) should understand what is being decided. Prefer "Which gap should we tackle first \u2014 bundle analysis, end-to-end dry-run, or registry?" over "What's next?"
|
|
1371
|
-
|
|
1372
|
-
Every option must be a concrete, actionable next step. No filler like "Have questions?" or "Want to learn more?" \u2014 if clarification is needed, phrase it as a specific action: "Discuss tradeoffs of X vs Y".
|
|
1373
|
-
|
|
1374
1435
|
Before each AskUserQuestion call, emit a \`<recap>...</recap>\` block in your text output. Use markdown \u2014 a short bullet list is usually the most scannable shape; a single short paragraph is fine when the turn was one cohesive thing. Keep it concise. The recap covers what happened since the previous AskUserQuestion (or session start) \u2014 what you found, decided, or did. Write the gist for someone reading the session timeline, not a process log. The dashboard renders these between AskUserQuestion events; do not use this tag for any other purpose.
|
|
1375
1436
|
`;
|
|
1376
1437
|
var init_template = () => {};
|
|
@@ -1379,25 +1440,14 @@ var init_template = () => {};
|
|
|
1379
1440
|
function buildContract(sessionName, ...contextLayers) {
|
|
1380
1441
|
const base = template_default.replace("{sessionName}", sessionName);
|
|
1381
1442
|
const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
1382
|
-
|
|
1443
|
+
if (layers.length === 0)
|
|
1444
|
+
return base;
|
|
1445
|
+
return base + `
|
|
1383
1446
|
|
|
1384
1447
|
` + layers.join(`
|
|
1385
1448
|
|
|
1386
1449
|
`);
|
|
1387
|
-
return base + middle + `
|
|
1388
|
-
|
|
1389
|
-
` + TRAILING_CRITICAL_RULES;
|
|
1390
1450
|
}
|
|
1391
|
-
var TRAILING_CRITICAL_RULES = `## CRITICAL RULE \u2014 applies to every AskUserQuestion call
|
|
1392
|
-
|
|
1393
|
-
Every AskUserQuestion call MUST set \`multiSelect: true\` on every question. No exceptions.
|
|
1394
|
-
|
|
1395
|
-
The AskUserQuestion tool schema describes multiSelect as "allow multiple answers to be selected instead of just one." **Ignore that framing.** In bertrand, multiSelect is a UX-safety mechanism, not a cardinality signal \u2014 single-select submits on Enter the moment a question block gains focus, causing accidental selections. multiSelect requires explicit confirmation before submitting.
|
|
1396
|
-
|
|
1397
|
-
Apply this rule even when options are mutually exclusive (e.g., "Which library: A, B, C, or D?"). The mechanic is about preventing accidental submissions, not about how many answers the user picks.
|
|
1398
|
-
|
|
1399
|
-
WRONG: \`{ "multiSelect": false, "options": [...] }\`
|
|
1400
|
-
RIGHT: \`{ "multiSelect": true, "options": [...] }\``;
|
|
1401
1451
|
var init_template2 = __esm(() => {
|
|
1402
1452
|
init_template();
|
|
1403
1453
|
});
|
|
@@ -1785,7 +1835,7 @@ async function runSessionLoop(sessionId) {
|
|
|
1785
1835
|
case "save":
|
|
1786
1836
|
break;
|
|
1787
1837
|
case "archive":
|
|
1788
|
-
|
|
1838
|
+
archiveSession(sessionId);
|
|
1789
1839
|
break;
|
|
1790
1840
|
case "discard":
|
|
1791
1841
|
deleteSession(sessionId);
|
|
@@ -1810,11 +1860,6 @@ async function startTui() {
|
|
|
1810
1860
|
await runSessionLoop(sessionId);
|
|
1811
1861
|
break;
|
|
1812
1862
|
}
|
|
1813
|
-
case "resume": {
|
|
1814
|
-
const sessionId = await resume(selection);
|
|
1815
|
-
await runSessionLoop(sessionId);
|
|
1816
|
-
break;
|
|
1817
|
-
}
|
|
1818
1863
|
case "pick": {
|
|
1819
1864
|
const conversationId = await resolveConversationForResume(selection.sessionId);
|
|
1820
1865
|
if (!conversationId)
|
|
@@ -1832,6 +1877,7 @@ var SCREEN_ENTRY;
|
|
|
1832
1877
|
var init_app = __esm(() => {
|
|
1833
1878
|
init_sessions();
|
|
1834
1879
|
init_conversations();
|
|
1880
|
+
init_session_archive();
|
|
1835
1881
|
init_session();
|
|
1836
1882
|
SCREEN_ENTRY = (() => {
|
|
1837
1883
|
const built = join3(import.meta.dir, "run-screen.js");
|
|
@@ -1939,11 +1985,21 @@ var init_migrate = __esm(() => {
|
|
|
1939
1985
|
function waitingScript(bin) {
|
|
1940
1986
|
const BIN = bin;
|
|
1941
1987
|
return `#!/usr/bin/env bash
|
|
1942
|
-
# Hook: PreToolUse AskUserQuestion \u2192 mark session as waiting
|
|
1988
|
+
# Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
|
|
1943
1989
|
sid="\${BERTRAND_SESSION:-}"
|
|
1944
1990
|
[ -z "$sid" ] && exit 0
|
|
1945
1991
|
|
|
1946
1992
|
input="$(cat)"
|
|
1993
|
+
|
|
1994
|
+
# Block AUQ calls that omit multiSelect:true on any question. multiSelect is a
|
|
1995
|
+
# UX-safety mechanism in bertrand (prevents submit-on-focus), not a cardinality
|
|
1996
|
+
# signal. Enforce mechanically so the rule sticks in subagent / job contexts
|
|
1997
|
+
# where the system-prompt contract never reaches the agent.
|
|
1998
|
+
if printf '%s' "$input" | jq -e '.tool_input.questions[]? | select(.multiSelect != true)' > /dev/null 2>&1; then
|
|
1999
|
+
printf 'All AskUserQuestion questions must set multiSelect:true. This is a UX-safety mechanism in bertrand (prevents submit-on-focus when the question block gains focus), not a cardinality signal. Retry with multiSelect:true on every question.\\n' >&2
|
|
2000
|
+
exit 2
|
|
2001
|
+
fi
|
|
2002
|
+
|
|
1947
2003
|
cid="\${BERTRAND_CLAUDE_ID:-}"
|
|
1948
2004
|
|
|
1949
2005
|
# Extract question \u2014 grep for simple field extraction (~1ms vs jq ~15ms)
|
|
@@ -2984,7 +3040,7 @@ function dur(seconds) {
|
|
|
2984
3040
|
function renderGlobal(metrics, isJson) {
|
|
2985
3041
|
const totals = metrics.reduce((acc, m) => ({
|
|
2986
3042
|
sessions: acc.sessions + 1,
|
|
2987
|
-
active: acc.active + (
|
|
3043
|
+
active: acc.active + (ACTIVE_STATUSES2.includes(m.status) ? 1 : 0),
|
|
2988
3044
|
durationS: acc.durationS + m.durationS,
|
|
2989
3045
|
claudeWorkS: acc.claudeWorkS + m.claudeWorkS,
|
|
2990
3046
|
userWaitS: acc.userWaitS + m.userWaitS,
|
|
@@ -3057,7 +3113,7 @@ function renderSession(m, isJson) {
|
|
|
3057
3113
|
console.log(` Interactions: ${m.interactionCount}`);
|
|
3058
3114
|
console.log(` PRs: ${m.prCount}`);
|
|
3059
3115
|
}
|
|
3060
|
-
var
|
|
3116
|
+
var ACTIVE_STATUSES2;
|
|
3061
3117
|
var init_stats2 = __esm(() => {
|
|
3062
3118
|
init_router();
|
|
3063
3119
|
init_sessions();
|
|
@@ -3066,7 +3122,7 @@ var init_stats2 = __esm(() => {
|
|
|
3066
3122
|
init_events();
|
|
3067
3123
|
init_timing();
|
|
3068
3124
|
init_format();
|
|
3069
|
-
|
|
3125
|
+
ACTIVE_STATUSES2 = ["active", "waiting"];
|
|
3070
3126
|
register("stats", async (args) => {
|
|
3071
3127
|
const isJson = args.includes("--json");
|
|
3072
3128
|
const filteredArgs = args.filter((a) => !a.startsWith("--"));
|
|
@@ -3139,32 +3195,27 @@ function resolveSession(name) {
|
|
|
3139
3195
|
}
|
|
3140
3196
|
return { session, groupPath };
|
|
3141
3197
|
}
|
|
3142
|
-
var ACTIVE_STATUSES2;
|
|
3143
3198
|
var init_archive = __esm(() => {
|
|
3144
3199
|
init_router();
|
|
3145
3200
|
init_sessions();
|
|
3146
3201
|
init_groups();
|
|
3147
|
-
|
|
3202
|
+
init_session_archive();
|
|
3148
3203
|
register("archive", async (args) => {
|
|
3149
3204
|
const isUndo = args.includes("--undo");
|
|
3150
3205
|
const isAllPaused = args.includes("--all-paused");
|
|
3151
3206
|
const filteredArgs = args.filter((a) => !a.startsWith("--"));
|
|
3152
3207
|
const sessionName = filteredArgs[0];
|
|
3153
3208
|
if (isAllPaused) {
|
|
3154
|
-
const
|
|
3155
|
-
|
|
3156
|
-
if (paused.length === 0) {
|
|
3209
|
+
const { archived } = archiveAllPaused();
|
|
3210
|
+
if (archived.length === 0) {
|
|
3157
3211
|
console.log("No paused sessions to archive.");
|
|
3158
3212
|
return;
|
|
3159
3213
|
}
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
updateSessionStatus(row.session.id, "archived");
|
|
3163
|
-
console.log(` archived ${row.groupPath}/${row.session.slug}`);
|
|
3164
|
-
archived++;
|
|
3214
|
+
for (const { session: session2, groupPath: groupPath2 } of archived) {
|
|
3215
|
+
console.log(` archived ${groupPath2}/${session2.slug}`);
|
|
3165
3216
|
}
|
|
3166
3217
|
console.log(`
|
|
3167
|
-
Archived ${archived} session${archived === 1 ? "" : "s"}.`);
|
|
3218
|
+
Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
|
|
3168
3219
|
return;
|
|
3169
3220
|
}
|
|
3170
3221
|
if (!sessionName) {
|
|
@@ -3176,23 +3227,33 @@ Archived ${archived} session${archived === 1 ? "" : "s"}.`);
|
|
|
3176
3227
|
const { session, groupPath } = resolveSession(sessionName);
|
|
3177
3228
|
const fullName = `${groupPath}/${session.slug}`;
|
|
3178
3229
|
if (isUndo) {
|
|
3179
|
-
|
|
3180
|
-
|
|
3230
|
+
const result2 = unarchiveSession(session.id);
|
|
3231
|
+
if (!result2.ok) {
|
|
3232
|
+
if (result2.reason === "not-archived") {
|
|
3233
|
+
console.error(`${fullName} is not archived (status: ${session.status})`);
|
|
3234
|
+
} else {
|
|
3235
|
+
console.error(`Session not found: ${fullName}`);
|
|
3236
|
+
}
|
|
3181
3237
|
process.exit(1);
|
|
3182
3238
|
}
|
|
3183
|
-
updateSessionStatus(session.id, "paused");
|
|
3184
3239
|
console.log(`Unarchived ${fullName}`);
|
|
3185
3240
|
return;
|
|
3186
3241
|
}
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3242
|
+
const result = archiveSession(session.id);
|
|
3243
|
+
if (!result.ok) {
|
|
3244
|
+
switch (result.reason) {
|
|
3245
|
+
case "active":
|
|
3246
|
+
console.error(`Cannot archive active session ${fullName} (status: ${session.status})`);
|
|
3247
|
+
break;
|
|
3248
|
+
case "already-archived":
|
|
3249
|
+
console.error(`${fullName} is already archived`);
|
|
3250
|
+
break;
|
|
3251
|
+
case "not-found":
|
|
3252
|
+
console.error(`Session not found: ${fullName}`);
|
|
3253
|
+
break;
|
|
3254
|
+
}
|
|
3193
3255
|
process.exit(1);
|
|
3194
3256
|
}
|
|
3195
|
-
updateSessionStatus(session.id, "archived");
|
|
3196
3257
|
console.log(`Archived ${fullName}`);
|
|
3197
3258
|
});
|
|
3198
3259
|
});
|
package/dist/run-screen.js
CHANGED
|
@@ -19,8 +19,8 @@ import { writeFileSync } from "fs";
|
|
|
19
19
|
import { render } from "@orchetron/storm";
|
|
20
20
|
|
|
21
21
|
// src/tui/screens/launch/index.tsx
|
|
22
|
-
import {
|
|
23
|
-
import { Box as
|
|
22
|
+
import { useMemo as useMemo2, useState as useState2 } from "react";
|
|
23
|
+
import { Box as Box4, Text as Text5, useTui } from "@orchetron/storm";
|
|
24
24
|
|
|
25
25
|
// src/tui/components/app-details.tsx
|
|
26
26
|
import { Box, Text } from "@orchetron/storm";
|
|
@@ -74,6 +74,24 @@ import { useMemo, useState } from "react";
|
|
|
74
74
|
import { Box as Box3, Text as Text3, TextInput, useInput } from "@orchetron/storm";
|
|
75
75
|
import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime";
|
|
76
76
|
var NEW_KEY = "__new__";
|
|
77
|
+
function isSelectable(row) {
|
|
78
|
+
if (row.value === NEW_KEY)
|
|
79
|
+
return true;
|
|
80
|
+
const item = row;
|
|
81
|
+
return item.kind !== "header" && !item.disabled;
|
|
82
|
+
}
|
|
83
|
+
function findNextSelectable(rows, from, direction) {
|
|
84
|
+
let i = from;
|
|
85
|
+
while (i >= 0 && i < rows.length) {
|
|
86
|
+
if (isSelectable(rows[i]))
|
|
87
|
+
return i;
|
|
88
|
+
i += direction;
|
|
89
|
+
}
|
|
90
|
+
return -1;
|
|
91
|
+
}
|
|
92
|
+
function findFirstSelectable(rows) {
|
|
93
|
+
return findNextSelectable(rows, 0, 1);
|
|
94
|
+
}
|
|
77
95
|
function Picker(props) {
|
|
78
96
|
const {
|
|
79
97
|
items,
|
|
@@ -81,7 +99,7 @@ function Picker(props) {
|
|
|
81
99
|
placeholder,
|
|
82
100
|
allowCreate = true,
|
|
83
101
|
emptyHint,
|
|
84
|
-
maxVisible =
|
|
102
|
+
maxVisible = 12
|
|
85
103
|
} = props;
|
|
86
104
|
const [filter, setFilter] = useState("");
|
|
87
105
|
const [cursor, setCursor] = useState(0);
|
|
@@ -89,13 +107,19 @@ function Picker(props) {
|
|
|
89
107
|
const q = filter.trim().toLowerCase();
|
|
90
108
|
if (!q)
|
|
91
109
|
return items;
|
|
92
|
-
return items.filter((it) => it.label.toLowerCase().includes(q));
|
|
110
|
+
return items.filter((it) => it.kind !== "header" && it.label.toLowerCase().includes(q));
|
|
93
111
|
}, [filter, items]);
|
|
94
|
-
const exactMatch = useMemo(() => filtered.some((it) => it.label.toLowerCase() === filter.trim().toLowerCase()), [filter, filtered]);
|
|
112
|
+
const exactMatch = useMemo(() => filtered.some((it) => it.kind !== "header" && it.label.toLowerCase() === filter.trim().toLowerCase()), [filter, filtered]);
|
|
95
113
|
const showCreate = allowCreate && filter.trim().length > 0 && !exactMatch;
|
|
96
114
|
const visibleRows = useMemo(() => showCreate ? [...filtered, { value: NEW_KEY }] : filtered, [filtered, showCreate]);
|
|
97
|
-
if (
|
|
98
|
-
|
|
115
|
+
if (visibleRows.length === 0) {
|
|
116
|
+
if (cursor !== 0)
|
|
117
|
+
setTimeout(() => setCursor(0), 0);
|
|
118
|
+
} else if (cursor >= visibleRows.length || !isSelectable(visibleRows[cursor])) {
|
|
119
|
+
const next = findFirstSelectable(visibleRows);
|
|
120
|
+
if (next !== -1 && next !== cursor) {
|
|
121
|
+
setTimeout(() => setCursor(next), 0);
|
|
122
|
+
}
|
|
99
123
|
}
|
|
100
124
|
useInput((e) => {
|
|
101
125
|
if (!isFocused)
|
|
@@ -108,16 +132,24 @@ function Picker(props) {
|
|
|
108
132
|
if (visibleRows.length === 0)
|
|
109
133
|
return;
|
|
110
134
|
if (e.key === "up") {
|
|
111
|
-
setCursor((c) =>
|
|
135
|
+
setCursor((c) => {
|
|
136
|
+
const next = findNextSelectable(visibleRows, c - 1, -1);
|
|
137
|
+
return next === -1 ? c : next;
|
|
138
|
+
});
|
|
112
139
|
} else if (e.key === "down") {
|
|
113
|
-
setCursor((c) =>
|
|
140
|
+
setCursor((c) => {
|
|
141
|
+
const next = findNextSelectable(visibleRows, c + 1, 1);
|
|
142
|
+
return next === -1 ? c : next;
|
|
143
|
+
});
|
|
114
144
|
} else if (e.key === "tab" && props.mode === "multi") {
|
|
115
145
|
props.onDone();
|
|
116
146
|
}
|
|
117
147
|
}, { isActive: isFocused });
|
|
118
148
|
const submitCursor = () => {
|
|
119
149
|
const row = visibleRows[cursor];
|
|
120
|
-
|
|
150
|
+
if (!row || !isSelectable(row))
|
|
151
|
+
return;
|
|
152
|
+
const value = row.value === NEW_KEY ? filter.trim() : row.value;
|
|
121
153
|
if (!value)
|
|
122
154
|
return;
|
|
123
155
|
if (props.mode === "single") {
|
|
@@ -230,28 +262,46 @@ function Picker(props) {
|
|
|
230
262
|
}, "__new__", true, undefined, this);
|
|
231
263
|
}
|
|
232
264
|
const item = row;
|
|
265
|
+
if (item.kind === "header") {
|
|
266
|
+
return /* @__PURE__ */ jsxDEV3(Box3, {
|
|
267
|
+
flexDirection: "row",
|
|
268
|
+
children: /* @__PURE__ */ jsxDEV3(Text3, {
|
|
269
|
+
dim: true,
|
|
270
|
+
bold: true,
|
|
271
|
+
color: item.color ?? undefined,
|
|
272
|
+
children: item.label
|
|
273
|
+
}, undefined, false, undefined, this)
|
|
274
|
+
}, `h:${item.value}`, false, undefined, this);
|
|
275
|
+
}
|
|
233
276
|
const isSelected = selectedSet.has(item.value);
|
|
234
277
|
const marker = props.mode === "multi" ? isSelected ? "\u2713 " : " " : "";
|
|
278
|
+
const dim = item.dim || item.disabled;
|
|
235
279
|
return /* @__PURE__ */ jsxDEV3(Box3, {
|
|
236
280
|
flexDirection: "row",
|
|
237
281
|
justifyContent: "space-between",
|
|
238
282
|
gap: 1,
|
|
239
283
|
backgroundColor: isCursor ? "green" : undefined,
|
|
240
284
|
children: [
|
|
241
|
-
/* @__PURE__ */ jsxDEV3(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
children:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
285
|
+
/* @__PURE__ */ jsxDEV3(Box3, {
|
|
286
|
+
flexDirection: "row",
|
|
287
|
+
gap: 0,
|
|
288
|
+
children: item.display ? item.display : /* @__PURE__ */ jsxDEV3(Text3, {
|
|
289
|
+
color: isCursor ? "black" : item.color ?? undefined,
|
|
290
|
+
bold: isCursor,
|
|
291
|
+
dim: !isCursor && dim,
|
|
292
|
+
children: [
|
|
293
|
+
marker,
|
|
294
|
+
item.label
|
|
295
|
+
]
|
|
296
|
+
}, undefined, true, undefined, this)
|
|
297
|
+
}, undefined, false, undefined, this),
|
|
249
298
|
item.meta && /* @__PURE__ */ jsxDEV3(Box3, {
|
|
250
299
|
paddingX: 1,
|
|
251
300
|
backgroundColor: isCursor ? "black" : "#3a3a3a",
|
|
252
301
|
children: /* @__PURE__ */ jsxDEV3(Text3, {
|
|
253
302
|
color: isCursor ? "green" : "white",
|
|
254
303
|
bold: true,
|
|
304
|
+
dim: !isCursor && dim,
|
|
255
305
|
children: item.meta
|
|
256
306
|
}, undefined, false, undefined, this)
|
|
257
307
|
}, undefined, false, undefined, this)
|
|
@@ -287,12 +337,8 @@ function StatusDot({ status }) {
|
|
|
287
337
|
children: "\u25CF"
|
|
288
338
|
}, undefined, false, undefined, this);
|
|
289
339
|
}
|
|
290
|
-
// src/
|
|
291
|
-
import {
|
|
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";
|
|
340
|
+
// src/db/queries/sessions.ts
|
|
341
|
+
import { eq, and, inArray, sql as sql2 } from "drizzle-orm";
|
|
296
342
|
|
|
297
343
|
// src/db/client.ts
|
|
298
344
|
import { Database } from "bun:sqlite";
|
|
@@ -442,19 +488,13 @@ function getDb() {
|
|
|
442
488
|
// src/lib/id.ts
|
|
443
489
|
import { nanoid } from "nanoid";
|
|
444
490
|
|
|
445
|
-
// src/db/queries/groups.ts
|
|
446
|
-
function getAllGroups() {
|
|
447
|
-
return getDb().select().from(groups).all();
|
|
448
|
-
}
|
|
449
|
-
|
|
450
491
|
// src/db/queries/sessions.ts
|
|
451
|
-
import { eq as eq2, and, inArray, sql as sql2 } from "drizzle-orm";
|
|
452
492
|
function getSession(id) {
|
|
453
|
-
return getDb().select().from(sessions).where(
|
|
493
|
+
return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
|
|
454
494
|
}
|
|
455
495
|
function getAllSessions(opts) {
|
|
456
496
|
const db = getDb();
|
|
457
|
-
const query = db.select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups,
|
|
497
|
+
const query = db.select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id));
|
|
458
498
|
if (opts?.excludeArchived) {
|
|
459
499
|
return query.where(inArray(sessions.status, [
|
|
460
500
|
"active",
|
|
@@ -465,214 +505,6 @@ function getAllSessions(opts) {
|
|
|
465
505
|
return query.all();
|
|
466
506
|
}
|
|
467
507
|
|
|
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
508
|
// src/lib/format.ts
|
|
677
509
|
var SECOND = 1000;
|
|
678
510
|
var MINUTE = 60 * SECOND;
|
|
@@ -706,8 +538,248 @@ function formatAgo(isoOrDate) {
|
|
|
706
538
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
707
539
|
}
|
|
708
540
|
|
|
541
|
+
// src/lib/parse-session-name.ts
|
|
542
|
+
function parseSessionName(input) {
|
|
543
|
+
const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
|
|
544
|
+
if (!trimmed) {
|
|
545
|
+
throw new Error("Session name cannot be empty");
|
|
546
|
+
}
|
|
547
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
548
|
+
if (segments.length < 2) {
|
|
549
|
+
throw new Error(`Session name must include at least one group: "group/session" (got "${trimmed}")`);
|
|
550
|
+
}
|
|
551
|
+
for (const segment of segments) {
|
|
552
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {
|
|
553
|
+
throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const slug = segments[segments.length - 1];
|
|
557
|
+
const groupPath = segments.slice(0, -1).join("/");
|
|
558
|
+
return { groupPath, slug };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/tui/screens/launch/index.tsx
|
|
562
|
+
import { jsxDEV as jsxDEV5, Fragment } from "react/jsx-dev-runtime";
|
|
563
|
+
var STATUS_COLOR = {
|
|
564
|
+
paused: "gold",
|
|
565
|
+
waiting: "red"
|
|
566
|
+
};
|
|
567
|
+
var STATUS_RANK = {
|
|
568
|
+
paused: 0,
|
|
569
|
+
waiting: 1
|
|
570
|
+
};
|
|
571
|
+
function statusRank(status) {
|
|
572
|
+
return STATUS_RANK[status] ?? 99;
|
|
573
|
+
}
|
|
574
|
+
function recencyKey(s) {
|
|
575
|
+
return s.session.endedAt ?? s.session.startedAt;
|
|
576
|
+
}
|
|
577
|
+
function sessionRow(s) {
|
|
578
|
+
const status = s.session.status;
|
|
579
|
+
const color = STATUS_COLOR[status] ?? "gray";
|
|
580
|
+
const disabled = status === "waiting";
|
|
581
|
+
return {
|
|
582
|
+
value: `${s.groupPath}/${s.session.slug}`,
|
|
583
|
+
label: `${s.groupPath}/${s.session.slug} ${status}`,
|
|
584
|
+
meta: formatAgo(recencyKey(s)),
|
|
585
|
+
disabled,
|
|
586
|
+
display: /* @__PURE__ */ jsxDEV5(Fragment, {
|
|
587
|
+
children: [
|
|
588
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
589
|
+
children: " "
|
|
590
|
+
}, undefined, false, undefined, this),
|
|
591
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
592
|
+
color,
|
|
593
|
+
children: "\u25CF "
|
|
594
|
+
}, undefined, false, undefined, this),
|
|
595
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
596
|
+
color,
|
|
597
|
+
dim: disabled,
|
|
598
|
+
children: status.padEnd(8)
|
|
599
|
+
}, undefined, false, undefined, this),
|
|
600
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
601
|
+
children: " "
|
|
602
|
+
}, undefined, false, undefined, this),
|
|
603
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
604
|
+
dim: disabled,
|
|
605
|
+
children: s.session.slug
|
|
606
|
+
}, undefined, false, undefined, this)
|
|
607
|
+
]
|
|
608
|
+
}, undefined, true, undefined, this)
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function groupHeader(groupPath) {
|
|
612
|
+
return {
|
|
613
|
+
value: `__group:${groupPath}`,
|
|
614
|
+
label: groupPath,
|
|
615
|
+
kind: "header"
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function Launch({ onSelect }) {
|
|
619
|
+
const { exit } = useTui();
|
|
620
|
+
const [error, setError] = useState2(null);
|
|
621
|
+
const allSessions = useMemo2(() => getAllSessions({ excludeArchived: true }), []);
|
|
622
|
+
const visibleSessions = useMemo2(() => {
|
|
623
|
+
return allSessions.filter((s) => s.session.status === "paused" || s.session.status === "waiting").sort((a, b) => {
|
|
624
|
+
const g = a.groupPath.localeCompare(b.groupPath);
|
|
625
|
+
if (g !== 0)
|
|
626
|
+
return g;
|
|
627
|
+
const r = statusRank(a.session.status) - statusRank(b.session.status);
|
|
628
|
+
if (r !== 0)
|
|
629
|
+
return r;
|
|
630
|
+
return recencyKey(b).localeCompare(recencyKey(a));
|
|
631
|
+
});
|
|
632
|
+
}, [allSessions]);
|
|
633
|
+
const items = useMemo2(() => {
|
|
634
|
+
const rows = [];
|
|
635
|
+
let lastGroup = null;
|
|
636
|
+
for (const s of visibleSessions) {
|
|
637
|
+
if (s.groupPath !== lastGroup) {
|
|
638
|
+
rows.push(groupHeader(s.groupPath));
|
|
639
|
+
lastGroup = s.groupPath;
|
|
640
|
+
}
|
|
641
|
+
rows.push(sessionRow(s));
|
|
642
|
+
}
|
|
643
|
+
return rows;
|
|
644
|
+
}, [visibleSessions]);
|
|
645
|
+
const sessionByValue = useMemo2(() => {
|
|
646
|
+
const map = new Map;
|
|
647
|
+
for (const s of allSessions) {
|
|
648
|
+
map.set(`${s.groupPath}/${s.session.slug}`, s);
|
|
649
|
+
}
|
|
650
|
+
return map;
|
|
651
|
+
}, [allSessions]);
|
|
652
|
+
const select = (selection) => {
|
|
653
|
+
onSelect(selection);
|
|
654
|
+
exit();
|
|
655
|
+
};
|
|
656
|
+
const handleSubmit = (value) => {
|
|
657
|
+
const existing = sessionByValue.get(value);
|
|
658
|
+
if (existing) {
|
|
659
|
+
if (existing.session.status === "paused") {
|
|
660
|
+
select({ type: "pick", sessionId: existing.session.id });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
setError(`${value} is ${existing.session.status} \u2014 can't resume from here.`);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
const { groupPath, slug } = parseSessionName(value);
|
|
668
|
+
select({ type: "create", groupPath, slug });
|
|
669
|
+
} catch (e) {
|
|
670
|
+
setError(e instanceof Error ? e.message : "Invalid name");
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
const counts = useMemo2(() => {
|
|
674
|
+
let paused = 0;
|
|
675
|
+
let waiting = 0;
|
|
676
|
+
for (const s of visibleSessions) {
|
|
677
|
+
if (s.session.status === "paused")
|
|
678
|
+
paused++;
|
|
679
|
+
else if (s.session.status === "waiting")
|
|
680
|
+
waiting++;
|
|
681
|
+
}
|
|
682
|
+
return { paused, waiting };
|
|
683
|
+
}, [visibleSessions]);
|
|
684
|
+
return /* @__PURE__ */ jsxDEV5(Box4, {
|
|
685
|
+
flexDirection: "column",
|
|
686
|
+
paddingY: 1,
|
|
687
|
+
gap: 1,
|
|
688
|
+
children: [
|
|
689
|
+
/* @__PURE__ */ jsxDEV5(Box4, {
|
|
690
|
+
marginX: 1,
|
|
691
|
+
children: /* @__PURE__ */ jsxDEV5(Logo, {}, undefined, false, undefined, this)
|
|
692
|
+
}, undefined, false, undefined, this),
|
|
693
|
+
/* @__PURE__ */ jsxDEV5(Box4, {
|
|
694
|
+
flexDirection: "column",
|
|
695
|
+
marginX: 2,
|
|
696
|
+
gap: 1,
|
|
697
|
+
children: [
|
|
698
|
+
/* @__PURE__ */ jsxDEV5(AppDetails, {}, undefined, false, undefined, this),
|
|
699
|
+
/* @__PURE__ */ jsxDEV5(Box4, {
|
|
700
|
+
flexDirection: "column",
|
|
701
|
+
gap: 1,
|
|
702
|
+
children: [
|
|
703
|
+
/* @__PURE__ */ jsxDEV5(Box4, {
|
|
704
|
+
flexDirection: "row",
|
|
705
|
+
gap: 1,
|
|
706
|
+
children: [
|
|
707
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
708
|
+
bold: true,
|
|
709
|
+
children: "Sessions"
|
|
710
|
+
}, undefined, false, undefined, this),
|
|
711
|
+
visibleSessions.length === 0 ? /* @__PURE__ */ jsxDEV5(Text5, {
|
|
712
|
+
dim: true,
|
|
713
|
+
children: "\xB7 none \u2014 type group/slug to create"
|
|
714
|
+
}, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV5(Fragment, {
|
|
715
|
+
children: [
|
|
716
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
717
|
+
dim: true,
|
|
718
|
+
children: "\xB7"
|
|
719
|
+
}, undefined, false, undefined, this),
|
|
720
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
721
|
+
color: "gold",
|
|
722
|
+
children: [
|
|
723
|
+
counts.paused,
|
|
724
|
+
" paused"
|
|
725
|
+
]
|
|
726
|
+
}, undefined, true, undefined, this),
|
|
727
|
+
counts.waiting > 0 && /* @__PURE__ */ jsxDEV5(Fragment, {
|
|
728
|
+
children: [
|
|
729
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
730
|
+
dim: true,
|
|
731
|
+
children: "\xB7"
|
|
732
|
+
}, undefined, false, undefined, this),
|
|
733
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
734
|
+
color: "red",
|
|
735
|
+
dim: true,
|
|
736
|
+
children: [
|
|
737
|
+
counts.waiting,
|
|
738
|
+
" waiting"
|
|
739
|
+
]
|
|
740
|
+
}, undefined, true, undefined, this)
|
|
741
|
+
]
|
|
742
|
+
}, undefined, true, undefined, this)
|
|
743
|
+
]
|
|
744
|
+
}, undefined, true, undefined, this)
|
|
745
|
+
]
|
|
746
|
+
}, undefined, true, undefined, this),
|
|
747
|
+
/* @__PURE__ */ jsxDEV5(Picker, {
|
|
748
|
+
mode: "single",
|
|
749
|
+
items,
|
|
750
|
+
isFocused: true,
|
|
751
|
+
placeholder: "Filter or type group/slug to create\u2026",
|
|
752
|
+
emptyHint: "No paused sessions. Type group/slug to create one.",
|
|
753
|
+
onSubmit: handleSubmit
|
|
754
|
+
}, undefined, false, undefined, this),
|
|
755
|
+
error && /* @__PURE__ */ jsxDEV5(Text5, {
|
|
756
|
+
color: "red",
|
|
757
|
+
children: error
|
|
758
|
+
}, undefined, false, undefined, this),
|
|
759
|
+
/* @__PURE__ */ jsxDEV5(Text5, {
|
|
760
|
+
dim: true,
|
|
761
|
+
children: "\u2191\u2193 navigate \xB7 enter continue/create \xB7 esc clear \xB7 ctrl+c quit"
|
|
762
|
+
}, undefined, false, undefined, this)
|
|
763
|
+
]
|
|
764
|
+
}, undefined, true, undefined, this)
|
|
765
|
+
]
|
|
766
|
+
}, undefined, true, undefined, this)
|
|
767
|
+
]
|
|
768
|
+
}, undefined, true, undefined, this);
|
|
769
|
+
}
|
|
770
|
+
|
|
709
771
|
// src/tui/screens/Exit.tsx
|
|
710
|
-
import {
|
|
772
|
+
import { useState as useState3 } from "react";
|
|
773
|
+
import { Box as Box5, Text as Text6, useInput as useInput2, useTui as useTui2 } from "@orchetron/storm";
|
|
774
|
+
|
|
775
|
+
// src/db/queries/conversations.ts
|
|
776
|
+
import { eq as eq2, and as and2, desc, sql as sql3 } from "drizzle-orm";
|
|
777
|
+
function getConversationsBySession(sessionId) {
|
|
778
|
+
return getDb().select().from(conversations).where(and2(eq2(conversations.sessionId, sessionId), eq2(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/tui/screens/Exit.tsx
|
|
782
|
+
import { jsxDEV as jsxDEV6 } from "react/jsx-dev-runtime";
|
|
711
783
|
var OPTIONS = [
|
|
712
784
|
{ action: "save", label: "Save", hint: "Keep session paused for later" },
|
|
713
785
|
{
|
|
@@ -727,11 +799,11 @@ var OPTIONS = [
|
|
|
727
799
|
}
|
|
728
800
|
];
|
|
729
801
|
function Exit({ sessionId, onAction }) {
|
|
730
|
-
const { exit } =
|
|
802
|
+
const { exit } = useTui2();
|
|
731
803
|
const [cursor, setCursor] = useState3(0);
|
|
732
804
|
const session = getSession(sessionId);
|
|
733
805
|
const conversations2 = session ? getConversationsBySession(session.id) : [];
|
|
734
|
-
|
|
806
|
+
useInput2((e) => {
|
|
735
807
|
if (e.key === "c" && e.ctrl)
|
|
736
808
|
exit();
|
|
737
809
|
if (e.key === "up" || e.key === "k") {
|
|
@@ -748,37 +820,37 @@ function Exit({ sessionId, onAction }) {
|
|
|
748
820
|
}
|
|
749
821
|
});
|
|
750
822
|
if (!session) {
|
|
751
|
-
return /* @__PURE__ */
|
|
823
|
+
return /* @__PURE__ */ jsxDEV6(Text6, {
|
|
752
824
|
color: "red",
|
|
753
825
|
children: "Session not found"
|
|
754
826
|
}, undefined, false, undefined, this);
|
|
755
827
|
}
|
|
756
828
|
const duration = session.endedAt && session.startedAt ? formatDuration(new Date(session.endedAt).getTime() - new Date(session.startedAt).getTime()) : null;
|
|
757
|
-
return /* @__PURE__ */
|
|
829
|
+
return /* @__PURE__ */ jsxDEV6(Box5, {
|
|
758
830
|
flexDirection: "column",
|
|
759
831
|
padding: 1,
|
|
760
832
|
gap: 1,
|
|
761
833
|
children: [
|
|
762
|
-
/* @__PURE__ */
|
|
834
|
+
/* @__PURE__ */ jsxDEV6(Text6, {
|
|
763
835
|
bold: true,
|
|
764
836
|
color: "#82AAFF",
|
|
765
837
|
children: "Session ended"
|
|
766
838
|
}, undefined, false, undefined, this),
|
|
767
|
-
/* @__PURE__ */
|
|
839
|
+
/* @__PURE__ */ jsxDEV6(Box5, {
|
|
768
840
|
flexDirection: "column",
|
|
769
841
|
children: [
|
|
770
|
-
/* @__PURE__ */
|
|
842
|
+
/* @__PURE__ */ jsxDEV6(Box5, {
|
|
771
843
|
flexDirection: "row",
|
|
772
844
|
gap: 1,
|
|
773
845
|
children: [
|
|
774
|
-
/* @__PURE__ */
|
|
846
|
+
/* @__PURE__ */ jsxDEV6(StatusDot, {
|
|
775
847
|
status: session.status
|
|
776
848
|
}, undefined, false, undefined, this),
|
|
777
|
-
/* @__PURE__ */
|
|
849
|
+
/* @__PURE__ */ jsxDEV6(Text6, {
|
|
778
850
|
bold: true,
|
|
779
851
|
children: session.name
|
|
780
852
|
}, undefined, false, undefined, this),
|
|
781
|
-
duration && /* @__PURE__ */
|
|
853
|
+
duration && /* @__PURE__ */ jsxDEV6(Text6, {
|
|
782
854
|
dim: true,
|
|
783
855
|
children: [
|
|
784
856
|
"(",
|
|
@@ -788,7 +860,7 @@ function Exit({ sessionId, onAction }) {
|
|
|
788
860
|
}, undefined, true, undefined, this)
|
|
789
861
|
]
|
|
790
862
|
}, undefined, true, undefined, this),
|
|
791
|
-
/* @__PURE__ */
|
|
863
|
+
/* @__PURE__ */ jsxDEV6(Text6, {
|
|
792
864
|
dim: true,
|
|
793
865
|
children: [
|
|
794
866
|
conversations2.length,
|
|
@@ -798,28 +870,28 @@ function Exit({ sessionId, onAction }) {
|
|
|
798
870
|
}, undefined, true, undefined, this)
|
|
799
871
|
]
|
|
800
872
|
}, undefined, true, undefined, this),
|
|
801
|
-
/* @__PURE__ */
|
|
873
|
+
/* @__PURE__ */ jsxDEV6(Box5, {
|
|
802
874
|
flexDirection: "column",
|
|
803
|
-
children: OPTIONS.map((opt, i) => /* @__PURE__ */
|
|
875
|
+
children: OPTIONS.map((opt, i) => /* @__PURE__ */ jsxDEV6(Box5, {
|
|
804
876
|
flexDirection: "row",
|
|
805
877
|
gap: 1,
|
|
806
878
|
children: [
|
|
807
|
-
/* @__PURE__ */
|
|
879
|
+
/* @__PURE__ */ jsxDEV6(Text6, {
|
|
808
880
|
children: i === cursor ? "\u276F" : " "
|
|
809
881
|
}, undefined, false, undefined, this),
|
|
810
|
-
/* @__PURE__ */
|
|
882
|
+
/* @__PURE__ */ jsxDEV6(Text6, {
|
|
811
883
|
bold: i === cursor,
|
|
812
884
|
children: opt.label
|
|
813
885
|
}, undefined, false, undefined, this),
|
|
814
|
-
/* @__PURE__ */
|
|
886
|
+
/* @__PURE__ */ jsxDEV6(Text6, {
|
|
815
887
|
dim: true,
|
|
816
888
|
children: opt.hint
|
|
817
889
|
}, undefined, false, undefined, this)
|
|
818
890
|
]
|
|
819
891
|
}, opt.action, true, undefined, this))
|
|
820
892
|
}, undefined, false, undefined, this),
|
|
821
|
-
/* @__PURE__ */
|
|
822
|
-
children: /* @__PURE__ */
|
|
893
|
+
/* @__PURE__ */ jsxDEV6(Box5, {
|
|
894
|
+
children: /* @__PURE__ */ jsxDEV6(Text6, {
|
|
823
895
|
dim: true,
|
|
824
896
|
children: "\u2191\u2193 navigate \xB7 enter select \xB7 q save & quit"
|
|
825
897
|
}, undefined, false, undefined, this)
|
|
@@ -830,10 +902,10 @@ function Exit({ sessionId, onAction }) {
|
|
|
830
902
|
|
|
831
903
|
// src/tui/screens/Resume.tsx
|
|
832
904
|
import { useState as useState4 } from "react";
|
|
833
|
-
import { Box as
|
|
834
|
-
import { jsxDEV as
|
|
905
|
+
import { Box as Box6, Text as Text7, useInput as useInput3, useTui as useTui3 } from "@orchetron/storm";
|
|
906
|
+
import { jsxDEV as jsxDEV7 } from "react/jsx-dev-runtime";
|
|
835
907
|
function Resume({ sessionId, onSelect }) {
|
|
836
|
-
const { exit } =
|
|
908
|
+
const { exit } = useTui3();
|
|
837
909
|
const [cursor, setCursor] = useState4(0);
|
|
838
910
|
const session = getSession(sessionId);
|
|
839
911
|
const conversations2 = getConversationsBySession(sessionId);
|
|
@@ -842,7 +914,7 @@ function Resume({ sessionId, onSelect }) {
|
|
|
842
914
|
onSelect(selection);
|
|
843
915
|
exit();
|
|
844
916
|
};
|
|
845
|
-
|
|
917
|
+
useInput3((e) => {
|
|
846
918
|
if (e.key === "c" && e.ctrl)
|
|
847
919
|
select({ type: "back" });
|
|
848
920
|
if (e.key === "up" || e.key === "k") {
|
|
@@ -861,17 +933,17 @@ function Resume({ sessionId, onSelect }) {
|
|
|
861
933
|
}
|
|
862
934
|
});
|
|
863
935
|
if (!session) {
|
|
864
|
-
return /* @__PURE__ */
|
|
936
|
+
return /* @__PURE__ */ jsxDEV7(Text7, {
|
|
865
937
|
color: "red",
|
|
866
938
|
children: "Session not found"
|
|
867
939
|
}, undefined, false, undefined, this);
|
|
868
940
|
}
|
|
869
|
-
return /* @__PURE__ */
|
|
941
|
+
return /* @__PURE__ */ jsxDEV7(Box6, {
|
|
870
942
|
flexDirection: "column",
|
|
871
943
|
padding: 1,
|
|
872
944
|
gap: 1,
|
|
873
945
|
children: [
|
|
874
|
-
/* @__PURE__ */
|
|
946
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
875
947
|
bold: true,
|
|
876
948
|
color: "#82AAFF",
|
|
877
949
|
children: [
|
|
@@ -879,17 +951,17 @@ function Resume({ sessionId, onSelect }) {
|
|
|
879
951
|
session.name
|
|
880
952
|
]
|
|
881
953
|
}, undefined, true, undefined, this),
|
|
882
|
-
/* @__PURE__ */
|
|
954
|
+
/* @__PURE__ */ jsxDEV7(Box6, {
|
|
883
955
|
flexDirection: "column",
|
|
884
956
|
children: [
|
|
885
|
-
/* @__PURE__ */
|
|
957
|
+
/* @__PURE__ */ jsxDEV7(Box6, {
|
|
886
958
|
flexDirection: "row",
|
|
887
959
|
gap: 1,
|
|
888
960
|
children: [
|
|
889
|
-
/* @__PURE__ */
|
|
961
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
890
962
|
children: cursor === 0 ? "\u276F" : " "
|
|
891
963
|
}, undefined, false, undefined, this),
|
|
892
|
-
/* @__PURE__ */
|
|
964
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
893
965
|
bold: cursor === 0,
|
|
894
966
|
color: "#34D399",
|
|
895
967
|
children: "+ New conversation"
|
|
@@ -901,42 +973,42 @@ function Resume({ sessionId, onSelect }) {
|
|
|
901
973
|
const isSelected = cursor === idx;
|
|
902
974
|
const duration = conv.endedAt ? formatDuration(new Date(conv.endedAt).getTime() - new Date(conv.startedAt).getTime()) : "active";
|
|
903
975
|
const ago = formatAgo(conv.startedAt);
|
|
904
|
-
return /* @__PURE__ */
|
|
976
|
+
return /* @__PURE__ */ jsxDEV7(Box6, {
|
|
905
977
|
flexDirection: "row",
|
|
906
978
|
gap: 1,
|
|
907
979
|
children: [
|
|
908
|
-
/* @__PURE__ */
|
|
980
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
909
981
|
children: isSelected ? "\u276F" : " "
|
|
910
982
|
}, undefined, false, undefined, this),
|
|
911
|
-
/* @__PURE__ */
|
|
983
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
912
984
|
bold: isSelected,
|
|
913
985
|
dim: conv.discarded,
|
|
914
986
|
children: conv.id.slice(0, 8)
|
|
915
987
|
}, undefined, false, undefined, this),
|
|
916
|
-
/* @__PURE__ */
|
|
988
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
917
989
|
dim: true,
|
|
918
990
|
children: ago
|
|
919
991
|
}, undefined, false, undefined, this),
|
|
920
|
-
/* @__PURE__ */
|
|
992
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
921
993
|
dim: true,
|
|
922
994
|
children: "\xB7"
|
|
923
995
|
}, undefined, false, undefined, this),
|
|
924
|
-
/* @__PURE__ */
|
|
996
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
925
997
|
dim: true,
|
|
926
998
|
children: [
|
|
927
999
|
conv.eventCount,
|
|
928
1000
|
" events"
|
|
929
1001
|
]
|
|
930
1002
|
}, undefined, true, undefined, this),
|
|
931
|
-
/* @__PURE__ */
|
|
1003
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
932
1004
|
dim: true,
|
|
933
1005
|
children: "\xB7"
|
|
934
1006
|
}, undefined, false, undefined, this),
|
|
935
|
-
/* @__PURE__ */
|
|
1007
|
+
/* @__PURE__ */ jsxDEV7(Text7, {
|
|
936
1008
|
dim: true,
|
|
937
1009
|
children: duration
|
|
938
1010
|
}, undefined, false, undefined, this),
|
|
939
|
-
conv.discarded && /* @__PURE__ */
|
|
1011
|
+
conv.discarded && /* @__PURE__ */ jsxDEV7(Text7, {
|
|
940
1012
|
color: "red",
|
|
941
1013
|
dim: true,
|
|
942
1014
|
children: "(discarded)"
|
|
@@ -946,8 +1018,8 @@ function Resume({ sessionId, onSelect }) {
|
|
|
946
1018
|
})
|
|
947
1019
|
]
|
|
948
1020
|
}, undefined, true, undefined, this),
|
|
949
|
-
/* @__PURE__ */
|
|
950
|
-
children: /* @__PURE__ */
|
|
1021
|
+
/* @__PURE__ */ jsxDEV7(Box6, {
|
|
1022
|
+
children: /* @__PURE__ */ jsxDEV7(Text7, {
|
|
951
1023
|
dim: true,
|
|
952
1024
|
children: "\u2191\u2193 navigate \xB7 enter select \xB7 q back"
|
|
953
1025
|
}, undefined, false, undefined, this)
|
|
@@ -957,7 +1029,7 @@ function Resume({ sessionId, onSelect }) {
|
|
|
957
1029
|
}
|
|
958
1030
|
|
|
959
1031
|
// src/tui/run-screen.tsx
|
|
960
|
-
import { jsxDEV as
|
|
1032
|
+
import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
|
|
961
1033
|
var [, , screen, outputPath, ...args] = process.argv;
|
|
962
1034
|
if (!screen || !outputPath) {
|
|
963
1035
|
console.error("Usage: run-screen <screen> <outputPath> [args...]");
|
|
@@ -967,7 +1039,7 @@ var result;
|
|
|
967
1039
|
switch (screen) {
|
|
968
1040
|
case "launch": {
|
|
969
1041
|
let selection = { type: "quit" };
|
|
970
|
-
const app = render(/* @__PURE__ */
|
|
1042
|
+
const app = render(/* @__PURE__ */ jsxDEV8(Launch, {
|
|
971
1043
|
onSelect: (s) => {
|
|
972
1044
|
selection = s;
|
|
973
1045
|
}
|
|
@@ -984,7 +1056,7 @@ switch (screen) {
|
|
|
984
1056
|
process.exit(1);
|
|
985
1057
|
}
|
|
986
1058
|
let action = "save";
|
|
987
|
-
const app = render(/* @__PURE__ */
|
|
1059
|
+
const app = render(/* @__PURE__ */ jsxDEV8(Exit, {
|
|
988
1060
|
sessionId,
|
|
989
1061
|
onAction: (a) => {
|
|
990
1062
|
action = a;
|
|
@@ -1002,7 +1074,7 @@ switch (screen) {
|
|
|
1002
1074
|
process.exit(1);
|
|
1003
1075
|
}
|
|
1004
1076
|
let selection = { type: "back" };
|
|
1005
|
-
const app = render(/* @__PURE__ */
|
|
1077
|
+
const app = render(/* @__PURE__ */ jsxDEV8(Resume, {
|
|
1006
1078
|
sessionId,
|
|
1007
1079
|
onSelect: (s) => {
|
|
1008
1080
|
selection = s;
|