tabby-clip-image 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +13 -1
  2. package/index.js +250 -17
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -10,7 +10,7 @@ In an SSH tab, paste with any of `Ctrl+V`, `Cmd+V`, `Ctrl+Shift+V`, `Shift+Inser
10
10
  | image **file(s)** copied in the file manager (png, jpg, gif, webp, bmp, tif, heic/heif, avif, svg, ico) | uploaded **unchanged**, original format kept, multiple files supported (Windows lists them via the built-in PowerShell) |
11
11
  | text, or non-image files | normal paste |
12
12
 
13
- the bytes go over the tab's **existing SSH connection** (new exec channel — no extra login; works through jump hosts/proxies) into `~/.cache/clip-recv/shots/shot-<time>-<pid>-<name>.<ext>` on the host, and the absolute path is pasted at the cursor.
13
+ the bytes go over the tab's **existing SSH connection** (built-in SSH tabs: new exec channel, no extra login) into `~/.cache/clip-recv/shots/shot-<time>-<pid>-<name>.<ext>` on the host, and the absolute path is pasted at the cursor.
14
14
 
15
15
  **Nothing to install on the host**: it only uses POSIX `sh`, `mkdir`, `date`, `cat`, `chmod`, `find`. No X11/Wayland needed.
16
16
 
@@ -18,8 +18,20 @@ Client side: Linux (X11/Wayland), macOS, Windows — local tabs and text pastes
18
18
 
19
19
  ## Install
20
20
 
21
+ Manual: copy this folder to Tabby's user plugin dir — Windows `%APPDATA%\tabby\plugins\node_modules\tabby-clip-image`, macOS `~/Library/Application Support/tabby/plugins/node_modules/tabby-clip-image`, Linux `~/.config/tabby/plugins/node_modules/tabby-clip-image` — then quit and restart Tabby. Or run `install-tabby-plugin.sh` / `.ps1` from the repo. Works in Tabby's built-in **SSH tabs** and, since 0.5.0, in **local tabs where you run `ssh`** (see below).
22
+
23
+ Plugin manager:
24
+
21
25
  Tabby → Settings → Plugins → search `clip-image` → Install → restart Tabby.
22
26
 
27
+ ## Local tabs running `ssh` (since 0.5.0)
28
+
29
+ If you connect with the system `ssh` from a Tabby local shell (`ssh myhost`, aliases from `~/.ssh/config`, jump hosts, keys in `ssh-agent`), the plugin finds the `ssh` process that the tab's shell is running, reuses its options and starts a **second, non-interactive** `ssh` (`-T -o BatchMode=yes`) to upload the image. Therefore:
30
+
31
+ - the host must be reachable without a prompt: key/agent auth, or `ControlMaster`/`ControlPersist` in `~/.ssh/config` (password-only hosts fail with an error toast);
32
+ - `ssh` must be started directly from the tab's shell (not inside a *local* tmux/screen). `tmux` on the **remote** host is fine;
33
+ - if nothing is running `ssh` in the tab, pasting behaves exactly as before.
34
+
23
35
  ## Configuration
24
36
 
25
37
  - Path prefix: `PATH_PREFIX` in `index.js` (default `''`). When the app enabled bracketed paste (Claude Code, Codex, …) each path is sent as a paste, exactly like drag-and-drop, so the TUI shows `[Image #1]`; otherwise (a shell prompt) the plain path is typed. Verified with real Claude Code and Codex; `'@'` only works as a file mention in Claude Code.
package/index.js CHANGED
@@ -8,6 +8,19 @@
8
8
  // the bytes are streamed over the tab's existing SSH connection to a fixed directory on the host
9
9
  // (plain `sh` + `cat`; nothing to install there) and the absolute path is typed at the cursor.
10
10
  // Anything else (text, non-image files, local tabs) pastes normally.
11
+ const VERSION = '0.5.0'
12
+ // Trace to the console AND to ~/.tabby-clip-image.log (capped) so problems can be diagnosed without DevTools.
13
+ const LOG_FILE = require('path').join(require('os').homedir(), '.tabby-clip-image.log')
14
+ const log = (...a) => {
15
+ console.log('[tabby-clip-image]', ...a)
16
+ try {
17
+ const fsx = require('fs')
18
+ try { if (fsx.statSync(LOG_FILE).size > 200 * 1024) { fsx.renameSync(LOG_FILE, LOG_FILE + '.old') } } catch { /* no file yet */ }
19
+ fsx.appendFileSync(LOG_FILE, `${new Date().toISOString()} ${a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')}\n`)
20
+ } catch { /* never break the terminal because of logging */ }
21
+ }
22
+
23
+ log('loaded v' + VERSION + ' on ' + process.platform + ' ' + process.arch)
11
24
  const fs = require('fs')
12
25
  const path = require('path')
13
26
  const { fileURLToPath } = require('url')
@@ -106,7 +119,7 @@ function safeBase (file) {
106
119
  * Each item: { base, ext, size?, load: () => Promise<Buffer> } or { error } for unusable files.
107
120
  */
108
121
  function collectItems (cb, platform = process.platform, deps) {
109
- if (!cb) { return [] }
122
+ if (!cb) { log('no electron clipboard available'); return [] }
110
123
  const files = clipboardFilePaths(cb, platform, deps)
111
124
  if (files.length) {
112
125
  const items = []
@@ -129,9 +142,24 @@ function collectItems (cb, platform = process.platform, deps) {
129
142
  } catch { return [] }
130
143
  }
131
144
 
145
+ /** Human-readable reason when a paste in an SSH tab found nothing to upload and no text (else null). */
146
+ function describeNothingToPaste (cb) {
147
+ try {
148
+ if (!cb || cb.readText()) { return null }
149
+ const f = cb.availableFormats()
150
+ return f.length ? `clipboard has no text or usable image (formats: ${f.join(', ')})` : 'clipboard is empty (a screenshot saved to a file is not on the clipboard; use Cmd+Ctrl+Shift+4 on macOS)'
151
+ } catch { return null }
152
+ }
153
+
154
+ let announced = false
155
+ function notifyTab (tab, kind, msg) { try { tab.notifications?.[kind]?.(msg) } catch { /* ignore */ } }
156
+
132
157
  // ---------- uploading ----------
133
158
 
134
- async function uploadOne (ssh, item) {
159
+ const { spawn, execFile } = require('child_process')
160
+
161
+ /** Tabby's built-in SSH tab: new exec channel on the tab's existing connection. */
162
+ async function uploadViaBuiltinSsh (ssh, item) {
135
163
  const bytes = await item.load()
136
164
  if (!bytes.length) { throw new Error('empty image') }
137
165
  const ch = await ssh.activateChannel(await ssh.openSessionChannel())
@@ -161,26 +189,182 @@ async function uploadOne (ssh, item) {
161
189
  return m[2].trim()
162
190
  }
163
191
 
192
+ // ---- local tabs that run the system `ssh` (e.g. `ssh myhost` typed in a Tabby local shell) ----
193
+
194
+ const SSH_ARG_FLAGS = new Set('BbcDEeFIiJLlmOopQRSWw'.split('')) // short ssh options that take a value
195
+ const SSH_DROP_FLAGS = new Set('tTNfnvqVGM'.split('')) // tty / background / verbosity / control flags we must not forward
196
+
197
+ function tokenizeCommandLine (s) {
198
+ const out = []
199
+ let cur = ''
200
+ let q = null
201
+ let has = false
202
+ for (const ch of String(s)) {
203
+ if (q) { if (ch === q) { q = null } else { cur += ch } continue }
204
+ if (ch === '"' || ch === "'") { q = ch; has = true; continue }
205
+ if (/\s/.test(ch)) { if (cur || has) { out.push(cur); cur = ''; has = false } continue }
206
+ cur += ch
207
+ }
208
+ if (cur || has) { out.push(cur) }
209
+ return out
210
+ }
211
+
212
+ /** tokens = ['ssh', ...args] -> { prefix: forwardable ssh options, dest } or null. The remote command (if any) is dropped. */
213
+ function parseSshCommand (tokens) {
214
+ const prefix = []
215
+ let i = 1
216
+ while (i < tokens.length) {
217
+ const t = tokens[i]
218
+ if (t === '--') { i++; break }
219
+ if (t[0] !== '-' || t.length === 1) { break }
220
+ for (let j = 1; j < t.length; j++) {
221
+ const ch = t[j]
222
+ if (SSH_ARG_FLAGS.has(ch)) {
223
+ const attached = t.slice(j + 1)
224
+ const val = attached || tokens[i + 1]
225
+ if (val === undefined) { return null }
226
+ prefix.push('-' + ch, val)
227
+ if (!attached) { i++ }
228
+ break
229
+ }
230
+ if (!SSH_DROP_FLAGS.has(ch)) { prefix.push('-' + ch) }
231
+ }
232
+ i++
233
+ }
234
+ const dest = tokens[i]
235
+ return dest ? { prefix, dest } : null
236
+ }
237
+
238
+ function execFileP (file, args, opts) {
239
+ return new Promise((resolve, reject) => execFile(file, args, { timeout: 4000, windowsHide: true, encoding: 'utf8', ...opts }, (e, so) => e ? reject(e) : resolve(so)))
240
+ }
241
+
242
+ /** Full command line of a process (Tabby only gives the name + pid on macOS/Windows). */
243
+ async function commandLineOf (pid) {
244
+ const n = Number(pid)
245
+ if (process.platform === 'win32') {
246
+ return (await execFileP('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter 'ProcessId=${n}').CommandLine`])).trim()
247
+ }
248
+ return (await execFileP('ps', ['-ww', '-o', 'args=', '-p', String(n)])).trim()
249
+ }
250
+
251
+ /** SSH_AUTH_SOCK of a running process (the GUI app may not have the agent socket that the user's shell exported). */
252
+ async function agentSockOf (pid) {
253
+ try {
254
+ const n = Number(pid)
255
+ if (process.platform === 'linux') {
256
+ const env = fs.readFileSync(`/proc/${n}/environ`, 'utf8').split('\0')
257
+ const hit = env.find(e => e.startsWith('SSH_AUTH_SOCK='))
258
+ return hit ? hit.slice('SSH_AUTH_SOCK='.length) : null
259
+ }
260
+ if (process.platform === 'darwin') {
261
+ const out = await execFileP('ps', ['-ww', '-E', '-o', 'command=', '-p', String(n)])
262
+ const m = /(?:^|\s)SSH_AUTH_SOCK=(\S+)/.exec(out)
263
+ return m ? m[1] : null
264
+ }
265
+ } catch { /* best effort */ }
266
+ return null
267
+ }
268
+
269
+ /** The `ssh` the local shell of this tab is currently running, as { prefix, dest }, or null. */
270
+ async function findLocalSsh (tab, deps = { commandLineOf, agentSockOf }) {
271
+ let procs = []
272
+ try { procs = (await tab.session?.getChildProcesses?.()) || [] } catch (e) { log('getChildProcesses failed: ' + e); return null }
273
+ for (const p of procs) {
274
+ const exe = path.basename(String(p.command || '')).replace(/\.exe$/i, '')
275
+ if (exe !== 'ssh') { continue }
276
+ let tokens
277
+ if (Array.isArray(p.arguments) && p.arguments.length) { tokens = [p.command, ...p.arguments] } else {
278
+ try { tokens = tokenizeCommandLine(await deps.commandLineOf(p.pid)) } catch (e) { log('cannot read the ssh command line: ' + e); continue }
279
+ }
280
+ const parsed = parseSshCommand(tokens)
281
+ if (parsed) {
282
+ if (!process.env.SSH_AUTH_SOCK || process.platform === 'darwin') { parsed.agentSock = await (deps.agentSockOf || agentSockOf)(p.pid) }
283
+ return parsed
284
+ }
285
+ }
286
+ return null
287
+ }
288
+
289
+ /** Upload with a separate, non-interactive `ssh` that reuses the user's own options and ~/.ssh/config (ProxyJump, keys, ControlMaster). */
290
+ function uploadViaSystemSsh (target, item, sshBin = 'ssh') {
291
+ return item.load().then(bytes => new Promise((resolve, reject) => {
292
+ if (!bytes.length) { reject(new Error('empty image')); return }
293
+ const args = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', ...target.prefix, target.dest, `sh -c '${REMOTE_SH}' sh ${item.base} ${item.ext}`]
294
+ const env = target.agentSock ? { ...process.env, SSH_AUTH_SOCK: target.agentSock } : process.env
295
+ const child = spawn(sshBin, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env })
296
+ let out = ''
297
+ let err = ''
298
+ let settled = false
299
+ const finish = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v) } }
300
+ const timer = setTimeout(() => { try { child.kill() } catch { /* ignore */ } finish(reject, new Error('timeout')) }, 20000 + bytes.length / 50)
301
+ child.stdout.on('data', d => { out += d })
302
+ child.stderr.on('data', d => { err += d })
303
+ child.on('error', e => finish(reject, e))
304
+ child.on('close', code => {
305
+ const m = /^ok (\S+) (.+)$/m.exec(out.trim())
306
+ if (m) { finish(resolve, m[2].trim()) } else {
307
+ const why = (err || out).trim().split('\n').filter(Boolean).slice(-2).join(' ')
308
+ finish(reject, new Error((why || `ssh exited with ${code}`) + (/permission denied|password|passphrase/i.test(why) ? ' (needs key/agent or ControlMaster: the upload ssh runs non-interactively)' : '')))
309
+ }
310
+ })
311
+ child.stdin.on('error', () => {})
312
+ child.stdin.end(Buffer.from(bytes))
313
+ }))
314
+ }
315
+
316
+ /** How to upload for this tab: Tabby's SSH connection, the local shell's running `ssh`, or null (nothing to do). */
317
+ async function resolveTransport (tab) {
318
+ if (tab.sshSession) {
319
+ const ssh = tab.sshSession.ssh
320
+ if (ssh && typeof ssh.openSessionChannel === 'function') { return { kind: 'tabby-ssh', upload: item => uploadViaBuiltinSsh(ssh, item) } }
321
+ return { kind: 'tabby-ssh', upload: async () => { throw new Error('SSH session not ready') } }
322
+ }
323
+ const target = await findLocalSsh(tab)
324
+ if (target) { return { kind: 'system-ssh', target, upload: item => uploadViaSystemSsh(target, item) } }
325
+ return null
326
+ }
327
+
164
328
  const busyTabs = new WeakSet()
329
+ const lastHandled = new WeakMap() // tab -> timestamp of the last paste this plugin took over
330
+ const markHandled = tab => lastHandled.set(tab, Date.now())
331
+ const handledRecently = tab => Date.now() - (lastHandled.get(tab) || 0) < 1500
332
+
333
+ // Diagnostics: do real Cmd/Ctrl+V key presses and DOM paste events reach the page? (logged to the trace file)
334
+ let diagInstalled = false
335
+ function installDiagnostics () {
336
+ if (diagInstalled || typeof document === 'undefined') { return }
337
+ diagInstalled = true
338
+ const where = el => el ? `${el.tagName}.${String(el.className || '').slice(0, 40)}` : 'null'
339
+ document.addEventListener('keydown', e => {
340
+ if ((e.key || '').toLowerCase() === 'v' && (e.metaKey || e.ctrlKey)) { log(`keydown v meta=${e.metaKey} ctrl=${e.ctrlKey} shift=${e.shiftKey} target=${where(e.target)} defaultPrevented=${e.defaultPrevented}`) }
341
+ }, true)
342
+ document.addEventListener('paste', e => {
343
+ log(`DOM paste event target=${where(e.target)} types=${e.clipboardData ? Array.from(e.clipboardData.types).join(',') : 'none'}`)
344
+ }, true)
345
+ }
346
+ installDiagnostics()
165
347
 
166
348
  /**
167
349
  * Upload all items and call emit(buffer) with the typed paths.
168
350
  * Resolves true when at least one path was emitted.
169
351
  */
170
- async function pasteItems (tab, items, emit) {
352
+ async function pasteItems (tab, items, emit, transport) {
171
353
  const notify = (kind, msg) => { try { tab.notifications?.[kind]?.(msg) } catch { /* ignore */ } }
172
- const ssh = tab.sshSession?.ssh
173
- if (!ssh || typeof ssh.openSessionChannel !== 'function') { notify('error', 'Image paste failed: SSH session not ready'); return false }
354
+ const tr = transport || await resolveTransport(tab)
355
+ if (!tr) { notify('error', 'Image paste: this tab is not connected to an SSH host'); return false }
356
+ log('uploading ' + items.length + ' item(s) via ' + tr.kind + (tr.target ? ' to ' + tr.target.dest : ''))
174
357
  const paths = []
175
358
  for (const item of items) {
176
359
  if (item.error) { notify('error', `Image paste: ${item.error}`); continue }
177
- try { paths.push(await uploadOne(ssh, item)) } catch (e) { notify('error', `Image paste failed: ${e.message || e}`) }
360
+ try { paths.push(await tr.upload(item)) } catch (e) { log('upload error: ' + (e && e.stack || e)); notify('error', `Image paste failed: ${e.message || e}`) }
178
361
  }
179
362
  if (!paths.length) { return false }
180
363
  notify('notice', paths.length === 1 ? `Image saved on host: ${paths[0]}` : `${paths.length} images saved on host`)
181
364
  // Like a drag-and-drop: one bracketed paste per path when the app enabled it (TUIs then attach the image),
182
365
  // plain text otherwise (shell prompt).
183
366
  const bracketed = !!(tab.frontend && tab.frontend.supportsBracketedPaste && tab.frontend.supportsBracketedPaste())
367
+ log('uploaded ' + paths.length + ' file(s), bracketed=' + bracketed)
184
368
  emit(Buffer.from(paths.map(p => bracketed ? `\x1b[200~${PATH_PREFIX}${p} \x1b[201~` : `${PATH_PREFIX}${p} `).join('')))
185
369
  return true
186
370
  }
@@ -201,11 +385,17 @@ class ClipImageMiddleware extends SessionMiddleware {
201
385
  if (this.busy) { this.queue.push(data); return }
202
386
  if (data.length === 1 && data[0] === 0x16) {
203
387
  const items = collectItems(this.getClipboard())
388
+ log('Ctrl+V: formats=' + (this.getClipboard()?.availableFormats?.().join(',') || '?') + ' -> ' + items.length + ' image(s)')
204
389
  if (items.length) {
205
390
  if (busyTabs.has(this.tab)) { return } // a paste is already uploading
206
391
  this.busy = true
207
392
  busyTabs.add(this.tab)
208
- pasteItems(this.tab, items, buf => this.outputToSession.next(buf))
393
+ resolveTransport(this.tab)
394
+ .then(tr => {
395
+ if (!tr) { return false }
396
+ markHandled(this.tab)
397
+ return pasteItems(this.tab, items, buf => this.outputToSession.next(buf), tr)
398
+ })
209
399
  .then(ok => { if (!ok) { this.outputToSession.next(data) } })
210
400
  .catch(() => this.outputToSession.next(data))
211
401
  .then(() => {
@@ -223,33 +413,76 @@ class ClipImageMiddleware extends SessionMiddleware {
223
413
 
224
414
  class ClipImageDecorator extends TerminalDecorator {
225
415
  attachToSession (tab) {
226
- if (!tab.session || !tab.sshSession) { return }
416
+ if (!tab.session || !tab.session.middleware) { return }
227
417
  tab.session.middleware.unshift(new ClipImageMiddleware(tab))
228
418
  }
229
419
 
420
+ // Shared by tab.paste() and the DOM paste event: take over when there is something to upload and somewhere to upload it.
421
+ takeOver (tab, items, original) {
422
+ busyTabs.add(tab)
423
+ return resolveTransport(tab)
424
+ .then(tr => {
425
+ if (!tr) { busyTabs.delete(tab); return original() }
426
+ markHandled(tab)
427
+ return pasteItems(tab, items, buf => tab.sendInput(buf), tr)
428
+ .catch(() => false)
429
+ .then(ok => { busyTabs.delete(tab); if (!ok) { return original() } })
430
+ })
431
+ .catch(e => { busyTabs.delete(tab); log('takeOver error: ' + (e && e.stack || e)); return original() })
432
+ }
433
+
230
434
  // Cmd+V, Ctrl+Shift+V, Shift+Insert, right-click and middle-click paste all call tab.paste().
231
435
  hookPaste (tab) {
232
436
  if (tab.__clipImageHooked || typeof tab.paste !== 'function') { return }
233
437
  tab.__clipImageHooked = true
234
438
  const original = tab.paste.bind(tab)
439
+ tab.__clipImageOriginalPaste = original
235
440
  tab.paste = () => {
236
- if (!tab.sshSession) { return original() }
441
+ log('tab.paste() called, sshSession=' + !!tab.sshSession + ' busy=' + busyTabs.has(tab))
237
442
  if (busyTabs.has(tab)) { return Promise.resolve() }
238
443
  const items = collectItems(getClipboard())
239
- if (!items.length) { return original() }
240
- busyTabs.add(tab)
241
- return pasteItems(tab, items, buf => tab.sendInput(buf))
242
- .catch(() => false)
243
- .then(ok => { busyTabs.delete(tab); if (!ok) { return original() } })
444
+ log('paste(): formats=' + (getClipboard()?.availableFormats?.().join(',') || '?') + ' -> ' + items.length + ' image(s)')
445
+ if (!items.length) {
446
+ const why = describeNothingToPaste(getClipboard())
447
+ if (why) { resolveTransport(tab).then(tr => { if (tr) { notifyTab(tab, 'info', `Image paste: ${why}`) } }).catch(() => {}) } // 'info' stays ~5s; Tabby's 'notice' only 1s
448
+ return original()
449
+ }
450
+ return this.takeOver(tab, items, original)
451
+ }
452
+ }
453
+
454
+ // macOS can deliver Cmd+V as a native "Paste" (DOM paste event on xterm's textarea) without going through tab.paste().
455
+ hookDomPaste (tab) {
456
+ if (tab.__clipImageDom || typeof document === 'undefined') { return }
457
+ tab.__clipImageDom = true
458
+ const handler = ev => {
459
+ try {
460
+ const ta = tab.frontend && tab.frontend.xterm && tab.frontend.xterm.textarea
461
+ if (!ta || ev.target !== ta) { return }
462
+ if (busyTabs.has(tab) || handledRecently(tab)) { ev.preventDefault(); ev.stopImmediatePropagation(); log('DOM paste: already handled, swallowed'); return }
463
+ const items = collectItems(getClipboard())
464
+ if (!items.length) {
465
+ const why = describeNothingToPaste(getClipboard())
466
+ if (why) { resolveTransport(tab).then(tr => { if (tr) { notifyTab(tab, 'info', `Image paste: ${why}`) } }).catch(() => {}) }
467
+ return // let xterm paste text normally
468
+ }
469
+ ev.preventDefault(); ev.stopImmediatePropagation()
470
+ log('DOM paste: taking over, ' + items.length + ' item(s)')
471
+ this.takeOver(tab, items, tab.__clipImageOriginalPaste || (() => {}))
472
+ } catch (e) { log('DOM paste handler error: ' + (e && e.stack || e)) }
244
473
  }
474
+ document.addEventListener('paste', handler, true)
475
+ try { tab.destroyed$?.subscribe?.(() => document.removeEventListener('paste', handler, true)) } catch { /* ignore */ }
245
476
  }
246
477
 
247
478
  attach (tab) {
248
- if (!('sshSession' in tab)) { return } // SSH tabs only; local tabs paste natively
479
+ log('attach tab=' + (tab && tab.constructor && tab.constructor.name) + ' hasSshSession=' + ('sshSession' in tab) + ' hasPaste=' + (typeof tab.paste))
249
480
  this.hookPaste(tab)
481
+ this.hookDomPaste(tab)
482
+ if (!announced) { announced = true; setTimeout(() => notifyTab(tab, 'info', `tabby-clip-image ${VERSION} is active (SSH tabs, and local tabs running ssh): paste an image or copied image file`), 1500) }
250
483
  setTimeout(() => {
251
484
  this.attachToSession(tab)
252
- this.subscribeUntilDetached(tab, tab.sessionChanged$.subscribe(() => this.attachToSession(tab)))
485
+ if (tab.sessionChanged$) { this.subscribeUntilDetached(tab, tab.sessionChanged$.subscribe(() => this.attachToSession(tab))) }
253
486
  })
254
487
  }
255
488
  }
@@ -261,4 +494,4 @@ NgModule({
261
494
  ],
262
495
  })(ClipImageModule)
263
496
 
264
- module.exports = { default: ClipImageModule, ClipImageMiddleware, collectItems, clipboardFilePaths, pasteItems, REMOTE_SH }
497
+ module.exports = { default: ClipImageModule, ClipImageMiddleware, collectItems, clipboardFilePaths, pasteItems, REMOTE_SH, parseSshCommand, tokenizeCommandLine, findLocalSsh, resolveTransport, uploadViaSystemSsh }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tabby-clip-image",
3
- "version": "0.4.0",
4
- "description": "Paste images (screenshots or copied image files: png/jpg/gif/webp/heic/...) in a Tabby SSH tab: uploaded over the existing SSH connection to a fixed dir on the host and the path is typed at the cursor. Nothing to install on the host.",
3
+ "version": "0.5.0",
4
+ "description": "Paste images (screenshots or copied image files) in Tabby into remote TUIs (Claude Code, Codex...) over SSH: works in built-in SSH tabs AND in local tabs running ssh. Uploads to a fixed dir on the host and pastes the path. Nothing to install on the host.",
5
5
  "keywords": [
6
6
  "tabby-plugin",
7
7
  "tabby",