feat(ai-assist): renderer controller + Cmd+K wiring (end-to-end)

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
This commit is contained in:
Amit Haridas
2026-09-30 20:17:57 +05:30
parent 72ca2cb46f
commit b36d0919a3
4 changed files with 589 additions and 7 deletions
+14 -7
View File
@@ -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 });
}
+33
View File
@@ -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();
+255
View File
@@ -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 };