@generativereality/cctabs 0.4.4 → 0.4.5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cctabs",
3
3
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux. Claude can orchestrate its own sibling sessions.",
4
- "version": "0.4.4",
4
+ "version": "0.4.5",
5
5
  "author": {
6
6
  "name": "generativereality",
7
7
  "url": "https://cctabs.com"
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { consola } from "consola";
11
11
  import * as p from "@clack/prompts";
12
12
  //#region package.json
13
13
  var name = "@generativereality/cctabs";
14
- var version = "0.4.4";
14
+ var version = "0.4.5";
15
15
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
16
16
  var package_default = {
17
17
  name,
@@ -709,7 +709,8 @@ var TabbyAdapter = class {
709
709
  cwd: opts.cwd,
710
710
  title: opts.title,
711
711
  command: opts.command,
712
- args: opts.args
712
+ args: opts.args,
713
+ afterActive: opts.afterActive ?? false
713
714
  }))?.uuid;
714
715
  if (!uuid) throw new Error("Tabby plugin did not return a tab uuid");
715
716
  return {
@@ -717,6 +718,10 @@ var TabbyAdapter = class {
717
718
  tabId: uuid
718
719
  };
719
720
  }
721
+ async reorderTabs(order) {
722
+ this.ensureHealthy();
723
+ await this.http("POST", "/api/tabs/reorder", { order });
724
+ }
720
725
  async sendInput(blockId, text) {
721
726
  return this.http("POST", `/api/tabs/${blockId}/send`, { data: text });
722
727
  }
@@ -1368,9 +1373,57 @@ async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1368
1373
  }
1369
1374
  consola.warn("Could not confirm the initial prompt was submitted — switch to the tab and press Enter to send it.");
1370
1375
  }
1376
+ /**
1377
+ * Auto-advance Claude's "resume" picker that appears when `claude --resume <id>`
1378
+ * reattaches a large/old session:
1379
+ *
1380
+ * ❯ 1. Resume from summary (recommended)
1381
+ * 2. Resume full session as-is
1382
+ * 3. Don't ask me again
1383
+ *
1384
+ * It blocks the tab until you choose, which is why a plain `restore` leaves
1385
+ * such tabs stuck. cctabs always wants the FULL session — the whole point of
1386
+ * restore is to bring the conversation back intact, not a lossy summary — so we
1387
+ * select option 2.
1388
+ *
1389
+ * Like the trust dialog, the picker has a brief not-ready window as it paints,
1390
+ * so we poll for it to appear, settle, then navigate. The default highlight is
1391
+ * option 1, so we move the cursor DOWN exactly once to reach option 2. We send
1392
+ * ↓ only once on purpose: spamming it across retries could land on option 3
1393
+ * ("Don't ask me again"), which permanently changes the user's config. The
1394
+ * confirm is the part we retry — re-pressing Enter on the same row is safe, and
1395
+ * if the single ↓ was ever dropped the worst case is a (still-usable) summary
1396
+ * resume, never option 3.
1397
+ */
1398
+ async function confirmResumePicker(adapter, blockId) {
1399
+ const stripped = (n) => adapter.scrollback(blockId, n).replace(/\s+/g, "");
1400
+ const pickerVisible = (n) => {
1401
+ const c = stripped(n);
1402
+ return /Resumefromsummary/i.test(c) && /Resumefullsession/i.test(c);
1403
+ };
1404
+ let appeared = false;
1405
+ for (let i = 0; i < 25; i++) {
1406
+ if (pickerVisible(30)) {
1407
+ appeared = true;
1408
+ break;
1409
+ }
1410
+ await sleep(1e3);
1411
+ }
1412
+ if (!appeared) return;
1413
+ await sleep(1200);
1414
+ await adapter.sendInput(blockId, "\x1B[B");
1415
+ await sleep(250);
1416
+ for (let attempt = 0; attempt < 8; attempt++) {
1417
+ await adapter.sendInput(blockId, "\r");
1418
+ await sleep(900);
1419
+ if (!pickerVisible(8)) return;
1420
+ }
1421
+ consola.warn("Could not confirm the resume picker was dismissed — switch to the tab and pick \"Resume full session as-is\".");
1422
+ }
1371
1423
  async function openSession(opts) {
1372
- const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
1424
+ const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride, afterActive } = opts;
1373
1425
  const tailDelayMs = opts.tailDelayMs ?? 2e3;
1426
+ const isResume = /--resume\b/.test(claudeCmd);
1374
1427
  const dir = resolve(opts.dir.replace(/^~/, homedir()));
1375
1428
  if (!existsSync(dir)) throw new Error(`Directory does not exist: ${dir}`);
1376
1429
  const config = loadConfig();
@@ -1400,9 +1453,14 @@ async function openSession(opts) {
1400
1453
  "-i",
1401
1454
  "-c",
1402
1455
  launch
1403
- ]
1456
+ ],
1457
+ afterActive
1404
1458
  });
1405
1459
  mark("openTabDirect");
1460
+ if (isResume) {
1461
+ await confirmResumePicker(adapter, blockId);
1462
+ mark("resumePicker");
1463
+ }
1406
1464
  if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
1407
1465
  adapter.closeSocket();
1408
1466
  return tabId;
@@ -1439,6 +1497,10 @@ async function openSession(opts) {
1439
1497
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
1440
1498
  const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}\r`;
1441
1499
  await adapter.sendInput(blockId, cmd);
1500
+ if (isResume) {
1501
+ await confirmResumePicker(adapter, blockId);
1502
+ mark("resumePicker");
1503
+ }
1442
1504
  if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
1443
1505
  if (tailDelayMs > 0) await new Promise((r) => setTimeout(r, tailDelayMs));
1444
1506
  mark("tail");
@@ -1729,7 +1791,8 @@ const newCommand = define({
1729
1791
  workspaceQuery: workspace,
1730
1792
  initialPromptFile,
1731
1793
  envVars,
1732
- modelOverride: resolvedModel
1794
+ modelOverride: resolvedModel,
1795
+ afterActive: true
1733
1796
  });
1734
1797
  const wt = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
1735
1798
  const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
@@ -1871,7 +1934,8 @@ const resumeCommand = define({
1871
1934
  dir,
1872
1935
  claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1873
1936
  envVars,
1874
- modelOverride: resolvedModel
1937
+ modelOverride: resolvedModel,
1938
+ afterActive: true
1875
1939
  });
1876
1940
  consola.success(`Tab "${name}" [${newTabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (recreated)`);
1877
1941
  return;
@@ -1902,7 +1966,8 @@ const resumeCommand = define({
1902
1966
  dir,
1903
1967
  claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1904
1968
  envVars,
1905
- modelOverride: resolvedModel
1969
+ modelOverride: resolvedModel,
1970
+ afterActive: true
1906
1971
  });
1907
1972
  consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (new tab)`);
1908
1973
  } else {
@@ -1987,7 +2052,8 @@ const forkCommand = define({
1987
2052
  const newTabId = await openSession({
1988
2053
  tabName: newName,
1989
2054
  dir: openDir,
1990
- claudeCmd: `claude --resume ${sessionId} --fork-session`
2055
+ claudeCmd: `claude --resume ${sessionId} --fork-session`,
2056
+ afterActive: true
1991
2057
  });
1992
2058
  consola.success(`Forked "${tabName}" → "${newName}" [${newTabId.slice(0, 8)}]`);
1993
2059
  consola.info(`session: ${sessionId}`);
@@ -2503,7 +2569,9 @@ async function runLegacyMode(rawDir, dryRun) {
2503
2569
  const { tabsById, workspaces, tabNames } = await adapter.getAllData();
2504
2570
  const currentTab = adapter.currentTabId();
2505
2571
  const tabs = [];
2572
+ const originalOrder = [];
2506
2573
  for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
2574
+ originalOrder.push(tabId);
2507
2575
  if (tabId === currentTab) continue;
2508
2576
  const blocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
2509
2577
  if (!blocks.length) continue;
@@ -2587,8 +2655,20 @@ async function runLegacyMode(rawDir, dryRun) {
2587
2655
  await Promise.all(unknownTabs.map(async (r) => {
2588
2656
  emptyById.set(r.tab.tabId, await adapter.confirmScrollbackEmpty(r.tab.blockId));
2589
2657
  }));
2658
+ const claimedNames = /* @__PURE__ */ new Set();
2590
2659
  for (const r of resolved) {
2591
2660
  const { tab, sessionId, sessionDir } = r;
2661
+ if (claimedNames.has(tab.name)) {
2662
+ const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2663
+ for (const bid of blockIds) adapter.deleteBlock(bid);
2664
+ consola.log(` ${tab.name} — duplicate dead tab, closing (already restoring one)`);
2665
+ results.push({
2666
+ name: tab.name,
2667
+ result: "duplicate dead tab — closed"
2668
+ });
2669
+ continue;
2670
+ }
2671
+ claimedNames.add(tab.name);
2592
2672
  if (tab.status === "unknown" && emptyById.get(tab.tabId)) {
2593
2673
  const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2594
2674
  toRecreate.push({
@@ -2632,6 +2712,7 @@ async function runLegacyMode(rawDir, dryRun) {
2632
2712
  for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
2633
2713
  adapter.closeSocket();
2634
2714
  consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
2715
+ const recreatedIds = /* @__PURE__ */ new Map();
2635
2716
  const recreateOne = async (t) => {
2636
2717
  try {
2637
2718
  const newTabId = await openSession({
@@ -2640,6 +2721,7 @@ async function runLegacyMode(rawDir, dryRun) {
2640
2721
  claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`,
2641
2722
  tailDelayMs: 500
2642
2723
  });
2724
+ recreatedIds.set(t.tabId, newTabId);
2643
2725
  const r = results.find((x) => x.name === t.name);
2644
2726
  r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
2645
2727
  } catch (err) {
@@ -2652,6 +2734,14 @@ async function runLegacyMode(rawDir, dryRun) {
2652
2734
  await recreateOne(t);
2653
2735
  if (usesDirectSpawn) await new Promise((r) => setTimeout(r, SPAWN_SETTLE_MS));
2654
2736
  }
2737
+ if (recreatedIds.size && typeof adapter.reorderTabs === "function") {
2738
+ const desiredOrder = originalOrder.map((id) => recreatedIds.get(id) ?? id);
2739
+ try {
2740
+ await adapter.reorderTabs(desiredOrder);
2741
+ } catch (err) {
2742
+ consola.warn(`Could not restore tab order: ${err.message}`);
2743
+ }
2744
+ }
2655
2745
  } else adapter.closeSocket();
2656
2746
  console.log("\nRestore summary:");
2657
2747
  for (const r of results) console.log(` ${r.name}: ${r.result}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -281,6 +281,18 @@ cctabs restore ~/Dev/myapp # restrict the search to one project dir
281
281
 
282
282
  If a session was started in a different `cwd` than the tab's current directory (common after `cd`-ing inside the tab), the global search still finds it via the recorded session metadata — no need to guess the right dir.
283
283
 
284
+ ### The "Resume from summary / full session" picker
285
+
286
+ When `claude --resume` reattaches a large or old session, Claude first shows a blocking picker:
287
+
288
+ ```
289
+ ❯ 1. Resume from summary (recommended)
290
+ 2. Resume full session as-is
291
+ 3. Don't ask me again
292
+ ```
293
+
294
+ **Always pick option 2, "Resume full session as-is."** The point of `restore` is to bring the conversation back intact — resuming from a summary discards the live context you're restoring for. `restore` auto-advances this picker for you (it moves down once to option 2 and confirms), so you normally never see it. If you ever do drive it manually (e.g. sending keys to a tab), send **↓ then Enter** — never the bare Enter that would accept the summary, and never option 3, which permanently silences the prompt in that session's config.
295
+
284
296
  ## Workflow: Moving sessions across machines
285
297
 
286
298
  Use `export` + `import` to migrate a tab (or a whole workspace) — and its underlying Claude conversation — from one machine to another, e.g. when switching laptops or sharing a debug session with a teammate.