refactor: fix dynamic tooltip resizing and split autocomplete into SPR modules

This commit is contained in:
CREDO23 2026-04-02 20:19:16 +02:00
parent 6899134a20
commit 9c1d9357c4
12 changed files with 326 additions and 193 deletions

View file

@ -19,7 +19,8 @@ if (!setupDeepLinks()) {
registerIpcHandlers();
function getInitialPath(): string {
if (process.platform === 'darwin' && !allPermissionsGranted()) {
const granted = allPermissionsGranted();
if (process.platform === 'darwin' && !granted) {
return '/desktop/permissions';
}
return '/dashboard';

View file

@ -1,20 +1,19 @@
import { BrowserWindow, clipboard, ipcMain, screen, shell } from 'electron';
import path from 'path';
import { IPC_CHANNELS } from '../ipc/channels';
import { allPermissionsGranted } from './permissions';
import { getFieldContent, getFrontmostApp, hasAccessibilityPermission, simulatePaste } from './platform';
import { getServerPort } from './server';
import { getMainWindow } from './window';
import { clipboard, ipcMain, screen } from 'electron';
import { IPC_CHANNELS } from '../../ipc/channels';
import { getFrontmostApp, hasAccessibilityPermission, simulatePaste } from '../platform';
import { getMainWindow } from '../window';
import {
appendToBuffer, buildKeycodeMap, getBuffer, getBufferTrimmed,
getLastTrackedApp, removeLastChar, resetBuffer, resolveChar, setLastTrackedApp,
} from './keystroke-buffer';
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
const DEBOUNCE_MS = 600;
const TOOLTIP_WIDTH = 420;
const TOOLTIP_HEIGHT = 140;
let uIOhook: any = null;
let UiohookKey: any = {};
let IGNORED_KEYCODES: Set<number> = new Set();
let suggestionWindow: BrowserWindow | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let hookStarted = false;
let autocompleteEnabled = true;
@ -38,12 +37,8 @@ function loadUiohook(): boolean {
UiohookKey.F5, UiohookKey.F6, UiohookKey.F7, UiohookKey.F8,
UiohookKey.F9, UiohookKey.F10, UiohookKey.F11, UiohookKey.F12,
UiohookKey.PrintScreen,
UiohookKey.Insert, UiohookKey.Delete,
UiohookKey.Home, UiohookKey.End,
UiohookKey.PageUp, UiohookKey.PageDown,
UiohookKey.ArrowUp, UiohookKey.ArrowDown,
UiohookKey.ArrowLeft, UiohookKey.ArrowRight,
]);
buildKeycodeMap();
console.log('[autocomplete] uiohook-napi loaded');
return true;
} catch (err) {
@ -52,70 +47,6 @@ function loadUiohook(): boolean {
}
}
function destroySuggestion(): void {
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
suggestionWindow.close();
}
suggestionWindow = null;
}
function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } {
const display = screen.getDisplayNearestPoint({ x, y });
const { x: dx, y: dy, width: dw, height: dh } = display.workArea;
return {
x: Math.max(dx, Math.min(x, dx + dw - w)),
y: Math.max(dy, Math.min(y, dy + dh - h)),
};
}
function createSuggestionWindow(x: number, y: number): BrowserWindow {
destroySuggestion();
const pos = clampToScreen(x, y + 20, TOOLTIP_WIDTH, TOOLTIP_HEIGHT);
suggestionWindow = new BrowserWindow({
width: TOOLTIP_WIDTH,
height: TOOLTIP_HEIGHT,
x: pos.x,
y: pos.y,
frame: false,
transparent: true,
focusable: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: true,
type: 'panel',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
show: false,
});
suggestionWindow.loadURL(`http://localhost:${getServerPort()}/desktop/suggestion?t=${Date.now()}`);
suggestionWindow.once('ready-to-show', () => {
suggestionWindow?.showInactive();
});
suggestionWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://localhost')) {
return { action: 'allow' };
}
shell.openExternal(url);
return { action: 'deny' };
});
suggestionWindow.on('closed', () => {
suggestionWindow = null;
});
return suggestionWindow;
}
function clearDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
@ -128,10 +59,24 @@ function isSurfSenseWindow(): boolean {
return app === 'Electron' || app === 'SurfSense' || app === 'surfsense-desktop';
}
function onKeyDown(event: { keycode: number; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }): void {
function onKeyDown(event: {
keycode: number;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
}): void {
if (!autocompleteEnabled) return;
if (event.keycode === UiohookKey.Tab && suggestionWindow && !suggestionWindow.isDestroyed()) {
const currentApp = getFrontmostApp();
if (currentApp !== getLastTrackedApp()) {
resetBuffer();
setLastTrackedApp(currentApp);
}
const win = getSuggestionWindow();
if (event.keycode === UiohookKey.Tab && win && !win.isDestroyed()) {
if (pendingSuggestionText) {
acceptAndInject(pendingSuggestionText);
}
@ -139,7 +84,7 @@ function onKeyDown(event: { keycode: number; ctrlKey?: boolean; metaKey?: boolea
}
if (event.keycode === UiohookKey.Escape) {
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
if (win && !win.isDestroyed()) {
destroySuggestion();
pendingSuggestionText = '';
}
@ -147,11 +92,41 @@ function onKeyDown(event: { keycode: number; ctrlKey?: boolean; metaKey?: boolea
return;
}
if (IGNORED_KEYCODES.has(event.keycode)) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (isSurfSenseWindow()) return;
if (currentApp === 'Electron' || currentApp === 'SurfSense' || currentApp === 'surfsense-desktop') {
return;
}
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
if (event.ctrlKey || event.metaKey || event.altKey) {
resetBuffer();
clearDebounce();
return;
}
if (event.keycode === UiohookKey.Backspace) {
removeLastChar();
} else if (event.keycode === UiohookKey.Delete) {
// forward delete doesn't affect our trailing buffer
} else if (event.keycode === UiohookKey.Enter) {
appendToBuffer('\n');
} else if (event.keycode === UiohookKey.Space) {
appendToBuffer(' ');
} else if (
event.keycode === UiohookKey.ArrowLeft || event.keycode === UiohookKey.ArrowRight ||
event.keycode === UiohookKey.ArrowUp || event.keycode === UiohookKey.ArrowDown ||
event.keycode === UiohookKey.Home || event.keycode === UiohookKey.End ||
event.keycode === UiohookKey.PageUp || event.keycode === UiohookKey.PageDown
) {
resetBuffer();
clearDebounce();
return;
} else if (IGNORED_KEYCODES.has(event.keycode)) {
return;
} else {
const ch = resolveChar(event.keycode, !!event.shiftKey);
if (ch) appendToBuffer(ch);
}
if (win && !win.isDestroyed()) {
destroySuggestion();
}
@ -161,13 +136,16 @@ function onKeyDown(event: { keycode: number; ctrlKey?: boolean; metaKey?: boolea
}, DEBOUNCE_MS);
}
function onMouseClick(): void {
resetBuffer();
}
async function triggerAutocomplete(): Promise<void> {
if (!hasAccessibilityPermission()) return;
if (isSurfSenseWindow()) return;
const fieldContent = getFieldContent();
if (!fieldContent || !fieldContent.text.trim()) return;
if (fieldContent.text.trim().length < 5) return;
const text = getBufferTrimmed();
if (!text || text.length < 5) return;
sourceApp = getFrontmostApp();
savedClipboard = clipboard.readText();
@ -186,13 +164,16 @@ async function triggerAutocomplete(): Promise<void> {
}
win.webContents.once('did-finish-load', () => {
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
suggestionWindow.webContents.send(IPC_CHANNELS.AUTOCOMPLETE_CONTEXT, {
text: fieldContent.text,
cursorPosition: fieldContent.cursorPosition,
searchSpaceId,
});
}
const sw = getSuggestionWindow();
setTimeout(() => {
if (sw && !sw.isDestroyed()) {
sw.webContents.send(IPC_CHANNELS.AUTOCOMPLETE_CONTEXT, {
text: getBuffer(),
cursorPosition: getBuffer().length,
searchSpaceId,
});
}
}, 300);
});
}
@ -209,6 +190,7 @@ async function acceptAndInject(text: string): Promise<void> {
simulatePaste();
await new Promise((r) => setTimeout(r, 100));
clipboard.writeText(savedClipboard);
appendToBuffer(text);
} catch {
clipboard.writeText(savedClipboard);
}
@ -238,21 +220,16 @@ function registerIpcHandlers(): void {
export function registerAutocomplete(): void {
registerIpcHandlers();
if (!allPermissionsGranted()) {
console.log('[autocomplete] Permissions not granted — hook not started');
return;
}
if (!loadUiohook()) {
console.error('[autocomplete] Cannot start: uiohook-napi failed to load');
return;
}
uIOhook.on('keydown', onKeyDown);
uIOhook.on('click', onMouseClick);
try {
uIOhook.start();
hookStarted = true;
console.log('[autocomplete] uIOhook started');
} catch (err) {
console.error('[autocomplete] uIOhook.start() failed:', err);
}

View file

@ -0,0 +1,76 @@
const MAX_BUFFER_LENGTH = 4000;
const KEYCODE_TO_CHAR: Record<number, [string, string]> = {};
let keystrokeBuffer = '';
let lastTrackedApp = '';
export function buildKeycodeMap(): void {
const letters: [string, number][] = [
['q', 16], ['w', 17], ['e', 18], ['r', 19], ['t', 20],
['y', 21], ['u', 22], ['i', 23], ['o', 24], ['p', 25],
['a', 30], ['s', 31], ['d', 32], ['f', 33], ['g', 34],
['h', 35], ['j', 36], ['k', 37], ['l', 38],
['z', 44], ['x', 45], ['c', 46], ['v', 47],
['b', 48], ['n', 49], ['m', 50],
];
for (const [ch, code] of letters) {
KEYCODE_TO_CHAR[code] = [ch, ch.toUpperCase()];
}
const digits: [string, string, number][] = [
['1', '!', 2], ['2', '@', 3], ['3', '#', 4], ['4', '$', 5],
['5', '%', 6], ['6', '^', 7], ['7', '&', 8], ['8', '*', 9],
['9', '(', 10], ['0', ')', 11],
];
for (const [norm, shifted, code] of digits) {
KEYCODE_TO_CHAR[code] = [norm, shifted];
}
const punctuation: [string, string, number][] = [
[';', ':', 39], ['=', '+', 13], [',', '<', 51], ['-', '_', 12],
['.', '>', 52], ['/', '?', 53], ['`', '~', 41], ['[', '{', 26],
['\\', '|', 43], [']', '}', 27], ["'", '"', 40],
];
for (const [norm, shifted, code] of punctuation) {
KEYCODE_TO_CHAR[code] = [norm, shifted];
}
}
export function resetBuffer(): void {
keystrokeBuffer = '';
}
export function appendToBuffer(char: string): void {
keystrokeBuffer += char;
if (keystrokeBuffer.length > MAX_BUFFER_LENGTH) {
keystrokeBuffer = keystrokeBuffer.slice(-MAX_BUFFER_LENGTH);
}
}
export function removeLastChar(): void {
if (keystrokeBuffer.length > 0) {
keystrokeBuffer = keystrokeBuffer.slice(0, -1);
}
}
export function getBuffer(): string {
return keystrokeBuffer;
}
export function getBufferTrimmed(): string {
return keystrokeBuffer.trim();
}
export function getLastTrackedApp(): string {
return lastTrackedApp;
}
export function setLastTrackedApp(app: string): void {
lastTrackedApp = app;
}
export function resolveChar(keycode: number, shift: boolean): string | null {
const mapping = KEYCODE_TO_CHAR[keycode];
if (!mapping) return null;
return shift ? mapping[1] : mapping[0];
}

View file

@ -0,0 +1,103 @@
import { BrowserWindow, screen, shell } from 'electron';
import path from 'path';
import { getServerPort } from '../server';
const TOOLTIP_WIDTH = 420;
const TOOLTIP_HEIGHT = 38;
const MAX_HEIGHT = 400;
let suggestionWindow: BrowserWindow | null = null;
let resizeTimer: ReturnType<typeof setInterval> | null = null;
function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } {
const display = screen.getDisplayNearestPoint({ x, y });
const { x: dx, y: dy, width: dw, height: dh } = display.workArea;
return {
x: Math.max(dx, Math.min(x, dx + dw - w)),
y: Math.max(dy, Math.min(y, dy + dh - h)),
};
}
function stopResizePolling(): void {
if (resizeTimer) { clearInterval(resizeTimer); resizeTimer = null; }
}
function startResizePolling(win: BrowserWindow): void {
stopResizePolling();
let lastH = 0;
resizeTimer = setInterval(async () => {
if (!win || win.isDestroyed()) { stopResizePolling(); return; }
try {
const h: number = await win.webContents.executeJavaScript(
`document.body.scrollHeight`
);
if (h > 0 && h !== lastH) {
lastH = h;
const clamped = Math.min(h, MAX_HEIGHT);
const bounds = win.getBounds();
win.setBounds({ x: bounds.x, y: bounds.y, width: TOOLTIP_WIDTH, height: clamped });
}
} catch {}
}, 150);
}
export function getSuggestionWindow(): BrowserWindow | null {
return suggestionWindow;
}
export function destroySuggestion(): void {
stopResizePolling();
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
suggestionWindow.close();
}
suggestionWindow = null;
}
export function createSuggestionWindow(x: number, y: number): BrowserWindow {
destroySuggestion();
const pos = clampToScreen(x, y + 20, TOOLTIP_WIDTH, TOOLTIP_HEIGHT);
suggestionWindow = new BrowserWindow({
width: TOOLTIP_WIDTH,
height: TOOLTIP_HEIGHT,
x: pos.x,
y: pos.y,
frame: false,
transparent: true,
focusable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: true,
type: 'panel',
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
show: false,
});
suggestionWindow.loadURL(`http://localhost:${getServerPort()}/desktop/suggestion?t=${Date.now()}`);
suggestionWindow.once('ready-to-show', () => {
suggestionWindow?.showInactive();
if (suggestionWindow) startResizePolling(suggestionWindow);
});
suggestionWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://localhost')) {
return { action: 'allow' };
}
shell.openExternal(url);
return { action: 'deny' };
});
suggestionWindow.on('closed', () => {
stopResizePolling();
suggestionWindow = null;
});
return suggestionWindow;
}

View file

@ -19,20 +19,6 @@ export function getFrontmostApp(): string {
return '';
}
export function getSelectedText(): string {
try {
if (process.platform === 'darwin') {
return execSync(
'osascript -e \'tell application "System Events" to get value of attribute "AXSelectedText" of focused UI element of first application process whose frontmost is true\''
).toString().trim();
}
// Windows: no reliable accessibility API for selected text across apps
} catch {
return '';
}
return '';
}
export function simulateCopy(): void {
if (process.platform === 'darwin') {
execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'');
@ -58,38 +44,3 @@ export function hasAccessibilityPermission(): boolean {
if (process.platform !== 'darwin') return true;
return systemPreferences.isTrustedAccessibilityClient(false);
}
export interface FieldContent {
text: string;
cursorPosition: number;
}
export function getFieldContent(): FieldContent | null {
if (process.platform !== 'darwin') return null;
try {
const text = execSync(
'osascript -e \'tell application "System Events" to get value of attribute "AXValue" of focused UI element of first application process whose frontmost is true\'',
{ timeout: 500 }
).toString().trim();
let cursorPosition = text.length;
try {
const rangeStr = execSync(
'osascript -e \'tell application "System Events" to get value of attribute "AXSelectedTextRange" of focused UI element of first application process whose frontmost is true\'',
{ timeout: 500 }
).toString().trim();
const locationMatch = rangeStr.match(/location[:\s]*(\d+)/i);
if (locationMatch) {
cursorPosition = parseInt(locationMatch[1], 10);
}
} catch {
// Fall back to end of text
}
return { text, cursorPosition };
} catch {
return null;
}
}