claude-term-tabs 0.0.0-bootstrap
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/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/claude-term-tabs +19 -0
- package/dist/args.js +171 -0
- package/dist/cache.js +76 -0
- package/dist/cli.js +275 -0
- package/dist/commands.js +150 -0
- package/dist/complete.js +18 -0
- package/dist/config.js +76 -0
- package/dist/discover.js +29 -0
- package/dist/exec.js +13 -0
- package/dist/foreground.js +19 -0
- package/dist/fsutil.js +18 -0
- package/dist/handoff.js +116 -0
- package/dist/help.js +38 -0
- package/dist/history.js +46 -0
- package/dist/live.js +86 -0
- package/dist/llm.js +105 -0
- package/dist/output.js +30 -0
- package/dist/paths.js +29 -0
- package/dist/pause/run.js +167 -0
- package/dist/pause/state.js +24 -0
- package/dist/pause/ui.js +149 -0
- package/dist/picker.js +83 -0
- package/dist/resume.js +61 -0
- package/dist/score.js +87 -0
- package/dist/shell.js +4 -0
- package/dist/snapshot.js +63 -0
- package/dist/terminals/adapter.js +32 -0
- package/dist/terminals/iterm2.js +73 -0
- package/dist/terminals/wezterm.js +34 -0
- package/dist/transcript.js +127 -0
- package/dist/types.js +1 -0
- package/package.json +53 -0
package/dist/pause/ui.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { writeOwnerOnly } from "../fsutil.js";
|
|
7
|
+
import { createServer } from 'node:net';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { tickPause, stragglerLines } from "./run.js";
|
|
10
|
+
import { FINAL_STATES } from "./state.js";
|
|
11
|
+
import { shellQuote } from "../shell.js";
|
|
12
|
+
/** How often the straggler list refreshes. */
|
|
13
|
+
export const POLL_MS = 3000;
|
|
14
|
+
const CLEAR = '\x1b[2J\x1b[H';
|
|
15
|
+
const SAFE_PATH = /^[A-Za-z0-9._\/-]+$/;
|
|
16
|
+
const HEADER = 'ctt pause: Enter or double-click jumps to a session · ctrl-r re-sends the stop prompt · Esc stops waiting';
|
|
17
|
+
/** fzf arguments for the live straggler list; `self` is the argv prefix that re-invokes ctt. */
|
|
18
|
+
export function fzfArgs(port, self) {
|
|
19
|
+
const ctt = self.map(shellQuote).join(' ');
|
|
20
|
+
return [
|
|
21
|
+
'--listen', `127.0.0.1:${port}`,
|
|
22
|
+
'--delimiter', '\t', '--with-nth', '2..',
|
|
23
|
+
'--no-sort', '--layout', 'reverse', '--height', '50%',
|
|
24
|
+
'--prompt', 'waiting> ', '--header', HEADER,
|
|
25
|
+
'--bind', `enter:execute-silent(${ctt} _focus {1})`,
|
|
26
|
+
'--bind', `double-click:execute-silent(${ctt} _focus {1})`,
|
|
27
|
+
'--bind', `ctrl-r:execute-silent(${ctt} _send-stop {1})`
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
/** The fzf reload action: it names only a ctt-chosen file, so no session data reaches fzf's action parser. */
|
|
31
|
+
export function reloadAction(linesFile) {
|
|
32
|
+
if (!SAFE_PATH.test(linesFile))
|
|
33
|
+
throw new Error(`unsafe list file path: ${linesFile}`);
|
|
34
|
+
return `reload(cat ${shellQuote(linesFile)})`;
|
|
35
|
+
}
|
|
36
|
+
/** Environment for fzf with a fresh random FZF_API_KEY, which fzf then requires on every --listen request. */
|
|
37
|
+
export function fzfEnv(base) {
|
|
38
|
+
return { ...base, FZF_API_KEY: randomBytes(32).toString('hex') };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Poll the pause every POLL_MS, showing stragglers until none remain (and nothing is still closing),
|
|
42
|
+
* the timeout passes, or the user closes the list. Returns the latest run.
|
|
43
|
+
*/
|
|
44
|
+
export async function runStragglerUi(run, deps, io, advance = tickPause) {
|
|
45
|
+
const deadline = deps.now() + io.timeoutMs;
|
|
46
|
+
let current = run;
|
|
47
|
+
const ui = io.hasFzf && io.isTty && process.env.CTT_NO_REAL_EXEC !== '1' ? await fzfUi(io, stragglerLines(current, deps.now())) : plainUi(io, deps);
|
|
48
|
+
try {
|
|
49
|
+
await ui.update(stragglerLines(current, deps.now()));
|
|
50
|
+
while (!ui.closed()) {
|
|
51
|
+
await io.sleep(POLL_MS);
|
|
52
|
+
if (ui.closed())
|
|
53
|
+
break;
|
|
54
|
+
try {
|
|
55
|
+
current = advance(current, deps);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
deps.warn(`claude-term-tabs: refresh failed, retrying: ${err.message}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const pending = current.tracked.some(t => !FINAL_STATES.has(t.state));
|
|
62
|
+
if (!pending || deps.now() >= deadline)
|
|
63
|
+
break;
|
|
64
|
+
await ui.update(stragglerLines(current, deps.now()));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await ui.finish();
|
|
69
|
+
}
|
|
70
|
+
return current;
|
|
71
|
+
}
|
|
72
|
+
function plainUi(io, deps) {
|
|
73
|
+
let last = '';
|
|
74
|
+
let shown = [];
|
|
75
|
+
const input = io.isTty && io.onPick !== undefined ? createInterface({ input: process.stdin }) : undefined;
|
|
76
|
+
input?.on('line', answer => {
|
|
77
|
+
const id = shown[Number(answer.trim()) - 1];
|
|
78
|
+
if (id !== undefined)
|
|
79
|
+
io.onPick?.(id);
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
update: async (lines) => {
|
|
83
|
+
shown = lines.map(l => l.split('\t')[0]);
|
|
84
|
+
const body = lines.map((l, i) => `${String(i + 1).padStart(2)}) ${l.split('\t')[1]}`).join('\n');
|
|
85
|
+
if (io.isTty) {
|
|
86
|
+
io.write(`${CLEAR}Waiting for ${lines.length} sessions (${new Date(deps.now()).toLocaleTimeString()}). Type a number and Enter to jump.\n${body}`);
|
|
87
|
+
}
|
|
88
|
+
else if (body !== last) {
|
|
89
|
+
io.write(body);
|
|
90
|
+
}
|
|
91
|
+
last = body;
|
|
92
|
+
},
|
|
93
|
+
finish: async () => { input?.close(); },
|
|
94
|
+
closed: () => false
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function fzfUi(io, initial) {
|
|
98
|
+
const port = await freePort();
|
|
99
|
+
const dir = mkdtempSync(join(tmpdir(), 'claude-term-tabs-'));
|
|
100
|
+
const linesFile = join(dir, 'lines');
|
|
101
|
+
writeOwnerOnly(linesFile, initial.join('\n'));
|
|
102
|
+
const env = fzfEnv(process.env);
|
|
103
|
+
const child = spawn('fzf', fzfArgs(port, io.self), { stdio: ['pipe', 'ignore', 'inherit'], env });
|
|
104
|
+
let exited = false;
|
|
105
|
+
child.on('exit', () => { exited = true; });
|
|
106
|
+
child.on('error', () => { exited = true; });
|
|
107
|
+
child.stdin.end(initial.join('\n'));
|
|
108
|
+
const post = async (action) => {
|
|
109
|
+
if (exited)
|
|
110
|
+
return;
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(`http://127.0.0.1:${port}`, { method: 'POST', headers: { 'x-api-key': env.FZF_API_KEY ?? '' }, body: action });
|
|
113
|
+
if (!res.ok)
|
|
114
|
+
process.stderr.write(`claude-term-tabs: the list refused an update (${res.status})\n`);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (!exited)
|
|
118
|
+
process.stderr.write(`claude-term-tabs: could not update the list: ${err.message}\n`);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
update: async (lines) => {
|
|
123
|
+
writeOwnerOnly(linesFile, lines.join('\n'));
|
|
124
|
+
await post(reloadAction(linesFile));
|
|
125
|
+
},
|
|
126
|
+
finish: async () => {
|
|
127
|
+
await post('abort');
|
|
128
|
+
if (!exited)
|
|
129
|
+
await new Promise(resolve => child.once('exit', () => resolve()));
|
|
130
|
+
rmSync(dir, { recursive: true, force: true });
|
|
131
|
+
},
|
|
132
|
+
closed: () => exited
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function freePort() {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const server = createServer();
|
|
138
|
+
server.once('error', reject);
|
|
139
|
+
server.listen(0, '127.0.0.1', () => {
|
|
140
|
+
const address = server.address();
|
|
141
|
+
server.close(() => {
|
|
142
|
+
if (typeof address === 'object' && address !== null)
|
|
143
|
+
resolve(address.port);
|
|
144
|
+
else
|
|
145
|
+
reject(new Error('could not pick a port for fzf'));
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
package/dist/picker.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { shellQuote } from "./shell.js";
|
|
5
|
+
const SEP = ' · ';
|
|
6
|
+
const FZF_CANCEL = new Set([1, 130]);
|
|
7
|
+
/** Compact age: 5m, 3h, 2d. */
|
|
8
|
+
export function formatAge(lastActive, now) {
|
|
9
|
+
const minutes = Math.max(0, Math.floor((now - lastActive) / 60_000));
|
|
10
|
+
if (minutes < 60)
|
|
11
|
+
return `${minutes}m`;
|
|
12
|
+
if (minutes < 24 * 60)
|
|
13
|
+
return `${Math.floor(minutes / 60)}h`;
|
|
14
|
+
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
15
|
+
}
|
|
16
|
+
/** One picker line: `<sessionId>\t<fields joined by ' · '>`, with control characters flattened. */
|
|
17
|
+
export function formatPickerLine(c, summary, now = Date.now()) {
|
|
18
|
+
const d = c.digest;
|
|
19
|
+
const fields = [
|
|
20
|
+
d.title ?? '(untitled)',
|
|
21
|
+
d.worktree !== undefined ? `${d.repo}/${d.worktree}` : d.repo,
|
|
22
|
+
d.branch,
|
|
23
|
+
formatAge(d.lastActive, now),
|
|
24
|
+
c.live ? c.status : 'closed',
|
|
25
|
+
c.pane !== undefined ? tabLabel(c) : undefined,
|
|
26
|
+
c.dirGone === true ? 'dir gone' : undefined,
|
|
27
|
+
summary
|
|
28
|
+
].filter((f) => f !== undefined && f !== '').map(cleanField);
|
|
29
|
+
return `${d.sessionId}\t${fields.join(SEP)}`;
|
|
30
|
+
}
|
|
31
|
+
/** Session id from a selected picker line. */
|
|
32
|
+
export function parsePickerSelection(line) {
|
|
33
|
+
return line.split('\t')[0].trim();
|
|
34
|
+
}
|
|
35
|
+
/** Let the user choose one candidate with fzf, or a numbered prompt without it. */
|
|
36
|
+
export function pick(cands, summaries, exec, io) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const lines = cands.map(c => formatPickerLine(c, summaries.get(c.digest.sessionId), now));
|
|
39
|
+
const byId = new Map(cands.map(c => [c.digest.sessionId, c]));
|
|
40
|
+
if (io.hasFzf)
|
|
41
|
+
return byId.get(runFzf(lines, cands, exec) ?? '');
|
|
42
|
+
const menu = lines.map((l, i) => `${String(i + 1).padStart(2)}) ${l.split('\t')[1]}`).join('\n');
|
|
43
|
+
const answer = io.prompt(`${menu}\nPick a session (number, Enter to cancel): `).trim();
|
|
44
|
+
const n = Number(answer);
|
|
45
|
+
return Number.isInteger(n) && n >= 1 && n <= cands.length ? cands[n - 1] : undefined;
|
|
46
|
+
}
|
|
47
|
+
function runFzf(lines, cands, exec) {
|
|
48
|
+
const previewDir = mkdtempSync(join(tmpdir(), 'claude-term-tabs-'));
|
|
49
|
+
try {
|
|
50
|
+
for (const c of cands)
|
|
51
|
+
writeFileSync(join(previewDir, c.digest.sessionId), previewText(c.digest), { mode: 0o600 });
|
|
52
|
+
const r = exec('fzf', [
|
|
53
|
+
'--delimiter', '\t', '--with-nth', '2..', '--no-sort', '--height', '60%', '--reverse',
|
|
54
|
+
'--prompt', 'session> ',
|
|
55
|
+
'--preview', `cat ${shellQuote(previewDir)}/{1}`, '--preview-window', 'down,6,wrap'
|
|
56
|
+
], lines.join('\n'));
|
|
57
|
+
if (FZF_CANCEL.has(r.status))
|
|
58
|
+
return undefined;
|
|
59
|
+
if (r.status !== 0)
|
|
60
|
+
throw new Error(`fzf failed (${r.status}): ${r.stderr.trim()}`);
|
|
61
|
+
return parsePickerSelection(r.stdout);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
rmSync(previewDir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function previewText(d) {
|
|
68
|
+
return [
|
|
69
|
+
`repo: ${d.repo}${d.worktree !== undefined ? ` (worktree ${d.worktree})` : ''}`,
|
|
70
|
+
`branch: ${d.branch ?? ''}`,
|
|
71
|
+
`cwd: ${d.cwd}`,
|
|
72
|
+
`first: ${d.firstPrompt ?? ''}`,
|
|
73
|
+
`last: ${d.lastPrompt ?? ''}`,
|
|
74
|
+
d.note !== undefined ? `note: ${d.note}` : ''
|
|
75
|
+
].map(cleanField).join('\n');
|
|
76
|
+
}
|
|
77
|
+
function tabLabel(c) {
|
|
78
|
+
return `tab ${c.pane?.tabIndex ?? '?'}`;
|
|
79
|
+
}
|
|
80
|
+
/** Flatten control characters and escape sequences out of a display field. */
|
|
81
|
+
export function cleanField(s) {
|
|
82
|
+
return s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '').replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ').replace(/ {2,}/g, ' ').trim();
|
|
83
|
+
}
|
package/dist/resume.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isResumableDir, resumeCommand } from "./history.js";
|
|
2
|
+
import { shellQuote } from "./shell.js";
|
|
3
|
+
/** Order recorded sessions by window then tab and decide, for each, whether to open it. */
|
|
4
|
+
export function planResume(snap, liveIds, dirExists, config, opts) {
|
|
5
|
+
const ordered = [...snap.sessions].sort((a, b) => position(a.windowIndex) - position(b.windowIndex) || position(a.tabIndex) - position(b.tabIndex));
|
|
6
|
+
const windowsOpened = new Set();
|
|
7
|
+
return ordered.map(session => {
|
|
8
|
+
const base = resumeCommand(config, session.sessionId);
|
|
9
|
+
const command = opts.noPrompt ? base : `${base} ${shellQuote(config.resumePrompt)}`;
|
|
10
|
+
if (liveIds.has(session.sessionId))
|
|
11
|
+
return { session, action: 'skip-live', command, newWindow: false };
|
|
12
|
+
if (!isResumableDir(session.cwd, dirExists))
|
|
13
|
+
return { session, action: 'skip-dir-gone', command, newWindow: false };
|
|
14
|
+
const group = String(session.windowIndex ?? 'none');
|
|
15
|
+
const newWindow = !windowsOpened.has(group);
|
|
16
|
+
windowsOpened.add(group);
|
|
17
|
+
return { session, action: 'open', command, newWindow };
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Carry out a resume plan. Returns 1 when there is no terminal to open tabs in, or a tab failed to open. */
|
|
21
|
+
export function runResume(steps, adapter, opts, write) {
|
|
22
|
+
const shell = (s) => `cd ${shellQuote(s.session.cwd)} && ${s.command}`;
|
|
23
|
+
if (adapter === undefined && !opts.dryRun) {
|
|
24
|
+
write('No supported terminal to open tabs in; run these yourself:');
|
|
25
|
+
for (const s of steps.filter(x => x.action === 'open'))
|
|
26
|
+
write(` ${shell(s)}`);
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
let opened = 0;
|
|
30
|
+
let failed = false;
|
|
31
|
+
for (const s of steps) {
|
|
32
|
+
const name = `${s.session.title ?? s.session.sessionId} · ${s.session.repo}`;
|
|
33
|
+
if (s.action === 'skip-live') {
|
|
34
|
+
write(` ${name} · already running`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (s.action === 'skip-dir-gone') {
|
|
38
|
+
write(` ${name} · ${s.session.cwd} no longer exists, not resumed\n ${shell(s)}`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (opts.dryRun) {
|
|
42
|
+
write(` ${shell(s)}${s.newWindow ? ' (new window)' : ''}`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
adapter?.openPane(s.session.cwd, s.command, { newWindow: s.newWindow });
|
|
47
|
+
opened++;
|
|
48
|
+
write(`→ ${name}`);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
failed = true;
|
|
52
|
+
write(`✗ ${name}: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!opts.dryRun)
|
|
56
|
+
write(`Resumed ${opened} of ${steps.length} sessions.`);
|
|
57
|
+
return failed ? 1 : 0;
|
|
58
|
+
}
|
|
59
|
+
function position(n) {
|
|
60
|
+
return n ?? Number.MAX_SAFE_INTEGER;
|
|
61
|
+
}
|
package/dist/score.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Minimum score for a local match to switch without asking. */
|
|
2
|
+
export const MIN_WINNER_SCORE = 4;
|
|
3
|
+
/** How far ahead of the runner-up a local winner must be. */
|
|
4
|
+
export const WINNER_RATIO = 1.5;
|
|
5
|
+
const MAX_RECENCY_BOOST = 0.5;
|
|
6
|
+
const RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const MIN_PREFIX_CHARS = 3;
|
|
8
|
+
const PREFIX_STRENGTH = 0.5;
|
|
9
|
+
const FILLER = new Set([
|
|
10
|
+
'a', 'about', 'an', 'and', 'claude', 'for', 'i', 'in', 'into', 'is', 'it', 'me', 'my', 'of', 'on',
|
|
11
|
+
'session', 'sessions', 'stuff', 'tab', 'that', 'the', 'thing', 'this', 'to', 'was', 'were',
|
|
12
|
+
'where', 'which', 'with', 'work', 'working'
|
|
13
|
+
]);
|
|
14
|
+
const TICKET = /^[a-z]+-\d+$/;
|
|
15
|
+
const FIELD_WEIGHTS = {
|
|
16
|
+
title: 3,
|
|
17
|
+
repo: 3,
|
|
18
|
+
worktree: 2,
|
|
19
|
+
branch: 2,
|
|
20
|
+
lastPrompt: 2,
|
|
21
|
+
firstPrompt: 1
|
|
22
|
+
};
|
|
23
|
+
/** Lowercased query tokens with filler words removed; hyphenated words stay whole. */
|
|
24
|
+
export function tokenize(query) {
|
|
25
|
+
return query.toLowerCase()
|
|
26
|
+
.split(/[^a-z0-9-]+/)
|
|
27
|
+
.map(t => t.replace(/^-+|-+$/g, ''))
|
|
28
|
+
.filter(t => t !== '' && !FILLER.has(t));
|
|
29
|
+
}
|
|
30
|
+
/** Candidates matching the query, highest score first. Rarer tokens count for more. */
|
|
31
|
+
export function rankLocal(query, cands, now = Date.now()) {
|
|
32
|
+
const tokens = tokenize(query);
|
|
33
|
+
if (tokens.length === 0)
|
|
34
|
+
return [];
|
|
35
|
+
const words = cands.map(c => fieldWords(c.digest));
|
|
36
|
+
const hits = tokens.map(t => words.map(w => bestHit(t, w)));
|
|
37
|
+
const idf = hits.map(perCand => {
|
|
38
|
+
const df = perCand.filter(h => h > 0).length;
|
|
39
|
+
return df === 0 ? 0 : Math.log(1 + cands.length / df);
|
|
40
|
+
});
|
|
41
|
+
return cands
|
|
42
|
+
.map((cand, i) => {
|
|
43
|
+
const base = hits.reduce((sum, perCand, t) => sum + perCand[i] * idf[t], 0);
|
|
44
|
+
return { cand, score: base > 0 ? base + recencyBoost(cand.digest.lastActive, now) : 0 };
|
|
45
|
+
})
|
|
46
|
+
.filter(r => r.score > 0)
|
|
47
|
+
.sort((a, b) => b.score - a.score);
|
|
48
|
+
}
|
|
49
|
+
/** The top candidate when it is strong enough and well ahead of the runner-up. */
|
|
50
|
+
export function clearWinner(ranked) {
|
|
51
|
+
const [top, next] = ranked;
|
|
52
|
+
if (top === undefined || top.score < MIN_WINNER_SCORE)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (next !== undefined && top.score < next.score * WINNER_RATIO)
|
|
55
|
+
return undefined;
|
|
56
|
+
return top.cand;
|
|
57
|
+
}
|
|
58
|
+
function fieldWords(d) {
|
|
59
|
+
const entries = Object.keys(FIELD_WEIGHTS).map(f => [f, splitWords(d[f])]);
|
|
60
|
+
return new Map(entries);
|
|
61
|
+
}
|
|
62
|
+
function splitWords(text) {
|
|
63
|
+
if (text === undefined)
|
|
64
|
+
return [];
|
|
65
|
+
const whole = text.toLowerCase().split(/[^a-z0-9-]+/).filter(Boolean);
|
|
66
|
+
const parts = whole.flatMap(w => w.includes('-') ? w.split('-').filter(Boolean) : []);
|
|
67
|
+
return [...whole, ...parts];
|
|
68
|
+
}
|
|
69
|
+
function bestHit(token, words) {
|
|
70
|
+
let best = 0;
|
|
71
|
+
for (const [field, list] of words) {
|
|
72
|
+
const strength = matchStrength(token, list);
|
|
73
|
+
best = Math.max(best, strength * FIELD_WEIGHTS[field]);
|
|
74
|
+
}
|
|
75
|
+
return best;
|
|
76
|
+
}
|
|
77
|
+
function matchStrength(token, words) {
|
|
78
|
+
if (words.includes(token))
|
|
79
|
+
return 1;
|
|
80
|
+
if (TICKET.test(token) || token.length < MIN_PREFIX_CHARS)
|
|
81
|
+
return 0;
|
|
82
|
+
return words.some(w => w.startsWith(token)) ? PREFIX_STRENGTH : 0;
|
|
83
|
+
}
|
|
84
|
+
function recencyBoost(lastActive, now) {
|
|
85
|
+
const age = Math.max(0, now - lastActive);
|
|
86
|
+
return MAX_RECENCY_BOOST * Math.max(0, 1 - age / RECENCY_WINDOW_MS);
|
|
87
|
+
}
|
package/dist/shell.js
ADDED
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { writeOwnerOnly } from "./fsutil.js";
|
|
4
|
+
const SUFFIX = '.json';
|
|
5
|
+
const NAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z\.json$/;
|
|
6
|
+
/** Build a snapshot of live candidates; its id is the creation time with ':' as '-'. */
|
|
7
|
+
export function snapshotFrom(cands, terminal, now) {
|
|
8
|
+
const createdAt = new Date(now).toISOString();
|
|
9
|
+
return {
|
|
10
|
+
id: createdAt.replaceAll(':', '-'),
|
|
11
|
+
createdAt,
|
|
12
|
+
sessions: cands.map(c => ({
|
|
13
|
+
sessionId: c.digest.sessionId,
|
|
14
|
+
cwd: c.digest.cwd,
|
|
15
|
+
title: c.digest.title,
|
|
16
|
+
repo: c.digest.repo,
|
|
17
|
+
branch: c.digest.branch,
|
|
18
|
+
windowIndex: c.pane?.windowIndex,
|
|
19
|
+
tabIndex: c.pane?.tabIndex,
|
|
20
|
+
terminal: c.pane !== undefined ? terminal : undefined,
|
|
21
|
+
handoffWritten: false
|
|
22
|
+
}))
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Write (or overwrite) a snapshot owner-only; returns its path. */
|
|
26
|
+
export function saveSnapshot(dir, snap) {
|
|
27
|
+
const path = join(dir, `${snap.id}${SUFFIX}`);
|
|
28
|
+
writeOwnerOnly(path, JSON.stringify(snap, null, 2));
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
/** Newest snapshot, or undefined when there is none. A corrupt newest file throws rather than falling back. */
|
|
32
|
+
export function latestSnapshot(dir) {
|
|
33
|
+
const newest = snapshotFiles(dir).at(-1);
|
|
34
|
+
if (newest === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
const path = join(dir, newest);
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new Error(`snapshot ${path} is unreadable: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Delete snapshots created more than `maxAgeMs` before `now`; returns the deleted paths. */
|
|
45
|
+
export function pruneSnapshots(dir, now, maxAgeMs) {
|
|
46
|
+
const cutoff = now - maxAgeMs;
|
|
47
|
+
const old = snapshotFiles(dir).filter(name => Date.parse(name.slice(0, -SUFFIX.length).replace(/T(\d{2})-(\d{2})-(\d{2})/, 'T$1:$2:$3')) < cutoff);
|
|
48
|
+
return old.map(name => {
|
|
49
|
+
const path = join(dir, name);
|
|
50
|
+
rmSync(path);
|
|
51
|
+
return path;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function snapshotFiles(dir) {
|
|
55
|
+
try {
|
|
56
|
+
return readdirSync(dir).filter(name => NAME.test(name)).sort();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err.code === 'ENOENT')
|
|
60
|
+
return [];
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { iterm2Adapter } from "./iterm2.js";
|
|
2
|
+
import { weztermAdapter } from "./wezterm.js";
|
|
3
|
+
const FACTORIES = {
|
|
4
|
+
iterm2: iterm2Adapter,
|
|
5
|
+
wezterm: weztermAdapter
|
|
6
|
+
};
|
|
7
|
+
/** The names `--terminal` accepts. */
|
|
8
|
+
export const TERMINAL_NAMES = Object.freeze(Object.keys(FACTORIES));
|
|
9
|
+
const BY_TERM_PROGRAM = {
|
|
10
|
+
'iTerm.app': 'iterm2',
|
|
11
|
+
WezTerm: 'wezterm'
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Pick the adapter: explicit override, then TERM_PROGRAM, then whichever supported terminal is
|
|
15
|
+
* running. Returns undefined when none is available; throws on an unknown override.
|
|
16
|
+
*/
|
|
17
|
+
export function detectAdapter(env, exec, override) {
|
|
18
|
+
if (override !== undefined) {
|
|
19
|
+
const name = TERMINAL_NAMES.find(n => n === override);
|
|
20
|
+
if (name === undefined)
|
|
21
|
+
throw new Error(`unknown terminal "${override}" (supported: ${TERMINAL_NAMES.join(', ')})`);
|
|
22
|
+
return FACTORIES[name](exec);
|
|
23
|
+
}
|
|
24
|
+
const fromEnv = BY_TERM_PROGRAM[env.TERM_PROGRAM ?? ''];
|
|
25
|
+
if (fromEnv !== undefined)
|
|
26
|
+
return FACTORIES[fromEnv](exec);
|
|
27
|
+
if (exec('pgrep', ['-x', 'iTerm2']).status === 0)
|
|
28
|
+
return iterm2Adapter(exec);
|
|
29
|
+
if (exec('wezterm', ['cli', 'list', '--format', 'json']).status === 0)
|
|
30
|
+
return weztermAdapter(exec);
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const LIST_SCRIPT = `function run () {
|
|
2
|
+
const app = Application('iTerm2')
|
|
3
|
+
const out = []
|
|
4
|
+
let shown = 0
|
|
5
|
+
for (const w of app.windows()) {
|
|
6
|
+
let wid, ids, ttys, names
|
|
7
|
+
try {
|
|
8
|
+
wid = String(w.id())
|
|
9
|
+
ids = w.tabs.sessions.id()
|
|
10
|
+
ttys = w.tabs.sessions.tty()
|
|
11
|
+
names = w.tabs.sessions.name()
|
|
12
|
+
} catch (e) {
|
|
13
|
+
continue
|
|
14
|
+
}
|
|
15
|
+
shown++
|
|
16
|
+
ids.forEach((tabIds, ti) => tabIds.forEach((id, si) => out.push({
|
|
17
|
+
id, tty: ttys[ti][si], title: names[ti][si], windowId: wid, windowIndex: shown, tabIndex: ti + 1
|
|
18
|
+
})))
|
|
19
|
+
}
|
|
20
|
+
return JSON.stringify(out)
|
|
21
|
+
}`;
|
|
22
|
+
const sessionScript = (onFound) => `function run (argv) {
|
|
23
|
+
const app = Application('iTerm2')
|
|
24
|
+
for (const w of app.windows()) {
|
|
25
|
+
let tabs
|
|
26
|
+
try { tabs = w.tabs() } catch (e) { continue }
|
|
27
|
+
for (const t of tabs) {
|
|
28
|
+
for (const s of t.sessions()) {
|
|
29
|
+
if (s.id() === argv[0]) {
|
|
30
|
+
${onFound}
|
|
31
|
+
return 'ok'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throw new Error('session not found: ' + argv[0])
|
|
37
|
+
}`;
|
|
38
|
+
const FOCUS_SCRIPT = sessionScript('s.select(); t.select(); w.select(); app.activate()');
|
|
39
|
+
const SEND_SCRIPT = sessionScript('s.write({ text: argv[1], newline: false }); delay(0.3); s.write({ text: "" })');
|
|
40
|
+
const PASTE_START = '\x1b[200~';
|
|
41
|
+
const PASTE_END = '\x1b[201~';
|
|
42
|
+
const CLOSE_SCRIPT = sessionScript("s.write({ text: 'exit' })");
|
|
43
|
+
const OPEN_SCRIPT = `function run (argv) {
|
|
44
|
+
const app = Application('iTerm2')
|
|
45
|
+
const quoted = "'" + argv[0].split("'").join("'\\\\''") + "'"
|
|
46
|
+
const fresh = argv[2] === 'new-window' || !app.currentWindow()
|
|
47
|
+
const session = fresh
|
|
48
|
+
? app.createWindowWithDefaultProfile().currentSession()
|
|
49
|
+
: app.currentWindow().createTabWithDefaultProfile().currentSession()
|
|
50
|
+
session.write({ text: 'cd ' + quoted + ' && ' + argv[1] })
|
|
51
|
+
app.activate()
|
|
52
|
+
return session.id()
|
|
53
|
+
}`;
|
|
54
|
+
const AUTOMATION_HINT = 'iTerm2 refused automation. Allow your terminal to control iTerm2 in System Settings > Privacy & Security > Automation.';
|
|
55
|
+
/** iTerm2 adapter driven by JXA through osascript. */
|
|
56
|
+
export function iterm2Adapter(exec) {
|
|
57
|
+
const jxa = (script, args = []) => {
|
|
58
|
+
const r = exec('osascript', ['-l', 'JavaScript', '-e', script, ...args]);
|
|
59
|
+
if (r.status === 0)
|
|
60
|
+
return r.stdout;
|
|
61
|
+
if (r.stderr.includes('-1743'))
|
|
62
|
+
throw new Error(AUTOMATION_HINT);
|
|
63
|
+
throw new Error(`osascript failed (${r.status}): ${r.stderr.trim()}`);
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
name: 'iterm2',
|
|
67
|
+
listPanes: () => JSON.parse(jxa(LIST_SCRIPT)),
|
|
68
|
+
focus: (pane) => { jxa(FOCUS_SCRIPT, [pane.id]); },
|
|
69
|
+
sendText: (pane, text) => { jxa(SEND_SCRIPT, [pane.id, `${PASTE_START}${text}${PASTE_END}`]); },
|
|
70
|
+
closePane: (pane) => { jxa(CLOSE_SCRIPT, [pane.id]); },
|
|
71
|
+
openPane: (cwd, command, opts) => jxa(OPEN_SCRIPT, [cwd, command, opts?.newWindow === true ? 'new-window' : 'tab']).trim()
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** WezTerm adapter driven by `wezterm cli`. */
|
|
2
|
+
export function weztermAdapter(exec, shell = process.env.SHELL ?? '/bin/sh') {
|
|
3
|
+
const cli = (args) => {
|
|
4
|
+
const r = exec('wezterm', ['cli', ...args]);
|
|
5
|
+
if (r.status !== 0)
|
|
6
|
+
throw new Error(`wezterm cli ${args[0]} failed (${r.status}): ${r.stderr.trim()}`);
|
|
7
|
+
return r.stdout;
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
name: 'wezterm',
|
|
11
|
+
listPanes: () => toPanes(JSON.parse(cli(['list', '--format', 'json']))),
|
|
12
|
+
focus: (pane) => { cli(['activate-pane', '--pane-id', pane.id]); },
|
|
13
|
+
sendText: (pane, text) => {
|
|
14
|
+
cli(['send-text', '--pane-id', pane.id, text]);
|
|
15
|
+
cli(['send-text', '--pane-id', pane.id, '--no-paste', '\r']);
|
|
16
|
+
},
|
|
17
|
+
closePane: (pane) => { cli(['kill-pane', '--pane-id', pane.id]); },
|
|
18
|
+
openPane: (cwd, command, opts) => {
|
|
19
|
+
return cli(['spawn', ...(opts?.newWindow === true ? ['--new-window'] : []), '--cwd', cwd, '--', shell, '-lc', `${command}; exec ${shell} -l`]).trim();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function toPanes(raw) {
|
|
24
|
+
const windows = [...new Set(raw.map(p => p.window_id))];
|
|
25
|
+
const tabsByWindow = new Map(windows.map(w => [w, [...new Set(raw.filter(p => p.window_id === w).map(p => p.tab_id))]]));
|
|
26
|
+
return raw.map(p => ({
|
|
27
|
+
id: String(p.pane_id),
|
|
28
|
+
tty: p.tty_name ?? '',
|
|
29
|
+
title: p.title,
|
|
30
|
+
windowId: String(p.window_id),
|
|
31
|
+
windowIndex: windows.indexOf(p.window_id) + 1,
|
|
32
|
+
tabIndex: (tabsByWindow.get(p.window_id) ?? []).indexOf(p.tab_id) + 1
|
|
33
|
+
}));
|
|
34
|
+
}
|