tabby-clip-image 0.0.0-stage → 0.4.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -2
  3. package/index.js +264 -0
  4. package/package.json +21 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 barton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # tabby-clip-image
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Paste **images** into remote TUIs (Claude Code, Codex, …) running over SSH in Tabby.
4
+
5
+ In an SSH tab, paste with any of `Ctrl+V`, `Cmd+V`, `Ctrl+Shift+V`, `Shift+Insert`, right-click or middle-click. If the clipboard holds
6
+
7
+ | Clipboard | Result |
8
+ |---|---|
9
+ | a screenshot / copied image (pixels) | uploaded as `.png` |
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
+ | text, or non-image files | normal paste |
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.
14
+
15
+ **Nothing to install on the host**: it only uses POSIX `sh`, `mkdir`, `date`, `cat`, `chmod`, `find`. No X11/Wayland needed.
16
+
17
+ Client side: Linux (X11/Wayland), macOS, Windows — local tabs and text pastes are untouched.
18
+
19
+ ## Install
20
+
21
+ Tabby → Settings → Plugins → search `clip-image` → Install → restart Tabby.
22
+
23
+ ## Configuration
24
+
25
+ - 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.
26
+ - Max file size: `MAX_FILE_BYTES` (default 50 MB).
27
+ - Target directory on the host: `CLIP_RECV_DIR` (must be visible to non-interactive `sh`). Files older than 2 days are pruned (`-mtime +2` in `REMOTE_SH`).
28
+
29
+ ## Notes
30
+
31
+ - Uploaded files are mode 600.
32
+ - Only triggers when the clipboard has an image (or image files) and no text; on failure a notification is shown and the normal paste/`Ctrl+V` is passed through.
33
+ - File-copy detection reads OS clipboard formats (`text/uri-list` / `x-special/gnome-copied-files` on Linux, `NSFilenamesPboardType` / `public.file-url` on macOS, `FileNameW` on Windows).
34
+
35
+ ## Troubleshooting (copied files not detected)
36
+
37
+ Open Tabby's DevTools (`Ctrl+Shift+I` / `Cmd+Option+I`), Console, copy a file in the file manager, then run:
38
+
39
+ ```js
40
+ require('electron').clipboard.availableFormats()
41
+ ```
42
+
43
+ then check what the plugin itself would upload for the current clipboard (empty array = it would paste normally):
44
+
45
+ ```js
46
+ require('electron').clipboard && require(require('path').join(require('os').homedir(), '.config/tabby/plugins/node_modules/tabby-clip-image')).collectItems(require('electron').clipboard).map(i => i.error || `${i.base}.${i.ext}`)
47
+ ```
48
+
49
+ (on Windows use `%APPDATA%\\tabby\\plugins\\node_modules\\tabby-clip-image`, on macOS `~/Library/Application Support/tabby/plugins/node_modules/tabby-clip-image`), and open an issue with the output and your OS. Expected: `text/uri-list` (Linux), `NSFilenamesPboardType`/`public.file-url` (macOS), `FileNameW` (Windows).
50
+
51
+ ## Verification status
52
+
53
+ - Linux client, real Tabby 1.0.238 over SSH, real Claude Code and Codex in remote tmux: screenshot and copied files (png/jpg/webp) pasted as `[Image #1]`.
54
+ - macOS and Windows clients: clipboard-format parsing covered by unit tests with simulated clipboards only; not yet run on real machines.
55
+
56
+ MIT
package/index.js ADDED
@@ -0,0 +1,264 @@
1
+ 'use strict'
2
+ // tabby-clip-image: paste images into remote TUIs (claude/codex/...) over SSH.
3
+ //
4
+ // When you paste in an SSH tab (Ctrl+V, Cmd+V, Ctrl+Shift+V, Shift+Insert, right/middle click)
5
+ // and the clipboard holds
6
+ // - copied image FILE(s) (png/jpg/gif/webp/bmp/tiff/heic/avif/svg/ico) -> uploaded as-is, or
7
+ // - a screenshot / copied image (pixels) -> uploaded as PNG,
8
+ // the bytes are streamed over the tab's existing SSH connection to a fixed directory on the host
9
+ // (plain `sh` + `cat`; nothing to install there) and the absolute path is typed at the cursor.
10
+ // Anything else (text, non-image files, local tabs) pastes normally.
11
+ const fs = require('fs')
12
+ const path = require('path')
13
+ const { fileURLToPath } = require('url')
14
+ const { NgModule } = require('@angular/core')
15
+ const { TerminalDecorator, SessionMiddleware } = require('tabby-terminal')
16
+
17
+ // Typed before each path. '' (plain absolute path) is recognised as an image attachment by both
18
+ // Claude Code and Codex when it arrives as a bracketed paste; '@' only works as a file mention in Claude Code.
19
+ const PATH_PREFIX = ''
20
+ const MAX_FILE_BYTES = 50 * 1024 * 1024
21
+ const CHUNK = 32 * 1024
22
+ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'jfif', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'heif', 'avif', 'svg', 'ico'])
23
+
24
+ // Runs on the host via the SSH exec channel as: sh -c '<script>' sh <name> <ext>
25
+ // Saves stdin to $CLIP_RECV_DIR (default ~/.cache/clip-recv/shots), prunes files older than
26
+ // 2 days and prints "ok <ext> <absolute path>".
27
+ const REMOTE_SH = [
28
+ 'd="${CLIP_RECV_DIR:-$HOME/.cache/clip-recv/shots}"',
29
+ 'mkdir -p "$d"',
30
+ 'f="$d/shot-$(date +%Y%m%d-%H%M%S)-$$-$1.$2"',
31
+ 'cat > "$f"',
32
+ '[ -s "$f" ] || { rm -f "$f"; false; }',
33
+ 'chmod 600 "$f"',
34
+ '{ find "$d" -maxdepth 1 -type f -name "shot-*" -mtime +2 -delete 2>/dev/null || true; }',
35
+ 'echo "ok $2 $f"',
36
+ ].join(' && ')
37
+
38
+ function getClipboard () {
39
+ try { return require('electron').clipboard } catch { return null }
40
+ }
41
+
42
+ // ---------- reading the local clipboard ----------
43
+
44
+ function decodeXml (s) {
45
+ return s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&')
46
+ }
47
+
48
+ function parseUriList (text) {
49
+ const out = []
50
+ for (const line of String(text).split(/\r?\n/)) {
51
+ const l = line.trim()
52
+ if (!l.startsWith('file://')) { continue }
53
+ try { out.push(fileURLToPath(l)) } catch { /* ignore */ }
54
+ }
55
+ return out
56
+ }
57
+
58
+ /** Windows only: every copied file via the built-in PowerShell (FileNameW holds just the first one). */
59
+ function winFileDropList () {
60
+ const { execFileSync } = require('child_process')
61
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-Command',
62
+ '[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::GetFileDropList() | ForEach-Object { $_ }',
63
+ ], { timeout: 4000, windowsHide: true, encoding: 'utf8' })
64
+ return out.split(/\r?\n/).map(x => x.trim()).filter(Boolean)
65
+ }
66
+
67
+ /** Paths of files currently copied in the OS file manager (empty if none). */
68
+ function clipboardFilePaths (cb, platform = process.platform, deps = { winFileDropList }) {
69
+ const read = (fn) => { try { return fn() } catch { return null } }
70
+ let paths = []
71
+ if (platform === 'win32') {
72
+ const buf = read(() => cb.readBuffer('FileNameW'))
73
+ if (buf && buf.length) {
74
+ const p = Buffer.from(buf).toString('ucs2').replace(/\0.*$/s, '')
75
+ if (p) {
76
+ paths.push(p)
77
+ try { const all = deps.winFileDropList(); if (all.length > 1) { paths = all } } catch { /* keep the first file */ }
78
+ }
79
+ }
80
+ } else if (platform === 'darwin') {
81
+ const plist = read(() => cb.read('NSFilenamesPboardType'))
82
+ if (plist) {
83
+ for (const m of plist.matchAll(/<string>([\s\S]*?)<\/string>/g)) { paths.push(decodeXml(m[1])) }
84
+ }
85
+ if (!paths.length) {
86
+ const u = read(() => cb.read('public.file-url'))
87
+ if (u) { paths = parseUriList(u) }
88
+ }
89
+ } else {
90
+ for (const fmt of ['text/uri-list', 'x-special/gnome-copied-files']) {
91
+ const t = read(() => Buffer.from(cb.readBuffer(fmt)).toString('utf8')) // renderer returns Uint8Array, not Buffer
92
+ if (t) { paths = parseUriList(t) }
93
+ if (paths.length) { break }
94
+ }
95
+ }
96
+ return [...new Set(paths)]
97
+ }
98
+
99
+ function safeBase (file) {
100
+ const b = path.basename(file, path.extname(file)).replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 32)
101
+ return b || 'img'
102
+ }
103
+
104
+ /**
105
+ * Image items to upload for the current clipboard, or [] when this is a normal (text) paste.
106
+ * Each item: { base, ext, size?, load: () => Promise<Buffer> } or { error } for unusable files.
107
+ */
108
+ function collectItems (cb, platform = process.platform, deps) {
109
+ if (!cb) { return [] }
110
+ const files = clipboardFilePaths(cb, platform, deps)
111
+ if (files.length) {
112
+ const items = []
113
+ for (const f of files) {
114
+ const ext = path.extname(f).slice(1).toLowerCase()
115
+ if (!IMAGE_EXTS.has(ext)) { continue }
116
+ let st
117
+ try { st = fs.statSync(f) } catch { continue }
118
+ if (!st.isFile()) { continue }
119
+ if (st.size > MAX_FILE_BYTES) { items.push({ error: `${path.basename(f)} is larger than ${MAX_FILE_BYTES >> 20} MB` }); continue }
120
+ items.push({ base: safeBase(f), ext: ext === 'jpeg' || ext === 'jfif' ? 'jpg' : ext === 'tiff' ? 'tif' : ext, size: st.size, load: () => fs.promises.readFile(f) })
121
+ }
122
+ return items // [] when only non-image files are copied -> normal paste
123
+ }
124
+ try {
125
+ if (cb.readText()) { return [] }
126
+ const img = cb.readImage()
127
+ if (!img || img.isEmpty()) { return [] }
128
+ return [{ base: 'clip', ext: 'png', load: async () => img.toPNG() }]
129
+ } catch { return [] }
130
+ }
131
+
132
+ // ---------- uploading ----------
133
+
134
+ async function uploadOne (ssh, item) {
135
+ const bytes = await item.load()
136
+ if (!bytes.length) { throw new Error('empty image') }
137
+ const ch = await ssh.activateChannel(await ssh.openSessionChannel())
138
+ let out = ''
139
+ const subs = []
140
+ const done = new Promise(resolve => {
141
+ subs.push(ch.data$.subscribe(b => { out += Buffer.from(b).toString() }))
142
+ subs.push(ch.extendedData$.subscribe(b => { out += Buffer.from(b).toString() }))
143
+ subs.push(ch.closed$.subscribe({ next: resolve, complete: resolve }))
144
+ subs.push(ch.eof$.subscribe({ next: () => {}, complete: () => {} }))
145
+ })
146
+ let timer
147
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 15000 + bytes.length / 50) })
148
+ try {
149
+ await ch.requestExec(`sh -c '${REMOTE_SH}' sh ${item.base} ${item.ext}`)
150
+ for (let i = 0; i < bytes.length; i += CHUNK) {
151
+ await ch.write(bytes.subarray(i, i + CHUNK))
152
+ }
153
+ await ch.eof()
154
+ await Promise.race([done, timeout])
155
+ } finally {
156
+ clearTimeout(timer)
157
+ subs.forEach(s => s.unsubscribe?.())
158
+ }
159
+ const m = /^ok (\S+) (.+)$/m.exec(out.trim())
160
+ if (!m) { throw new Error(out.trim() || 'no output from host (is `sh` available?)') }
161
+ return m[2].trim()
162
+ }
163
+
164
+ const busyTabs = new WeakSet()
165
+
166
+ /**
167
+ * Upload all items and call emit(buffer) with the typed paths.
168
+ * Resolves true when at least one path was emitted.
169
+ */
170
+ async function pasteItems (tab, items, emit) {
171
+ 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 }
174
+ const paths = []
175
+ for (const item of items) {
176
+ 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}`) }
178
+ }
179
+ if (!paths.length) { return false }
180
+ notify('notice', paths.length === 1 ? `Image saved on host: ${paths[0]}` : `${paths.length} images saved on host`)
181
+ // Like a drag-and-drop: one bracketed paste per path when the app enabled it (TUIs then attach the image),
182
+ // plain text otherwise (shell prompt).
183
+ const bracketed = !!(tab.frontend && tab.frontend.supportsBracketedPaste && tab.frontend.supportsBracketedPaste())
184
+ emit(Buffer.from(paths.map(p => bracketed ? `\x1b[200~${PATH_PREFIX}${p} \x1b[201~` : `${PATH_PREFIX}${p} `).join('')))
185
+ return true
186
+ }
187
+
188
+ // ---------- Tabby integration ----------
189
+
190
+ // Ctrl+V sends 0x16 into the session on Windows/Linux (and Ctrl+V on macOS).
191
+ class ClipImageMiddleware extends SessionMiddleware {
192
+ constructor (tab, clipboardProvider) {
193
+ super()
194
+ this.tab = tab
195
+ this.getClipboard = clipboardProvider || getClipboard
196
+ this.busy = false // only true while *this middleware* holds keystrokes during an upload
197
+ this.queue = []
198
+ }
199
+
200
+ feedFromTerminal (data) {
201
+ if (this.busy) { this.queue.push(data); return }
202
+ if (data.length === 1 && data[0] === 0x16) {
203
+ const items = collectItems(this.getClipboard())
204
+ if (items.length) {
205
+ if (busyTabs.has(this.tab)) { return } // a paste is already uploading
206
+ this.busy = true
207
+ busyTabs.add(this.tab)
208
+ pasteItems(this.tab, items, buf => this.outputToSession.next(buf))
209
+ .then(ok => { if (!ok) { this.outputToSession.next(data) } })
210
+ .catch(() => this.outputToSession.next(data))
211
+ .then(() => {
212
+ this.busy = false
213
+ busyTabs.delete(this.tab)
214
+ const q = this.queue; this.queue = []
215
+ q.forEach(d => this.feedFromTerminal(d))
216
+ })
217
+ return
218
+ }
219
+ }
220
+ this.outputToSession.next(data)
221
+ }
222
+ }
223
+
224
+ class ClipImageDecorator extends TerminalDecorator {
225
+ attachToSession (tab) {
226
+ if (!tab.session || !tab.sshSession) { return }
227
+ tab.session.middleware.unshift(new ClipImageMiddleware(tab))
228
+ }
229
+
230
+ // Cmd+V, Ctrl+Shift+V, Shift+Insert, right-click and middle-click paste all call tab.paste().
231
+ hookPaste (tab) {
232
+ if (tab.__clipImageHooked || typeof tab.paste !== 'function') { return }
233
+ tab.__clipImageHooked = true
234
+ const original = tab.paste.bind(tab)
235
+ tab.paste = () => {
236
+ if (!tab.sshSession) { return original() }
237
+ if (busyTabs.has(tab)) { return Promise.resolve() }
238
+ 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() } })
244
+ }
245
+ }
246
+
247
+ attach (tab) {
248
+ if (!('sshSession' in tab)) { return } // SSH tabs only; local tabs paste natively
249
+ this.hookPaste(tab)
250
+ setTimeout(() => {
251
+ this.attachToSession(tab)
252
+ this.subscribeUntilDetached(tab, tab.sessionChanged$.subscribe(() => this.attachToSession(tab)))
253
+ })
254
+ }
255
+ }
256
+
257
+ class ClipImageModule {}
258
+ NgModule({
259
+ providers: [
260
+ { provide: TerminalDecorator, useFactory: () => new ClipImageDecorator(), multi: true },
261
+ ],
262
+ })(ClipImageModule)
263
+
264
+ module.exports = { default: ClipImageModule, ClipImageMiddleware, collectItems, clipboardFilePaths, pasteItems, REMOTE_SH }
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "tabby-clip-image",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
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.",
5
+ "keywords": [
6
+ "tabby-plugin",
7
+ "tabby",
8
+ "ssh",
9
+ "clipboard",
10
+ "image",
11
+ "paste",
12
+ "claude",
13
+ "codex"
14
+ ],
15
+ "main": "index.js",
16
+ "files": [
17
+ "index.js",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "author": "barton",
22
+ "license": "MIT"
23
+ }