claude-remote-cli 2.0.0 → 2.2.0
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 +4 -2
- package/dist/bin/claude-remote-cli.js +80 -1
- package/dist/server/index.js +63 -31
- package/dist/server/sessions.js +9 -4
- package/dist/server/watcher.js +16 -5
- package/package.json +1 -1
- package/public/app.js +191 -18
- package/public/index.html +12 -0
- package/public/style.css +30 -0
package/README.md
CHANGED
|
@@ -142,8 +142,10 @@ The PIN hash is stored in config under `pinHash`. To reset:
|
|
|
142
142
|
## Features
|
|
143
143
|
|
|
144
144
|
- **PIN-protected access** with rate limiting
|
|
145
|
-
- **
|
|
146
|
-
- **
|
|
145
|
+
- **Repo sessions** — open Claude directly in any repo root, with fresh or `--continue` mode (one session per repo)
|
|
146
|
+
- **Branch-aware worktrees** — create worktrees from new or existing branches with a type-to-search branch picker
|
|
147
|
+
- **Tabbed sidebar** — switch between Repos and Worktrees views with shared filters and item counts
|
|
148
|
+
- **Worktree isolation** — each worktree session runs in its own git worktree under `.worktrees/`
|
|
147
149
|
- **Resume sessions** — click inactive worktrees to reconnect with `--continue`
|
|
148
150
|
- **Persistent session names** — display names, branch names, and timestamps survive server restarts
|
|
149
151
|
- **Clipboard image paste** — paste screenshots directly into remote terminal sessions (macOS clipboard + xclip on Linux)
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import { execFile } from 'node:child_process';
|
|
4
|
+
import { execFile, spawn } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import * as service from '../server/service.js';
|
|
8
8
|
import { DEFAULTS } from '../server/config.js';
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
function execErrorMessage(err, fallback) {
|
|
12
|
+
const e = err;
|
|
13
|
+
return (e.stderr || e.message || fallback).trimEnd();
|
|
14
|
+
}
|
|
11
15
|
// Parse CLI flags
|
|
12
16
|
const args = process.argv.slice(2);
|
|
13
17
|
if (args.includes('--help') || args.includes('-h')) {
|
|
@@ -19,12 +23,17 @@ Commands:
|
|
|
19
23
|
install Install as a background service (survives reboot)
|
|
20
24
|
uninstall Stop and remove the background service
|
|
21
25
|
status Show whether the service is running
|
|
26
|
+
worktree Manage git worktrees (wraps git worktree)
|
|
27
|
+
add [path] [-b branch] [--yolo] Create worktree and launch Claude
|
|
28
|
+
remove <path> Forward to git worktree remove
|
|
29
|
+
list Forward to git worktree list
|
|
22
30
|
|
|
23
31
|
Options:
|
|
24
32
|
--bg Shortcut: install and start as background service
|
|
25
33
|
--port <port> Override server port (default: 3456)
|
|
26
34
|
--host <host> Override bind address (default: 0.0.0.0)
|
|
27
35
|
--config <path> Path to config.json (default: ~/.config/claude-remote-cli/config.json)
|
|
36
|
+
--yolo With 'worktree add': pass --dangerously-skip-permissions to Claude
|
|
28
37
|
--version, -v Show version
|
|
29
38
|
--help, -h Show this help`);
|
|
30
39
|
process.exit(0);
|
|
@@ -87,6 +96,76 @@ if (command === 'update') {
|
|
|
87
96
|
}
|
|
88
97
|
process.exit(0);
|
|
89
98
|
}
|
|
99
|
+
if (command === 'worktree') {
|
|
100
|
+
const wtArgs = args.slice(1);
|
|
101
|
+
const subCommand = wtArgs[0];
|
|
102
|
+
if (!subCommand) {
|
|
103
|
+
console.error('Usage: claude-remote-cli worktree <add|remove|list> [options]');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
if (subCommand !== 'add') {
|
|
107
|
+
try {
|
|
108
|
+
const result = await execFileAsync('git', ['worktree', ...wtArgs]);
|
|
109
|
+
if (result.stdout)
|
|
110
|
+
console.log(result.stdout.trimEnd());
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.error(execErrorMessage(err, 'git worktree failed'));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
// Handle 'add' -- strip --yolo, determine path, forward to git, then launch claude
|
|
119
|
+
const hasYolo = wtArgs.includes('--yolo');
|
|
120
|
+
const gitWtArgs = wtArgs.filter(function (a) { return a !== '--yolo'; });
|
|
121
|
+
const addSubArgs = gitWtArgs.slice(1);
|
|
122
|
+
let targetDir;
|
|
123
|
+
const bIdx = gitWtArgs.indexOf('-b');
|
|
124
|
+
const branchForDefault = bIdx !== -1 && bIdx + 1 < gitWtArgs.length ? gitWtArgs[bIdx + 1] : undefined;
|
|
125
|
+
if (addSubArgs.length === 0 || addSubArgs[0].startsWith('-')) {
|
|
126
|
+
let repoRoot;
|
|
127
|
+
try {
|
|
128
|
+
const result = await execFileAsync('git', ['rev-parse', '--show-toplevel']);
|
|
129
|
+
repoRoot = result.stdout.trim();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
console.error('Not inside a git repository.');
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
const dirName = branchForDefault
|
|
136
|
+
? branchForDefault.replace(/\//g, '-')
|
|
137
|
+
: 'worktree-' + Date.now().toString(36);
|
|
138
|
+
targetDir = path.join(repoRoot, '.worktrees', dirName);
|
|
139
|
+
gitWtArgs.splice(1, 0, targetDir);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
targetDir = path.resolve(addSubArgs[0]);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const result = await execFileAsync('git', ['worktree', ...gitWtArgs]);
|
|
146
|
+
if (result.stdout)
|
|
147
|
+
console.log(result.stdout.trimEnd());
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.error(execErrorMessage(err, 'git worktree add failed'));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
console.log(`Worktree created at ${targetDir}`);
|
|
154
|
+
const claudeArgs = [];
|
|
155
|
+
if (hasYolo)
|
|
156
|
+
claudeArgs.push('--dangerously-skip-permissions');
|
|
157
|
+
console.log(`Launching claude${hasYolo ? ' (yolo mode)' : ''} in ${targetDir}...`);
|
|
158
|
+
const child = spawn('claude', claudeArgs, {
|
|
159
|
+
cwd: targetDir,
|
|
160
|
+
stdio: 'inherit',
|
|
161
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
162
|
+
});
|
|
163
|
+
child.on('exit', (code) => {
|
|
164
|
+
process.exit(code ?? 0);
|
|
165
|
+
});
|
|
166
|
+
// Block until child exits via the handler above
|
|
167
|
+
await new Promise(() => { });
|
|
168
|
+
}
|
|
90
169
|
if (command === 'install' || command === 'uninstall' || command === 'status' || args.includes('--bg')) {
|
|
91
170
|
if (command === 'uninstall') {
|
|
92
171
|
runServiceCommand(() => { service.uninstall(); });
|
package/dist/server/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, ensureMetaDir }
|
|
|
12
12
|
import * as auth from './auth.js';
|
|
13
13
|
import * as sessions from './sessions.js';
|
|
14
14
|
import { setupWebSocket } from './ws.js';
|
|
15
|
-
import { WorktreeWatcher } from './watcher.js';
|
|
15
|
+
import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath } from './watcher.js';
|
|
16
16
|
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
17
17
|
import { extensionForMime, setClipboardImage } from './clipboard.js';
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -59,6 +59,10 @@ async function getLatestVersion() {
|
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
function execErrorMessage(err, fallback) {
|
|
63
|
+
const e = err;
|
|
64
|
+
return (e.stderr || e.message || fallback).trim();
|
|
65
|
+
}
|
|
62
66
|
function parseTTL(ttl) {
|
|
63
67
|
if (typeof ttl !== 'string')
|
|
64
68
|
return 24 * 60 * 60 * 1000;
|
|
@@ -246,28 +250,30 @@ async function main() {
|
|
|
246
250
|
reposToScan = scanAllRepos(roots);
|
|
247
251
|
}
|
|
248
252
|
for (const repo of reposToScan) {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
for (const entry of entries) {
|
|
258
|
-
if (!entry.isDirectory())
|
|
253
|
+
for (const dir of WORKTREE_DIRS) {
|
|
254
|
+
const worktreeDir = path.join(repo.path, dir);
|
|
255
|
+
let entries;
|
|
256
|
+
try {
|
|
257
|
+
entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
|
|
258
|
+
}
|
|
259
|
+
catch (_) {
|
|
259
260
|
continue;
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
261
|
+
}
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
if (!entry.isDirectory())
|
|
264
|
+
continue;
|
|
265
|
+
const wtPath = path.join(worktreeDir, entry.name);
|
|
266
|
+
const meta = readMeta(CONFIG_PATH, wtPath);
|
|
267
|
+
worktrees.push({
|
|
268
|
+
name: entry.name,
|
|
269
|
+
path: wtPath,
|
|
270
|
+
repoName: repo.name,
|
|
271
|
+
repoPath: repo.path,
|
|
272
|
+
root: repo.root,
|
|
273
|
+
displayName: meta ? meta.displayName : '',
|
|
274
|
+
lastActivity: meta ? meta.lastActivity : '',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
271
277
|
}
|
|
272
278
|
}
|
|
273
279
|
res.json(worktrees);
|
|
@@ -315,9 +321,8 @@ async function main() {
|
|
|
315
321
|
res.status(400).json({ error: 'worktreePath and repoPath are required' });
|
|
316
322
|
return;
|
|
317
323
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
res.status(400).json({ error: 'Path is not inside a .worktrees/ directory' });
|
|
324
|
+
if (!isValidWorktreePath(worktreePath)) {
|
|
325
|
+
res.status(400).json({ error: 'Path is not inside a worktree directory' });
|
|
321
326
|
return;
|
|
322
327
|
}
|
|
323
328
|
// Check no active session is using this worktree
|
|
@@ -335,9 +340,7 @@ async function main() {
|
|
|
335
340
|
await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoPath });
|
|
336
341
|
}
|
|
337
342
|
catch (err) {
|
|
338
|
-
|
|
339
|
-
const message = (execErr.stderr || execErr.message || 'Failed to remove worktree').trim();
|
|
340
|
-
res.status(500).json({ error: message });
|
|
343
|
+
res.status(500).json({ error: execErrorMessage(err, 'Failed to remove worktree') });
|
|
341
344
|
return;
|
|
342
345
|
}
|
|
343
346
|
try {
|
|
@@ -427,9 +430,7 @@ async function main() {
|
|
|
427
430
|
}
|
|
428
431
|
}
|
|
429
432
|
catch (err) {
|
|
430
|
-
|
|
431
|
-
const message = (execErr.stderr || execErr.message || 'Failed to create worktree').trim();
|
|
432
|
-
res.status(500).json({ error: message });
|
|
433
|
+
res.status(500).json({ error: execErrorMessage(err, 'Failed to create worktree') });
|
|
433
434
|
return;
|
|
434
435
|
}
|
|
435
436
|
worktreeName = dirName;
|
|
@@ -439,6 +440,7 @@ async function main() {
|
|
|
439
440
|
}
|
|
440
441
|
const displayName = branchName || worktreeName;
|
|
441
442
|
const session = sessions.create({
|
|
443
|
+
type: 'worktree',
|
|
442
444
|
repoName: name,
|
|
443
445
|
repoPath: sessionRepoPath,
|
|
444
446
|
cwd,
|
|
@@ -459,6 +461,36 @@ async function main() {
|
|
|
459
461
|
}
|
|
460
462
|
res.status(201).json(session);
|
|
461
463
|
});
|
|
464
|
+
// POST /sessions/repo — start a session in the repo root (no worktree)
|
|
465
|
+
app.post('/sessions/repo', requireAuth, (req, res) => {
|
|
466
|
+
const { repoPath, repoName, continue: continueSession, claudeArgs } = req.body;
|
|
467
|
+
if (!repoPath) {
|
|
468
|
+
res.status(400).json({ error: 'repoPath is required' });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// One repo session at a time
|
|
472
|
+
const existing = sessions.findRepoSession(repoPath);
|
|
473
|
+
if (existing) {
|
|
474
|
+
res.status(409).json({ error: 'A session already exists for this repo', sessionId: existing.id });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
478
|
+
const baseArgs = [...(config.claudeArgs || []), ...(claudeArgs || [])];
|
|
479
|
+
const args = continueSession ? ['--continue', ...baseArgs] : [...baseArgs];
|
|
480
|
+
const roots = config.rootDirs || [];
|
|
481
|
+
const root = roots.find(function (r) { return repoPath.startsWith(r); }) || '';
|
|
482
|
+
const session = sessions.create({
|
|
483
|
+
type: 'repo',
|
|
484
|
+
repoName: name,
|
|
485
|
+
repoPath,
|
|
486
|
+
cwd: repoPath,
|
|
487
|
+
root,
|
|
488
|
+
displayName: name,
|
|
489
|
+
command: config.claudeCommand,
|
|
490
|
+
args,
|
|
491
|
+
});
|
|
492
|
+
res.status(201).json(session);
|
|
493
|
+
});
|
|
462
494
|
// DELETE /sessions/:id
|
|
463
495
|
app.delete('/sessions/:id', requireAuth, (req, res) => {
|
|
464
496
|
try {
|
package/dist/server/sessions.js
CHANGED
|
@@ -11,7 +11,7 @@ let idleChangeCallback = null;
|
|
|
11
11
|
function onIdleChange(cb) {
|
|
12
12
|
idleChangeCallback = cb;
|
|
13
13
|
}
|
|
14
|
-
function create({ repoName, repoPath, cwd, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
|
|
14
|
+
function create({ type, repoName, repoPath, cwd, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
|
|
15
15
|
const id = crypto.randomBytes(8).toString('hex');
|
|
16
16
|
const createdAt = new Date().toISOString();
|
|
17
17
|
// Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
|
|
@@ -30,6 +30,7 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
|
|
|
30
30
|
const MAX_SCROLLBACK = 256 * 1024; // 256KB max
|
|
31
31
|
const session = {
|
|
32
32
|
id,
|
|
33
|
+
type: type || 'worktree',
|
|
33
34
|
root: root || '',
|
|
34
35
|
repoName: repoName || '',
|
|
35
36
|
repoPath,
|
|
@@ -96,15 +97,16 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
|
|
|
96
97
|
const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
|
|
97
98
|
fs.rm(tmpDir, { recursive: true, force: true }, () => { });
|
|
98
99
|
});
|
|
99
|
-
return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
|
|
100
|
+
return { id, type: session.type, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
|
|
100
101
|
}
|
|
101
102
|
function get(id) {
|
|
102
103
|
return sessions.get(id);
|
|
103
104
|
}
|
|
104
105
|
function list() {
|
|
105
106
|
return Array.from(sessions.values())
|
|
106
|
-
.map(({ id, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity, idle }) => ({
|
|
107
|
+
.map(({ id, type, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity, idle }) => ({
|
|
107
108
|
id,
|
|
109
|
+
type,
|
|
108
110
|
root,
|
|
109
111
|
repoName,
|
|
110
112
|
repoPath,
|
|
@@ -145,4 +147,7 @@ function write(id, data) {
|
|
|
145
147
|
}
|
|
146
148
|
session.pty.write(data);
|
|
147
149
|
}
|
|
148
|
-
|
|
150
|
+
function findRepoSession(repoPath) {
|
|
151
|
+
return list().find((s) => s.type === 'repo' && s.repoPath === repoPath);
|
|
152
|
+
}
|
|
153
|
+
export { create, get, list, kill, resize, updateDisplayName, write, onIdleChange, findRepoSession };
|
package/dist/server/watcher.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { EventEmitter } from 'node:events';
|
|
4
|
+
export const WORKTREE_DIRS = ['.worktrees', '.claude/worktrees'];
|
|
5
|
+
export function isValidWorktreePath(worktreePath) {
|
|
6
|
+
const resolved = path.resolve(worktreePath);
|
|
7
|
+
return WORKTREE_DIRS.some(function (dir) {
|
|
8
|
+
return resolved.includes(path.sep + dir + path.sep);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
4
11
|
export class WorktreeWatcher extends EventEmitter {
|
|
5
12
|
_watchers;
|
|
6
13
|
_debounceTimer;
|
|
@@ -30,12 +37,16 @@ export class WorktreeWatcher extends EventEmitter {
|
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
_watchRepo(repoPath) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
let anyWatched = false;
|
|
41
|
+
for (const dir of WORKTREE_DIRS) {
|
|
42
|
+
const worktreeDir = path.join(repoPath, dir);
|
|
43
|
+
if (fs.existsSync(worktreeDir)) {
|
|
44
|
+
this._addWatch(worktreeDir);
|
|
45
|
+
anyWatched = true;
|
|
46
|
+
}
|
|
36
47
|
}
|
|
37
|
-
|
|
38
|
-
// Watch
|
|
48
|
+
if (!anyWatched) {
|
|
49
|
+
// Watch repo root so we detect when either dir is first created
|
|
39
50
|
this._addWatch(repoPath);
|
|
40
51
|
}
|
|
41
52
|
}
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
var dialogYolo = document.getElementById('dialog-yolo');
|
|
40
40
|
var dialogBranchInput = document.getElementById('dialog-branch-input');
|
|
41
41
|
var dialogBranchList = document.getElementById('dialog-branch-list');
|
|
42
|
+
var dialogContinue = document.getElementById('dialog-continue');
|
|
43
|
+
var dialogContinueField = document.getElementById('dialog-continue-field');
|
|
42
44
|
var contextMenu = document.getElementById('context-menu');
|
|
43
45
|
var ctxResumeYolo = document.getElementById('ctx-resume-yolo');
|
|
44
46
|
var ctxDeleteWorktree = document.getElementById('ctx-delete-worktree');
|
|
@@ -60,6 +62,10 @@
|
|
|
60
62
|
var terminalScrollbarThumb = document.getElementById('terminal-scrollbar-thumb');
|
|
61
63
|
var mobileInput = document.getElementById('mobile-input');
|
|
62
64
|
var mobileHeader = document.getElementById('mobile-header');
|
|
65
|
+
var sidebarTabs = document.querySelectorAll('.sidebar-tab');
|
|
66
|
+
var tabReposCount = document.getElementById('tab-repos-count');
|
|
67
|
+
var tabWorktreesCount = document.getElementById('tab-worktrees-count');
|
|
68
|
+
var activeTab = 'repos';
|
|
63
69
|
var isMobileDevice = 'ontouchstart' in window;
|
|
64
70
|
|
|
65
71
|
// Context menu state
|
|
@@ -92,6 +98,7 @@
|
|
|
92
98
|
var cachedWorktrees = [];
|
|
93
99
|
var allRepos = [];
|
|
94
100
|
var allBranches = [];
|
|
101
|
+
var cachedRepos = [];
|
|
95
102
|
var attentionSessions = {};
|
|
96
103
|
|
|
97
104
|
function loadBranches(repoPath) {
|
|
@@ -523,10 +530,12 @@
|
|
|
523
530
|
Promise.all([
|
|
524
531
|
fetch('/sessions').then(function (res) { return res.json(); }),
|
|
525
532
|
fetch('/worktrees').then(function (res) { return res.json(); }),
|
|
533
|
+
fetch('/repos').then(function (res) { return res.json(); }),
|
|
526
534
|
])
|
|
527
535
|
.then(function (results) {
|
|
528
536
|
cachedSessions = results[0] || [];
|
|
529
537
|
cachedWorktrees = results[1] || [];
|
|
538
|
+
cachedRepos = results[2] || [];
|
|
530
539
|
|
|
531
540
|
// Prune attention flags for sessions that no longer exist
|
|
532
541
|
var activeIds = {};
|
|
@@ -606,6 +615,16 @@
|
|
|
606
615
|
renderUnifiedList();
|
|
607
616
|
});
|
|
608
617
|
|
|
618
|
+
sidebarTabs.forEach(function (tab) {
|
|
619
|
+
tab.addEventListener('click', function () {
|
|
620
|
+
activeTab = tab.dataset.tab;
|
|
621
|
+
sidebarTabs.forEach(function (t) { t.classList.remove('active'); });
|
|
622
|
+
tab.classList.add('active');
|
|
623
|
+
newSessionBtn.textContent = activeTab === 'repos' ? '+ New Session' : '+ New Worktree';
|
|
624
|
+
renderUnifiedList();
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
609
628
|
function rootShortName(path) {
|
|
610
629
|
return path.split('/').filter(Boolean).pop() || path;
|
|
611
630
|
}
|
|
@@ -633,7 +652,42 @@
|
|
|
633
652
|
var repoFilter = sidebarRepoFilter.value;
|
|
634
653
|
var textFilter = sessionFilter.value.toLowerCase();
|
|
635
654
|
|
|
636
|
-
|
|
655
|
+
// Split sessions by type
|
|
656
|
+
var repoSessions = cachedSessions.filter(function (s) { return s.type === 'repo'; });
|
|
657
|
+
var worktreeSessions = cachedSessions.filter(function (s) { return s.type !== 'repo'; });
|
|
658
|
+
|
|
659
|
+
// Filtered repo sessions
|
|
660
|
+
var filteredRepoSessions = repoSessions.filter(function (s) {
|
|
661
|
+
if (rootFilter && s.root !== rootFilter) return false;
|
|
662
|
+
if (repoFilter && s.repoName !== repoFilter) return false;
|
|
663
|
+
if (textFilter) {
|
|
664
|
+
var name = (s.displayName || s.repoName || s.id).toLowerCase();
|
|
665
|
+
if (name.indexOf(textFilter) === -1) return false;
|
|
666
|
+
}
|
|
667
|
+
return true;
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// Idle repos: all repos without an active repo session
|
|
671
|
+
var activeRepoPathSet = new Set();
|
|
672
|
+
repoSessions.forEach(function (s) { activeRepoPathSet.add(s.repoPath); });
|
|
673
|
+
|
|
674
|
+
var filteredIdleRepos = cachedRepos.filter(function (r) {
|
|
675
|
+
if (activeRepoPathSet.has(r.path)) return false;
|
|
676
|
+
if (rootFilter && r.root !== rootFilter) return false;
|
|
677
|
+
if (repoFilter && r.name !== repoFilter) return false;
|
|
678
|
+
if (textFilter) {
|
|
679
|
+
var name = (r.name || '').toLowerCase();
|
|
680
|
+
if (name.indexOf(textFilter) === -1) return false;
|
|
681
|
+
}
|
|
682
|
+
return true;
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
filteredIdleRepos.sort(function (a, b) {
|
|
686
|
+
return (a.name || '').localeCompare(b.name || '');
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Filtered worktree sessions
|
|
690
|
+
var filteredWorktreeSessions = worktreeSessions.filter(function (s) {
|
|
637
691
|
if (rootFilter && s.root !== rootFilter) return false;
|
|
638
692
|
if (repoFilter && s.repoName !== repoFilter) return false;
|
|
639
693
|
if (textFilter) {
|
|
@@ -643,8 +697,9 @@
|
|
|
643
697
|
return true;
|
|
644
698
|
});
|
|
645
699
|
|
|
700
|
+
// Inactive worktrees (deduped against active sessions)
|
|
646
701
|
var activeWorktreePaths = new Set();
|
|
647
|
-
|
|
702
|
+
worktreeSessions.forEach(function (s) {
|
|
648
703
|
if (s.repoPath) activeWorktreePaths.add(s.repoPath);
|
|
649
704
|
});
|
|
650
705
|
|
|
@@ -663,23 +718,35 @@
|
|
|
663
718
|
return (a.name || '').localeCompare(b.name || '');
|
|
664
719
|
});
|
|
665
720
|
|
|
666
|
-
|
|
721
|
+
// Update tab counts
|
|
722
|
+
tabReposCount.textContent = filteredRepoSessions.length + filteredIdleRepos.length;
|
|
723
|
+
tabWorktreesCount.textContent = filteredWorktreeSessions.length + filteredWorktrees.length;
|
|
667
724
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
});
|
|
725
|
+
// Render based on active tab
|
|
726
|
+
sessionList.innerHTML = '';
|
|
671
727
|
|
|
672
|
-
if (
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
728
|
+
if (activeTab === 'repos') {
|
|
729
|
+
filteredRepoSessions.forEach(function (session) {
|
|
730
|
+
sessionList.appendChild(createActiveSessionLi(session));
|
|
731
|
+
});
|
|
732
|
+
if (filteredRepoSessions.length > 0 && filteredIdleRepos.length > 0) {
|
|
733
|
+
sessionList.appendChild(createSectionDivider('Available'));
|
|
734
|
+
}
|
|
735
|
+
filteredIdleRepos.forEach(function (repo) {
|
|
736
|
+
sessionList.appendChild(createIdleRepoLi(repo));
|
|
737
|
+
});
|
|
738
|
+
} else {
|
|
739
|
+
filteredWorktreeSessions.forEach(function (session) {
|
|
740
|
+
sessionList.appendChild(createActiveSessionLi(session));
|
|
741
|
+
});
|
|
742
|
+
if (filteredWorktreeSessions.length > 0 && filteredWorktrees.length > 0) {
|
|
743
|
+
sessionList.appendChild(createSectionDivider('Available'));
|
|
744
|
+
}
|
|
745
|
+
filteredWorktrees.forEach(function (wt) {
|
|
746
|
+
sessionList.appendChild(createInactiveWorktreeLi(wt));
|
|
747
|
+
});
|
|
677
748
|
}
|
|
678
749
|
|
|
679
|
-
filteredWorktrees.forEach(function (wt) {
|
|
680
|
-
sessionList.appendChild(createInactiveWorktreeLi(wt));
|
|
681
|
-
});
|
|
682
|
-
|
|
683
750
|
highlightActiveSession();
|
|
684
751
|
}
|
|
685
752
|
|
|
@@ -824,6 +891,45 @@
|
|
|
824
891
|
return li;
|
|
825
892
|
}
|
|
826
893
|
|
|
894
|
+
function createSectionDivider(label) {
|
|
895
|
+
var divider = document.createElement('li');
|
|
896
|
+
divider.className = 'session-divider';
|
|
897
|
+
divider.textContent = label;
|
|
898
|
+
return divider;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function createIdleRepoLi(repo) {
|
|
902
|
+
var li = document.createElement('li');
|
|
903
|
+
li.className = 'inactive-worktree';
|
|
904
|
+
li.title = repo.path;
|
|
905
|
+
|
|
906
|
+
var infoDiv = document.createElement('div');
|
|
907
|
+
infoDiv.className = 'session-info';
|
|
908
|
+
|
|
909
|
+
var nameSpan = document.createElement('span');
|
|
910
|
+
nameSpan.className = 'session-name';
|
|
911
|
+
nameSpan.textContent = repo.name;
|
|
912
|
+
nameSpan.title = repo.name;
|
|
913
|
+
|
|
914
|
+
var dot = document.createElement('span');
|
|
915
|
+
dot.className = 'status-dot status-dot--inactive';
|
|
916
|
+
|
|
917
|
+
var subSpan = document.createElement('span');
|
|
918
|
+
subSpan.className = 'session-sub';
|
|
919
|
+
subSpan.textContent = repo.root ? rootShortName(repo.root) : repo.path;
|
|
920
|
+
|
|
921
|
+
infoDiv.appendChild(dot);
|
|
922
|
+
infoDiv.appendChild(nameSpan);
|
|
923
|
+
infoDiv.appendChild(subSpan);
|
|
924
|
+
li.appendChild(infoDiv);
|
|
925
|
+
|
|
926
|
+
li.addEventListener('click', function () {
|
|
927
|
+
openNewSessionDialogForRepo(repo);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
return li;
|
|
931
|
+
}
|
|
932
|
+
|
|
827
933
|
function startRename(li, session) {
|
|
828
934
|
var nameSpan = li.querySelector('.session-name');
|
|
829
935
|
if (!nameSpan) return;
|
|
@@ -1043,13 +1149,74 @@
|
|
|
1043
1149
|
.catch(function () {});
|
|
1044
1150
|
}
|
|
1045
1151
|
|
|
1046
|
-
|
|
1152
|
+
function resetDialogFields() {
|
|
1047
1153
|
customPath.value = '';
|
|
1048
1154
|
dialogYolo.checked = false;
|
|
1155
|
+
dialogContinue.checked = false;
|
|
1049
1156
|
dialogBranchInput.value = '';
|
|
1050
1157
|
dialogBranchList.hidden = true;
|
|
1051
1158
|
allBranches = [];
|
|
1052
1159
|
populateDialogRootSelect();
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function showDialogForTab(tab) {
|
|
1163
|
+
var dialogBranchField = dialogBranchInput.closest('.dialog-field');
|
|
1164
|
+
if (tab === 'repos') {
|
|
1165
|
+
dialogBranchField.hidden = true;
|
|
1166
|
+
dialogContinueField.hidden = false;
|
|
1167
|
+
dialogStart.textContent = 'New Session';
|
|
1168
|
+
} else {
|
|
1169
|
+
dialogBranchField.hidden = false;
|
|
1170
|
+
dialogContinueField.hidden = true;
|
|
1171
|
+
dialogStart.textContent = 'New Worktree';
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function openNewSessionDialogForRepo(repo) {
|
|
1176
|
+
resetDialogFields();
|
|
1177
|
+
|
|
1178
|
+
if (repo.root) {
|
|
1179
|
+
dialogRootSelect.value = repo.root;
|
|
1180
|
+
dialogRootSelect.dispatchEvent(new Event('change'));
|
|
1181
|
+
dialogRepoSelect.value = repo.path;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
showDialogForTab('repos');
|
|
1185
|
+
dialog.showModal();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function startRepoSession(repoPath, continueSession, claudeArgs) {
|
|
1189
|
+
var body = { repoPath: repoPath };
|
|
1190
|
+
if (continueSession) body.continue = true;
|
|
1191
|
+
if (claudeArgs) body.claudeArgs = claudeArgs;
|
|
1192
|
+
|
|
1193
|
+
fetch('/sessions/repo', {
|
|
1194
|
+
method: 'POST',
|
|
1195
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1196
|
+
body: JSON.stringify(body),
|
|
1197
|
+
})
|
|
1198
|
+
.then(function (res) {
|
|
1199
|
+
if (res.status === 409) {
|
|
1200
|
+
return res.json().then(function (data) {
|
|
1201
|
+
if (dialog.open) dialog.close();
|
|
1202
|
+
refreshAll();
|
|
1203
|
+
if (data.sessionId) connectToSession(data.sessionId);
|
|
1204
|
+
return null;
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
return res.json();
|
|
1208
|
+
})
|
|
1209
|
+
.then(function (data) {
|
|
1210
|
+
if (!data) return;
|
|
1211
|
+
if (dialog.open) dialog.close();
|
|
1212
|
+
refreshAll();
|
|
1213
|
+
if (data.id) connectToSession(data.id);
|
|
1214
|
+
})
|
|
1215
|
+
.catch(function () {});
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
newSessionBtn.addEventListener('click', function () {
|
|
1219
|
+
resetDialogFields();
|
|
1053
1220
|
|
|
1054
1221
|
var sidebarRoot = sidebarRootFilter.value;
|
|
1055
1222
|
if (sidebarRoot) {
|
|
@@ -1069,6 +1236,7 @@
|
|
|
1069
1236
|
dialogRepoSelect.disabled = true;
|
|
1070
1237
|
}
|
|
1071
1238
|
|
|
1239
|
+
showDialogForTab(activeTab);
|
|
1072
1240
|
dialog.showModal();
|
|
1073
1241
|
});
|
|
1074
1242
|
|
|
@@ -1076,8 +1244,13 @@
|
|
|
1076
1244
|
var repoPathValue = customPath.value.trim() || dialogRepoSelect.value;
|
|
1077
1245
|
if (!repoPathValue) return;
|
|
1078
1246
|
var args = dialogYolo.checked ? ['--dangerously-skip-permissions'] : undefined;
|
|
1079
|
-
|
|
1080
|
-
|
|
1247
|
+
|
|
1248
|
+
if (activeTab === 'repos') {
|
|
1249
|
+
startRepoSession(repoPathValue, dialogContinue.checked, args);
|
|
1250
|
+
} else {
|
|
1251
|
+
var branch = dialogBranchInput.value.trim() || undefined;
|
|
1252
|
+
startSession(repoPathValue, undefined, args, branch);
|
|
1253
|
+
}
|
|
1081
1254
|
});
|
|
1082
1255
|
|
|
1083
1256
|
customPath.addEventListener('blur', function () {
|
package/public/index.html
CHANGED
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
</select>
|
|
53
53
|
<input type="text" id="session-filter" placeholder="Filter..." />
|
|
54
54
|
</div>
|
|
55
|
+
<div class="sidebar-tabs">
|
|
56
|
+
<button class="sidebar-tab active" data-tab="repos">Repos (<span id="tab-repos-count">0</span>)</button>
|
|
57
|
+
<button class="sidebar-tab" data-tab="worktrees">Worktrees (<span id="tab-worktrees-count">0</span>)</button>
|
|
58
|
+
</div>
|
|
55
59
|
<ul id="session-list"></ul>
|
|
56
60
|
<button id="new-session-btn">+ New Session</button>
|
|
57
61
|
<button id="settings-btn">Settings</button>
|
|
@@ -140,6 +144,14 @@
|
|
|
140
144
|
<span class="dialog-option-hint">Leave empty for auto-generated name</span>
|
|
141
145
|
</div>
|
|
142
146
|
|
|
147
|
+
<div class="dialog-field" id="dialog-continue-field" hidden>
|
|
148
|
+
<label>
|
|
149
|
+
<input type="checkbox" id="dialog-continue" />
|
|
150
|
+
Continue previous conversation
|
|
151
|
+
</label>
|
|
152
|
+
<span class="dialog-option-hint">Resume where you left off (--continue)</span>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
143
155
|
<hr class="dialog-separator" />
|
|
144
156
|
<div class="dialog-custom-path">
|
|
145
157
|
<label for="custom-path-input">Or enter a local path:</label>
|
package/public/style.css
CHANGED
|
@@ -202,6 +202,36 @@ html, body {
|
|
|
202
202
|
border-color: var(--accent);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/* Sidebar Tabs */
|
|
206
|
+
.sidebar-tabs {
|
|
207
|
+
display: flex;
|
|
208
|
+
gap: 0;
|
|
209
|
+
padding: 0 8px;
|
|
210
|
+
border-bottom: 1px solid var(--border);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.sidebar-tab {
|
|
214
|
+
flex: 1;
|
|
215
|
+
background: none;
|
|
216
|
+
border: none;
|
|
217
|
+
border-bottom: 2px solid transparent;
|
|
218
|
+
color: var(--text-muted);
|
|
219
|
+
font-size: 0.7rem;
|
|
220
|
+
padding: 6px 4px;
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
transition: color 0.15s, border-color 0.15s;
|
|
223
|
+
text-align: center;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.sidebar-tab:hover {
|
|
227
|
+
color: var(--text);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.sidebar-tab.active {
|
|
231
|
+
color: var(--accent);
|
|
232
|
+
border-bottom-color: var(--accent);
|
|
233
|
+
}
|
|
234
|
+
|
|
205
235
|
#session-list {
|
|
206
236
|
list-style: none;
|
|
207
237
|
flex: 1;
|