From b36d0919a38e9d3b5c65ff0d8b7adead883d36ca Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Wed, 30 Sep 2026 20:17:57 +0530 Subject: [PATCH] feat(ai-assist): renderer controller + Cmd+K wiring (end-to-end) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createInlineAiController() glues the popover, the streaming bridge, and the CodeMirror editor together. Each Cmd+K opens a fresh request; the controller: - reads the active selection from the CodeMirror view - builds the rewrite/shorten/expand prompt via inline-assist.js - clears the selection so chunks can fill back in - dispatches each chunk as it arrives (typewriter into the editor) - on done: applies the final result (or restores selection on no-op) - on error: restores the original selection and shows error state - on Esc: cancels the in-flight request, restores the selection, hides the popover main-side streaming handler now reads the AI Assistant plugin's provider config from settings.json under plugins.ai-assistant.* — keys never cross the IPC boundary. renderer.js: - new getCreateInlineAiController() lazy loader - new ensureInlineAiController() factory - new Cmd+K / Ctrl+K keymap handler in the global keydown listener that resolves the active tab's CodeMirror view via tabManager 16 new controller tests covering show/handleKey wiring, streaming chunk application, done/error finalization, stale-request filtering, and detach cleanup. Amit Haridas --- src/main.js | 21 +- src/renderer.js | 33 +++ src/renderer/inline-ai-controller.js | 255 ++++++++++++++++++++++++ tests/inline-ai-controller.test.js | 287 +++++++++++++++++++++++++++ 4 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 src/renderer/inline-ai-controller.js create mode 100644 tests/inline-ai-controller.test.js diff --git a/src/main.js b/src/main.js index 24d787a..4673221 100644 --- a/src/main.js +++ b/src/main.js @@ -4910,19 +4910,26 @@ ipcMain.on('clear-recent-files', (event) => { ipcMain.handle('recent-files:get', () => getRecentFiles()); // Inline AI assist (v4.13.0): streaming proxy from renderer to provider. -// Renderer sends {requestId, request}; main streams chunks back via -// 'ai-assist-stream:chunk' events with the same requestId, plus a -// 'done' or 'error' terminal event. Renderer can abort via -// 'ai-assist-stream:cancel'. +// Renderer sends {requestId, request:{system, messages}}; main streams +// chunks back via 'ai-assist-stream:chunk' events with the same +// requestId, plus a 'done' or 'error' terminal event. Renderer can +// abort via 'ai-assist-stream:cancel'. Provider config comes from the +// AI Assistant plugin's settings (same path the plugin itself uses), +// so the renderer never sees API keys. const aiAssistStreams = new Map(); // requestId -> { abort, sender } ipcMain.on('ai-assist-stream:start', async (event, { requestId, request } = {}) => { if (!requestId || !request) return; const sender = event.sender; - let ac; try { - ac = new AbortController(); + const ac = new AbortController(); aiAssistStreams.set(requestId, { abort: () => ac.abort(), sender }); - for await (const chunk of completeStream(request, { signal: ac.signal })) { + const aiSettings = getAiAssistantSettings(); + const fullRequest = { + ...aiSettings, + system: request.system, + messages: request.messages, + }; + for await (const chunk of completeStream(fullRequest, { signal: ac.signal })) { if (ac.signal.aborted) break; sender.send('ai-assist-stream:chunk', { requestId, chunk }); } diff --git a/src/renderer.js b/src/renderer.js index 7cbf971..5e313a8 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -220,6 +220,13 @@ function getCreateQuickSwitcherOverlay() { require('./quick-switcher/quick-switcher-overlay').createQuickSwitcherOverlay; return _createQuickSwitcherOverlay; } +let _createInlineAiController; +function getCreateInlineAiController() { + if (!_createInlineAiController) + _createInlineAiController = require('./renderer/inline-ai-controller') + .createInlineAiController; + return _createInlineAiController; +} function getPrintPreview() { if (!_PrintPreview) _PrintPreview = require('./print-preview').PrintPreview; return _PrintPreview; @@ -2575,6 +2582,24 @@ document.addEventListener('DOMContentLoaded', async () => { }); return quickSwitcherOverlayInstance; } + + // Inline AI assist controller (Cmd+K on selected text) — lazy-instantiated + // so its dependency cost is paid only on first use. The active tab's + // CodeMirror view is sourced via tabManager each time Cmd+K fires, so + // tab switches automatically pick up the right editor. + let inlineAiControllerInstance = null; + function ensureInlineAiController() { + if (inlineAiControllerInstance) return inlineAiControllerInstance; + const create = getCreateInlineAiController(); + inlineAiControllerInstance = create({ + getEditorView: () => { + const active = tabManager.activeTab || (tabManager.activeTabId && tabManager.tabs.get(tabManager.activeTabId)); + return active ? active.editorView : null; + }, + electronAPI: window.electronAPI, + }); + return inlineAiControllerInstance; + } const pluginRegistry = new PluginRegistry({ sidebar: sidebarManager, commands: commandPalette, @@ -2987,6 +3012,14 @@ document.addEventListener('DOMContentLoaded', async () => { e.preventDefault(); commandPalette.open(); } + // Cmd+K / Ctrl+K — Inline AI assist (Rewrite / Shorten / Expand) on + // selected text. v4.13.0. Lazy-instantiated; uses the active tab's + // CodeMirror view for selection bounds and dispatch. + if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + ensureInlineAiController().handleKey(e); + return; + } // F11 — Zen Mode if (e.key === 'F11') { e.preventDefault(); diff --git a/src/renderer/inline-ai-controller.js b/src/renderer/inline-ai-controller.js new file mode 100644 index 0000000..d33bfea --- /dev/null +++ b/src/renderer/inline-ai-controller.js @@ -0,0 +1,255 @@ +/** + * @jest-environment jsdom + * + * Inline AI assist popover — DOM + interaction tests. + * + * Pure controller. Wires the floating popover to the editor's selection + * state and to the IPC streaming bridge. Holds no global state across + * invocations: each Cmd+K opens a fresh request with its own requestId. + * + * Public API: + * const ctl = createInlineAiController({ ... }); + * ctl.attach() // registers Cmd+K keymap + * ctl.detach() // tears down + * + * Or call ctl.showForSelection() directly from a custom shortcut. + * + * @module inline-ai-controller + */ + +const { createInlineAiPopover } = require('../ai-assist/inline-ai-popover'); +const { + buildAssistPrompt, + applyAssistResult, + MAX_SELECTION_CHARS, +} = require('../ai-assist/inline-assist'); + +/** + * @param {object} deps + * @param {() => any} deps.getEditorView - returns CodeMirror EditorView + * @param {object} deps.electronAPI - window.electronAPI shape + * @param {(key:string, e:Event) => boolean} [deps.onShortcut] - returns + * true when the keymap handler should claim the event. Used to keep + * Esc-cancel from firing when the popover isn't open. + */ +function createInlineAiController(deps) { + const { getEditorView, electronAPI, onShortcut } = deps; + if (typeof getEditorView !== 'function') { + throw new Error('createInlineAiController: getEditorView is required'); + } + if (!electronAPI || !electronAPI.aiAssist) { + throw new Error('createInlineAiController: electronAPI.aiAssist is required'); + } + + let popover = null; + let activeRequestId = null; + let activeSelection = null; // { from, to, original } + let activeAction = null; // 'rewrite' | 'shorten' | 'expand' + let unsubscribers = []; // [{ off }] + + function ensurePopover() { + if (popover) return popover; + popover = createInlineAiPopover(document.body, { + onAction: (action, selectionText) => runAssist(action, selectionText), + onCancel: () => cancelActive(), + onRetry: () => { + if (activeAction && activeSelection) { + runAssist(activeAction, activeSelection.original); + } + }, + }); + return popover; + } + + function getSelectionFromView() { + const view = getEditorView(); + if (!view) return null; + const sel = view.state.selection.main; + if (sel.empty) return null; + const text = view.state.sliceDoc(sel.from, sel.to); + return { from: sel.from, to: sel.to, original: text }; + } + + function rectForSelection(view, from, to) { + const start = view.coordsAtPos(from); + const end = view.coordsAtPos(to); + if (!start || !end) return null; + return { + top: Math.min(start.top, end.top), + bottom: Math.max(start.bottom, end.bottom), + left: Math.min(start.left, end.left), + width: Math.max(80, Math.abs(end.left - start.left)), + right: Math.max(start.right, end.right), + }; + } + + function showForSelection() { + const view = getEditorView(); + if (!view) return false; + const sel = getSelectionFromView(); + if (!sel) return false; + if (sel.original.length === 0 || sel.original.length > MAX_SELECTION_CHARS) { + return false; + } + const rect = rectForSelection(view, sel.from, sel.to); + if (!rect) return false; + + cancelActive({ silent: true }); + activeSelection = sel; + activeAction = null; + ensurePopover().show({ text: sel.original, rect }); + return true; + } + + async function runAssist(action, selectionText) { + const view = getEditorView(); + if (!view) return; + + let prompt; + try { + prompt = buildAssistPrompt(action, selectionText); + } catch (err) { + ensurePopover().setState('error', { message: err.message || 'Invalid selection.' }); + return; + } + + activeAction = action; + ensurePopover().setState('loading'); + + // Empty out the selection; the stream will fill it back in. + const insertAt = activeSelection ? activeSelection.from : view.state.selection.main.from; + view.dispatch({ + changes: { from: insertAt, to: activeSelection?.to ?? insertAt, insert: '' }, + selection: { anchor: insertAt }, + }); + + const requestId = `assist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + activeRequestId = requestId; + let accumulated = ''; + + const offChunk = electronAPI.on('ai-assist-stream:chunk', (_e, payload) => { + if (!payload || payload.requestId !== activeRequestId) return; + accumulated += payload.chunk || ''; + view.dispatch({ + changes: { from: insertAt, to: insertAt, insert: accumulated }, + selection: { anchor: insertAt + accumulated.length }, + }); + }); + const offDone = electronAPI.on('ai-assist-stream:done', (_e, payload) => { + if (!payload || payload.requestId !== activeRequestId) return; + finalizeSuccess(view, insertAt, accumulated, selectionText); + }); + const offError = electronAPI.on('ai-assist-stream:error', (_e, payload) => { + if (!payload || payload.requestId !== activeRequestId) return; + finalizeError(view, insertAt, accumulated, selectionText, payload.message); + }); + unsubscribers.push(offChunk, offDone, offError); + + electronAPI.aiAssist.start(requestId, { + system: prompt.system, + messages: prompt.messages, + }); + } + + function finalizeSuccess(view, insertAt, accumulated, selectionText) { + const finalText = applyAssistResult(selectionText, accumulated); + cleanupListeners(); + if (finalText === null) { + // No-op (empty / unchanged) — restore the original selection + view.dispatch({ + changes: { from: insertAt, to: insertAt + accumulated.length, insert: selectionText }, + selection: { anchor: insertAt + selectionText.length }, + }); + } else { + view.dispatch({ + changes: { from: insertAt, to: insertAt + accumulated.length, insert: finalText }, + selection: { anchor: insertAt + finalText.length }, + }); + } + ensurePopover().hide(); + activeRequestId = null; + activeSelection = null; + activeAction = null; + } + + function finalizeError(view, insertAt, accumulated, selectionText, message) { + cleanupListeners(); + // Restore the original selection on error + if (accumulated.length > 0) { + view.dispatch({ + changes: { from: insertAt, to: insertAt + accumulated.length, insert: selectionText }, + selection: { anchor: insertAt + selectionText.length }, + }); + } + ensurePopover().setState('error', { message: message || 'AI request failed.' }); + activeRequestId = null; + } + + function cancelActive({ silent = false } = {}) { + if (activeRequestId) { + electronAPI.aiAssist.cancel(activeRequestId); + } + cleanupListeners(); + const view = getEditorView(); + if (view && activeSelection) { + // Restore the original if we cleared the selection + const sel = view.state.selection.main; + if (sel.empty) { + view.dispatch({ + changes: { from: activeSelection.from, to: sel.anchor, insert: activeSelection.original }, + selection: { anchor: activeSelection.from + activeSelection.original.length }, + }); + } + } + if (popover && !silent) popover.hide(); + activeRequestId = null; + activeSelection = null; + activeAction = null; + } + + function cleanupListeners() { + for (const off of unsubscribers) { + try { + off(); + } catch { + // ignore + } + } + unsubscribers = []; + } + + function detach() { + cancelActive({ silent: true }); + if (popover) { + popover.destroy(); + popover = null; + } + } + + // Lightweight keymap helper. Returns true if the event was handled. + function handleKey(e) { + // Esc while popover is open → cancel + if (e.key === 'Escape' && popover && popover.isVisible()) { + e.preventDefault(); + cancelActive(); + return true; + } + // Cmd+K / Ctrl+K → open popover for current selection + const meta = e.metaKey || e.ctrlKey; + if (meta && (e.key === 'k' || e.key === 'K')) { + if (typeof onShortcut === 'function' && !onShortcut('cmd-k', e)) return false; + e.preventDefault(); + return showForSelection(); + } + return false; + } + + return { + showForSelection, + cancelActive, + handleKey, + detach, + }; +} + +module.exports = { createInlineAiController }; diff --git a/tests/inline-ai-controller.test.js b/tests/inline-ai-controller.test.js new file mode 100644 index 0000000..6ebae2d --- /dev/null +++ b/tests/inline-ai-controller.test.js @@ -0,0 +1,287 @@ +/** + * @jest-environment jsdom + * + * Inline AI assist controller — DOM + IPC + editor integration. + * + * The controller is the glue between the floating popover, the IPC + * streaming bridge, and the CodeMirror editor. These tests mock the + * editor (a minimal state/dispatch stand-in) and the electronAPI + * surface to verify the wiring without bringing up CodeMirror. + */ + +const { createInlineAiController } = require('../src/renderer/inline-ai-controller'); + +// --- Minimal CodeMirror stand-in --------------------------------------- + +function makeEditor(initialContent = '') { + const doc = { text: initialContent, length: initialContent.length }; + const sel = { from: 0, to: 0, empty: true }; + const dispatched = []; + const view = { + state: { + get doc() { + return doc; + }, + get selection() { + return { main: sel }; + }, + sliceDoc(from, to) { + return doc.text.slice(from, to); + }, + }, + dispatch(tr) { + dispatched.push(tr); + if (tr.changes) { + const { from, to, insert } = tr.changes; + doc.text = doc.text.slice(0, from) + insert + doc.text.slice(to); + doc.length = doc.text.length; + const anchor = tr.selection ? tr.selection.anchor : from + insert.length; + sel.from = anchor; + sel.to = anchor; + sel.anchor = anchor; + sel.head = anchor; + sel.empty = true; + } + }, + coordsAtPos(_pos) { + return { top: 100, bottom: 120, left: 50, right: 80 }; + }, + _dispatched: dispatched, + _setSelection(from, to) { + sel.from = from; + sel.to = to; + sel.empty = from === to; + }, + }; + return view; +} + +// --- Minimal electronAPI stand-in --------------------------------------- + +function makeApi() { + const listeners = { chunk: [], done: [], error: [] }; + const started = []; + const cancelled = []; + return { + api: { + aiAssist: { + start: (requestId, request) => started.push({ requestId, request }), + cancel: (requestId) => cancelled.push(requestId), + onChunk: (cb) => listeners.chunk.push(cb), + onDone: (cb) => listeners.done.push(cb), + onError: (cb) => listeners.error.push(cb), + }, + on: (channel, cb) => { + if (channel === 'ai-assist-stream:chunk') listeners.chunk.push(cb); + else if (channel === 'ai-assist-stream:done') listeners.done.push(cb); + else if (channel === 'ai-assist-stream:error') listeners.error.push(cb); + return () => {}; + }, + }, + _state: { listeners, started, cancelled }, + }; +} + +function fakeKeyEvent(init) { + return Object.assign({ preventDefault: () => {}, stopPropagation: () => {} }, init); +} + +function mount(opts = {}) { + const editor = opts.editor || makeEditor(opts.content || ''); + const { api, _state } = makeApi(); + const electronAPI = Object.assign(opts.electronAPI || {}, api); + const controller = createInlineAiController({ + getEditorView: () => editor, + electronAPI, + }); + return { controller, editor, electronAPI, _state }; +} + +afterEach(() => { + document.querySelectorAll('.inline-ai-popover').forEach((el) => el.remove()); +}); + +function getPopoverButton(action) { + const popovers = document.querySelectorAll('.inline-ai-popover'); + // Use the LAST (most recent) popover, which belongs to this test + const popover = popovers[popovers.length - 1]; + if (!popover) throw new Error('No popover mounted'); + return popover.querySelector(`button[data-action="${action}"]`); +} + +function emit(state, type, payload) { + for (const cb of state.listeners[type]) cb({}, payload); +} + +describe('createInlineAiController — showForSelection', () => { + test('returns false when no selection', () => { + const { controller } = mount({ content: 'hello world' }); + expect(controller.showForSelection()).toBe(false); + }); + + test('returns false when selection is too large', () => { + const editor = makeEditor('a'.repeat(9000)); + editor.state.selection.main = { from: 0, to: 9000, empty: false }; + const { controller } = mount({ editor }); + expect(controller.showForSelection()).toBe(false); + }); + + test('opens the popover when a valid selection exists', () => { + const editor = makeEditor('hello world'); + editor._setSelection(0, 5); + const { controller } = mount({ editor }); + expect(controller.showForSelection()).toBe(true); + expect(document.querySelector('.inline-ai-popover')).not.toBeNull(); + }); +}); + +describe('createInlineAiController — handleKey', () => { + test('Cmd+K opens the popover for the current selection', () => { + const editor = makeEditor('hello world'); + editor._setSelection(0, 5); + const { controller } = mount({ editor }); + const handled = controller.handleKey(fakeKeyEvent({ key: 'k', metaKey: true })); + expect(handled).toBe(true); + expect(document.querySelector('.inline-ai-popover')).not.toBeNull(); + }); + + test('Cmd+K with no selection is a no-op (returns false)', () => { + const { controller } = mount({ content: '' }); + const handled = controller.handleKey(fakeKeyEvent({ key: 'k', metaKey: true })); + expect(handled).toBe(false); + }); + + test('Esc while popover is open cancels and hides', () => { + const editor = makeEditor('hello world'); + editor._setSelection(0, 5); + const { controller } = mount({ editor }); + controller.showForSelection(); + const handled = controller.handleKey(fakeKeyEvent({ key: 'Escape' })); + expect(handled).toBe(true); + expect(document.querySelector('.inline-ai-popover.open')).toBeNull(); + }); + + test('Esc when popover is closed does nothing', () => { + const { controller } = mount(); + const handled = controller.handleKey(fakeKeyEvent({ key: 'Escape' })); + expect(handled).toBe(false); + }); + + test('non-shortcut keys return false', () => { + const { controller } = mount(); + expect(controller.handleKey(fakeKeyEvent({ key: 'a' }))).toBe(false); + }); +}); + +describe('createInlineAiController — streaming', () => { + function setupWithSelection(content = 'the quick brown fox', from = 0, to = 19) { + const editor = makeEditor(content); + editor._setSelection(from, to); + const m = mount({ editor }); + m.controller.showForSelection(); + return m; + } + + test('clicking an action starts a stream and clears the selection', () => { + const m = setupWithSelection(); + const rewriteBtn = getPopoverButton('rewrite'); + rewriteBtn.click(); + // selection should be replaced with empty insert (changes[0]) + expect(m.editor._dispatched[0].changes).toEqual({ from: 0, to: 19, insert: '' }); + // aiAssist.start should have been called + expect(m._state.started).toHaveLength(1); + expect(m._state.started[0].request.system).toMatch(/Rewrite/i); + expect(m._state.started[0].request.messages[0].content).toBe('the quick brown fox'); + }); + + test('chunks progressively replace the selection', () => { + const m = setupWithSelection(); + getPopoverButton('rewrite').click(); + const requestId = m._state.started[0].requestId; + + emit(m._state, 'chunk', { requestId, chunk: 'A ' }); + emit(m._state, 'chunk', { requestId, chunk: 'faster ' }); + emit(m._state, 'chunk', { requestId, chunk: 'fox' }); + + // The most recent dispatch should contain the accumulated text + const last = m.editor._dispatched[m.editor._dispatched.length - 1]; + expect(last.changes.insert).toBe('A faster fox'); + }); + + test('done event finalizes and hides the popover', () => { + const m = setupWithSelection(); + getPopoverButton('rewrite').click(); + const requestId = m._state.started[0].requestId; + + emit(m._state, 'chunk', { requestId, chunk: 'final text' }); + emit(m._state, 'done', { requestId }); + + expect(document.querySelector('.inline-ai-popover.open')).toBeNull(); + // editor text should now contain 'final text' + expect(m.editor.state.doc.text).toContain('final text'); + }); + + test('error event restores the original selection', () => { + const m = setupWithSelection('original text'); + getPopoverButton('rewrite').click(); + const requestId = m._state.started[0].requestId; + + emit(m._state, 'chunk', { requestId, chunk: 'partial' }); + emit(m._state, 'error', { requestId, message: 'boom' }); + + // popover should show error state + const popover = document.querySelector('.inline-ai-popover'); + expect(popover.dataset.state).toBe('error'); + // original selection restored (partial replaced with original) + expect(m.editor.state.doc.text).toContain('original text'); + expect(m.editor.state.doc.text).not.toContain('partial'); + }); + + test('stale events for an old requestId are ignored', () => { + const m = setupWithSelection(); + getPopoverButton('rewrite').click(); + const requestId = m._state.started[0].requestId; + + // Emit a chunk for a different requestId + emit(m._state, 'chunk', { requestId: 'old-id', chunk: 'should not apply' }); + // No additional dispatch beyond the initial clear should have happened + // for chunks targeting 'old-id' + const chunkDispatches = m.editor._dispatched.filter( + (d) => d.changes && d.changes.insert === 'should not apply' + ); + expect(chunkDispatches).toHaveLength(0); + + // Real chunk should still work + emit(m._state, 'chunk', { requestId, chunk: 'real' }); + const last = m.editor._dispatched[m.editor._dispatched.length - 1]; + expect(last.changes.insert).toBe('real'); + }); +}); + +describe('createInlineAiController — detach', () => { + test('detach removes DOM and clears state', () => { + const editor = makeEditor('hello world'); + editor._setSelection(0, 5); + const { controller } = mount({ editor }); + controller.showForSelection(); + controller.detach(); + expect(document.querySelector('.inline-ai-popover')).toBeNull(); + }); +}); + +describe('createInlineAiController — validation', () => { + test('throws when getEditorView is missing', () => { + expect(() => + createInlineAiController({ + electronAPI: { aiAssist: {} }, + getEditorView: undefined, + }) + ).toThrow(/getEditorView/); + }); + + test('throws when electronAPI.aiAssist is missing', () => { + expect(() => createInlineAiController({ getEditorView: () => null, electronAPI: {} })).toThrow( + /aiAssist/ + ); + }); +});