tabby-clip-image 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +67 -2
- package/index.js +497 -0
- 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,68 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tabby-clip-image
|
|
2
2
|
|
|
3
|
-
|
|
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** (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
|
+
|
|
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
|
+
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
|
+
|
|
25
|
+
Tabby → Settings → Plugins → search `clip-image` → Install → restart Tabby.
|
|
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
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
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.
|
|
38
|
+
- Max file size: `MAX_FILE_BYTES` (default 50 MB).
|
|
39
|
+
- 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`).
|
|
40
|
+
|
|
41
|
+
## Notes
|
|
42
|
+
|
|
43
|
+
- Uploaded files are mode 600.
|
|
44
|
+
- 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.
|
|
45
|
+
- 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).
|
|
46
|
+
|
|
47
|
+
## Troubleshooting (copied files not detected)
|
|
48
|
+
|
|
49
|
+
Open Tabby's DevTools (`Ctrl+Shift+I` / `Cmd+Option+I`), Console, copy a file in the file manager, then run:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
require('electron').clipboard.availableFormats()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
then check what the plugin itself would upload for the current clipboard (empty array = it would paste normally):
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
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}`)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
(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).
|
|
62
|
+
|
|
63
|
+
## Verification status
|
|
64
|
+
|
|
65
|
+
- 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]`.
|
|
66
|
+
- macOS and Windows clients: clipboard-format parsing covered by unit tests with simulated clipboards only; not yet run on real machines.
|
|
67
|
+
|
|
68
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
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 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)
|
|
24
|
+
const fs = require('fs')
|
|
25
|
+
const path = require('path')
|
|
26
|
+
const { fileURLToPath } = require('url')
|
|
27
|
+
const { NgModule } = require('@angular/core')
|
|
28
|
+
const { TerminalDecorator, SessionMiddleware } = require('tabby-terminal')
|
|
29
|
+
|
|
30
|
+
// Typed before each path. '' (plain absolute path) is recognised as an image attachment by both
|
|
31
|
+
// Claude Code and Codex when it arrives as a bracketed paste; '@' only works as a file mention in Claude Code.
|
|
32
|
+
const PATH_PREFIX = ''
|
|
33
|
+
const MAX_FILE_BYTES = 50 * 1024 * 1024
|
|
34
|
+
const CHUNK = 32 * 1024
|
|
35
|
+
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'jfif', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'heif', 'avif', 'svg', 'ico'])
|
|
36
|
+
|
|
37
|
+
// Runs on the host via the SSH exec channel as: sh -c '<script>' sh <name> <ext>
|
|
38
|
+
// Saves stdin to $CLIP_RECV_DIR (default ~/.cache/clip-recv/shots), prunes files older than
|
|
39
|
+
// 2 days and prints "ok <ext> <absolute path>".
|
|
40
|
+
const REMOTE_SH = [
|
|
41
|
+
'd="${CLIP_RECV_DIR:-$HOME/.cache/clip-recv/shots}"',
|
|
42
|
+
'mkdir -p "$d"',
|
|
43
|
+
'f="$d/shot-$(date +%Y%m%d-%H%M%S)-$$-$1.$2"',
|
|
44
|
+
'cat > "$f"',
|
|
45
|
+
'[ -s "$f" ] || { rm -f "$f"; false; }',
|
|
46
|
+
'chmod 600 "$f"',
|
|
47
|
+
'{ find "$d" -maxdepth 1 -type f -name "shot-*" -mtime +2 -delete 2>/dev/null || true; }',
|
|
48
|
+
'echo "ok $2 $f"',
|
|
49
|
+
].join(' && ')
|
|
50
|
+
|
|
51
|
+
function getClipboard () {
|
|
52
|
+
try { return require('electron').clipboard } catch { return null }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- reading the local clipboard ----------
|
|
56
|
+
|
|
57
|
+
function decodeXml (s) {
|
|
58
|
+
return s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseUriList (text) {
|
|
62
|
+
const out = []
|
|
63
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
64
|
+
const l = line.trim()
|
|
65
|
+
if (!l.startsWith('file://')) { continue }
|
|
66
|
+
try { out.push(fileURLToPath(l)) } catch { /* ignore */ }
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Windows only: every copied file via the built-in PowerShell (FileNameW holds just the first one). */
|
|
72
|
+
function winFileDropList () {
|
|
73
|
+
const { execFileSync } = require('child_process')
|
|
74
|
+
const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-Command',
|
|
75
|
+
'[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::GetFileDropList() | ForEach-Object { $_ }',
|
|
76
|
+
], { timeout: 4000, windowsHide: true, encoding: 'utf8' })
|
|
77
|
+
return out.split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Paths of files currently copied in the OS file manager (empty if none). */
|
|
81
|
+
function clipboardFilePaths (cb, platform = process.platform, deps = { winFileDropList }) {
|
|
82
|
+
const read = (fn) => { try { return fn() } catch { return null } }
|
|
83
|
+
let paths = []
|
|
84
|
+
if (platform === 'win32') {
|
|
85
|
+
const buf = read(() => cb.readBuffer('FileNameW'))
|
|
86
|
+
if (buf && buf.length) {
|
|
87
|
+
const p = Buffer.from(buf).toString('ucs2').replace(/\0.*$/s, '')
|
|
88
|
+
if (p) {
|
|
89
|
+
paths.push(p)
|
|
90
|
+
try { const all = deps.winFileDropList(); if (all.length > 1) { paths = all } } catch { /* keep the first file */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} else if (platform === 'darwin') {
|
|
94
|
+
const plist = read(() => cb.read('NSFilenamesPboardType'))
|
|
95
|
+
if (plist) {
|
|
96
|
+
for (const m of plist.matchAll(/<string>([\s\S]*?)<\/string>/g)) { paths.push(decodeXml(m[1])) }
|
|
97
|
+
}
|
|
98
|
+
if (!paths.length) {
|
|
99
|
+
const u = read(() => cb.read('public.file-url'))
|
|
100
|
+
if (u) { paths = parseUriList(u) }
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
for (const fmt of ['text/uri-list', 'x-special/gnome-copied-files']) {
|
|
104
|
+
const t = read(() => Buffer.from(cb.readBuffer(fmt)).toString('utf8')) // renderer returns Uint8Array, not Buffer
|
|
105
|
+
if (t) { paths = parseUriList(t) }
|
|
106
|
+
if (paths.length) { break }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...new Set(paths)]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function safeBase (file) {
|
|
113
|
+
const b = path.basename(file, path.extname(file)).replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 32)
|
|
114
|
+
return b || 'img'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Image items to upload for the current clipboard, or [] when this is a normal (text) paste.
|
|
119
|
+
* Each item: { base, ext, size?, load: () => Promise<Buffer> } or { error } for unusable files.
|
|
120
|
+
*/
|
|
121
|
+
function collectItems (cb, platform = process.platform, deps) {
|
|
122
|
+
if (!cb) { log('no electron clipboard available'); return [] }
|
|
123
|
+
const files = clipboardFilePaths(cb, platform, deps)
|
|
124
|
+
if (files.length) {
|
|
125
|
+
const items = []
|
|
126
|
+
for (const f of files) {
|
|
127
|
+
const ext = path.extname(f).slice(1).toLowerCase()
|
|
128
|
+
if (!IMAGE_EXTS.has(ext)) { continue }
|
|
129
|
+
let st
|
|
130
|
+
try { st = fs.statSync(f) } catch { continue }
|
|
131
|
+
if (!st.isFile()) { continue }
|
|
132
|
+
if (st.size > MAX_FILE_BYTES) { items.push({ error: `${path.basename(f)} is larger than ${MAX_FILE_BYTES >> 20} MB` }); continue }
|
|
133
|
+
items.push({ base: safeBase(f), ext: ext === 'jpeg' || ext === 'jfif' ? 'jpg' : ext === 'tiff' ? 'tif' : ext, size: st.size, load: () => fs.promises.readFile(f) })
|
|
134
|
+
}
|
|
135
|
+
return items // [] when only non-image files are copied -> normal paste
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
if (cb.readText()) { return [] }
|
|
139
|
+
const img = cb.readImage()
|
|
140
|
+
if (!img || img.isEmpty()) { return [] }
|
|
141
|
+
return [{ base: 'clip', ext: 'png', load: async () => img.toPNG() }]
|
|
142
|
+
} catch { return [] }
|
|
143
|
+
}
|
|
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
|
+
|
|
157
|
+
// ---------- uploading ----------
|
|
158
|
+
|
|
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) {
|
|
163
|
+
const bytes = await item.load()
|
|
164
|
+
if (!bytes.length) { throw new Error('empty image') }
|
|
165
|
+
const ch = await ssh.activateChannel(await ssh.openSessionChannel())
|
|
166
|
+
let out = ''
|
|
167
|
+
const subs = []
|
|
168
|
+
const done = new Promise(resolve => {
|
|
169
|
+
subs.push(ch.data$.subscribe(b => { out += Buffer.from(b).toString() }))
|
|
170
|
+
subs.push(ch.extendedData$.subscribe(b => { out += Buffer.from(b).toString() }))
|
|
171
|
+
subs.push(ch.closed$.subscribe({ next: resolve, complete: resolve }))
|
|
172
|
+
subs.push(ch.eof$.subscribe({ next: () => {}, complete: () => {} }))
|
|
173
|
+
})
|
|
174
|
+
let timer
|
|
175
|
+
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 15000 + bytes.length / 50) })
|
|
176
|
+
try {
|
|
177
|
+
await ch.requestExec(`sh -c '${REMOTE_SH}' sh ${item.base} ${item.ext}`)
|
|
178
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
179
|
+
await ch.write(bytes.subarray(i, i + CHUNK))
|
|
180
|
+
}
|
|
181
|
+
await ch.eof()
|
|
182
|
+
await Promise.race([done, timeout])
|
|
183
|
+
} finally {
|
|
184
|
+
clearTimeout(timer)
|
|
185
|
+
subs.forEach(s => s.unsubscribe?.())
|
|
186
|
+
}
|
|
187
|
+
const m = /^ok (\S+) (.+)$/m.exec(out.trim())
|
|
188
|
+
if (!m) { throw new Error(out.trim() || 'no output from host (is `sh` available?)') }
|
|
189
|
+
return m[2].trim()
|
|
190
|
+
}
|
|
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
|
+
|
|
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()
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Upload all items and call emit(buffer) with the typed paths.
|
|
350
|
+
* Resolves true when at least one path was emitted.
|
|
351
|
+
*/
|
|
352
|
+
async function pasteItems (tab, items, emit, transport) {
|
|
353
|
+
const notify = (kind, msg) => { try { tab.notifications?.[kind]?.(msg) } catch { /* ignore */ } }
|
|
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 : ''))
|
|
357
|
+
const paths = []
|
|
358
|
+
for (const item of items) {
|
|
359
|
+
if (item.error) { notify('error', `Image paste: ${item.error}`); continue }
|
|
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}`) }
|
|
361
|
+
}
|
|
362
|
+
if (!paths.length) { return false }
|
|
363
|
+
notify('notice', paths.length === 1 ? `Image saved on host: ${paths[0]}` : `${paths.length} images saved on host`)
|
|
364
|
+
// Like a drag-and-drop: one bracketed paste per path when the app enabled it (TUIs then attach the image),
|
|
365
|
+
// plain text otherwise (shell prompt).
|
|
366
|
+
const bracketed = !!(tab.frontend && tab.frontend.supportsBracketedPaste && tab.frontend.supportsBracketedPaste())
|
|
367
|
+
log('uploaded ' + paths.length + ' file(s), bracketed=' + bracketed)
|
|
368
|
+
emit(Buffer.from(paths.map(p => bracketed ? `\x1b[200~${PATH_PREFIX}${p} \x1b[201~` : `${PATH_PREFIX}${p} `).join('')))
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------- Tabby integration ----------
|
|
373
|
+
|
|
374
|
+
// Ctrl+V sends 0x16 into the session on Windows/Linux (and Ctrl+V on macOS).
|
|
375
|
+
class ClipImageMiddleware extends SessionMiddleware {
|
|
376
|
+
constructor (tab, clipboardProvider) {
|
|
377
|
+
super()
|
|
378
|
+
this.tab = tab
|
|
379
|
+
this.getClipboard = clipboardProvider || getClipboard
|
|
380
|
+
this.busy = false // only true while *this middleware* holds keystrokes during an upload
|
|
381
|
+
this.queue = []
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
feedFromTerminal (data) {
|
|
385
|
+
if (this.busy) { this.queue.push(data); return }
|
|
386
|
+
if (data.length === 1 && data[0] === 0x16) {
|
|
387
|
+
const items = collectItems(this.getClipboard())
|
|
388
|
+
log('Ctrl+V: formats=' + (this.getClipboard()?.availableFormats?.().join(',') || '?') + ' -> ' + items.length + ' image(s)')
|
|
389
|
+
if (items.length) {
|
|
390
|
+
if (busyTabs.has(this.tab)) { return } // a paste is already uploading
|
|
391
|
+
this.busy = true
|
|
392
|
+
busyTabs.add(this.tab)
|
|
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
|
+
})
|
|
399
|
+
.then(ok => { if (!ok) { this.outputToSession.next(data) } })
|
|
400
|
+
.catch(() => this.outputToSession.next(data))
|
|
401
|
+
.then(() => {
|
|
402
|
+
this.busy = false
|
|
403
|
+
busyTabs.delete(this.tab)
|
|
404
|
+
const q = this.queue; this.queue = []
|
|
405
|
+
q.forEach(d => this.feedFromTerminal(d))
|
|
406
|
+
})
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
this.outputToSession.next(data)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
class ClipImageDecorator extends TerminalDecorator {
|
|
415
|
+
attachToSession (tab) {
|
|
416
|
+
if (!tab.session || !tab.session.middleware) { return }
|
|
417
|
+
tab.session.middleware.unshift(new ClipImageMiddleware(tab))
|
|
418
|
+
}
|
|
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
|
+
|
|
434
|
+
// Cmd+V, Ctrl+Shift+V, Shift+Insert, right-click and middle-click paste all call tab.paste().
|
|
435
|
+
hookPaste (tab) {
|
|
436
|
+
if (tab.__clipImageHooked || typeof tab.paste !== 'function') { return }
|
|
437
|
+
tab.__clipImageHooked = true
|
|
438
|
+
const original = tab.paste.bind(tab)
|
|
439
|
+
tab.__clipImageOriginalPaste = original
|
|
440
|
+
tab.paste = () => {
|
|
441
|
+
log('tab.paste() called, sshSession=' + !!tab.sshSession + ' busy=' + busyTabs.has(tab))
|
|
442
|
+
if (busyTabs.has(tab)) { return Promise.resolve() }
|
|
443
|
+
const items = collectItems(getClipboard())
|
|
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)) }
|
|
473
|
+
}
|
|
474
|
+
document.addEventListener('paste', handler, true)
|
|
475
|
+
try { tab.destroyed$?.subscribe?.(() => document.removeEventListener('paste', handler, true)) } catch { /* ignore */ }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
attach (tab) {
|
|
479
|
+
log('attach tab=' + (tab && tab.constructor && tab.constructor.name) + ' hasSshSession=' + ('sshSession' in tab) + ' hasPaste=' + (typeof tab.paste))
|
|
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) }
|
|
483
|
+
setTimeout(() => {
|
|
484
|
+
this.attachToSession(tab)
|
|
485
|
+
if (tab.sessionChanged$) { this.subscribeUntilDetached(tab, tab.sessionChanged$.subscribe(() => this.attachToSession(tab))) }
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
class ClipImageModule {}
|
|
491
|
+
NgModule({
|
|
492
|
+
providers: [
|
|
493
|
+
{ provide: TerminalDecorator, useFactory: () => new ClipImageDecorator(), multi: true },
|
|
494
|
+
],
|
|
495
|
+
})(ClipImageModule)
|
|
496
|
+
|
|
497
|
+
module.exports = { default: ClipImageModule, ClipImageMiddleware, collectItems, clipboardFilePaths, pasteItems, REMOTE_SH, parseSshCommand, tokenizeCommandLine, findLocalSsh, resolveTransport, uploadViaSystemSsh }
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabby-clip-image",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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
|
+
"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
|
+
}
|