claude-term-tabs 2.1.2 → 2.1.4

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/README.md CHANGED
@@ -6,12 +6,14 @@
6
6
 
7
7
  Find a running Claude Code session by what you remember about it, and switch to its terminal tab.
8
8
 
9
+ ![ctt switching to a session by a few words, ctt pause --auto-close closing every session once it has written a HANDOFF, then ctt resume reopening them all](https://raw.githubusercontent.com/ecoutu/claude-term-tabs/main/docs/demo.gif)
10
+
9
11
  ```bash
10
12
  ctt working on pause in claude-term-tabs
11
13
  # → Pause sessions before a restart · claude-term-tabs · tab 4
12
14
  ```
13
15
 
14
- Works with iTerm2 and WezTerm on macOS.
16
+ Works with iTerm2 on macOS, and WezTerm on macOS and Linux.
15
17
 
16
18
  ## Install
17
19
 
@@ -149,6 +151,9 @@ npm run test:package # packs, installs and runs the npm package
149
151
  No runtime dependencies. A clone runs the TypeScript directly; the npm package ships it built to JavaScript.
150
152
  [CONTRIBUTING.md](CONTRIBUTING.md) has the commit and pull request rules.
151
153
 
154
+ `scripts/record-demo.sh` re-records `docs/demo.gif`. It runs Claude Code sessions in a scratch HOME on a hidden
155
+ display, so it needs Linux, Xvfb, ffmpeg and a token from `claude setup-token`; its header lists the rest.
156
+
152
157
  ### Releases
153
158
 
154
159
  Every push to `main` runs CI, then semantic-release reads the conventional commit
package/dist/cache.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { writeOwnerOnly } from "./fsutil.js";
3
- const CACHE_VERSION = 1;
3
+ const CACHE_VERSION = 2;
4
4
  /** Digest cache keyed by transcript path, valid while the file's size and mtime are unchanged. */
5
5
  export class DigestCache {
6
6
  file;
package/dist/handoff.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ import { withoutPasteTags } from "./transcript.js";
2
3
  const DEFAULT_TAIL_BYTES = 128 * 1024;
3
4
  const HANDOFF_LINE = /^\s*(?:#+\s*|\*\*)?HANDOFF\b(?!S)/m;
4
5
  /**
@@ -94,7 +95,7 @@ export function latestAssistantAfterPrompt(path, promptStart, sinceMs, opts = {}
94
95
  const start = Math.max(0, size - window);
95
96
  const lines = readRange(fd, start, size - start).split('\n');
96
97
  const entries = (start > 0 ? lines.slice(1) : lines).flatMap(l => parse(l) ?? []);
97
- const promptAt = findLastIndex(entries, e => e.type === 'user' && Date.parse(String(e.timestamp)) >= sinceMs && (textOf(e.message) ?? '').trimStart().startsWith(promptStart));
98
+ const promptAt = findLastIndex(entries, e => e.type === 'user' && Date.parse(String(e.timestamp)) >= sinceMs && withoutPasteTags(textOf(e.message) ?? '').trimStart().startsWith(promptStart));
98
99
  if (promptAt >= 0) {
99
100
  const replies = entries.slice(promptAt + 1).filter(e => e.type === 'assistant').map(e => textOf(e.message)).filter((t) => t !== undefined);
100
101
  return replies.at(-1);
package/dist/pause/ui.js CHANGED
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { writeOwnerOnly } from "../fsutil.js";
7
- import { createServer } from 'node:net';
7
+ import { connect, createServer } from 'node:net';
8
8
  import { createInterface } from 'node:readline';
9
9
  import { tickPause, stragglerLines } from "./run.js";
10
10
  import { FINAL_STATES } from "./state.js";
@@ -12,6 +12,7 @@ import { shellQuote } from "../shell.js";
12
12
  /** How often the straggler list refreshes. */
13
13
  export const POLL_MS = 3000;
14
14
  const CLEAR = '\x1b[2J\x1b[H';
15
+ const LISTEN_WAIT_MS = 2000;
15
16
  const SAFE_PATH = /^[A-Za-z0-9._\/-]+$/;
16
17
  const HEADER = 'ctt pause: Enter or double-click jumps to a session · ctrl-r re-sends the stop prompt · Esc stops waiting';
17
18
  /** fzf arguments for the live straggler list; `self` is the argv prefix that re-invokes ctt. */
@@ -105,7 +106,9 @@ async function fzfUi(io, initial) {
105
106
  child.on('exit', () => { exited = true; });
106
107
  child.on('error', () => { exited = true; });
107
108
  child.stdin.end(initial.join('\n'));
108
- const post = async (action) => {
109
+ await listening(port, () => exited);
110
+ // `abort` closes fzf, which often exits before it answers, so its failure is expected.
111
+ const post = async (action, closing = false) => {
109
112
  if (exited)
110
113
  return;
111
114
  try {
@@ -114,7 +117,7 @@ async function fzfUi(io, initial) {
114
117
  process.stderr.write(`claude-term-tabs: the list refused an update (${res.status})\n`);
115
118
  }
116
119
  catch (err) {
117
- if (!exited)
120
+ if (!exited && !closing)
118
121
  process.stderr.write(`claude-term-tabs: could not update the list: ${err.message}\n`);
119
122
  }
120
123
  };
@@ -124,7 +127,7 @@ async function fzfUi(io, initial) {
124
127
  await post(reloadAction(linesFile));
125
128
  },
126
129
  finish: async () => {
127
- await post('abort');
130
+ await post('abort', true);
128
131
  if (!exited)
129
132
  await new Promise(resolve => child.once('exit', () => resolve()));
130
133
  rmSync(dir, { recursive: true, force: true });
@@ -132,6 +135,20 @@ async function fzfUi(io, initial) {
132
135
  closed: () => exited
133
136
  };
134
137
  }
138
+ /** Resolves once fzf accepts connections on `port`, it has exited, or LISTEN_WAIT_MS has passed. */
139
+ async function listening(port, gone) {
140
+ const deadline = Date.now() + LISTEN_WAIT_MS;
141
+ while (!gone() && Date.now() < deadline) {
142
+ const up = await new Promise(resolve => {
143
+ const socket = connect(port, '127.0.0.1');
144
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
145
+ socket.once('error', () => { resolve(false); });
146
+ });
147
+ if (up)
148
+ return;
149
+ await new Promise(resolve => setTimeout(resolve, 50));
150
+ }
151
+ }
135
152
  function freePort() {
136
153
  return new Promise((resolve, reject) => {
137
154
  const server = createServer();
@@ -6,9 +6,12 @@ export function weztermAdapter(exec, shell = process.env.SHELL ?? '/bin/sh') {
6
6
  throw new Error(`wezterm cli ${args[0]} failed (${r.status}): ${r.stderr.trim()}`);
7
7
  return r.stdout;
8
8
  };
9
+ const list = () => JSON.parse(cli(['list', '--format', 'json']));
10
+ // A spawn without --window-id lands in the caller's window, so tabs that follow a new window go there explicitly.
11
+ let newWindowPane;
9
12
  return {
10
13
  name: 'wezterm',
11
- listPanes: () => toPanes(JSON.parse(cli(['list', '--format', 'json']))),
14
+ listPanes: () => toPanes(list()),
12
15
  focus: (pane) => { cli(['activate-pane', '--pane-id', pane.id]); },
13
16
  sendText: (pane, text) => {
14
17
  cli(['send-text', '--pane-id', pane.id, text]);
@@ -16,7 +19,15 @@ export function weztermAdapter(exec, shell = process.env.SHELL ?? '/bin/sh') {
16
19
  },
17
20
  closePane: (pane) => { cli(['kill-pane', '--pane-id', pane.id]); },
18
21
  openPane: (cwd, command, opts) => {
19
- return cli(['spawn', ...(opts?.newWindow === true ? ['--new-window'] : []), '--cwd', cwd, '--', shell, '-lc', `${command}; exec ${shell} -l`]).trim();
22
+ const newWindow = opts?.newWindow === true;
23
+ const windowId = newWindow || newWindowPane === undefined ? undefined : list().find(p => String(p.pane_id) === newWindowPane)?.window_id;
24
+ const where = newWindow ? ['--new-window'] : windowId !== undefined ? ['--window-id', String(windowId)] : [];
25
+ if (newWindow)
26
+ newWindowPane = undefined;
27
+ const id = cli(['spawn', ...where, '--cwd', cwd, '--', shell, '-lc', `${command}; exec ${shell} -l`]).trim();
28
+ if (newWindow)
29
+ newWindowPane = id;
30
+ return id;
20
31
  }
21
32
  };
22
33
  }
@@ -4,6 +4,8 @@ const HEAD_BYTES = 64 * 1024;
4
4
  const DEFAULT_TAIL_BYTES = 256 * 1024;
5
5
  const PROMPT_CHARS = 200;
6
6
  const WRAPPER_PREFIXES = ['<command', '<local-command', '<system', '<user-prompt-submit-hook', 'Caveat:'];
7
+ /** Claude Code records pasted text between `<pasted_content id="…">` and `</pasted_content id="…">` tags. */
8
+ const PASTE_TAG = /<\/?pasted_content\b[^>]*>/g;
7
9
  /**
8
10
  * Build a Digest from a transcript by reading its head (first prompt, cwd) and a tail window
9
11
  * (latest title, last prompt, branch). A missing or unreadable file yields the fallback fields
@@ -120,7 +122,12 @@ function userText(message) {
120
122
  function trimPrompt(text) {
121
123
  if (text === undefined)
122
124
  return undefined;
123
- return text.replace(/\s+/g, ' ').trim().slice(0, PROMPT_CHARS);
125
+ const prompt = withoutPasteTags(text).replace(/\s+/g, ' ').trim().slice(0, PROMPT_CHARS);
126
+ return prompt === '' ? undefined : prompt;
127
+ }
128
+ /** A prompt as it was typed, without the tags Claude Code puts around pasted text. */
129
+ export function withoutPasteTags(text) {
130
+ return text.replace(PASTE_TAG, '');
124
131
  }
125
132
  function str(v) {
126
133
  return typeof v === 'string' && v !== '' ? v : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-term-tabs",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "description": "Find a running Claude Code session by what you remember, and jump to its terminal tab",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,6 +47,7 @@
47
47
  "@semantic-release/changelog": "7.0.0",
48
48
  "@semantic-release/git": "11.0.1",
49
49
  "@types/node": "24.19.2",
50
+ "conventional-changelog-conventionalcommits": "9.3.1",
50
51
  "husky": "9.1.7",
51
52
  "semantic-release": "25.0.9",
52
53
  "typescript": "5.9.3"