Skip to content

Commit 4ac7064

Browse files
committed
Add support for pasted images in AI queries and enhance UI components
1 parent f07044e commit 4ac7064

9 files changed

Lines changed: 115 additions & 27 deletions

File tree

src/main/ipcHandlers.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -824,7 +824,7 @@ function resolveFileReferences(query) {
824824

825825
let activeQueryId = 0;
826826

827-
async function handleAIQuery(event, query, usePro, forceShowOutput, followUp) {
827+
async function handleAIQuery(event, query, usePro, forceShowOutput, followUp, pastedImages) {
828828
const queryId = ++activeQueryId;
829829

830830
if (!ai) {
@@ -846,6 +846,17 @@ async function handleAIQuery(event, query, usePro, forceShowOutput, followUp) {
846846

847847
// Build contents: continue existing conversation or start fresh
848848
const userParts = [{ text: resolvedQuery }, ...extraParts];
849+
850+
// Add pasted images
851+
if (Array.isArray(pastedImages) && pastedImages.length > 0) {
852+
for (const img of pastedImages) {
853+
if (img.dataUri && img.mimeType) {
854+
const base64 = img.dataUri.replace(/^data:[^;]+;base64,/, '');
855+
userParts.push({ inlineData: { mimeType: img.mimeType, data: base64 } });
856+
}
857+
}
858+
userParts[0] = { text: resolvedQuery + '\n\n[Pasted image attached]' };
859+
}
849860
let contents;
850861
if (followUp && chatHistory && chatHistory.model === modelName) {
851862
contents = [...chatHistory.contents, { role: 'user', parts: userParts }];
@@ -1443,8 +1454,8 @@ function registerHandlers(ipcMain) {
14431454
}
14441455
});
14451456

1446-
ipcMain.handle(IPC.AI_QUERY, async (event, query, usePro, forceShowOutput, followUp) => {
1447-
return handleAIQuery(event, query, usePro, forceShowOutput, followUp);
1457+
ipcMain.handle(IPC.AI_QUERY, async (event, query, usePro, forceShowOutput, followUp, pastedImages) => {
1458+
return handleAIQuery(event, query, usePro, forceShowOutput, followUp, pastedImages);
14481459
});
14491460

14501461
ipcMain.handle('trim:copy-image', async (_e, dataUri) => {
@@ -1458,6 +1469,19 @@ function registerHandlers(ipcMain) {
14581469
}
14591470
});
14601471

1472+
ipcMain.handle('trim:read-clipboard-image', async () => {
1473+
try {
1474+
const { nativeImage, clipboard } = require('electron');
1475+
const img = clipboard.readImage();
1476+
if (img.isEmpty()) return null;
1477+
const pngBuffer = img.toPNG();
1478+
const dataUri = `data:image/png;base64,${pngBuffer.toString('base64')}`;
1479+
return { dataUri, mimeType: 'image/png' };
1480+
} catch {
1481+
return null;
1482+
}
1483+
});
1484+
14611485
ipcMain.handle(IPC.SEARCH_FOLDERS, async (_e, query) => {
14621486
try {
14631487
const sender = _e.sender;

src/renderer/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
spellcheck="false"
3333
>
3434
</div>
35+
<div id="image-attachments"></div>
3536
<div id="chip-container"></div>
3637
<span class="search-hint" id="search-hint"></span>
3738
</div>

src/renderer/inputRouter.js

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ let activeFilePickRequestId = null;
2424
const aiFileRefs = new Map(); // label -> absolute path
2525
let inputOverlayEl = null;
2626
let calcSyntaxEnabled = true;
27+
let pastedImageData = null; // { dataUri, mimeType } or null
2728

2829
function init() {
2930
const input = document.getElementById('search-input');
@@ -38,6 +39,23 @@ function init() {
3839
calcSyntaxEnabled = s && s.calcSyntax === false ? false : true;
3940
}).catch(() => {});
4041

42+
// Handle image paste - use Electron's native clipboard via IPC (sandbox-safe)
43+
input.addEventListener('paste', async (e) => {
44+
const items = e.clipboardData?.items;
45+
if (!items) return;
46+
let hasImage = false;
47+
for (const item of items) {
48+
if (item.type.startsWith('image/')) { hasImage = true; break; }
49+
}
50+
if (!hasImage) return;
51+
e.preventDefault();
52+
const result = await window.trim.readClipboardImage();
53+
if (result && result.dataUri) {
54+
pastedImageData = { dataUri: result.dataUri, mimeType: result.mimeType };
55+
if (window._chips) window._chips.renderChips();
56+
}
57+
});
58+
4159
window.trim.offFolderSearchUpdate();
4260
window.trim.onFolderSearchUpdate((data) => {
4361
const currentRaw = input.value;
@@ -445,24 +463,25 @@ function renderCalcOverlay(inputEl, raw) {
445463

446464
function syncOverlayScroll(inputEl) {
447465
if (!inputOverlayEl || inputOverlayEl.style.display === 'none') return;
448-
const containerW = inputOverlayEl.parentElement.clientWidth;
449-
const contentW = inputOverlayEl.scrollWidth;
450-
if (contentW > containerW) {
451-
inputOverlayEl.style.transform = `translateX(${containerW - contentW}px)`;
452-
} else {
453-
inputOverlayEl.style.transform = '';
454-
}
466+
inputOverlayEl.style.transform = `translateX(${-inputEl.scrollLeft}px)`;
455467
}
456468

457469
function refreshInputDecor(inputEl) {
458470
pruneStaleFileRefs(inputEl.value || '');
459471
updateFileRefTooltip(inputEl);
460472
renderInputOverlay(inputEl);
461-
requestAnimationFrame(() => syncOverlayScroll(inputEl));
473+
requestAnimationFrame(() => {
474+
syncOverlayScroll(inputEl);
475+
// Double-rAF: input.scrollLeft may update after layout
476+
requestAnimationFrame(() => syncOverlayScroll(inputEl));
477+
});
462478
}
463479

464480
function isFilePickActive() {
465481
return filePickActive;
466482
}
467483

468-
window._inputRouter = { init, route, detectMode, isFilePickActive, resolveAIFileRefsInQuery, refreshInputDecor, setCalcSyntax(v) { calcSyntaxEnabled = v; } };
484+
window._inputRouter = { init, route, detectMode, isFilePickActive, resolveAIFileRefsInQuery, refreshInputDecor, setCalcSyntax(v) { calcSyntaxEnabled = v; },
485+
getPastedImage() { return pastedImageData; },
486+
clearPastedImage() { pastedImageData = null; if (window._chips) window._chips.renderChips(); },
487+
};

src/renderer/modules/aiQuery.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ async function search(query) {
88
return [{ type: 'ai-loading' }];
99
}
1010

11-
async function execute(query, mode, forceShow, renderFn) {
11+
async function execute(query, mode, forceShow, renderFn, pastedImages) {
1212
if (isQuerying) return; // Prevent concurrent queries
1313
isQuerying = true;
1414

@@ -22,7 +22,7 @@ async function execute(query, mode, forceShow, renderFn) {
2222
});
2323

2424
try {
25-
const result = await window.trim.aiQuery(query, usePro, forceShow, followUp);
25+
const result = await window.trim.aiQuery(query, usePro, forceShow, followUp, pastedImages || []);
2626

2727
// Silently ignore aborted queries
2828
if (result.error === '__aborted__') return;

src/renderer/modules/chips.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function register(id, opts) {
1616
modes: opts.modes || [], // which input modes show this chip
1717
action: typeof opts.action === 'function' ? opts.action : null,
1818
visibleWhen: typeof opts.visibleWhen === 'function' ? opts.visibleWhen : null,
19+
dismissable: opts.dismissable || false,
1920
});
2021
activeToggles[id] = opts.default || false;
2122
}
@@ -87,6 +88,16 @@ register('update_available', {
8788
action: () => { window.trim.quitAndInstall(); },
8889
});
8990

91+
register('pasted_image', {
92+
label: 'Clipboard image',
93+
icon: 'image',
94+
default: false,
95+
modes: ['ai', 'ai_pro'],
96+
dismissable: true,
97+
visibleWhen: () => window._inputRouter && !!window._inputRouter.getPastedImage(),
98+
action: () => { if (window._inputRouter) window._inputRouter.clearPastedImage(); },
99+
});
100+
90101
if (window.trim.onUpdateReady) {
91102
window.trim.onUpdateReady(() => {
92103
updateReady = true;
@@ -164,6 +175,14 @@ function renderChips(mode) {
164175
label.textContent = chip.label;
165176
el.appendChild(label);
166177

178+
if (chip.dismissable) {
179+
const dismiss = document.createElement('span');
180+
dismiss.className = 'material-symbols-rounded chip-dismiss';
181+
dismiss.style.fontSize = '14px';
182+
dismiss.textContent = 'close';
183+
el.appendChild(dismiss);
184+
}
185+
167186
// Prevent chip from stealing focus from the input
168187
el.addEventListener('mousedown', (e) => e.preventDefault());
169188

@@ -198,4 +217,4 @@ function triggerVisibleAction() {
198217
return false;
199218
}
200219

201-
window._chips = { init, updateMode, isActive, register, toggle, deactivate, setResultsCount, triggerVisibleAction };
220+
window._chips = { init, updateMode, isActive, register, toggle, deactivate, setResultsCount, triggerVisibleAction, renderChips() { renderChips(chipMode); } };

src/renderer/preload.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ contextBridge.exposeInMainWorld('trim', {
66
getUsage: () => ipcRenderer.invoke('trim:get-usage'),
77
getDisplayScale: () => ipcRenderer.invoke('trim:get-display-scale'),
88
openApp: (appPath, appName) => ipcRenderer.invoke('trim:open-app', appPath, appName),
9-
aiQuery: (query, usePro, forceShow, followUp) => ipcRenderer.invoke('trim:ai-query', query, usePro, forceShow, followUp),
9+
aiQuery: (query, usePro, forceShow, followUp, pastedImages) => ipcRenderer.invoke('trim:ai-query', query, usePro, forceShow, followUp, pastedImages),
1010
searchFolders: (query) => ipcRenderer.invoke('trim:search-folders', query),
1111
onFolderSearchUpdate: (cb) => ipcRenderer.on('trim:folder-search-update', (_e, data) => cb(data)),
1212
offFolderSearchUpdate: () => ipcRenderer.removeAllListeners('trim:folder-search-update'),
@@ -29,4 +29,5 @@ contextBridge.exposeInMainWorld('trim', {
2929
onUpdateReady: (cb) => ipcRenderer.on('trim:update-ready', cb),
3030
quitAndInstall: () => ipcRenderer.send('trim:quit-and-install'),
3131
copyImageToClipboard: (dataUri) => ipcRenderer.invoke('trim:copy-image', dataUri),
32+
readClipboardImage: () => ipcRenderer.invoke('trim:read-clipboard-image'),
3233
});

src/renderer/renderer.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ function boot() {
5252
window._settings.dismiss();
5353
}
5454

55+
// Always clear pasted images on hide
56+
if (window._inputRouter) window._inputRouter.clearPastedImage();
57+
5558
const hasChat = window._aiQuery && window._aiQuery.isFollowUp();
5659
input.value = '';
5760
if (window._inputRouter) window._inputRouter.refreshInputDecor(input);

src/renderer/styles/search.css

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@
5858

5959
#search-input-overlay {
6060
position: absolute;
61-
inset: 0;
61+
top: 0;
62+
left: 0;
63+
bottom: 0;
6264
pointer-events: none;
6365
z-index: 1;
6466
display: flex;
6567
align-items: center;
6668
white-space: pre;
67-
overflow: hidden;
69+
width: max-content;
6870
font-family: var(--font);
6971
font-size: 16px;
7072
font-weight: 400;
@@ -92,16 +94,13 @@
9294
}
9395

9496
.file-ref-pill {
95-
display: inline-flex;
96-
align-items: center;
97-
margin: 0 1px;
98-
padding: 1px 7px;
97+
display: inline;
9998
border-radius: 6px;
10099
background: color-mix(in srgb, var(--accent) 12%, transparent);
101100
color: color-mix(in srgb, var(--accent) 85%, #ffffff);
102-
font-size: 0.82em;
103-
font-weight: 500;
104-
line-height: 1.35;
101+
font-size: inherit;
102+
font-weight: inherit;
103+
line-height: inherit;
105104
}
106105

107106
.file-ref-hidden-brackets {
@@ -190,6 +189,16 @@
190189
color: #fff;
191190
}
192191

192+
.chip-dismiss {
193+
opacity: 0.5;
194+
margin-left: -2px;
195+
transition: opacity 0.15s ease;
196+
}
197+
198+
.input-chip:hover .chip-dismiss {
199+
opacity: 1;
200+
}
201+
193202
/* Results list */
194203
#results-container {
195204
overflow-y: auto;

src/renderer/ui.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,21 +73,23 @@ function handleKeyboard(e) {
7373
const query = input.slice(2).trim();
7474
const resolvedQuery = window._inputRouter.resolveAIFileRefsInQuery(query);
7575
if (query) {
76+
const pastedImages = collectPastedImages();
7677
window._aiQuery.prepareForQuery('ai_pro');
7778
showAILoading('Asking Gemini Pro...');
7879
window._aiQuery.execute(resolvedQuery, 'ai_pro', forceShow, (response) => {
7980
renderAIResponse(response);
80-
});
81+
}, pastedImages);
8182
}
8283
} else if (input.startsWith('?')) {
8384
const query = input.slice(1).trim();
8485
const resolvedQuery = window._inputRouter.resolveAIFileRefsInQuery(query);
8586
if (query) {
87+
const pastedImages = collectPastedImages();
8688
window._aiQuery.prepareForQuery('ai');
8789
showAILoading();
8890
window._aiQuery.execute(resolvedQuery, 'ai', forceShow, (response) => {
8991
renderAIResponse(response);
90-
});
92+
}, pastedImages);
9193
}
9294
} else {
9395
const didExecute = executeSelected();
@@ -947,6 +949,8 @@ function clearResults() {
947949
renderPlaceholderState('empty', rawInput);
948950
// Clear conversation history
949951
if (window._aiQuery) window._aiQuery.clearConversation();
952+
// Clear any pasted image
953+
if (window._inputRouter) window._inputRouter.clearPastedImage();
950954
}
951955

952956
function smartScroll(aiContainer) {
@@ -1072,6 +1076,14 @@ function restoreAIArea() {
10721076
}
10731077
}
10741078

1079+
function collectPastedImages() {
1080+
if (!window._inputRouter) return [];
1081+
const img = window._inputRouter.getPastedImage();
1082+
if (!img) return [];
1083+
window._inputRouter.clearPastedImage();
1084+
return [img];
1085+
}
1086+
10751087
function escapeHtml(str) {
10761088
const div = document.createElement('div');
10771089
div.textContent = str;

0 commit comments

Comments
 (0)