diff --git a/src/main.js b/src/main.js index a2c5c43..a1a326c 100644 --- a/src/main.js +++ b/src/main.js @@ -1475,8 +1475,19 @@ function createMenu() { click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues'), }, { + // v4.13.0 — trigger the in-app updater (electron-updater). + // If the check throws (no app-update.yml in dev, no network), + // fall back to opening the Releases page in the browser so the + // user still has a way to check manually. label: 'Check for Updates', - click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases'), + click: async () => { + try { + await ensureAutoUpdater().check(); + } catch (err) { + console.warn('Check-for-updates failed; falling back to releases page:', err.message); + shell.openExternal('https://github.com/amitwh/markdown-converter/releases'); + } + }, }, ], }, @@ -5153,6 +5164,16 @@ ipcMain.handle('ai-assistant:status', async () => { }; }); +// v4.13.0 — Inline AI first-use confirmation. Returns a small payload +// (provider + model name; never the key) for the renderer's confirm +// dialog. The renderer is responsible for asking the user. +ipcMain.handle('ai-assistant:confirm-info', async () => { + const settings = getAiAssistantSettings(); + const provider = settings.provider || 'unknown'; + const model = settings.model || AiProviders.PROVIDER_DEFAULTS[provider]?.defaultModel || ''; + return { provider, model }; +}); + /** * Run a chat completion. The payload is {system?, messages:[{role,content}]} * with provider settings taken from the store, never from the caller — a diff --git a/src/preload.js b/src/preload.js index 2ee3bff..9a74d1d 100644 --- a/src/preload.js +++ b/src/preload.js @@ -44,6 +44,9 @@ const ALLOWED_SEND_CHANNELS = [ 'ai-assistant:complete', 'ai-assistant:status', + // Inline AI assist confirm helpers + 'ai-assistant:confirm-info', + // v4.13.0 — Inline AI assist streaming (Cmd+K in editor) 'ai-assist-stream:start', 'ai-assist-stream:cancel', @@ -597,6 +600,13 @@ contextBridge.exposeInMainWorld('electronAPI', { onStatus: (cb) => ipcRenderer.on('updates:status', (_e, p) => cb(p)), }, + // v4.13.0 — Inline AI first-use confirmation. Returns a small + // user-safe payload (no keys) describing what the AI request will do, + // for the renderer to surface in a confirm dialog. + aiAssistant: { + confirmInfo: () => ipcRenderer.invoke('ai-assistant:confirm-info'), + }, + getAppVersion: () => ipcRenderer.invoke('get-app-version'), }); diff --git a/src/renderer.js b/src/renderer.js index aa3b847..7e65102 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -2604,9 +2604,20 @@ document.addEventListener('DOMContentLoaded', async () => { // CodeMirror view is sourced via tabManager each time Cmd+K fires, so // tab switches automatically pick up the right editor. let inlineAiControllerInstance = null; + let inlineAiProviderLabel = 'your AI provider'; function ensureInlineAiController() { if (inlineAiControllerInstance) return inlineAiControllerInstance; const create = getCreateInlineAiController(); + // Fetch provider label once (used in the first-use confirm dialog). + window.electronAPI.aiAssistant + .confirmInfo() + .then((info) => { + if (info && info.provider) { + inlineAiProviderLabel = `${info.provider}${info.model ? ` (${info.model})` : ''}`; + } + }) + .catch(() => {}); + inlineAiControllerInstance = create({ getEditorView: () => { const active = @@ -2615,6 +2626,18 @@ document.addEventListener('DOMContentLoaded', async () => { return active ? active.editorView : null; }, electronAPI: window.electronAPI, + // v4.13.0 — first-use confirmation. Once per session, then never + // re-prompts. Uses native window.confirm for v1; can be upgraded + // to a custom modal later without changing the controller. + getProviderLabel: () => inlineAiProviderLabel, + confirmFirstUse: ({ providerLabel, selectionLength, action }) => { + const verb = action === 'rewrite' ? 'Rewrite' : action === 'shorten' ? 'Shorten' : 'Expand'; + return window.confirm( + `${verb} ${selectionLength} characters using ${providerLabel}?\n\n` + + 'This sends your selected text to the AI provider. API charges may apply ' + + 'according to your provider plan. You will not be prompted again this session.' + ); + }, }); return inlineAiControllerInstance; } diff --git a/src/renderer/inline-ai-controller.js b/src/renderer/inline-ai-controller.js index d33bfea..2c9fbe3 100644 --- a/src/renderer/inline-ai-controller.js +++ b/src/renderer/inline-ai-controller.js @@ -33,7 +33,7 @@ const { * Esc-cancel from firing when the popover isn't open. */ function createInlineAiController(deps) { - const { getEditorView, electronAPI, onShortcut } = deps; + const { getEditorView, electronAPI, onShortcut, confirmFirstUse, getProviderLabel } = deps; if (typeof getEditorView !== 'function') { throw new Error('createInlineAiController: getEditorView is required'); } @@ -46,6 +46,7 @@ function createInlineAiController(deps) { let activeSelection = null; // { from, to, original } let activeAction = null; // 'rewrite' | 'shorten' | 'expand' let unsubscribers = []; // [{ off }] + let firstUseConfirmed = false; // session-scoped — never re-prompts after OK function ensurePopover() { if (popover) return popover; @@ -113,6 +114,25 @@ function createInlineAiController(deps) { return; } + // First-use confirmation — only on the first action of the session, + // and only when the renderer supplied a confirmFirstUse callback. + if (!firstUseConfirmed && typeof confirmFirstUse === 'function') { + const providerLabel = + typeof getProviderLabel === 'function' ? getProviderLabel() : 'your configured AI provider'; + const ok = confirmFirstUse({ + providerLabel, + selectionLength: selectionText.length, + action, + }); + if (!ok) { + ensurePopover().setState('error', { + message: 'Cancelled — first-use confirmation declined.', + }); + return; + } + firstUseConfirmed = true; + } + activeAction = action; ensurePopover().setState('loading'); diff --git a/tests/inline-ai-controller.test.js b/tests/inline-ai-controller.test.js index 6ebae2d..a8e130a 100644 --- a/tests/inline-ai-controller.test.js +++ b/tests/inline-ai-controller.test.js @@ -269,6 +269,75 @@ describe('createInlineAiController — detach', () => { }); }); +describe('createInlineAiController — first-use confirmation', () => { + test('confirmFirstUse is called with provider label and selection length', () => { + const confirmFirstUse = jest.fn().mockReturnValue(true); + const editor = makeEditor('hello world longer'); + editor._setSelection(0, 5); + const { api } = makeApi(); + const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({ + getEditorView: () => editor, + electronAPI: api, + getProviderLabel: () => 'openai (gpt-4o)', + confirmFirstUse, + }); + controller.showForSelection(); + getPopoverButton('rewrite').click(); + expect(confirmFirstUse).toHaveBeenCalledTimes(1); + expect(confirmFirstUse).toHaveBeenCalledWith( + expect.objectContaining({ + providerLabel: 'openai (gpt-4o)', + selectionLength: 5, // selection is 'hello' (5 chars) + action: 'rewrite', + }) + ); + }); + + test('declining the confirm aborts and surfaces an error state', () => { + const confirmFirstUse = jest.fn().mockReturnValue(false); + const editor = makeEditor('hello world'); + editor._setSelection(0, 5); + const { api, _state } = makeApi(); + const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({ + getEditorView: () => editor, + electronAPI: api, + confirmFirstUse, + }); + controller.showForSelection(); + getPopoverButton('rewrite').click(); + expect(_state.started).toHaveLength(0); + const popovers = document.querySelectorAll('.inline-ai-popover'); + expect(popovers[popovers.length - 1].dataset.state).toBe('error'); + }); + + test('confirm is only asked once per session', () => { + const confirmFirstUse = jest.fn().mockReturnValue(true); + const editor = makeEditor('hello world'); + editor._setSelection(0, 5); + const { api } = makeApi(); + const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({ + getEditorView: () => editor, + electronAPI: api, + confirmFirstUse, + }); + controller.showForSelection(); + getPopoverButton('rewrite').click(); + // Reset selection for a second action — confirm should NOT be asked again + editor._setSelection(0, 5); + getPopoverButton('shorten').click(); + expect(confirmFirstUse).toHaveBeenCalledTimes(1); + }); + + test('no confirmFirstUse dep means no prompt (still streams)', () => { + const editor = makeEditor('hello world'); + editor._setSelection(0, 5); + const { controller, _state } = mount({ editor }); + controller.showForSelection(); + getPopoverButton('rewrite').click(); + expect(_state.started).toHaveLength(1); + }); +}); + describe('createInlineAiController — validation', () => { test('throws when getEditorView is missing', () => { expect(() =>