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.
@@ -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
+ }
@@ -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
+ }
@@ -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: path.join(worktreeDir, entry.name),
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 send /rename through PTY
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 && session.pty) {
372
- session.pty.write('/rename "' + displayName.replace(/"/g, '\\"') + '"\r');
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();
@@ -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
- export { create, get, list, kill, resize, updateDisplayName };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
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
- nameSpan.textContent = wt.name;
439
- nameSpan.title = wt.name;
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">&times;</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
+ }