Skip to content

Commit 1b3faab

Browse files
authored
Merge pull request #23 from yugo-ibuki/claude/add-image-loading-nseoj
Add image attachment support to input area
2 parents 94343f0 + ece671b commit 1b3faab

7 files changed

Lines changed: 271 additions & 42 deletions

File tree

src/main/index.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { app, BrowserWindow, globalShortcut, ipcMain, Menu } from 'electron'
1+
import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, protocol } from 'electron'
22
import { join } from 'path'
33
import { homedir } from 'os'
44
import { readdir, readFile } from 'fs/promises'
@@ -136,16 +136,45 @@ function createWindow(): void {
136136
mainWindow!.show()
137137
})
138138

139+
// Relay image drops from preload to renderer
140+
ipcMain.on('image-dropped-from-renderer', (_event, paths: string[]) => {
141+
mainWindow?.webContents.send('image-dropped', paths)
142+
})
143+
139144
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
140145
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
141146
} else {
142147
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
143148
}
144149
}
145150

151+
// Register custom protocol to serve local files for image thumbnails
152+
protocol.registerSchemesAsPrivileged([
153+
{ scheme: 'local-image', privileges: { bypassCSP: true, supportFetchAPI: true } }
154+
])
155+
146156
app.whenReady().then(() => {
147157
electronApp.setAppUserModelId('com.unitmux')
148158

159+
// Handle local-image:// requests by serving files from disk
160+
protocol.handle('local-image', async (request) => {
161+
const filePath = decodeURIComponent(new URL(request.url).pathname)
162+
const ext = filePath.split('.').pop()?.toLowerCase() ?? ''
163+
const mimeMap: Record<string, string> = {
164+
png: 'image/png',
165+
jpg: 'image/jpeg',
166+
jpeg: 'image/jpeg',
167+
gif: 'image/gif',
168+
webp: 'image/webp',
169+
svg: 'image/svg+xml',
170+
bmp: 'image/bmp'
171+
}
172+
const data = await readFile(filePath)
173+
return new Response(data, {
174+
headers: { 'Content-Type': mimeMap[ext] ?? 'application/octet-stream' }
175+
})
176+
})
177+
149178
// Custom menu: remove Cmd+H (Hide) accelerator to prevent conflict with Ctrl+Cmd+H
150179
const menu = Menu.buildFromTemplate([
151180
{
@@ -187,8 +216,25 @@ app.whenReady().then(() => {
187216
}
188217
})
189218

190-
ipcMain.handle('tmux:send-input', async (_event, { target, text, vimMode }) => {
191-
return sendInput(target, text, vimMode)
219+
ipcMain.handle('tmux:send-input', async (_event, { target, text, vimMode, images }) => {
220+
return sendInput(target, text, vimMode, images)
221+
})
222+
223+
ipcMain.handle('dialog:open-image', async () => {
224+
const win = mainWindow
225+
if (!win) return []
226+
// Temporarily disable alwaysOnTop so the native dialog is visible on macOS
227+
const wasOnTop = win.isAlwaysOnTop()
228+
if (wasOnTop) win.setAlwaysOnTop(false)
229+
try {
230+
const result = await dialog.showOpenDialog(win, {
231+
properties: ['openFile', 'multiSelections'],
232+
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'] }]
233+
})
234+
return result.canceled ? [] : result.filePaths
235+
} finally {
236+
if (wasOnTop) win.setAlwaysOnTop(true)
237+
}
192238
})
193239

194240
ipcMain.handle('tmux:capture-pane', async (_event, target: string) => {

src/main/tmux.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,8 @@ const TARGET_PATTERN = /^[a-zA-Z0-9_-]+:\d+\.\d+$/
677677
export async function sendInput(
678678
target: string,
679679
text: string,
680-
vimMode = false
680+
vimMode = false,
681+
images: string[] = []
681682
): Promise<{ success: boolean; error?: string }> {
682683
if (!TARGET_PATTERN.test(target)) {
683684
return { success: false, error: 'Invalid target format' }
@@ -708,13 +709,22 @@ export async function sendInput(
708709
await new Promise((r) => setTimeout(r, 100))
709710
}
710711

712+
// Send image paths as bracketed paste so Claude CLI detects them as images
713+
if (images.length > 0) {
714+
const imagePaths = images.join(' ')
715+
await run(['send-keys', '-t', target, '\x1b[200~'])
716+
await run(['send-keys', '-t', target, '-l', imagePaths])
717+
await run(['send-keys', '-t', target, '\x1b[201~'])
718+
await new Promise((r) => setTimeout(r, 500))
719+
}
720+
711721
const isCodex = command === 'codex'
712722
const hasNewlines = text.includes('\n')
713723

714724
if (isCodex) {
715725
// Codex ignores Enter from external tmux clients. Use run-shell
716726
// to execute send-keys from within the tmux server process itself.
717-
await run(['send-keys', '-t', target, '-l', text])
727+
if (text) await run(['send-keys', '-t', target, '-l', text])
718728
await run(['run-shell', `${tmuxBin} send-keys -t ${target} Enter`])
719729
} else if (hasNewlines) {
720730
// Send bracketed paste escape sequences to preserve newlines
@@ -724,9 +734,12 @@ export async function sendInput(
724734
await run(['send-keys', '-t', target, '\x1b[201~'])
725735
await new Promise((r) => setTimeout(r, 300))
726736
await run(['send-keys', '-t', target, '', 'Enter'])
727-
} else {
737+
} else if (text) {
728738
await run(['send-keys', '-t', target, '-l', text])
729739
await run(['send-keys', '-t', target, 'Enter'])
740+
} else {
741+
// Images only, no text — just send Enter to submit
742+
await run(['send-keys', '-t', target, 'Enter'])
730743
}
731744
return { success: true }
732745
} catch (e) {

src/preload/index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ interface TmuxAPI {
5656
killPane: (target: string) => Promise<SendResult>
5757
findShellPane: (session: string) => Promise<string | null>
5858
ensureShellPane: (session: string, cwd: string) => Promise<{ success: boolean; target?: string; error?: string }>
59-
sendInput: (target: string, text: string, vimMode?: boolean) => Promise<SendResult>
59+
sendInput: (target: string, text: string, vimMode?: boolean, images?: string[]) => Promise<SendResult>
6060
capturePane: (target: string) => Promise<string>
6161
getPaneDetail: (target: string) => Promise<PaneDetail | null>
6262
listTmuxSessions: () => Promise<string[]>
@@ -79,6 +79,8 @@ interface TmuxAPI {
7979
stopStream: () => Promise<boolean>
8080
onStreamData: (callback: (content: string) => void) => () => void
8181
onChatData: (callback: (messages: ChatMessage[]) => void) => () => void
82+
selectImages: () => Promise<string[]>
83+
onImageDropped: (callback: (paths: string[]) => void) => () => void
8284
}
8385

8486
declare global {

src/preload/index.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { contextBridge, ipcRenderer } from 'electron'
1+
import { contextBridge, ipcRenderer, webUtils } from 'electron'
22

33
export interface SkillEntry {
44
name: string
@@ -42,8 +42,8 @@ export interface SendResult {
4242

4343
const api = {
4444
listSessions: (): Promise<TmuxPane[]> => ipcRenderer.invoke('tmux:list-sessions'),
45-
sendInput: (target: string, text: string, vimMode = false): Promise<SendResult> =>
46-
ipcRenderer.invoke('tmux:send-input', { target, text, vimMode }),
45+
sendInput: (target: string, text: string, vimMode = false, images: string[] = []): Promise<SendResult> =>
46+
ipcRenderer.invoke('tmux:send-input', { target, text, vimMode, images }),
4747
capturePane: (target: string): Promise<string> => ipcRenderer.invoke('tmux:capture-pane', target),
4848
getPaneDetail: (target: string): Promise<PaneDetail | null> =>
4949
ipcRenderer.invoke('tmux:pane-detail', target),
@@ -95,6 +95,12 @@ const api = {
9595
const handler = (_event: unknown, messages: ChatMessage[]): void => callback(messages)
9696
ipcRenderer.on('tmux:chat-data', handler)
9797
return () => ipcRenderer.removeListener('tmux:chat-data', handler)
98+
},
99+
selectImages: (): Promise<string[]> => ipcRenderer.invoke('dialog:open-image'),
100+
onImageDropped: (callback: (paths: string[]) => void): (() => void) => {
101+
const handler = (_event: unknown, paths: string[]): void => callback(paths)
102+
ipcRenderer.on('image-dropped', handler)
103+
return () => ipcRenderer.removeListener('image-dropped', handler)
98104
}
99105
}
100106

@@ -104,3 +110,30 @@ if (process.contextIsolated) {
104110
// @ts-ignore (define in dts)
105111
window.api = api
106112
}
113+
114+
// Prevent Electron from navigating to dropped files & capture image drops
115+
window.addEventListener('DOMContentLoaded', () => {
116+
const imageExt = /\.(png|jpe?g|gif|webp|svg|bmp)$/i
117+
118+
document.addEventListener('dragover', (e) => {
119+
e.preventDefault()
120+
e.stopPropagation()
121+
})
122+
123+
document.addEventListener('drop', (e) => {
124+
e.preventDefault()
125+
e.stopPropagation()
126+
const paths: string[] = []
127+
if (e.dataTransfer) {
128+
for (const file of Array.from(e.dataTransfer.files)) {
129+
const filePath = webUtils.getPathForFile(file)
130+
if (filePath && imageExt.test(filePath)) {
131+
paths.push(filePath)
132+
}
133+
}
134+
}
135+
if (paths.length > 0) {
136+
ipcRenderer.send('image-dropped-from-renderer', paths)
137+
}
138+
})
139+
})

src/renderer/src/App.css

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,89 @@
442442
inset 0 0 12px rgba(91, 141, 239, 0.03);
443443
}
444444

445+
/* ── Image Attachments ── */
446+
.image-attachments {
447+
display: flex;
448+
gap: 6px;
449+
flex-wrap: wrap;
450+
padding: 4px 0;
451+
flex-shrink: 0;
452+
}
453+
454+
.image-thumb {
455+
position: relative;
456+
width: 48px;
457+
height: 48px;
458+
border-radius: var(--radius);
459+
border: 1px solid var(--border);
460+
overflow: hidden;
461+
background: var(--bg-input);
462+
}
463+
464+
.image-thumb img {
465+
width: 100%;
466+
height: 100%;
467+
object-fit: cover;
468+
display: block;
469+
}
470+
471+
.image-thumb-remove {
472+
position: absolute;
473+
top: 1px;
474+
right: 1px;
475+
width: 14px;
476+
height: 14px;
477+
border-radius: 50%;
478+
border: none;
479+
background: rgba(0, 0, 0, 0.7);
480+
color: #fff;
481+
font-size: 9px;
482+
line-height: 1;
483+
cursor: pointer;
484+
display: flex;
485+
align-items: center;
486+
justify-content: center;
487+
padding: 0;
488+
opacity: 0;
489+
transition: opacity var(--transition);
490+
}
491+
492+
.image-thumb:hover .image-thumb-remove {
493+
opacity: 1;
494+
}
495+
496+
.image-thumb-name {
497+
position: absolute;
498+
bottom: 0;
499+
left: 0;
500+
right: 0;
501+
font-size: 7px;
502+
background: rgba(0, 0, 0, 0.6);
503+
color: #fff;
504+
padding: 1px 2px;
505+
white-space: nowrap;
506+
overflow: hidden;
507+
text-overflow: ellipsis;
508+
}
509+
510+
.attach-btn {
511+
padding: 6px 10px;
512+
border-radius: var(--radius);
513+
border: 1px solid var(--border);
514+
background: transparent;
515+
color: var(--text-dim);
516+
font-size: 10px;
517+
font-weight: 600;
518+
font-family: var(--font-mono);
519+
cursor: pointer;
520+
transition: all var(--transition);
521+
}
522+
523+
.attach-btn:hover {
524+
color: var(--accent);
525+
border-color: var(--accent);
526+
}
527+
445528
/* ── Footer ── */
446529
.footer {
447530
display: flex;

0 commit comments

Comments
 (0)