dual-brain 0.1.1 → 0.1.3

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.
@@ -998,112 +998,94 @@ function loadTerminalState(cwd, terminalId) {
998
998
  } catch { return null; }
999
999
  }
1000
1000
 
1001
- // ─── Screen: mainScreen ───────────────────────────────────────────────────────
1002
-
1003
- async function mainScreen(rl, ask) {
1004
- const cwd = process.cwd();
1005
- const version = readVersion();
1006
- const profile = loadProfile(cwd);
1007
- const auth = await detectAuth();
1001
+ // ─── Dashboard box helpers ────────────────────────────────────────────────────
1008
1002
 
1009
- const claudeSub = profile?.providers?.claude;
1010
- const openaiSub = profile?.providers?.openai;
1011
- const claudePlan = claudeSub?.plan ?? 'Pro';
1012
- const openaiPlan = openaiSub?.plan ?? 'Plus';
1003
+ /**
1004
+ * Build a provider status string for the dashboard status line.
1005
+ * Returns a string like: "🟢 Claude $100×2 $20×1 🟢 OpenAI $100"
1006
+ * Uses ANSI color codes for the dots (no emoji width issues).
1007
+ */
1008
+ function buildProviderStatusLine(profile, auth) {
1009
+ const GREEN = '\x1b[32m●\x1b[0m';
1010
+ const RED = '\x1b[31m●\x1b[0m';
1011
+ const now = Date.now();
1013
1012
 
1014
- // Check subscription expiry
1015
- const now = Date.now();
1016
- const claudeExpired = claudeSub?.expiresAt && Date.parse(claudeSub.expiresAt) < now;
1017
- const openaiExpired = openaiSub?.expiresAt && Date.parse(openaiSub.expiresAt) < now;
1013
+ function providerSegment(provKey, displayName) {
1014
+ const sub = profile?.providers?.[provKey];
1015
+ const found = provKey === 'claude' ? auth.claude.found : auth.openai.found;
1016
+ if (!found) return `${RED} ${displayName}: not connected`;
1018
1017
 
1019
- const claudeDays = daysUntil(claudeSub?.expiresAt);
1020
- const openaiDays = daysUntil(openaiSub?.expiresAt);
1018
+ const expired = sub?.expiresAt && Date.parse(sub.expiresAt) < now;
1019
+ if (expired) return `${RED} ${displayName}: expired`;
1021
1020
 
1022
- function subLine(name, plan, found, expired, days, sub) {
1023
- const label = sub?.label ? ` [${sub.label}]` : '';
1024
- if (!found) return `⚠️ ${name}: not logged in — run: ${name === 'Claude' ? 'claude auth login' : 'codex login'}`;
1025
- // Multi-sub: show aggregated counts when more than one sub exists
1021
+ const dot = GREEN;
1022
+ // Multi-sub: show aggregated plan amounts
1026
1023
  const subs = sub?.subs;
1027
- if (subs && subs.length > 1) {
1028
- const aggregate = aggregatePlans(subs);
1029
- return `✅ ${name}: ${aggregate} [${subs.length} subs]`;
1024
+ if (subs && subs.length > 0) {
1025
+ const agg = aggregatePlans(subs);
1026
+ return `${dot} ${displayName} ${agg}`;
1030
1027
  }
1031
- if (expired) return `🔴 ${name}: ${plan} expired${label} — will re-auth`;
1032
- const daysNote = (days !== null && days <= 7) ? ` (${days}d left)` : '';
1033
- return `✅ ${name}: ${plan}${label}${daysNote}`;
1028
+ // Single plan
1029
+ const planPrice = PLAN_PRICES[sub?.plan] || sub?.plan || 'connected';
1030
+ return `${dot} ${displayName} ${planPrice}`;
1034
1031
  }
1035
1032
 
1036
- const headerLines = [
1037
- subLine('Claude', claudePlan, auth.claude.found, claudeExpired, claudeDays, claudeSub),
1038
- subLine('OpenAI', openaiPlan, auth.openai.found, openaiExpired, openaiDays, openaiSub),
1039
- ];
1033
+ const parts = [];
1034
+ parts.push(providerSegment('claude', 'Claude'));
1035
+ parts.push(providerSegment('openai', 'OpenAI'));
1036
+ return parts.join(' ');
1037
+ }
1040
1038
 
1041
- const rtMain = detectReplitTools(cwd);
1042
- const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
1043
- console.log(`🧠 Dual Brain v${version}`);
1044
- const latestVersion = await checkForUpdates(version);
1045
- if (latestVersion) {
1046
- console.log(` ⬆️ Update available: v${version} v${latestVersion}`);
1047
- console.log(` Run: npx -y dual-brain@latest`);
1048
- }
1049
- console.log('');
1039
+ /**
1040
+ * Render a box row padded to inner width W (stripping ANSI for length calculation).
1041
+ * Returns a string like: "│ content padded to W │"
1042
+ */
1043
+ function makeBoxRow(content, W) {
1044
+ const plain = content.replace(/\x1b\[[0-9;]*m/g, '');
1045
+ const padding = Math.max(0, W - plain.length);
1046
+ return `│ ${content}${' '.repeat(padding)} │`;
1047
+ }
1050
1048
 
1051
- // Provider status (outside the box)
1052
- for (const line of headerLines) {
1053
- console.log(` ${line}`);
1054
- }
1055
- if (dtVersion) {
1056
- console.log(` 📦 data-tools v${dtVersion} detected`);
1057
- }
1049
+ // ─── Screen: mainScreen ───────────────────────────────────────────────────────
1058
1050
 
1059
- const sparkline = buildSparkline(cwd);
1060
- if (sparkline) {
1061
- console.log(` Activity: ${sparkline}`);
1062
- }
1051
+ async function mainScreen(rl, ask) {
1052
+ const cwd = process.cwd();
1053
+ const version = readVersion();
1054
+ const profile = loadProfile(cwd);
1055
+ const auth = await detectAuth();
1056
+
1057
+ const claudeSub = profile?.providers?.claude;
1058
+ const openaiSub = profile?.providers?.openai;
1063
1059
 
1064
- // Silent OAuth token auto-refresh (like data-tools)
1060
+ // Check subscription expiry for auto-refresh
1061
+ const now = Date.now();
1062
+ const claudeExpired = claudeSub?.expiresAt && Date.parse(claudeSub.expiresAt) < now;
1063
+ const openaiExpired = openaiSub?.expiresAt && Date.parse(openaiSub.expiresAt) < now;
1064
+
1065
+ // Silent OAuth token auto-refresh
1065
1066
  try {
1066
1067
  const { autoRefreshToken } = await import('../src/profile.mjs');
1067
- const refreshResult = await autoRefreshToken(cwd);
1068
- if (refreshResult.status === 'refreshed') {
1069
- console.log(` 🔄 Token auto-refreshed (${refreshResult.hoursRemaining}h remaining)`);
1070
- }
1068
+ await autoRefreshToken(cwd);
1071
1069
  } catch {}
1072
1070
 
1073
- // Append-only session archive sync (like data-tools)
1071
+ // Append-only session archive sync
1074
1072
  try {
1075
1073
  const { syncSessionMirror } = await import('../src/session.mjs');
1076
- const mirror = syncSessionMirror(cwd);
1077
- if (mirror.copied > 0 || mirror.grew > 0) {
1078
- console.log(` ✅ Archive mirror: +${mirror.copied} new, ${mirror.grew} updated`);
1079
- }
1074
+ syncSessionMirror(cwd);
1080
1075
  } catch {}
1081
1076
 
1082
1077
  // Auto-refresh expired subscriptions
1083
1078
  if (claudeExpired || openaiExpired) {
1084
1079
  const { spawnSync } = await import('node:child_process');
1085
- const expired = [];
1086
- if (claudeExpired) expired.push('Claude');
1087
- if (openaiExpired) expired.push('OpenAI');
1088
- console.log(`\n ${expired.join(' & ')} subscription expired. Re-authenticating...`);
1089
1080
  if (claudeExpired) {
1090
1081
  const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 30000 });
1091
- if (r.status === 0) {
1092
- claudeSub.expiresAt = null;
1093
- saveProfile(profile, { cwd });
1094
- console.log(' ✓ Claude re-authenticated');
1095
- }
1082
+ if (r.status === 0) { claudeSub.expiresAt = null; saveProfile(profile, { cwd }); }
1096
1083
  }
1097
1084
  if (openaiExpired) {
1098
1085
  const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 30000 });
1099
- if (r.status === 0) {
1100
- openaiSub.expiresAt = null;
1101
- saveProfile(profile, { cwd });
1102
- console.log(' ✓ OpenAI re-authenticated');
1103
- }
1086
+ if (r.status === 0) { openaiSub.expiresAt = null; saveProfile(profile, { cwd }); }
1104
1087
  }
1105
1088
  }
1106
- console.log('');
1107
1089
 
1108
1090
  // Build session index in background (powers search + smart resume)
1109
1091
  try {
@@ -1111,245 +1093,284 @@ async function mainScreen(rl, ask) {
1111
1093
  buildSessionIndex(cwd);
1112
1094
  } catch {}
1113
1095
 
1114
- const recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 7);
1096
+ // Gather recent sessions
1097
+ const recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 3);
1098
+
1099
+ // Detect data-tools version
1100
+ const rtMain = detectReplitTools(cwd);
1101
+ const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
1102
+
1103
+ // ── Box layout ────────────────────────────────────────────────────────────
1104
+ const termW = process.stdout.columns || 60;
1105
+ const boxW = Math.min(termW - 2, 60); // outer width (including │ │)
1106
+ const W = boxW - 4; // inner content width (│ {content} │)
1107
+
1108
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
1109
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
1110
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
1111
+
1112
+ const row = (content) => makeBoxRow(content, W);
1113
+
1114
+ // ── Header: one line above the box ────────────────────────────────────────
1115
+ process.stdout.write(`\n🧠 dual-brain v${version}\n`);
1116
+ {
1117
+ let gitName = '';
1118
+ try {
1119
+ const { execSync } = await import('node:child_process');
1120
+ gitName = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
1121
+ } catch { /* ignore */ }
1122
+ if (gitName) {
1123
+ const hour = new Date().getHours();
1124
+ let greet;
1125
+ if (hour >= 5 && hour <= 11) greet = 'Good morning';
1126
+ else if (hour >= 12 && hour <= 16) greet = 'Good afternoon';
1127
+ else if (hour >= 17 && hour <= 21) greet = 'Good evening';
1128
+ else greet = 'Late night';
1129
+ process.stdout.write(`\x1b[2m${greet}, ${gitName}\x1b[0m\n`);
1130
+ }
1131
+ }
1115
1132
 
1116
- if (recentSessions.length > 0) {
1117
- console.log(' Recent Sessions:');
1133
+ // ── Status section ────────────────────────────────────────────────────────
1134
+ const providerLine = buildProviderStatusLine(profile, auth);
1135
+
1136
+ const statusRows = [row(providerLine)];
1137
+ if (dtVersion) {
1138
+ statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
1139
+ }
1140
+
1141
+ // ── Sessions section ──────────────────────────────────────────────────────
1142
+ const sessionRows = [];
1143
+ if (recentSessions.length === 0) {
1144
+ const noSessMsg = 'No sessions yet. Press n to start.';
1145
+ sessionRows.push(row(noSessMsg));
1146
+ } else {
1118
1147
  recentSessions.forEach((sess, i) => {
1119
- const pin = sess.pinned ? '📌 ' : ' ';
1120
- const active = sess.isActive ? ' ●' : '';
1121
- const cat = sess.category ? ` [${sess.category}]` : '';
1122
- const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
1123
- // If the name is still the "Session XXXXXXXX" fallback, try the project path instead
1148
+ // Normalize name: strip "Session XXXXXXXX" fallbacks
1124
1149
  let rawName = sess.name || '';
1125
1150
  if (/^Session [0-9a-f]{8,}$/i.test(rawName)) {
1126
- rawName = sess.project ? sess.project.replace(/^-/, '/').replace(/-/g, '/') : sess.id.slice(0, 8);
1151
+ rawName = sess.project
1152
+ ? sess.project.replace(/^-/, '/').replace(/-/g, '/')
1153
+ : sess.id.slice(0, 8);
1127
1154
  }
1128
- const displayName = rawName.length > 40 ? rawName.slice(0, 37) + '...' : (rawName || sess.id.slice(0, 8));
1129
- console.log(` [${i + 1}] ${pin}${tool} ${sess.age.padEnd(8)} ${displayName}${active}${cat}`);
1155
+ // Layout: "{num} {name...} {age}"
1156
+ const numStr = String(i + 1);
1157
+ const ageStr = sess.age || '';
1158
+ // Available for name: W - numStr.length - 2 spaces - 2 spaces before age - ageStr.length
1159
+ const nameMax = W - numStr.length - 2 - 2 - ageStr.length;
1160
+ const name = rawName.length > nameMax
1161
+ ? rawName.slice(0, nameMax - 3) + '...'
1162
+ : rawName.padEnd(nameMax);
1163
+ const content = `${numStr} ${name} ${ageStr}`;
1164
+ sessionRows.push(row(content));
1130
1165
  });
1131
- console.log('');
1132
1166
  }
1133
1167
 
1134
- const brandW = 37;
1135
- const brandTop = ` ┌${'─'.repeat(brandW)}┐`;
1136
- const brandBottom = ` └${'─'.repeat(brandW)}┘`;
1137
- const brandPad = (s) => {
1138
- const leftPad = Math.floor((brandW - s.length) / 2);
1139
- const rightPad = brandW - s.length - leftPad;
1140
- return ' '.repeat(leftPad) + s + ' '.repeat(rightPad);
1168
+ // ── Actions bar ───────────────────────────────────────────────────────────
1169
+ const actionsContent = '↵ Resume n New / Search s Settings q Quit';
1170
+ const actionsRow = row(actionsContent);
1171
+
1172
+ // ── Print the full box ────────────────────────────────────────────────────
1173
+ const lines = [
1174
+ top,
1175
+ ...statusRows,
1176
+ sep,
1177
+ ...sessionRows,
1178
+ sep,
1179
+ actionsRow,
1180
+ bot,
1181
+ ];
1182
+ process.stdout.write(lines.join('\n') + '\n');
1183
+ process.stdout.write(`\x1b[2mPowered by data-tools · Steve Moraco\x1b[0m\n\n`);
1184
+
1185
+ // ── Key handling ──────────────────────────────────────────────────────────
1186
+ // Use raw keypress mode so we can show a live type-to-start buffer.
1187
+ // Single-key commands (n, s, q, /, 1-9, Enter) only fire when buffer is empty.
1188
+ let taskBuffer = '';
1189
+
1190
+ const readline = await import('node:readline');
1191
+
1192
+ // Render the type-ahead line below the box (overwrites the current cursor line)
1193
+ const renderBuffer = (buf) => {
1194
+ // Move to the prompt line (we're already at it after printing the box + footer)
1195
+ // Use carriage return + clear-to-end-of-line to overwrite
1196
+ if (buf.length === 0) {
1197
+ process.stdout.write('\r\x1b[K');
1198
+ } else {
1199
+ const display = buf.length > W - 4 ? buf.slice(-(W - 4)) : buf;
1200
+ process.stdout.write(`\r\x1b[K> ${display}\x1b[7m \x1b[0m`);
1201
+ }
1141
1202
  };
1142
- console.log(brandTop);
1143
- console.log(` │ ${brandPad('Dual Brain Session Manager')}│`);
1144
- console.log(` │ ${brandPad('Built on data-tools by Steve Moraco')}│`);
1145
- console.log(brandBottom);
1146
- console.log('');
1147
1203
 
1148
- const running = countRunningInstances();
1149
- const runningParts = [];
1150
- if (running.claude > 0) runningParts.push(`${running.claude} claude`);
1151
- if (running.codex > 0) runningParts.push(`${running.codex} codex`);
1152
- if (runningParts.length > 0) {
1153
- console.log(` (${runningParts.join(', ')} running)`);
1154
- console.log('');
1155
- }
1204
+ // Enable keypress events on stdin (safe to call multiple times)
1205
+ readline.emitKeypressEvents(process.stdin, rl);
1156
1206
 
1157
- console.log(' [c] Continue last session');
1158
- console.log(' [n] New session');
1159
- console.log('');
1160
- if (recentSessions.length > 0) {
1161
- console.log(' [1-9] Resume numbered above');
1162
- }
1163
- console.log(' [r] Resume (full list)');
1164
- console.log(' [/] Search sessions');
1165
- console.log(' [e] Manage sessions');
1166
- console.log(' [m] Manage subscriptions');
1167
- console.log(' [s] Settings');
1168
- console.log(' [?] Help & shortcuts');
1169
- console.log('');
1170
- console.log(' \x1b[2mreplit-tools:\x1b[0m');
1171
- console.log(' [i] Import sessions');
1172
- console.log(' [d] Switch to data-tools');
1173
- console.log('');
1174
- console.log(' [q] Exit');
1175
- console.log('');
1207
+ const raw = await new Promise((resolve) => {
1208
+ // Switch to raw mode if possible (TTY only)
1209
+ const wasRaw = process.stdin.isRaw;
1210
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1211
+ if (canRaw) process.stdin.setRawMode(true);
1176
1212
 
1177
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
1213
+ const cleanup = () => {
1214
+ process.stdin.removeListener('keypress', onKey);
1215
+ if (canRaw) {
1216
+ try { process.stdin.setRawMode(wasRaw || false); } catch {}
1217
+ }
1218
+ };
1178
1219
 
1179
- if (choice === '?') {
1180
- const W = 37;
1181
- const helpTop = ` ┌${'─'.repeat(W)}┐`;
1182
- const helpSep = ` ├${'─'.repeat(W)}┤`;
1183
- const helpBottom = ` └${'─'.repeat(W)}┘`;
1184
- const helpPad = (s) => s + ' '.repeat(Math.max(0, W - s.length));
1185
- console.log('');
1186
- console.log(helpTop);
1187
- console.log(` │ ${helpPad('At ~/workspace$ prompt:')}│`);
1188
- console.log(` │ ${helpPad('db = show this menu')}│`);
1189
- console.log(` │ ${helpPad('j = login to claude')}│`);
1190
- console.log(` │ ${helpPad('k = login to codex')}│`);
1191
- console.log(helpSep);
1192
- console.log(` │ ${helpPad('In Claude:')}│`);
1193
- console.log(` │ ${helpPad('Ctrl+C x2 = back to menu')}│`);
1194
- console.log(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│`);
1195
- console.log(helpBottom);
1196
- console.log('');
1197
- await ask(' Press Enter to continue...');
1198
- return { next: 'main' };
1199
- }
1220
+ const onKey = (str, key) => {
1221
+ if (!key) return;
1200
1222
 
1201
- if (choice === 'n') { return { next: 'new-session' }; }
1223
+ const name = key.name || '';
1224
+ const seq = key.sequence || str || '';
1202
1225
 
1203
- if (choice === 'c') {
1204
- const termId = getTerminalId();
1205
- const termState = loadTerminalState(cwd, termId);
1206
- const sessions = importReplitSessions(cwd);
1226
+ // Ctrl-C / Ctrl-D → exit
1227
+ if (key.ctrl && (name === 'c' || name === 'd')) {
1228
+ cleanup();
1229
+ process.stdout.write('\n');
1230
+ resolve('q');
1231
+ return;
1232
+ }
1207
1233
 
1208
- // Priority: terminal-specific last session, then global last session
1209
- const targetId = termState?.sessionId || (sessions.length > 0 ? sessions[0].id : null);
1234
+ // Enter key
1235
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
1236
+ cleanup();
1237
+ if (taskBuffer.length > 0) {
1238
+ process.stdout.write('\n');
1239
+ resolve(`__task__:${taskBuffer}`);
1240
+ } else {
1241
+ resolve('');
1242
+ }
1243
+ return;
1244
+ }
1210
1245
 
1211
- if (!targetId) {
1212
- console.log('\n No recent sessions found.\n');
1213
- await ask(' Press Enter to continue...');
1214
- return { next: 'main' };
1215
- }
1246
+ // Escape → clear buffer
1247
+ if (name === 'escape') {
1248
+ taskBuffer = '';
1249
+ renderBuffer('');
1250
+ return;
1251
+ }
1216
1252
 
1217
- // Smart resume preview
1218
- try {
1219
- const { getSessionContext } = await import('../src/session.mjs');
1220
- const ctx = getSessionContext(targetId, cwd);
1221
- if (ctx) {
1222
- console.log('');
1223
- if (ctx.lastPrompt) console.log(` Last working on: ${ctx.lastPrompt}`);
1224
- if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
1253
+ // Backspace / delete
1254
+ if (name === 'backspace' || name === 'delete') {
1255
+ if (taskBuffer.length > 0) {
1256
+ taskBuffer = taskBuffer.slice(0, -1);
1257
+ renderBuffer(taskBuffer);
1258
+ }
1259
+ return;
1225
1260
  }
1226
- } catch {}
1227
1261
 
1262
+ // Ignore non-printable / control keys
1263
+ if (key.ctrl || key.meta || !str || str.length === 0) return;
1264
+ const code = str.codePointAt(0);
1265
+ if (code < 32 || code === 127) return;
1266
+
1267
+ // Single-key commands only fire when buffer is empty
1268
+ if (taskBuffer.length === 0) {
1269
+ const lower = str.toLowerCase();
1270
+ if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/') {
1271
+ cleanup();
1272
+ process.stdout.write('\n');
1273
+ resolve(lower);
1274
+ return;
1275
+ }
1276
+ const digit = parseInt(str, 10);
1277
+ if (!isNaN(digit) && digit >= 1 && digit <= 9) {
1278
+ cleanup();
1279
+ process.stdout.write('\n');
1280
+ resolve(str);
1281
+ return;
1282
+ }
1283
+ }
1284
+
1285
+ // Accumulate into buffer
1286
+ taskBuffer += str;
1287
+ renderBuffer(taskBuffer);
1288
+ };
1289
+
1290
+ process.stdin.on('keypress', onKey);
1291
+ });
1292
+
1293
+ const choice = typeof raw === 'string' ? raw.toLowerCase() : '';
1294
+
1295
+ // Typed task → dispatch as "dual-brain go"
1296
+ if (raw.startsWith('__task__:')) {
1297
+ const prompt = raw.slice('__task__:'.length).trim();
1298
+ if (prompt) {
1299
+ return { next: 'go', prompt };
1300
+ }
1301
+ return { next: 'main' };
1302
+ }
1303
+
1304
+ // Enter (empty) → resume most recent session
1305
+ if (raw === '' || choice === '\r') {
1306
+ if (recentSessions.length === 0) {
1307
+ return { next: 'new-session' };
1308
+ }
1309
+ const sess = recentSessions[0];
1228
1310
  const { spawnSync } = await import('node:child_process');
1229
- const tool = termState?.tool || 'claude';
1230
- console.log(`\n Resuming: ${tool} --resume ${targetId}\n`);
1231
- spawnSync(tool === 'codex' ? 'codex' : 'claude', ['--resume', targetId], { stdio: 'inherit' });
1232
- saveTerminalState(cwd, termId, targetId, tool);
1311
+ process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
1312
+ spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
1313
+ saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
1233
1314
  return { next: 'main' };
1234
1315
  }
1235
1316
 
1236
- const numChoice = parseInt(choice, 10);
1317
+ // Number 1-3 resume that session
1318
+ const numChoice = parseInt(raw, 10);
1237
1319
  if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
1238
1320
  const sess = recentSessions[numChoice - 1];
1239
-
1240
- // Smart resume preview
1241
1321
  try {
1242
1322
  const { getSessionContext } = await import('../src/session.mjs');
1243
1323
  const ctx = getSessionContext(sess.id, cwd);
1244
1324
  if (ctx) {
1245
- console.log('');
1246
- if (ctx.lastPrompt) console.log(` Last working on: ${ctx.lastPrompt}`);
1247
- if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
1325
+ if (ctx.lastPrompt) process.stdout.write(`\n Last working on: ${ctx.lastPrompt}\n`);
1326
+ if (ctx.filesTouched.length > 0) process.stdout.write(` Files touched: ${ctx.filesTouched.join(', ')}\n`);
1248
1327
  }
1249
1328
  } catch {}
1250
-
1251
1329
  const { spawnSync } = await import('node:child_process');
1252
- console.log(`\n Launching: claude --resume ${sess.id}\n`);
1330
+ process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
1253
1331
  spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
1254
1332
  saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
1255
1333
  return { next: 'main' };
1256
1334
  }
1257
1335
 
1258
- if (choice === 'r') {
1259
- const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
1260
- if (allSessions.length === 0) {
1261
- console.log('\n No sessions found.\n');
1262
- await ask(' Press Enter to continue...');
1263
- return { next: 'main' };
1264
- }
1265
-
1266
- console.log('\n All Sessions:');
1267
- allSessions.forEach((sess, i) => {
1268
- const pin = sess.pinned ? '📌 ' : ' ';
1269
- const active = sess.isActive ? ' ●' : '';
1270
- const cat = sess.category ? ` [${sess.category}]` : '';
1271
- const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
1272
- console.log(` [${String(i + 1).padStart(2)}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
1273
- });
1274
- console.log('');
1275
-
1276
- const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
1277
- const num = parseInt(pick, 10);
1278
- if (!isNaN(num) && num >= 1 && num <= allSessions.length) {
1279
- const sess = allSessions[num - 1];
1280
- const { spawnSync } = await import('node:child_process');
1281
- const tool = sess.tool === 'codex' ? 'codex' : 'claude';
1282
- console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
1283
- spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
1284
- }
1285
- return { next: 'main' };
1286
- }
1336
+ if (choice === 'n') { return { next: 'new-session' }; }
1287
1337
 
1288
1338
  if (choice === '/') {
1289
1339
  const query = (await ask(' Search: ')).trim();
1290
1340
  if (!query) return { next: 'main' };
1291
1341
 
1292
1342
  const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
1293
- // Build index if needed (silent)
1294
1343
  try { buildSessionIndex(cwd); } catch {}
1295
1344
 
1296
1345
  const results = searchSessions(query, cwd);
1297
1346
  if (results.length === 0) {
1298
- console.log(`\n No sessions matching "${query}"\n`);
1347
+ process.stdout.write(`\n No sessions matching "${query}"\n\n`);
1299
1348
  await ask(' Press Enter to continue...');
1300
1349
  return { next: 'main' };
1301
1350
  }
1302
1351
 
1303
- console.log(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:`);
1352
+ process.stdout.write(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:\n`);
1304
1353
  results.slice(0, 9).forEach((sess, i) => {
1305
- const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
1306
- const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
1354
+ const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
1355
+ const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
1307
1356
  const topics = sess.topics.slice(0, 3).join(', ');
1308
- console.log(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}`);
1309
- if (topics) console.log(` topics: ${topics}`);
1357
+ process.stdout.write(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}\n`);
1358
+ if (topics) process.stdout.write(` topics: ${topics}\n`);
1310
1359
  });
1311
- console.log('');
1360
+ process.stdout.write('\n');
1312
1361
 
1313
1362
  const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
1314
- const num = parseInt(pick, 10);
1363
+ const num = parseInt(pick, 10);
1315
1364
  if (!isNaN(num) && num >= 1 && num <= Math.min(results.length, 9)) {
1316
1365
  const sess = results[num - 1];
1317
1366
  const { spawnSync } = await import('node:child_process');
1318
1367
  const tool = sess.tool === 'codex' ? 'codex' : 'claude';
1319
- console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
1368
+ process.stdout.write(`\n Launching: ${tool} --resume ${sess.id}\n\n`);
1320
1369
  spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
1321
1370
  }
1322
1371
  return { next: 'main' };
1323
1372
  }
1324
1373
 
1325
- if (choice === 'e') { return { next: 'sessions' }; }
1326
-
1327
- if (choice === 'i') {
1328
- const sessions = importReplitSessions(cwd);
1329
- if (sessions.length === 0) {
1330
- console.log('\n No replit-tools sessions found to import.\n');
1331
- } else {
1332
- console.log(`\n ✅ Found ${sessions.length} sessions from replit-tools.`);
1333
- console.log(' Sessions are automatically available in the list above.\n');
1334
- }
1335
- await ask(' Press Enter to continue...');
1336
- return { next: 'main' };
1337
- }
1338
-
1339
- if (choice === 'd') {
1340
- const { spawnSync } = await import('node:child_process');
1341
- const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
1342
- if (which.status === 0) {
1343
- spawnSync('claude-menu', { stdio: 'inherit' });
1344
- } else {
1345
- console.log('\n data-tools not found — install with: npm i -g replit-tools\n');
1346
- await ask(' Press Enter to continue...');
1347
- }
1348
- return { next: 'main' };
1349
- }
1350
-
1351
- if (choice === 'm') { return { next: 'subscriptions' }; }
1352
-
1353
1374
  if (choice === 's') { return { next: 'settings' }; }
1354
1375
  if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
1355
1376
 
@@ -1391,84 +1412,90 @@ async function newSessionScreen(rl, ask) {
1391
1412
 
1392
1413
  async function settingsScreen(rl, ask) {
1393
1414
  const cwd = process.cwd();
1394
- const profile = loadProfile(cwd);
1395
- const auth = await detectAuth();
1396
1415
 
1397
- let guardCount = 0;
1398
- try {
1399
- const settingsFile = join(cwd, '.claude', 'settings.json');
1400
- if (existsSync(settingsFile)) {
1401
- const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
1402
- const preToolUse = settings?.hooks?.PreToolUse ?? [];
1403
- const guardCmd = 'node .claude/hooks/head-guard.mjs';
1404
- const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
1405
- const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
1406
- const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
1407
- const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
1408
- const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
1409
- guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
1410
- }
1411
- } catch { /* ignore */ }
1412
-
1413
- const modeLabel = (m) => m === profile.mode ? `${m} (active)` : m;
1414
-
1415
- const claudeSub = profile?.providers?.claude;
1416
- const openaiSub = profile?.providers?.openai;
1417
- const claudePlanLabel = claudeSub?.enabled
1418
- ? (CLAUDE_PLAN_LABELS[claudeSub.plan] ?? claudeSub.plan ?? 'n/a')
1419
- : 'disabled';
1420
- const openaiPlanLabel = openaiSub?.enabled
1421
- ? (OPENAI_PLAN_LABELS[openaiSub.plan] ?? openaiSub.plan ?? 'n/a')
1422
- : 'disabled';
1423
-
1424
- const settingsLines = [
1425
- `Mode:`,
1426
- ` [1] ${modeLabel('cost-saver')}`,
1427
- ` [2] ${modeLabel('balanced')}`,
1428
- ` [3] ${modeLabel('quality-first')}`,
1429
- '',
1430
- `Subscriptions:`,
1431
- ` Claude: ${auth.claude.found ? 'logged in' : 'not logged in'} — ${claudePlanLabel}${claudeSub?.label ? ` [${claudeSub.label}]` : ''}`,
1432
- ` OpenAI: ${auth.openai.found ? 'logged in' : 'not logged in'} — ${openaiPlanLabel}${openaiSub?.label ? ` [${openaiSub.label}]` : ''}`,
1433
- '',
1434
- `Enforcement: ${guardCount}/4 guards active`,
1416
+ // Box layout matching dashboard
1417
+ const termW = process.stdout.columns || 60;
1418
+ const boxW = Math.min(termW - 2, 60);
1419
+ const W = boxW - 4;
1420
+
1421
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
1422
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
1423
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
1424
+ const row = (content) => makeBoxRow(content, W);
1425
+
1426
+ const lines = [
1427
+ top,
1428
+ row('Settings'),
1429
+ sep,
1430
+ row('[m] Manage subscriptions'),
1431
+ row('[e] Manage sessions'),
1432
+ row('[i] Import from replit-tools'),
1433
+ row('[d] Switch to data-tools'),
1434
+ row('[?] Help & shortcuts'),
1435
+ row('[x] Diagnostics'),
1436
+ row(''),
1437
+ row('[Esc/b] Back to dashboard'),
1438
+ bot,
1435
1439
  ];
1440
+ process.stdout.write('\n' + lines.join('\n') + '\n\n');
1436
1441
 
1437
- console.log('');
1438
- console.log(box('Settings', settingsLines));
1439
- console.log('');
1440
- console.log(menu([
1441
- { key: '1', label: 'Switch to cost-saver', section: 'Mode' },
1442
- { key: '2', label: 'Switch to balanced', section: 'Mode' },
1443
- { key: '3', label: 'Switch to quality-first', section: 'Mode' },
1444
- { key: 'a', label: 'Manage subscriptions', section: 'Subscriptions' },
1445
- { key: 'i', label: 'Reinstall hooks', section: 'Enforcement' },
1446
- { key: 'b', label: 'Back', section: '' },
1447
- ]));
1448
- console.log('');
1442
+ const raw = (await ask(' Choice: ')).trim();
1443
+ const choice = raw.toLowerCase();
1449
1444
 
1450
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
1445
+ if (choice === 'm') { return { next: 'subscriptions' }; }
1451
1446
 
1452
- if (choice === '1' || choice === '2' || choice === '3') {
1453
- const modeMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
1454
- profile.mode = modeMap[choice];
1455
- saveProfile(profile, { cwd });
1456
- console.log(` Mode set to: ${profile.mode}`);
1447
+ if (choice === 'e') { return { next: 'sessions' }; }
1448
+
1449
+ if (choice === 'i') {
1450
+ const sessions = importReplitSessions(cwd);
1451
+ if (sessions.length === 0) {
1452
+ process.stdout.write('\n No replit-tools sessions found to import.\n\n');
1453
+ } else {
1454
+ process.stdout.write(`\n Found ${sessions.length} sessions from replit-tools.\n`);
1455
+ process.stdout.write(' Sessions are automatically available in the Recent list.\n\n');
1456
+ }
1457
+ await ask(' Press Enter to continue...');
1457
1458
  return { next: 'settings' };
1458
1459
  }
1459
1460
 
1460
- if (choice === 'a') {
1461
- return { next: 'subscriptions' };
1461
+ if (choice === 'd') {
1462
+ const { spawnSync } = await import('node:child_process');
1463
+ const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
1464
+ if (which.status === 0) {
1465
+ spawnSync('claude-menu', { stdio: 'inherit' });
1466
+ } else {
1467
+ process.stdout.write('\n data-tools not found — install with: npm i -g replit-tools\n\n');
1468
+ await ask(' Press Enter to continue...');
1469
+ }
1470
+ return { next: 'settings' };
1462
1471
  }
1463
1472
 
1464
- if (choice === 'i') {
1465
- await cmdInstall();
1473
+ if (choice === '?') {
1474
+ const W2 = 37;
1475
+ const helpTop = ` ┌${'─'.repeat(W2)}┐`;
1476
+ const helpSep = ` ├${'─'.repeat(W2)}┤`;
1477
+ const helpBottom = ` └${'─'.repeat(W2)}┘`;
1478
+ const helpPad = (s) => s + ' '.repeat(Math.max(0, W2 - s.length));
1479
+ process.stdout.write('\n');
1480
+ process.stdout.write(helpTop + '\n');
1481
+ process.stdout.write(` │ ${helpPad('At ~/workspace$ prompt:')}│\n`);
1482
+ process.stdout.write(` │ ${helpPad('db = show this menu')}│\n`);
1483
+ process.stdout.write(` │ ${helpPad('j = login to claude')}│\n`);
1484
+ process.stdout.write(` │ ${helpPad('k = login to codex')}│\n`);
1485
+ process.stdout.write(helpSep + '\n');
1486
+ process.stdout.write(` │ ${helpPad('In Claude:')}│\n`);
1487
+ process.stdout.write(` │ ${helpPad('Ctrl+C x2 = back to menu')}│\n`);
1488
+ process.stdout.write(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│\n`);
1489
+ process.stdout.write(helpBottom + '\n\n');
1490
+ await ask(' Press Enter to continue...');
1466
1491
  return { next: 'settings' };
1467
1492
  }
1468
1493
 
1469
- if (choice === 'b' || choice === 'back') { return { next: 'main' }; }
1494
+ if (choice === 'x') { return { next: 'diagnostics' }; }
1470
1495
 
1471
- return { next: 'settings' };
1496
+ if (choice === 'b' || choice === 'back' || raw === '\x1b') { return { next: 'main' }; }
1497
+
1498
+ return { next: 'main' };
1472
1499
  }
1473
1500
 
1474
1501
  // ─── Helper: aggregatePlans ───────────────────────────────────────────────────
@@ -2559,13 +2586,40 @@ async function runScreens(startScreen = 'dashboard') {
2559
2586
  let current = startScreen;
2560
2587
  let ctx = {};
2561
2588
  while (current && current !== 'exit') {
2589
+ // Handle type-to-start dispatch from mainScreen
2590
+ if (current === 'go' && ctx.prompt) {
2591
+ const prompt = ctx.prompt;
2592
+ const cwd = process.cwd();
2593
+ const profile = loadProfile(cwd);
2594
+ const detection = detectTask({ prompt });
2595
+ const decision = decideRoute({ profile, detection, cwd });
2596
+ process.stdout.write(`\n Routing: ${decision.provider}/${decision.model} (${decision.tier})\n`);
2597
+ process.stdout.write(` Reason: ${decision.explanation}\n\n`);
2598
+ const { spawnSync } = await import('node:child_process');
2599
+ const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
2600
+ if (launchTool === 'codex') {
2601
+ spawnSync('codex', [prompt], { stdio: 'inherit' });
2602
+ } else {
2603
+ spawnSync('claude', ['-p', prompt], { stdio: 'inherit' });
2604
+ }
2605
+ const freshSessions = importReplitSessions(cwd);
2606
+ if (freshSessions.length > 0) {
2607
+ saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
2608
+ }
2609
+ current = 'main';
2610
+ ctx = {};
2611
+ continue;
2612
+ }
2613
+
2562
2614
  const screen = SCREENS[current];
2563
2615
  if (!screen) break;
2564
2616
  try {
2565
2617
  const result = await screen(rl, ask, ctx);
2566
2618
  current = result?.next || 'exit';
2567
- // Pass through context (e.g. selected session) to next screen
2568
- ctx = result?.session ? { session: result.session } : {};
2619
+ // Pass through context (e.g. selected session, typed prompt) to next screen
2620
+ ctx = result?.session ? { session: result.session }
2621
+ : result?.prompt ? { prompt: result.prompt }
2622
+ : {};
2569
2623
  } catch (e) {
2570
2624
  console.error(`Error: ${e.message}`);
2571
2625
  current = 'main';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/session.mjs CHANGED
@@ -461,23 +461,38 @@ export function importReplitSessions(cwd = process.cwd()) {
461
461
  const windowMs = windowHours * 60 * 60 * 1000;
462
462
  const cutoff = Date.now() - windowMs;
463
463
 
464
+ // Load existing session index for smartName lookup (best-effort, non-fatal)
465
+ let sessionIndex = {};
466
+ try {
467
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
468
+ if (existsSync(indexPath)) {
469
+ sessionIndex = JSON.parse(readFileSync(indexPath, 'utf8'));
470
+ }
471
+ } catch { /* non-fatal */ }
472
+
464
473
  // Build session list
465
474
  for (const [id, sess] of bySession) {
466
475
  // Skip sessions outside the recency window (timestamps are in ms)
467
476
  if (sess.lastTimestamp < cutoff) continue;
468
- // Derive display name
469
- let name = sess.firstPrompt;
477
+
478
+ // Use smartName from index if available, otherwise fall back to first prompt
479
+ let name = sessionIndex[id]?.smartName || null;
480
+
470
481
  if (!name) {
471
- // Fallback: use first non-login display
472
- const firstReal = sess.entries.find(e => e.display && e.display !== 'login');
473
- name = firstReal?.display || `Session ${id.slice(0, 8)}`;
482
+ // Classic fallback: first meaningful prompt
483
+ name = sess.firstPrompt;
484
+ if (!name) {
485
+ const firstReal = sess.entries.find(e => e.display && e.display !== 'login');
486
+ name = firstReal?.display || `Session ${id.slice(0, 8)}`;
487
+ }
488
+ // Truncate long names that came from raw prompts
489
+ if (name.length > 60) name = name.slice(0, 57) + '...';
474
490
  }
475
- // Truncate long names
476
- if (name.length > 60) name = name.slice(0, 57) + '...';
477
491
 
478
492
  sessions.push({
479
493
  id: sess.sessionId,
480
494
  name,
495
+ smartName: sessionIndex[id]?.smartName || null,
481
496
  project: sess.project,
482
497
  promptCount: sess.entries.length,
483
498
  lastActive: new Date(sess.lastTimestamp).toISOString(),
@@ -741,6 +756,159 @@ export function syncSessionMirror(cwd = process.cwd()) {
741
756
  return { copied: totalCopied, grew: totalGrew };
742
757
  }
743
758
 
759
+ // ─── Smart session naming ─────────────────────────────────────────────────────
760
+
761
+ /**
762
+ * File pattern → human label mapping (checked in order, first match wins).
763
+ * Each entry: { pattern: RegExp, label: string, action?: string }
764
+ */
765
+ const FILE_PATTERN_RULES = [
766
+ { pattern: /auth/i, label: 'Auth', action: 'Refactor' },
767
+ { pattern: /test|spec/i, label: 'Tests', action: 'Fix' },
768
+ { pattern: /dispatch/i, label: 'Dispatch', action: 'Update' },
769
+ { pattern: /session/i, label: 'Session', action: 'Update' },
770
+ { pattern: /profile/i, label: 'Profile', action: 'Update' },
771
+ { pattern: /detect/i, label: 'Detection', action: 'Update' },
772
+ { pattern: /decide/i, label: 'Routing', action: 'Update' },
773
+ { pattern: /budget/i, label: 'Budget', action: 'Update' },
774
+ { pattern: /hook/i, label: 'Hooks', action: 'Update' },
775
+ { pattern: /install/i, label: 'Install', action: 'Update' },
776
+ { pattern: /config/i, label: 'Config', action: 'Update' },
777
+ { pattern: /migrate/i, label: 'Migration', action: 'Add' },
778
+ ];
779
+
780
+ /**
781
+ * Topic words that suggest a dominant action verb.
782
+ */
783
+ const TOPIC_ACTION_MAP = [
784
+ { words: ['fix', 'bug', 'error', 'crash', 'broken', 'fail'], action: 'Fix' },
785
+ { words: ['refactor', 'cleanup', 'clean', 'reorganize'], action: 'Refactor' },
786
+ { words: ['add', 'implement', 'create', 'build', 'write'], action: 'Add' },
787
+ { words: ['update', 'upgrade', 'bump', 'patch'], action: 'Update' },
788
+ { words: ['test', 'spec', 'coverage'], action: 'Fix' },
789
+ { words: ['deploy', 'release', 'publish'], action: 'Deploy' },
790
+ { words: ['audit', 'review', 'check'], action: 'Review' },
791
+ ];
792
+
793
+ /**
794
+ * Convert a string to Title Case.
795
+ * @param {string} str
796
+ * @returns {string}
797
+ */
798
+ function toTitleCase(str) {
799
+ return str.replace(/\b\w/g, c => c.toUpperCase());
800
+ }
801
+
802
+ /**
803
+ * Strip file extensions from a name candidate.
804
+ * @param {string} name
805
+ * @returns {string}
806
+ */
807
+ function stripExtensions(name) {
808
+ return name.replace(/\.(mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/gi, '');
809
+ }
810
+
811
+ /**
812
+ * Truncate a string to maxLen characters, preserving whole words where possible.
813
+ * @param {string} str
814
+ * @param {number} maxLen
815
+ * @returns {string}
816
+ */
817
+ function truncate(str, maxLen = 40) {
818
+ if (str.length <= maxLen) return str;
819
+ const cut = str.slice(0, maxLen).replace(/\s+\S*$/, '');
820
+ return cut || str.slice(0, maxLen);
821
+ }
822
+
823
+ /**
824
+ * Generate a smart human-readable session name from session index data.
825
+ *
826
+ * Priority:
827
+ * 1. Dominant file pattern (e.g. auth*.mjs → "Refactor Auth Module")
828
+ * 2. Top topics (e.g. ['auth','token','refresh'] → "Auth Token Refresh")
829
+ * 3. Fallback: first prompt truncated to 40 chars
830
+ *
831
+ * Rules: ≤40 chars, Title Case, no file extensions, action-prefixed when detectable.
832
+ *
833
+ * @param {{ topics?: string[], files?: string[], prompts?: { first?: string } }} sessionData
834
+ * @returns {string}
835
+ */
836
+ export function generateSmartName(sessionData) {
837
+ const topics = sessionData.topics || [];
838
+ const files = sessionData.files || [];
839
+ const firstPrompt = sessionData.prompts?.first || '';
840
+
841
+ // ── Step 1: Detect dominant action from topics ─────────────────────────────
842
+ let detectedAction = null;
843
+ for (const { words, action } of TOPIC_ACTION_MAP) {
844
+ if (topics.some(t => words.includes(t))) {
845
+ detectedAction = action;
846
+ break;
847
+ }
848
+ }
849
+
850
+ // ── Step 2: Try file pattern match ─────────────────────────────────────────
851
+ if (files.length > 0) {
852
+ // Flatten all filenames for pattern matching
853
+ const fileNames = files.map(f => f.split('/').pop()).join(' ');
854
+
855
+ for (const { pattern, label, action } of FILE_PATTERN_RULES) {
856
+ if (pattern.test(fileNames)) {
857
+ const actionWord = detectedAction || action || 'Update';
858
+ const candidate = `${actionWord} ${label}`;
859
+ return truncate(toTitleCase(candidate));
860
+ }
861
+ }
862
+
863
+ // No named pattern — derive a label from the most common directory or base name
864
+ const basenames = files.map(f => {
865
+ const base = f.split('/').pop() || f;
866
+ // Strip extension and convert camelCase/kebab to words
867
+ return stripExtensions(base)
868
+ .replace(/[-_]/g, ' ')
869
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
870
+ .trim();
871
+ }).filter(Boolean);
872
+
873
+ if (basenames.length > 0) {
874
+ // Use the most common prefix or first significant basename
875
+ const label = basenames[0];
876
+ const actionWord = detectedAction || 'Update';
877
+ const candidate = `${actionWord} ${label}`;
878
+ return truncate(toTitleCase(stripExtensions(candidate)));
879
+ }
880
+ }
881
+
882
+ // ── Step 3: Try top topics ─────────────────────────────────────────────────
883
+ if (topics.length >= 2) {
884
+ // Take top 3 topics and compose a name
885
+ const topTopics = topics.slice(0, 3);
886
+ const actionWord = detectedAction || null;
887
+
888
+ let candidate;
889
+ if (actionWord) {
890
+ // Use action + remaining topics
891
+ candidate = [actionWord, ...topTopics.filter(t => t !== actionWord.toLowerCase())].slice(0, 3).join(' ');
892
+ } else {
893
+ candidate = topTopics.join(' ');
894
+ }
895
+
896
+ return truncate(toTitleCase(candidate));
897
+ }
898
+
899
+ if (topics.length === 1) {
900
+ const actionWord = detectedAction || 'Work on';
901
+ return truncate(toTitleCase(`${actionWord} ${topics[0]}`));
902
+ }
903
+
904
+ // ── Step 4: Fallback — first prompt truncated ──────────────────────────────
905
+ if (firstPrompt) {
906
+ return truncate(firstPrompt);
907
+ }
908
+
909
+ return 'Session';
910
+ }
911
+
744
912
  // ─── Session index ────────────────────────────────────────────────────────────
745
913
 
746
914
  /**
@@ -841,7 +1009,7 @@ export function buildSessionIndex(cwd = process.cwd()) {
841
1009
  .slice(0, 10)
842
1010
  .map(([w]) => w);
843
1011
 
844
- index[sessionId] = {
1012
+ const sessionEntry = {
845
1013
  id: sessionId,
846
1014
  topics,
847
1015
  files: [...fileSet].slice(0, 20),
@@ -851,6 +1019,8 @@ export function buildSessionIndex(cwd = process.cwd()) {
851
1019
  tool: 'claude',
852
1020
  _fileSize: fileSize,
853
1021
  };
1022
+ sessionEntry.smartName = generateSmartName(sessionEntry);
1023
+ index[sessionId] = sessionEntry;
854
1024
  } catch { continue; }
855
1025
  }
856
1026
  }
@@ -904,12 +1074,14 @@ export function buildSessionIndex(cwd = process.cwd()) {
904
1074
  } catch { continue; }
905
1075
  }
906
1076
 
907
- index[id] = {
1077
+ const codexEntry = {
908
1078
  id, topics: [], files: [],
909
1079
  prompts: { first: firstPrompt || '', last: lastPrompt || '' },
910
1080
  date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
911
1081
  messageCount, tool: 'codex', _fileSize: fileSize,
912
1082
  };
1083
+ codexEntry.smartName = generateSmartName(codexEntry);
1084
+ index[id] = codexEntry;
913
1085
  } catch { continue; }
914
1086
  }
915
1087
  }