@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +98 -8
- package/package.json +1 -1
- package/skills/cctabs/SKILL.md +12 -0
|
@@ -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
|
+
"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.
|
|
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
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -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.
|