claude-remote-cli 1.5.0 → 1.7.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/dist/server/clipboard.js +56 -0
- package/dist/server/config.js +29 -0
- package/dist/server/index.js +62 -8
- package/dist/server/sessions.js +35 -2
- package/package.json +1 -1
- package/public/app.js +159 -2
- package/public/index.html +11 -0
- package/public/style.css +56 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const SUPPORTED_MIME = {
|
|
5
|
+
'image/png': { ext: '.png', osascriptClass: '«class PNGf»' },
|
|
6
|
+
'image/jpeg': { ext: '.jpg', osascriptClass: '«class JPEG»' },
|
|
7
|
+
'image/gif': { ext: '.gif', osascriptClass: '«class GIFf»' },
|
|
8
|
+
'image/webp': { ext: '.webp', osascriptClass: '«class PNGf»' },
|
|
9
|
+
};
|
|
10
|
+
let cachedTool;
|
|
11
|
+
export function detectClipboardTool() {
|
|
12
|
+
if (cachedTool !== undefined)
|
|
13
|
+
return cachedTool;
|
|
14
|
+
if (process.platform === 'darwin') {
|
|
15
|
+
cachedTool = 'osascript';
|
|
16
|
+
return cachedTool;
|
|
17
|
+
}
|
|
18
|
+
if (process.env['DISPLAY'] || process.env['WAYLAND_DISPLAY']) {
|
|
19
|
+
try {
|
|
20
|
+
execFileSync('which', ['xclip'], { stdio: 'ignore' });
|
|
21
|
+
cachedTool = 'xclip';
|
|
22
|
+
return cachedTool;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// xclip not found
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
cachedTool = null;
|
|
29
|
+
return cachedTool;
|
|
30
|
+
}
|
|
31
|
+
function mimeInfo(mimeType) {
|
|
32
|
+
const info = SUPPORTED_MIME[mimeType];
|
|
33
|
+
if (!info)
|
|
34
|
+
throw new Error(`Unsupported MIME type: ${mimeType}`);
|
|
35
|
+
return info;
|
|
36
|
+
}
|
|
37
|
+
export function extensionForMime(mimeType) {
|
|
38
|
+
return mimeInfo(mimeType).ext;
|
|
39
|
+
}
|
|
40
|
+
export async function setClipboardImage(filePath, mimeType) {
|
|
41
|
+
const tool = detectClipboardTool();
|
|
42
|
+
const info = mimeInfo(mimeType); // throws if unsupported
|
|
43
|
+
if (tool === 'osascript') {
|
|
44
|
+
const script = `set the clipboard to (read (POSIX file "${filePath}") as ${info.osascriptClass})`;
|
|
45
|
+
await execFileAsync('osascript', ['-e', script]);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (tool === 'xclip') {
|
|
49
|
+
await execFileAsync('xclip', ['-selection', 'clipboard', '-t', mimeType, '-i', filePath]);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
export function _resetForTesting() {
|
|
55
|
+
cachedTool = undefined;
|
|
56
|
+
}
|
package/dist/server/config.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
2
4
|
export const DEFAULTS = {
|
|
3
5
|
host: '0.0.0.0',
|
|
4
6
|
port: 3456,
|
|
@@ -18,3 +20,30 @@ export function loadConfig(configPath) {
|
|
|
18
20
|
export function saveConfig(configPath, config) {
|
|
19
21
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
20
22
|
}
|
|
23
|
+
function metaDir(configPath) {
|
|
24
|
+
return path.join(path.dirname(configPath), 'worktree-meta');
|
|
25
|
+
}
|
|
26
|
+
function metaFilePath(configPath, worktreePath) {
|
|
27
|
+
const hash = crypto.createHash('sha256').update(worktreePath).digest('hex').slice(0, 16);
|
|
28
|
+
return path.join(metaDir(configPath), hash + '.json');
|
|
29
|
+
}
|
|
30
|
+
export function ensureMetaDir(configPath) {
|
|
31
|
+
const dir = metaDir(configPath);
|
|
32
|
+
if (!fs.existsSync(dir)) {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function readMeta(configPath, worktreePath) {
|
|
37
|
+
const fp = metaFilePath(configPath, worktreePath);
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
catch (_) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function writeMeta(configPath, meta) {
|
|
46
|
+
const fp = metaFilePath(configPath, meta.worktreePath);
|
|
47
|
+
ensureMetaDir(configPath);
|
|
48
|
+
fs.writeFileSync(fp, JSON.stringify(meta, null, 2), 'utf8');
|
|
49
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import http from 'node:http';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import readline from 'node:readline';
|
|
@@ -7,18 +8,21 @@ import { execFile } from 'node:child_process';
|
|
|
7
8
|
import { promisify } from 'node:util';
|
|
8
9
|
import express from 'express';
|
|
9
10
|
import cookieParser from 'cookie-parser';
|
|
10
|
-
import { loadConfig, saveConfig, DEFAULTS } from './config.js';
|
|
11
|
+
import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, ensureMetaDir } from './config.js';
|
|
11
12
|
import * as auth from './auth.js';
|
|
12
13
|
import * as sessions from './sessions.js';
|
|
13
14
|
import { setupWebSocket } from './ws.js';
|
|
14
15
|
import { WorktreeWatcher } from './watcher.js';
|
|
15
16
|
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
17
|
+
import { extensionForMime, setClipboardImage } from './clipboard.js';
|
|
16
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
19
|
const __dirname = path.dirname(__filename);
|
|
18
20
|
const execFileAsync = promisify(execFile);
|
|
19
21
|
// When run via CLI bin, config lives in ~/.config/claude-remote-cli/
|
|
20
22
|
// When run directly (development), fall back to local config.json
|
|
21
23
|
const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
|
|
24
|
+
// Ensure worktree metadata directory exists alongside config
|
|
25
|
+
ensureMetaDir(CONFIG_PATH);
|
|
22
26
|
const VERSION_CACHE_TTL = 5 * 60 * 1000;
|
|
23
27
|
let versionCache = null;
|
|
24
28
|
function getCurrentVersion() {
|
|
@@ -127,7 +131,7 @@ async function main() {
|
|
|
127
131
|
}
|
|
128
132
|
const authenticatedTokens = new Set();
|
|
129
133
|
const app = express();
|
|
130
|
-
app.use(express.json());
|
|
134
|
+
app.use(express.json({ limit: '15mb' }));
|
|
131
135
|
app.use(cookieParser());
|
|
132
136
|
app.use(express.static(path.join(__dirname, '..', '..', 'public')));
|
|
133
137
|
const requireAuth = (req, res, next) => {
|
|
@@ -214,12 +218,16 @@ async function main() {
|
|
|
214
218
|
for (const entry of entries) {
|
|
215
219
|
if (!entry.isDirectory())
|
|
216
220
|
continue;
|
|
221
|
+
const wtPath = path.join(worktreeDir, entry.name);
|
|
222
|
+
const meta = readMeta(CONFIG_PATH, wtPath);
|
|
217
223
|
worktrees.push({
|
|
218
224
|
name: entry.name,
|
|
219
|
-
path:
|
|
225
|
+
path: wtPath,
|
|
220
226
|
repoName: repo.name,
|
|
221
227
|
repoPath: repo.path,
|
|
222
228
|
root: repo.root,
|
|
229
|
+
displayName: meta ? meta.displayName : '',
|
|
230
|
+
lastActivity: meta ? meta.lastActivity : '',
|
|
223
231
|
});
|
|
224
232
|
}
|
|
225
233
|
}
|
|
@@ -325,8 +333,8 @@ async function main() {
|
|
|
325
333
|
let cwd;
|
|
326
334
|
let worktreeName;
|
|
327
335
|
if (worktreePath) {
|
|
328
|
-
// Resume existing worktree — run claude inside the worktree directory
|
|
329
|
-
args = [...baseArgs];
|
|
336
|
+
// Resume existing worktree — run claude --continue inside the worktree directory
|
|
337
|
+
args = ['--continue', ...baseArgs];
|
|
330
338
|
cwd = worktreePath;
|
|
331
339
|
worktreeName = worktreePath.split('/').pop() || '';
|
|
332
340
|
}
|
|
@@ -344,6 +352,7 @@ async function main() {
|
|
|
344
352
|
displayName: worktreeName,
|
|
345
353
|
command: config.claudeCommand,
|
|
346
354
|
args,
|
|
355
|
+
configPath: CONFIG_PATH,
|
|
347
356
|
});
|
|
348
357
|
res.status(201).json(session);
|
|
349
358
|
});
|
|
@@ -357,7 +366,7 @@ async function main() {
|
|
|
357
366
|
res.status(404).json({ error: 'Session not found' });
|
|
358
367
|
}
|
|
359
368
|
});
|
|
360
|
-
// PATCH /sessions/:id — update displayName and
|
|
369
|
+
// PATCH /sessions/:id — update displayName and persist to metadata
|
|
361
370
|
app.patch('/sessions/:id', requireAuth, (req, res) => {
|
|
362
371
|
const { displayName } = req.body;
|
|
363
372
|
if (!displayName) {
|
|
@@ -368,8 +377,8 @@ async function main() {
|
|
|
368
377
|
const id = req.params['id'];
|
|
369
378
|
const updated = sessions.updateDisplayName(id, displayName);
|
|
370
379
|
const session = sessions.get(id);
|
|
371
|
-
if (session
|
|
372
|
-
|
|
380
|
+
if (session) {
|
|
381
|
+
writeMeta(CONFIG_PATH, { worktreePath: session.repoPath, displayName, lastActivity: session.lastActivity });
|
|
373
382
|
}
|
|
374
383
|
res.json(updated);
|
|
375
384
|
}
|
|
@@ -377,6 +386,51 @@ async function main() {
|
|
|
377
386
|
res.status(404).json({ error: 'Session not found' });
|
|
378
387
|
}
|
|
379
388
|
});
|
|
389
|
+
// POST /sessions/:id/image — upload clipboard image, proxy to system clipboard
|
|
390
|
+
const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
391
|
+
app.post('/sessions/:id/image', requireAuth, async (req, res) => {
|
|
392
|
+
const { data, mimeType } = req.body;
|
|
393
|
+
if (!data || !mimeType) {
|
|
394
|
+
res.status(400).json({ error: 'data and mimeType are required' });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (!ALLOWED_IMAGE_TYPES.includes(mimeType)) {
|
|
398
|
+
res.status(400).json({ error: 'Unsupported image type: ' + mimeType });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// base64 is ~33% larger than binary; 10MB binary ≈ 13.3MB base64
|
|
402
|
+
if (data.length > 14 * 1024 * 1024) {
|
|
403
|
+
res.status(413).json({ error: 'Image too large (max 10MB)' });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const sessionId = req.params['id'];
|
|
407
|
+
if (!sessions.get(sessionId)) {
|
|
408
|
+
res.status(404).json({ error: 'Session not found' });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const ext = extensionForMime(mimeType);
|
|
413
|
+
const dir = path.join(os.tmpdir(), 'claude-remote-cli', sessionId);
|
|
414
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
415
|
+
const filePath = path.join(dir, 'paste-' + Date.now() + ext);
|
|
416
|
+
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
|
|
417
|
+
let clipboardSet = false;
|
|
418
|
+
try {
|
|
419
|
+
clipboardSet = await setClipboardImage(filePath, mimeType);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// Clipboard tools failed — fall back to path
|
|
423
|
+
}
|
|
424
|
+
if (clipboardSet) {
|
|
425
|
+
sessions.write(sessionId, '\x16');
|
|
426
|
+
}
|
|
427
|
+
res.json({ path: filePath, clipboardSet });
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
const message = err instanceof Error ? err.message : 'Image upload failed';
|
|
431
|
+
res.status(500).json({ error: message });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
380
434
|
// GET /version — check current vs latest
|
|
381
435
|
app.get('/version', requireAuth, async (_req, res) => {
|
|
382
436
|
const current = getCurrentVersion();
|
package/dist/server/sessions.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import pty from 'node-pty';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { readMeta, writeMeta } from './config.js';
|
|
3
7
|
// In-memory registry: id -> Session
|
|
4
8
|
const sessions = new Map();
|
|
5
|
-
function create({ repoName, repoPath, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24 }) {
|
|
9
|
+
function create({ repoName, repoPath, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
|
|
6
10
|
const id = crypto.randomBytes(8).toString('hex');
|
|
7
11
|
const createdAt = new Date().toISOString();
|
|
8
12
|
// Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
|
|
@@ -32,6 +36,15 @@ function create({ repoName, repoPath, root, worktreeName, displayName, command,
|
|
|
32
36
|
scrollback,
|
|
33
37
|
};
|
|
34
38
|
sessions.set(id, session);
|
|
39
|
+
// Load existing metadata to preserve a previously-set displayName
|
|
40
|
+
if (configPath && worktreeName) {
|
|
41
|
+
const existing = readMeta(configPath, repoPath);
|
|
42
|
+
if (existing && existing.displayName) {
|
|
43
|
+
session.displayName = existing.displayName;
|
|
44
|
+
}
|
|
45
|
+
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: createdAt });
|
|
46
|
+
}
|
|
47
|
+
let metaFlushTimer = null;
|
|
35
48
|
ptyProcess.onData((data) => {
|
|
36
49
|
session.lastActivity = new Date().toISOString();
|
|
37
50
|
scrollback.push(data);
|
|
@@ -40,9 +53,22 @@ function create({ repoName, repoPath, root, worktreeName, displayName, command,
|
|
|
40
53
|
while (scrollbackBytes > MAX_SCROLLBACK && scrollback.length > 1) {
|
|
41
54
|
scrollbackBytes -= scrollback.shift().length;
|
|
42
55
|
}
|
|
56
|
+
if (configPath && worktreeName && !metaFlushTimer) {
|
|
57
|
+
metaFlushTimer = setTimeout(() => {
|
|
58
|
+
metaFlushTimer = null;
|
|
59
|
+
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
|
|
60
|
+
}, 5000);
|
|
61
|
+
}
|
|
43
62
|
});
|
|
44
63
|
ptyProcess.onExit(() => {
|
|
64
|
+
if (metaFlushTimer)
|
|
65
|
+
clearTimeout(metaFlushTimer);
|
|
66
|
+
if (configPath && worktreeName) {
|
|
67
|
+
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
|
|
68
|
+
}
|
|
45
69
|
sessions.delete(id);
|
|
70
|
+
const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
|
|
71
|
+
fs.rm(tmpDir, { recursive: true, force: true }, () => { });
|
|
46
72
|
});
|
|
47
73
|
return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt };
|
|
48
74
|
}
|
|
@@ -85,4 +111,11 @@ function resize(id, cols, rows) {
|
|
|
85
111
|
}
|
|
86
112
|
session.pty.resize(cols, rows);
|
|
87
113
|
}
|
|
88
|
-
|
|
114
|
+
function write(id, data) {
|
|
115
|
+
const session = sessions.get(id);
|
|
116
|
+
if (!session) {
|
|
117
|
+
throw new Error(`Session not found: ${id}`);
|
|
118
|
+
}
|
|
119
|
+
session.pty.write(data);
|
|
120
|
+
}
|
|
121
|
+
export { create, get, list, kill, resize, updateDisplayName, write };
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
var updateToastText = document.getElementById('update-toast-text');
|
|
47
47
|
var updateToastBtn = document.getElementById('update-toast-btn');
|
|
48
48
|
var updateToastDismiss = document.getElementById('update-toast-dismiss');
|
|
49
|
+
var imageToast = document.getElementById('image-toast');
|
|
50
|
+
var imageToastText = document.getElementById('image-toast-text');
|
|
51
|
+
var imageToastInsert = document.getElementById('image-toast-insert');
|
|
52
|
+
var imageToastDismiss = document.getElementById('image-toast-dismiss');
|
|
49
53
|
|
|
50
54
|
// Context menu state
|
|
51
55
|
var contextMenuTarget = null; // stores { worktreePath, repoPath, name }
|
|
@@ -316,6 +320,24 @@
|
|
|
316
320
|
return path.split('/').filter(Boolean).pop() || path;
|
|
317
321
|
}
|
|
318
322
|
|
|
323
|
+
function formatRelativeTime(isoString) {
|
|
324
|
+
if (!isoString) return '';
|
|
325
|
+
var now = Date.now();
|
|
326
|
+
var then = new Date(isoString).getTime();
|
|
327
|
+
var diffSec = Math.floor((now - then) / 1000);
|
|
328
|
+
if (diffSec < 60) return 'just now';
|
|
329
|
+
var diffMin = Math.floor(diffSec / 60);
|
|
330
|
+
if (diffMin < 60) return diffMin + ' min ago';
|
|
331
|
+
var diffHr = Math.floor(diffMin / 60);
|
|
332
|
+
if (diffHr < 24) return diffHr + (diffHr === 1 ? ' hour ago' : ' hours ago');
|
|
333
|
+
var diffDay = Math.floor(diffHr / 24);
|
|
334
|
+
if (diffDay === 1) return 'yesterday';
|
|
335
|
+
if (diffDay < 7) return diffDay + ' days ago';
|
|
336
|
+
var d = new Date(isoString);
|
|
337
|
+
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
338
|
+
return months[d.getMonth()] + ' ' + d.getDate();
|
|
339
|
+
}
|
|
340
|
+
|
|
319
341
|
function renderUnifiedList() {
|
|
320
342
|
var rootFilter = sidebarRootFilter.value;
|
|
321
343
|
var repoFilter = sidebarRepoFilter.value;
|
|
@@ -392,6 +414,11 @@
|
|
|
392
414
|
infoDiv.appendChild(nameSpan);
|
|
393
415
|
infoDiv.appendChild(subSpan);
|
|
394
416
|
|
|
417
|
+
var timeSpan = document.createElement('span');
|
|
418
|
+
timeSpan.className = 'session-time';
|
|
419
|
+
timeSpan.textContent = formatRelativeTime(session.lastActivity);
|
|
420
|
+
infoDiv.appendChild(timeSpan);
|
|
421
|
+
|
|
395
422
|
var actionsDiv = document.createElement('div');
|
|
396
423
|
actionsDiv.className = 'session-actions';
|
|
397
424
|
|
|
@@ -435,8 +462,9 @@
|
|
|
435
462
|
|
|
436
463
|
var nameSpan = document.createElement('span');
|
|
437
464
|
nameSpan.className = 'session-name';
|
|
438
|
-
|
|
439
|
-
nameSpan.
|
|
465
|
+
var wtDisplayName = wt.displayName || wt.name;
|
|
466
|
+
nameSpan.textContent = wtDisplayName;
|
|
467
|
+
nameSpan.title = wtDisplayName;
|
|
440
468
|
|
|
441
469
|
var subSpan = document.createElement('span');
|
|
442
470
|
subSpan.className = 'session-sub';
|
|
@@ -445,6 +473,11 @@
|
|
|
445
473
|
infoDiv.appendChild(nameSpan);
|
|
446
474
|
infoDiv.appendChild(subSpan);
|
|
447
475
|
|
|
476
|
+
var timeSpan = document.createElement('span');
|
|
477
|
+
timeSpan.className = 'session-time';
|
|
478
|
+
timeSpan.textContent = formatRelativeTime(wt.lastActivity);
|
|
479
|
+
infoDiv.appendChild(timeSpan);
|
|
480
|
+
|
|
448
481
|
li.appendChild(infoDiv);
|
|
449
482
|
|
|
450
483
|
// Click to resume (but not if context menu just opened or long-press fired)
|
|
@@ -851,6 +884,130 @@
|
|
|
851
884
|
}
|
|
852
885
|
});
|
|
853
886
|
|
|
887
|
+
// ── Image Paste Handling ─────────────────────────────────────────────────────
|
|
888
|
+
|
|
889
|
+
var imageUploadInProgress = false;
|
|
890
|
+
var pendingImagePath = null;
|
|
891
|
+
|
|
892
|
+
function showImageToast(text, showInsert) {
|
|
893
|
+
imageToastText.textContent = text;
|
|
894
|
+
imageToastInsert.hidden = !showInsert;
|
|
895
|
+
imageToast.hidden = false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function hideImageToast() {
|
|
899
|
+
imageToast.hidden = true;
|
|
900
|
+
pendingImagePath = null;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function autoDismissImageToast(ms) {
|
|
904
|
+
setTimeout(function () {
|
|
905
|
+
if (!pendingImagePath) {
|
|
906
|
+
hideImageToast();
|
|
907
|
+
}
|
|
908
|
+
}, ms);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function uploadImage(blob, mimeType) {
|
|
912
|
+
if (imageUploadInProgress) return;
|
|
913
|
+
if (!activeSessionId) return;
|
|
914
|
+
|
|
915
|
+
imageUploadInProgress = true;
|
|
916
|
+
showImageToast('Pasting image\u2026', false);
|
|
917
|
+
|
|
918
|
+
var reader = new FileReader();
|
|
919
|
+
reader.onload = function () {
|
|
920
|
+
var base64 = reader.result.split(',')[1];
|
|
921
|
+
|
|
922
|
+
fetch('/sessions/' + activeSessionId + '/image', {
|
|
923
|
+
method: 'POST',
|
|
924
|
+
headers: { 'Content-Type': 'application/json' },
|
|
925
|
+
body: JSON.stringify({ data: base64, mimeType: mimeType }),
|
|
926
|
+
})
|
|
927
|
+
.then(function (res) {
|
|
928
|
+
if (res.status === 413) {
|
|
929
|
+
showImageToast('Image too large (max 10MB)', false);
|
|
930
|
+
autoDismissImageToast(4000);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (!res.ok) {
|
|
934
|
+
return res.json().then(function (data) {
|
|
935
|
+
showImageToast(data.error || 'Image upload failed', false);
|
|
936
|
+
autoDismissImageToast(4000);
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
return res.json().then(function (data) {
|
|
940
|
+
if (data.clipboardSet) {
|
|
941
|
+
showImageToast('Image pasted', false);
|
|
942
|
+
autoDismissImageToast(2000);
|
|
943
|
+
} else {
|
|
944
|
+
pendingImagePath = data.path;
|
|
945
|
+
showImageToast(data.path, true);
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
})
|
|
949
|
+
.catch(function () {
|
|
950
|
+
showImageToast('Image upload failed', false);
|
|
951
|
+
autoDismissImageToast(4000);
|
|
952
|
+
})
|
|
953
|
+
.then(function () {
|
|
954
|
+
imageUploadInProgress = false;
|
|
955
|
+
});
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
reader.readAsDataURL(blob);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
terminalContainer.addEventListener('paste', function (e) {
|
|
962
|
+
if (!e.clipboardData || !e.clipboardData.items) return;
|
|
963
|
+
|
|
964
|
+
var items = e.clipboardData.items;
|
|
965
|
+
for (var i = 0; i < items.length; i++) {
|
|
966
|
+
if (items[i].type.indexOf('image/') === 0) {
|
|
967
|
+
e.preventDefault();
|
|
968
|
+
e.stopPropagation();
|
|
969
|
+
var blob = items[i].getAsFile();
|
|
970
|
+
if (blob) {
|
|
971
|
+
uploadImage(blob, items[i].type);
|
|
972
|
+
}
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
terminalContainer.addEventListener('dragover', function (e) {
|
|
979
|
+
if (e.dataTransfer && e.dataTransfer.types.indexOf('Files') !== -1) {
|
|
980
|
+
e.preventDefault();
|
|
981
|
+
terminalContainer.classList.add('drag-over');
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
terminalContainer.addEventListener('dragleave', function () {
|
|
986
|
+
terminalContainer.classList.remove('drag-over');
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
terminalContainer.addEventListener('drop', function (e) {
|
|
990
|
+
e.preventDefault();
|
|
991
|
+
terminalContainer.classList.remove('drag-over');
|
|
992
|
+
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
|
993
|
+
|
|
994
|
+
var file = e.dataTransfer.files[0];
|
|
995
|
+
if (file.type.indexOf('image/') === 0) {
|
|
996
|
+
uploadImage(file, file.type);
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
imageToastInsert.addEventListener('click', function () {
|
|
1001
|
+
if (pendingImagePath && ws && ws.readyState === WebSocket.OPEN) {
|
|
1002
|
+
ws.send(pendingImagePath);
|
|
1003
|
+
}
|
|
1004
|
+
hideImageToast();
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
imageToastDismiss.addEventListener('click', function () {
|
|
1008
|
+
hideImageToast();
|
|
1009
|
+
});
|
|
1010
|
+
|
|
854
1011
|
// ── Keyboard-Aware Viewport ─────────────────────────────────────────────────
|
|
855
1012
|
|
|
856
1013
|
(function () {
|
package/public/index.html
CHANGED
|
@@ -89,6 +89,17 @@
|
|
|
89
89
|
</div>
|
|
90
90
|
</div>
|
|
91
91
|
|
|
92
|
+
<!-- Image Paste Toast -->
|
|
93
|
+
<div id="image-toast" hidden>
|
|
94
|
+
<div id="image-toast-content">
|
|
95
|
+
<span id="image-toast-text"></span>
|
|
96
|
+
<div id="image-toast-actions">
|
|
97
|
+
<button id="image-toast-insert" class="btn-accent" hidden>Insert Path</button>
|
|
98
|
+
<button id="image-toast-dismiss" aria-label="Dismiss">×</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
92
103
|
</div>
|
|
93
104
|
|
|
94
105
|
<!-- New Session Dialog -->
|
package/public/style.css
CHANGED
|
@@ -242,6 +242,10 @@ html, body {
|
|
|
242
242
|
color: rgba(255, 255, 255, 0.7);
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
#session-list li.active-session.active .session-time {
|
|
246
|
+
color: rgba(255, 255, 255, 0.5);
|
|
247
|
+
}
|
|
248
|
+
|
|
245
249
|
/* Inactive worktree (outline) */
|
|
246
250
|
#session-list li.inactive-worktree {
|
|
247
251
|
background: transparent;
|
|
@@ -280,6 +284,15 @@ html, body {
|
|
|
280
284
|
white-space: nowrap;
|
|
281
285
|
}
|
|
282
286
|
|
|
287
|
+
.session-time {
|
|
288
|
+
font-size: 0.65rem;
|
|
289
|
+
color: var(--text-muted);
|
|
290
|
+
opacity: 0.6;
|
|
291
|
+
overflow: hidden;
|
|
292
|
+
text-overflow: ellipsis;
|
|
293
|
+
white-space: nowrap;
|
|
294
|
+
}
|
|
295
|
+
|
|
283
296
|
.session-actions {
|
|
284
297
|
display: flex;
|
|
285
298
|
align-items: center;
|
|
@@ -1013,3 +1026,46 @@ dialog#delete-worktree-dialog h2 {
|
|
|
1013
1026
|
background: var(--border);
|
|
1014
1027
|
border-radius: 4px;
|
|
1015
1028
|
}
|
|
1029
|
+
|
|
1030
|
+
/* ===== Image Paste Toast ===== */
|
|
1031
|
+
#image-toast {
|
|
1032
|
+
position: fixed;
|
|
1033
|
+
bottom: 60px;
|
|
1034
|
+
left: 50%;
|
|
1035
|
+
transform: translateX(-50%);
|
|
1036
|
+
z-index: 1000;
|
|
1037
|
+
background: #2d2d2d;
|
|
1038
|
+
border: 1px solid #555;
|
|
1039
|
+
border-radius: 8px;
|
|
1040
|
+
padding: 8px 14px;
|
|
1041
|
+
color: #d4d4d4;
|
|
1042
|
+
font-size: 13px;
|
|
1043
|
+
max-width: 90vw;
|
|
1044
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
#image-toast-content {
|
|
1048
|
+
display: flex;
|
|
1049
|
+
align-items: center;
|
|
1050
|
+
gap: 10px;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
#image-toast-actions {
|
|
1054
|
+
display: flex;
|
|
1055
|
+
gap: 6px;
|
|
1056
|
+
align-items: center;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
#image-toast-dismiss {
|
|
1060
|
+
background: none;
|
|
1061
|
+
border: none;
|
|
1062
|
+
color: #999;
|
|
1063
|
+
cursor: pointer;
|
|
1064
|
+
font-size: 16px;
|
|
1065
|
+
padding: 2px 6px;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
#terminal-container.drag-over {
|
|
1069
|
+
outline: 2px dashed #007acc;
|
|
1070
|
+
outline-offset: -2px;
|
|
1071
|
+
}
|