@bobfrankston/mailx 1.0.100 → 1.0.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/app.js CHANGED
@@ -809,6 +809,23 @@ optEditorTiptap?.addEventListener("change", () => {
809
809
  if (optEditorTiptap.checked)
810
810
  saveEditorSetting("tiptap");
811
811
  });
812
+ // ── AI autocomplete toggle ──
813
+ const optAutocomplete = document.getElementById("opt-autocomplete");
814
+ // Load current autocomplete setting
815
+ fetch("/api/autocomplete/settings").then(r => r.json()).then(ac => {
816
+ if (optAutocomplete)
817
+ optAutocomplete.checked = ac.enabled || false;
818
+ }).catch(() => { });
819
+ optAutocomplete?.addEventListener("change", () => {
820
+ fetch("/api/autocomplete/settings").then(r => r.json()).then(ac => {
821
+ ac.enabled = optAutocomplete.checked;
822
+ fetch("/api/autocomplete/settings", {
823
+ method: "POST",
824
+ headers: { "Content-Type": "application/json" },
825
+ body: JSON.stringify(ac),
826
+ });
827
+ }).catch(() => { });
828
+ });
812
829
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
813
830
  fetch("/api/version").then(r => r.json()).then(d => {
814
831
  const el = document.getElementById("app-version");
@@ -261,3 +261,19 @@ body {
261
261
  margin-top: var(--gap-md);
262
262
  color: var(--color-text-muted);
263
263
  }
264
+
265
+ /* Ghost text autocomplete overlay */
266
+ .ghost-text {
267
+ position: absolute;
268
+ color: var(--color-text-muted);
269
+ font-style: italic;
270
+ opacity: 0.5;
271
+ pointer-events: none;
272
+ white-space: pre-wrap;
273
+ z-index: 10;
274
+ font-family: inherit;
275
+ font-size: inherit;
276
+ line-height: inherit;
277
+ max-width: 60ch;
278
+ }
279
+ .ql-editor, .tt-content .tiptap { position: relative; }
@@ -41,14 +41,15 @@ async function loadEditorAssets(type) {
41
41
  }
42
42
  // ── Determine editor type from settings ──
43
43
  let editorType = "quill";
44
+ let appSettings = null;
44
45
  try {
45
46
  const res = await fetch("/api/version");
46
47
  if (res.ok) {
47
48
  // Check settings for editor preference
48
49
  const settingsRes = await fetch("/api/settings");
49
50
  if (settingsRes.ok) {
50
- const settings = await settingsRes.json();
51
- if (settings.ui?.editor === "tiptap")
51
+ appSettings = await settingsRes.json();
52
+ if (appSettings.ui?.editor === "tiptap")
52
53
  editorType = "tiptap";
53
54
  }
54
55
  }
@@ -65,6 +66,15 @@ const toInput = document.getElementById("compose-to");
65
66
  const ccInput = document.getElementById("compose-cc");
66
67
  const bccInput = document.getElementById("compose-bcc");
67
68
  const subjectInput = document.getElementById("compose-subject");
69
+ // ── AI ghost text autocomplete ──
70
+ if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !== "off") {
71
+ import("./ghost-text.js").then(({ initGhostText }) => {
72
+ initGhostText(editor, {
73
+ getSubject: () => subjectInput.value,
74
+ getTo: () => toInput.value,
75
+ }, { debounceMs: appSettings.autocomplete.debounceMs || 600 });
76
+ }).catch(() => { });
77
+ }
68
78
  /** Populate the From dropdown with accounts */
69
79
  function populateFromSelect(accounts, selectedId) {
70
80
  fromSelect.innerHTML = "";
@@ -34,7 +34,21 @@ function createQuillEditor(container) {
34
34
  setCursor(pos) {
35
35
  q.setSelection(pos, 0);
36
36
  },
37
- root: q.root
37
+ root: q.root,
38
+ getScrollContainer() {
39
+ return q.root;
40
+ },
41
+ onContentChange(handler) {
42
+ q.on("text-change", handler);
43
+ },
44
+ onKeyDown(handler) {
45
+ q.root.addEventListener("keydown", handler);
46
+ },
47
+ insertTextAtCursor(text) {
48
+ const sel = q.getSelection();
49
+ if (sel)
50
+ q.insertText(sel.index, text);
51
+ }
38
52
  };
39
53
  }
40
54
  // ── tiptap ──
@@ -147,7 +161,19 @@ async function createTiptapEditor(container) {
147
161
  setCursor(pos) {
148
162
  ed.commands.focus("start");
149
163
  },
150
- root: editorEl
164
+ root: editorEl,
165
+ getScrollContainer() {
166
+ return editorEl;
167
+ },
168
+ onContentChange(handler) {
169
+ ed.on("update", handler);
170
+ },
171
+ onKeyDown(handler) {
172
+ editorEl.addEventListener("keydown", handler);
173
+ },
174
+ insertTextAtCursor(text) {
175
+ ed.commands.insertContent(text);
176
+ }
151
177
  };
152
178
  }
153
179
  // ── Factory ──
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Ghost text autocomplete — shows AI suggestions as translucent overlay at cursor.
3
+ * Tab accepts, Escape dismisses, any other key dismisses and re-triggers after debounce.
4
+ * Editor-agnostic: works with both Quill and tiptap via MailxEditor interface.
5
+ */
6
+ import { autocomplete } from "../lib/api-client.js";
7
+ let ghostEl = null;
8
+ let currentSuggestion = null;
9
+ let debounceTimer = null;
10
+ let abortController = null;
11
+ let activeEditor = null;
12
+ let debounceMs = 600;
13
+ function dismiss() {
14
+ currentSuggestion = null;
15
+ if (ghostEl) {
16
+ ghostEl.remove();
17
+ ghostEl = null;
18
+ }
19
+ }
20
+ function positionGhost(editor, text) {
21
+ dismiss();
22
+ const sel = window.getSelection();
23
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed)
24
+ return;
25
+ const range = sel.getRangeAt(0);
26
+ let rect = range.getBoundingClientRect();
27
+ // Collapsed range may return zero-width rect — use a temp span to measure
28
+ if (rect.width === 0 && rect.height === 0) {
29
+ const span = document.createElement("span");
30
+ span.textContent = "\u200B"; // zero-width space
31
+ range.insertNode(span);
32
+ rect = span.getBoundingClientRect();
33
+ span.remove();
34
+ // Restore selection
35
+ sel.removeAllRanges();
36
+ sel.addRange(range);
37
+ }
38
+ const container = editor.getScrollContainer();
39
+ const containerRect = container.getBoundingClientRect();
40
+ ghostEl = document.createElement("span");
41
+ ghostEl.className = "ghost-text";
42
+ ghostEl.textContent = text;
43
+ ghostEl.style.top = `${rect.top - containerRect.top + container.scrollTop}px`;
44
+ ghostEl.style.left = `${rect.left - containerRect.left + container.scrollLeft}px`;
45
+ container.appendChild(ghostEl);
46
+ currentSuggestion = text;
47
+ }
48
+ function requestSuggestion(editor, context) {
49
+ // Cancel any in-flight request
50
+ if (abortController) {
51
+ abortController.abort();
52
+ abortController = null;
53
+ }
54
+ const bodyText = editor.getText();
55
+ if (!bodyText || bodyText.trim().length < 10)
56
+ return; // too short to suggest
57
+ abortController = new AbortController();
58
+ const signal = abortController.signal;
59
+ autocomplete({
60
+ subject: context.getSubject(),
61
+ to: context.getTo(),
62
+ bodyText,
63
+ cursorOffset: bodyText.length,
64
+ }, signal).then((result) => {
65
+ if (signal.aborted)
66
+ return;
67
+ if (result.suggestion) {
68
+ positionGhost(editor, result.suggestion);
69
+ }
70
+ }).catch((e) => {
71
+ if (e.name === "AbortError")
72
+ return;
73
+ // Silently ignore autocomplete errors
74
+ });
75
+ }
76
+ export function initGhostText(editor, context, options) {
77
+ activeEditor = editor;
78
+ if (options?.debounceMs)
79
+ debounceMs = options.debounceMs;
80
+ // Debounced content change → request suggestion
81
+ editor.onContentChange(() => {
82
+ dismiss();
83
+ if (debounceTimer)
84
+ clearTimeout(debounceTimer);
85
+ debounceTimer = setTimeout(() => {
86
+ requestSuggestion(editor, context);
87
+ }, debounceMs);
88
+ });
89
+ // Key handler: Tab accepts, Escape dismisses
90
+ editor.onKeyDown((e) => {
91
+ if (!currentSuggestion)
92
+ return;
93
+ if (e.key === "Tab") {
94
+ e.preventDefault();
95
+ e.stopPropagation();
96
+ const text = currentSuggestion;
97
+ dismiss();
98
+ editor.insertTextAtCursor(text);
99
+ return;
100
+ }
101
+ if (e.key === "Escape") {
102
+ e.preventDefault();
103
+ e.stopPropagation();
104
+ dismiss();
105
+ return;
106
+ }
107
+ // Any other key: dismiss (new suggestion will come after debounce)
108
+ dismiss();
109
+ });
110
+ // Dismiss on blur/scroll
111
+ editor.root.addEventListener("blur", dismiss);
112
+ editor.getScrollContainer().addEventListener("scroll", dismiss);
113
+ }
114
+ export function destroyGhostText() {
115
+ dismiss();
116
+ if (debounceTimer)
117
+ clearTimeout(debounceTimer);
118
+ if (abortController)
119
+ abortController.abort();
120
+ activeEditor = null;
121
+ }
122
+ //# sourceMappingURL=ghost-text.js.map
package/client/index.html CHANGED
@@ -34,6 +34,8 @@
34
34
  <span class="tb-menu-label">Editor</span>
35
35
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
36
36
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
37
+ <hr class="tb-menu-sep">
38
+ <label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
37
39
  </div>
38
40
  </div>
39
41
  <span id="app-version" class="app-version"></span>
@@ -222,6 +222,18 @@ export function connectEvents() {
222
222
  };
223
223
  }
224
224
  }
225
+ // ── Autocomplete ──
226
+ export function autocomplete(body, signal) {
227
+ if (hasIPC)
228
+ return mailxapi.autocomplete?.(body);
229
+ return api("/autocomplete", { method: "POST", body: JSON.stringify(body), signal });
230
+ }
231
+ export function getAutocompleteSettings() {
232
+ return api("/autocomplete/settings");
233
+ }
234
+ export function saveAutocompleteSettings(settings) {
235
+ return api("/autocomplete/settings", { method: "POST", body: JSON.stringify(settings) });
236
+ }
225
237
  // Legacy exports for backward compatibility
226
238
  export const connectWebSocket = connectEvents;
227
239
  export const onWsEvent = onEvent;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.100",
3
+ "version": "1.0.102",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -84,6 +84,27 @@ export function createApiRouter(db, imapManager) {
84
84
  res.status(500).json({ error: e.message });
85
85
  }
86
86
  });
87
+ // ── Autocomplete ──
88
+ router.post("/autocomplete", async (req, res) => {
89
+ try {
90
+ res.json(await svc.autocomplete(req.body));
91
+ }
92
+ catch (e) {
93
+ res.status(500).json({ error: e.message, suggestion: "" });
94
+ }
95
+ });
96
+ router.get("/autocomplete/settings", (req, res) => {
97
+ res.json(svc.getAutocompleteSettings());
98
+ });
99
+ router.post("/autocomplete/settings", (req, res) => {
100
+ try {
101
+ svc.saveAutocompleteSettings(req.body);
102
+ res.json({ ok: true });
103
+ }
104
+ catch (e) {
105
+ res.status(500).json({ error: e.message });
106
+ }
107
+ });
87
108
  // ── Send ──
88
109
  router.post("/send", async (req, res) => {
89
110
  try {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-api",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { MailxDB } from "@bobfrankston/mailx-store";
7
7
  import { ImapManager } from "@bobfrankston/mailx-imap";
8
- import type { Folder } from "@bobfrankston/mailx-types";
8
+ import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings } from "@bobfrankston/mailx-types";
9
9
  export declare function sanitizeHtml(html: string): {
10
10
  html: string;
11
11
  hasRemoteContent: boolean;
@@ -56,5 +56,8 @@ export declare class MailxService {
56
56
  provider: string;
57
57
  mode: string;
58
58
  };
59
+ getAutocompleteSettings(): AutocompleteSettings;
60
+ saveAutocompleteSettings(settings: AutocompleteSettings): void;
61
+ autocomplete(req: AutocompleteRequest): Promise<AutocompleteResponse>;
59
62
  }
60
63
  //# sourceMappingURL=index.d.ts.map
@@ -3,7 +3,7 @@
3
3
  * Pure business logic — no HTTP, no Express.
4
4
  * Both the Express API (mailx-api) and the Android bridge call these functions.
5
5
  */
6
- import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
6
+ import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
7
7
  import { simpleParser } from "mailparser";
8
8
  // ── Sanitize ──
9
9
  export function sanitizeHtml(html) {
@@ -466,5 +466,98 @@ export class MailxService {
466
466
  getStorageInfo() {
467
467
  return getStorageInfo();
468
468
  }
469
+ // ── Autocomplete ──
470
+ getAutocompleteSettings() {
471
+ return loadAutocomplete();
472
+ }
473
+ saveAutocompleteSettings(settings) {
474
+ saveAutocomplete(settings);
475
+ }
476
+ async autocomplete(req) {
477
+ const acConfig = loadAutocomplete();
478
+ if (!acConfig.enabled || acConfig.provider === "off") {
479
+ return { suggestion: "" };
480
+ }
481
+ const bodyText = req.bodyText || "";
482
+ const prompt = `You are an email writing assistant. Complete the following email naturally.\nOutput ONLY the completion text — no explanation, no greeting repeat.\nKeep it to 1-2 sentences max.\n\nTo: ${req.to || ""}\nSubject: ${req.subject || ""}\n\n${bodyText}`;
483
+ try {
484
+ if (acConfig.provider === "ollama") {
485
+ const truncated = bodyText.slice(-500);
486
+ const ollamaPrompt = prompt.replace(bodyText, truncated);
487
+ const res = await fetch(`${acConfig.ollamaUrl}/api/generate`, {
488
+ method: "POST",
489
+ headers: { "Content-Type": "application/json" },
490
+ body: JSON.stringify({
491
+ model: acConfig.ollamaModel,
492
+ prompt: ollamaPrompt,
493
+ stream: false,
494
+ options: { num_predict: acConfig.maxTokens },
495
+ }),
496
+ });
497
+ if (!res.ok)
498
+ return { suggestion: "" };
499
+ const data = await res.json();
500
+ return { suggestion: trimSuggestion(data.response || "") };
501
+ }
502
+ if (acConfig.provider === "claude") {
503
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
504
+ method: "POST",
505
+ headers: {
506
+ "Content-Type": "application/json",
507
+ "x-api-key": acConfig.cloudApiKey,
508
+ "anthropic-version": "2023-06-01",
509
+ },
510
+ body: JSON.stringify({
511
+ model: acConfig.cloudModel,
512
+ max_tokens: acConfig.maxTokens,
513
+ messages: [{ role: "user", content: prompt }],
514
+ }),
515
+ });
516
+ if (!res.ok)
517
+ return { suggestion: "" };
518
+ const data = await res.json();
519
+ const text = data.content?.[0]?.text || "";
520
+ return { suggestion: trimSuggestion(text) };
521
+ }
522
+ if (acConfig.provider === "openai") {
523
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
524
+ method: "POST",
525
+ headers: {
526
+ "Content-Type": "application/json",
527
+ "Authorization": `Bearer ${acConfig.cloudApiKey}`,
528
+ },
529
+ body: JSON.stringify({
530
+ model: acConfig.cloudModel,
531
+ max_tokens: acConfig.maxTokens,
532
+ messages: [
533
+ { role: "system", content: "You are an email writing assistant. Output ONLY the completion text." },
534
+ { role: "user", content: prompt },
535
+ ],
536
+ }),
537
+ });
538
+ if (!res.ok)
539
+ return { suggestion: "" };
540
+ const data = await res.json();
541
+ const text = data.choices?.[0]?.message?.content || "";
542
+ return { suggestion: trimSuggestion(text) };
543
+ }
544
+ }
545
+ catch (e) {
546
+ console.error(` [autocomplete] ${acConfig.provider} error: ${e.message}`);
547
+ }
548
+ return { suggestion: "" };
549
+ }
550
+ }
551
+ /** Trim suggestion: remove leading/trailing whitespace, cap at sentence boundary */
552
+ function trimSuggestion(text) {
553
+ let s = text.trim();
554
+ if (!s)
555
+ return "";
556
+ // Cap at 2 sentences
557
+ const sentences = s.match(/[^.!?]*[.!?]/g);
558
+ if (sentences && sentences.length > 2) {
559
+ s = sentences.slice(0, 2).join("").trim();
560
+ }
561
+ return s;
469
562
  }
470
563
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-service",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * The old settings.jsonc is still supported for backward compatibility.
17
17
  */
18
- import type { MailxSettings, AccountConfig } from "@bobfrankston/mailx-types";
18
+ import type { MailxSettings, AccountConfig, AutocompleteSettings } from "@bobfrankston/mailx-types";
19
19
  declare const LOCAL_DIR: string;
20
20
  declare function getSharedDir(): string;
21
21
  /** Read a file via cloud API (when filesystem mount not available) */
@@ -41,7 +41,18 @@ declare const DEFAULT_PREFERENCES: {
41
41
  intervalMinutes: number;
42
42
  historyDays: number;
43
43
  };
44
+ autocomplete: {
45
+ enabled: boolean;
46
+ provider: "ollama";
47
+ ollamaUrl: string;
48
+ ollamaModel: string;
49
+ cloudApiKey: string;
50
+ cloudModel: string;
51
+ debounceMs: number;
52
+ maxTokens: number;
53
+ };
44
54
  };
55
+ declare const DEFAULT_AUTOCOMPLETE: AutocompleteSettings;
45
56
  declare const DEFAULT_ALLOWLIST: {
46
57
  senders: string[];
47
58
  domains: string[];
@@ -54,7 +65,11 @@ export declare function saveAccounts(accounts: AccountConfig[]): void;
54
65
  /** Load preferences (shared + local overrides, with legacy fallback) */
55
66
  export declare function loadPreferences(): typeof DEFAULT_PREFERENCES;
56
67
  /** Save preferences */
57
- export declare function savePreferences(prefs: typeof DEFAULT_PREFERENCES): void;
68
+ export declare function savePreferences(prefs: any): void;
69
+ /** Load autocomplete settings */
70
+ export declare function loadAutocomplete(): AutocompleteSettings;
71
+ /** Save autocomplete settings */
72
+ export declare function saveAutocomplete(settings: AutocompleteSettings): void;
58
73
  /** Load remote content allow-list */
59
74
  export declare function loadAllowlist(): typeof DEFAULT_ALLOWLIST;
60
75
  /** Save allow-list */
@@ -74,5 +89,5 @@ export declare function initLocalConfig(sharedDir?: string, storePath?: string):
74
89
  declare const DEFAULT_SETTINGS: MailxSettings;
75
90
  /** Get historyDays for an account: per-account override > system override > shared default */
76
91
  export declare function getHistoryDays(accountId?: string): number;
77
- export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, LOCAL_DIR };
92
+ export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, DEFAULT_AUTOCOMPLETE, LOCAL_DIR };
78
93
  //# sourceMappingURL=index.d.ts.map
@@ -319,6 +319,26 @@ const DEFAULT_PREFERENCES = {
319
319
  intervalMinutes: 5,
320
320
  historyDays: 30,
321
321
  },
322
+ autocomplete: {
323
+ enabled: false,
324
+ provider: "ollama",
325
+ ollamaUrl: "http://localhost:11434",
326
+ ollamaModel: "qwen2.5-coder:1.5b",
327
+ cloudApiKey: "",
328
+ cloudModel: "claude-sonnet-4-20250514",
329
+ debounceMs: 600,
330
+ maxTokens: 60,
331
+ },
332
+ };
333
+ const DEFAULT_AUTOCOMPLETE = {
334
+ enabled: false,
335
+ provider: "ollama",
336
+ ollamaUrl: "http://localhost:11434",
337
+ ollamaModel: "qwen2.5-coder:1.5b",
338
+ cloudApiKey: "",
339
+ cloudModel: "claude-sonnet-4-20250514",
340
+ debounceMs: 600,
341
+ maxTokens: 60,
322
342
  };
323
343
  const DEFAULT_ALLOWLIST = {
324
344
  senders: [],
@@ -391,12 +411,24 @@ export function loadPreferences() {
391
411
  return {
392
412
  ui: { ...DEFAULT_PREFERENCES.ui, ...shared.ui },
393
413
  sync: { ...DEFAULT_PREFERENCES.sync, ...shared.sync },
414
+ autocomplete: { ...DEFAULT_AUTOCOMPLETE, ...shared.autocomplete },
394
415
  };
395
416
  }
396
417
  /** Save preferences */
397
418
  export function savePreferences(prefs) {
398
419
  saveFile("preferences.jsonc", prefs);
399
420
  }
421
+ /** Load autocomplete settings */
422
+ export function loadAutocomplete() {
423
+ const prefs = loadPreferences();
424
+ return prefs.autocomplete;
425
+ }
426
+ /** Save autocomplete settings */
427
+ export function saveAutocomplete(settings) {
428
+ const prefs = loadPreferences();
429
+ prefs.autocomplete = settings;
430
+ savePreferences(prefs);
431
+ }
400
432
  /** Load remote content allow-list */
401
433
  export function loadAllowlist() {
402
434
  return loadFile("allowlist.jsonc", DEFAULT_ALLOWLIST);
@@ -426,6 +458,7 @@ export function loadSettings() {
426
458
  accounts,
427
459
  ui: prefs.ui,
428
460
  sync: prefs.sync,
461
+ autocomplete: prefs.autocomplete,
429
462
  store: {
430
463
  basePath: localConfig.storePath || DEFAULT_STORE_PATH,
431
464
  compressionBoundaryDays: 365,
@@ -512,6 +545,7 @@ const DEFAULT_SETTINGS = {
512
545
  accounts: [],
513
546
  ui: DEFAULT_PREFERENCES.ui,
514
547
  sync: DEFAULT_PREFERENCES.sync,
548
+ autocomplete: DEFAULT_AUTOCOMPLETE,
515
549
  store: { basePath: DEFAULT_STORE_PATH, compressionBoundaryDays: 365 },
516
550
  };
517
551
  /** Get historyDays for an account: per-account override > system override > shared default */
@@ -525,5 +559,5 @@ export function getHistoryDays(accountId) {
525
559
  const prefs = loadPreferences();
526
560
  return prefs.sync.historyDays || 0;
527
561
  }
528
- export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, LOCAL_DIR };
562
+ export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, DEFAULT_AUTOCOMPLETE, LOCAL_DIR };
529
563
  //# sourceMappingURL=index.js.map
@@ -196,6 +196,26 @@ export interface MailxSettings {
196
196
  basePath: string; /** Where message bodies are stored */
197
197
  compressionBoundaryDays: number; /** Messages older than this get compressed */
198
198
  };
199
+ autocomplete?: AutocompleteSettings;
200
+ }
201
+ export interface AutocompleteSettings {
202
+ enabled: boolean;
203
+ provider: "ollama" | "claude" | "openai" | "off";
204
+ ollamaUrl: string;
205
+ ollamaModel: string;
206
+ cloudApiKey: string;
207
+ cloudModel: string;
208
+ debounceMs: number;
209
+ maxTokens: number;
210
+ }
211
+ export interface AutocompleteRequest {
212
+ subject: string;
213
+ to: string;
214
+ bodyText: string;
215
+ cursorOffset: number;
216
+ }
217
+ export interface AutocompleteResponse {
218
+ suggestion: string;
199
219
  }
200
220
  /** Body storage backend interface -- implementations are swappable */
201
221
  export interface MessageStore {