Skip to content

Commit d8bf8da

Browse files
authored
feat(desktop): add next-edit suggestions (#517)
## Summary - Next-edit suggestions (Inkdrop NES): ghost text at the cursor from the configured AI provider. - **Manual** by default (\`Alt+\\\`); **Automatic** after idle; **Disabled** off. Settings → AI. - Tab accepts, Escape dismisses. \`editor:trigger-nes\` / \`accept-nes\` / \`dismiss-nes\`. - Prompt and completion are not logged (PHI). ## Test plan - [x] \`extractNesInsertion\` / prompt tests - [x] settings default \`nesMode: manual\` - [x] renderer typecheck - [ ] Set an AI provider, Alt+\\ on a half-written list item, Tab to accept - [ ] Escape dismisses; Tab still indents when no ghost is showing - [ ] Automatic mode fires after a pause; Disabled is a no-op <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added AI-powered next-edit suggestions in the Markdown editor. * Suggestions can be triggered manually, generated automatically after idle time, or disabled. * Added keyboard shortcuts and command palette actions to trigger, accept, or dismiss suggestions. * Added a new settings option to choose the suggestion mode. * **Bug Fixes** * Pressing Escape now dismisses active edit suggestions appropriately. * **Tests** * Added coverage for suggestion parsing and settings persistence. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 96c725a commit d8bf8da

17 files changed

Lines changed: 574 additions & 4 deletions

File tree

apps/desktop/src/renderer/components/CommandPalette.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
ArrowRight,
3333
Maximize2,
3434
SquareArrowOutUpRight,
35+
Sparkles,
3536
} from 'lucide-react';
3637
import type { LucideIcon } from 'lucide-react';
3738
import type { CommandCategory } from '@dripnex/command-registry';
@@ -105,6 +106,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
105106
ArrowRight,
106107
Maximize2,
107108
SquareArrowOutUpRight,
109+
Sparkles,
108110
};
109111

110112
const CATEGORY_ORDER: { category: CommandCategory; label: string }[] = [

apps/desktop/src/renderer/components/MarkdownEditor.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { htmlToGfmMarkdown } from '../utils/htmlToMarkdown';
6666
import { scrollBehavior } from '../utils/motion';
6767
import { useEditorBufferStore } from '../stores/editorBufferStore';
6868
import { useSettingsStore, selectEditor } from '../stores/settings';
69+
import { createNesExtension, requestNesCompletion } from '../editor/nes';
6970
import { setEditorView } from '../hooks/useCommandRegistry';
7071
import { emojiShortcodeCompletions } from '../plugins/emojiShortcodes';
7172
import {
@@ -98,6 +99,7 @@ interface MarkdownEditorProps {
9899
onReady?: () => void;
99100
/** Current note ID (for excluding from wikilink autocomplete) */
100101
noteId?: string;
102+
noteTitle?: string;
101103
notebookId?: string | null;
102104
/** Callback to get resolved embed URL (for inline image preview) */
103105
getEmbedUrl?: (target: string) => string | null;
@@ -143,6 +145,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
143145
placeholder = 'Start writing...',
144146
onReady,
145147
noteId,
148+
noteTitle,
146149
notebookId,
147150
getEmbedUrl,
148151
onWikilinkClick,
@@ -156,6 +159,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
156159
const viewRef = useRef<EditorView | null>(null);
157160
const onChangeRef = useRef(onChange);
158161
const noteIdRef = useRef(noteId);
162+
const noteTitleRef = useRef(noteTitle ?? '');
159163
const getEmbedUrlRef = useRef(getEmbedUrl);
160164
const onWikilinkClickRef = useRef(onWikilinkClick);
161165
const onWikilinkHoverRef = useRef(onWikilinkHover);
@@ -310,6 +314,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
310314
// Keep refs updated
311315
onChangeRef.current = onChange;
312316
noteIdRef.current = noteId;
317+
noteTitleRef.current = noteTitle ?? '';
313318
getEmbedUrlRef.current = getEmbedUrl;
314319
onWikilinkClickRef.current = onWikilinkClick;
315320
onWikilinkHoverRef.current = onWikilinkHover;
@@ -435,6 +440,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
435440
// Plugin extensions compartment (reconfigured dynamically)
436441
pluginExtensionCompartment.of([]),
437442

443+
createNesExtension({
444+
getMode: () => useSettingsStore.getState().settings.ai.nesMode ?? 'manual',
445+
getTitle: () => noteTitleRef.current,
446+
complete: requestNesCompletion,
447+
}),
448+
438449
// Update listener
439450
EditorView.updateListener.of(update => {
440451
if (update.docChanged) {

apps/desktop/src/renderer/components/NoteEditor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ export function NoteEditor({
576576
onChange={handleChange}
577577
onReady={onEditorReady}
578578
noteId={note.id}
579+
noteTitle={note.title}
579580
notebookId={note.notebookId}
580581
getEmbedUrl={getEmbedUrl}
581582
onWikilinkClick={onWikilinkClick}

apps/desktop/src/renderer/components/editorTheme.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export function createEditorTheme(fontSize: number, fontFamily: string, lineHeig
6969
'.cm-line': {
7070
padding: '0 4px',
7171
},
72+
'.cm-nes-ghost': {
73+
opacity: '0.45',
74+
pointerEvents: 'none',
75+
color: 'var(--text-muted)',
76+
},
7277
'&.cm-focused .cm-matchingBracket': {
7378
backgroundColor: 'var(--cm-bracket-match)',
7479
outline: 'none',
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { buildNesPrompt, extractNesInsertion, nesLineContext } from '../parse';
3+
4+
describe('nesLineContext', () => {
5+
it('splits the cursor line and windows surrounding text', () => {
6+
const content = 'alpha\nHello |\nomega';
7+
const cursor = content.indexOf('|');
8+
const doc = content.slice(0, cursor) + content.slice(cursor + 1);
9+
const ctx = nesLineContext(doc, cursor, 'Note');
10+
expect(ctx).toEqual({
11+
prefix: 'Hello ',
12+
suffix: '',
13+
before: 'alpha\n',
14+
after: '\nomega',
15+
title: 'Note',
16+
});
17+
});
18+
});
19+
20+
describe('buildNesPrompt', () => {
21+
it('marks the cursor on the editable line', () => {
22+
const prompt = buildNesPrompt({
23+
prefix: '- [ ] ',
24+
suffix: '',
25+
before: '# Tasks\n',
26+
after: '',
27+
title: 'Ship',
28+
});
29+
expect(prompt).toContain('TITLE: Ship');
30+
expect(prompt).toContain('- [ ] ⟦CURSOR⟧');
31+
expect(prompt).toContain('# Tasks');
32+
});
33+
});
34+
35+
describe('extractNesInsertion', () => {
36+
it('strips a reproduced prefix and suffix', () => {
37+
expect(extractNesInsertion('Hello world!', 'Hello ', '!')).toBe('world');
38+
});
39+
40+
it('accepts a bare continuation when the prefix is empty', () => {
41+
expect(extractNesInsertion('next item', '', '')).toBe('next item');
42+
});
43+
44+
it('rejects output that does not reproduce the prefix', () => {
45+
expect(extractNesInsertion('The capital of France', 'Hello ', '')).toBeNull();
46+
});
47+
48+
it('skips a fenced block wrapper', () => {
49+
expect(extractNesInsertion('```\nHello world\n```', 'Hello ', '')).toBe('world');
50+
});
51+
52+
it('rejects empty insertions', () => {
53+
expect(extractNesInsertion('Hello ', 'Hello ', '')).toBeNull();
54+
});
55+
});
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import {
2+
Annotation,
3+
Facet,
4+
Prec,
5+
StateEffect,
6+
StateField,
7+
type Extension,
8+
} from '@codemirror/state';
9+
import { Decoration, EditorView, WidgetType, keymap, type DecorationSet } from '@codemirror/view';
10+
import type { NesMode } from './types';
11+
12+
export interface NesSuggestion {
13+
text: string;
14+
pos: number;
15+
}
16+
17+
export interface NesCompleteInput {
18+
title: string;
19+
content: string;
20+
cursor: number;
21+
}
22+
23+
export interface NesExtensionOptions {
24+
getMode: () => NesMode;
25+
getTitle: () => string;
26+
complete: (input: NesCompleteInput) => Promise<string | null>;
27+
idleMs?: number;
28+
}
29+
30+
const setNesEffect = StateEffect.define<NesSuggestion | null>();
31+
const nesAccepted = Annotation.define<boolean>();
32+
33+
export const nesOptionsFacet = Facet.define<NesExtensionOptions, NesExtensionOptions | null>({
34+
combine(values) {
35+
return values[0] ?? null;
36+
},
37+
});
38+
39+
class NesGhostWidget extends WidgetType {
40+
constructor(readonly text: string) {
41+
super();
42+
}
43+
44+
eq(other: NesGhostWidget): boolean {
45+
return other.text === this.text;
46+
}
47+
48+
toDOM(): HTMLElement {
49+
const span = document.createElement('span');
50+
span.className = 'cm-nes-ghost';
51+
span.textContent = this.text;
52+
span.setAttribute('aria-hidden', 'true');
53+
return span;
54+
}
55+
56+
ignoreEvent(): boolean {
57+
return true;
58+
}
59+
}
60+
61+
function decorationsFor(suggestion: NesSuggestion | null): DecorationSet {
62+
if (!suggestion?.text) return Decoration.none;
63+
return Decoration.set([
64+
Decoration.widget({
65+
widget: new NesGhostWidget(suggestion.text),
66+
side: 1,
67+
}).range(suggestion.pos),
68+
]);
69+
}
70+
71+
export const nesField = StateField.define<NesSuggestion | null>({
72+
create: () => null,
73+
update(value, tr) {
74+
for (const effect of tr.effects) {
75+
if (effect.is(setNesEffect)) return effect.value;
76+
}
77+
if (tr.docChanged || tr.selection) return null;
78+
return value;
79+
},
80+
provide: field => [
81+
EditorView.decorations.from(field, decorationsFor),
82+
EditorView.editorAttributes.from(field, value =>
83+
value ? { class: 'cm-nes-active' } : ({} as Record<string, string>)
84+
),
85+
],
86+
});
87+
88+
export function hasNesSuggestion(view: EditorView): boolean {
89+
return view.state.field(nesField, false) != null;
90+
}
91+
92+
export function dismissNes(view: EditorView): boolean {
93+
if (!hasNesSuggestion(view)) return false;
94+
view.dispatch({ effects: setNesEffect.of(null) });
95+
return true;
96+
}
97+
98+
export function acceptNes(view: EditorView): boolean {
99+
const suggestion = view.state.field(nesField, false);
100+
if (!suggestion) return false;
101+
const pos = suggestion.pos;
102+
if (pos < 0 || pos > view.state.doc.length) {
103+
view.dispatch({ effects: setNesEffect.of(null) });
104+
return false;
105+
}
106+
view.dispatch({
107+
changes: { from: pos, insert: suggestion.text },
108+
selection: { anchor: pos + suggestion.text.length },
109+
effects: setNesEffect.of(null),
110+
annotations: nesAccepted.of(true),
111+
userEvent: 'input',
112+
});
113+
return true;
114+
}
115+
116+
const generations = new WeakMap<EditorView, number>();
117+
118+
function nextGeneration(view: EditorView): number {
119+
const n = (generations.get(view) ?? 0) + 1;
120+
generations.set(view, n);
121+
return n;
122+
}
123+
124+
export function triggerNes(view: EditorView): boolean {
125+
const options = view.state.facet(nesOptionsFacet);
126+
if (!options || options.getMode() === 'disabled') return false;
127+
void requestSuggestion(view, options);
128+
return true;
129+
}
130+
131+
async function requestSuggestion(view: EditorView, options: NesExtensionOptions): Promise<void> {
132+
const cursor = view.state.selection.main.head;
133+
const doc = view.state.doc.toString();
134+
const generation = nextGeneration(view);
135+
const insertion = await options.complete({
136+
title: options.getTitle(),
137+
content: doc,
138+
cursor,
139+
});
140+
if (generations.get(view) !== generation) return;
141+
if (!view.dom.isConnected) return;
142+
if (!insertion) return;
143+
if (view.state.doc.toString() !== doc) return;
144+
if (view.state.selection.main.head !== cursor) return;
145+
view.dispatch({
146+
effects: setNesEffect.of({ text: insertion, pos: cursor }),
147+
});
148+
}
149+
150+
export function createNesExtension(options: NesExtensionOptions): Extension {
151+
const idleMs = options.idleMs ?? 500;
152+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
153+
154+
function clearIdle(): void {
155+
if (idleTimer) {
156+
clearTimeout(idleTimer);
157+
idleTimer = null;
158+
}
159+
}
160+
161+
return [
162+
nesOptionsFacet.of(options),
163+
nesField,
164+
Prec.high(
165+
keymap.of([
166+
{ key: 'Tab', run: acceptNes },
167+
{ key: 'Escape', run: dismissNes },
168+
])
169+
),
170+
EditorView.updateListener.of(update => {
171+
if (update.docChanged || update.selectionSet) {
172+
nextGeneration(update.view);
173+
}
174+
if (!update.docChanged) return;
175+
if (update.transactions.some(tr => tr.annotation(nesAccepted))) return;
176+
clearIdle();
177+
if (options.getMode() !== 'automatic') return;
178+
const view = update.view;
179+
idleTimer = setTimeout(() => {
180+
idleTimer = null;
181+
if (options.getMode() !== 'automatic') return;
182+
if (!view.dom.isConnected) return;
183+
if (view.state.selection.main.empty) {
184+
void requestSuggestion(view, options);
185+
}
186+
}, idleMs);
187+
}),
188+
EditorView.domEventObservers({
189+
blur() {
190+
clearIdle();
191+
},
192+
}),
193+
];
194+
}
195+
196+
export function setNesSuggestion(view: EditorView, text: string, pos: number): void {
197+
view.dispatch({ effects: setNesEffect.of({ text, pos }) });
198+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type { NesMode } from './types';
2+
export { extractNesInsertion, buildNesPrompt, nesLineContext } from './parse';
3+
export {
4+
createNesExtension,
5+
triggerNes,
6+
acceptNes,
7+
dismissNes,
8+
hasNesSuggestion,
9+
nesField,
10+
setNesSuggestion,
11+
} from './extension';
12+
export type { NesExtensionOptions, NesCompleteInput } from './extension';
13+
export { requestNesCompletion } from './request';

0 commit comments

Comments
 (0)