Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
Maximize2,
SquareArrowOutUpRight,
Sparkles,
PenLine,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { CommandCategory } from '@dripnex/command-registry';
Expand Down Expand Up @@ -107,6 +108,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
Maximize2,
SquareArrowOutUpRight,
Sparkles,
PenLine,
};

const CATEGORY_ORDER: { category: CommandCategory; label: string }[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@
color: var(--text-primary);
}

.aiItem:disabled,
.aiSend:disabled,
.aiInput:disabled {
opacity: 0.6;
cursor: default;
}

.aiError {
margin: 0 0 6px;
padding: 0 8px;
color: var(--danger, #c44);
font-size: 12px;
}

.alertMark {
font-size: 13px;
font-weight: 700;
Expand Down
112 changes: 86 additions & 26 deletions apps/desktop/src/renderer/components/editor/SelectionToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,55 @@ import {
} from 'lucide-react';
import { insertGithubAlert, type GithubAlertKind } from '@dripnex/commands';
import { dispatchCommand, getEditorView } from '../../hooks/useCommandRegistry';
import { requestInlineEdit } from '../../editor/inlineAi/request';
import styles from './SelectionToolbar.module.css';

const AI_ACTIONS = [
{
id: 'mermaid',
label: 'Create a Mermaid diagram',
system:
'Convert the selection into a mermaid diagram. Reply with a mermaid fenced code block only.',
instruction: 'Convert the selection into a mermaid diagram.',
keepFence: true,
},
{
id: 'table',
label: 'Convert to Markdown table',
system:
'Convert the selection into a GitHub-flavored markdown table. Reply with the table only.',
instruction: 'Convert the selection into a GitHub-flavored markdown table.',
},
{
id: 'proofread',
label: 'Proofread',
system:
'Fix grammar and spelling. Keep the meaning and markdown. Reply with the revised text only.',
instruction: 'Fix grammar and spelling. Keep the meaning and markdown.',
},
{
id: 'reformat',
label: 'Reformat',
system:
'Clean the markdown structure. Do not change meaning. Reply with the revised markdown only.',
instruction: 'Clean the markdown structure. Do not change meaning.',
},
{
id: 'improve',
label: 'Improve writing',
system:
'Improve clarity and rhythm. Keep the author voice and markdown. Reply with the revised text only.',
instruction: 'Improve clarity and rhythm. Keep the author voice and markdown.',
},
{
id: 'summarize',
label: 'Summarize',
instruction: 'Condense the selection into a concise summary.',
},
{
id: 'bullets',
label: 'Convert to bullet list',
instruction: 'Rewrite the selection as a Markdown bulleted list.',
},
{
id: 'tasks',
label: 'Convert to task list',
instruction: 'Rewrite the selection as a Markdown task list (- [ ] items).',
},
{
id: 'headings',
label: 'Add headings',
instruction: 'Reorganize the selection with appropriate Markdown headings.',
},
] as const;

Expand All @@ -72,6 +89,8 @@ export function SelectionToolbar() {
const [aiOpen, setAiOpen] = useState(false);
const [alertOpen, setAlertOpen] = useState(false);
const [aiPrompt, setAiPrompt] = useState('');
const [aiBusy, setAiBusy] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const aiOpenRef = useRef(false);
aiOpenRef.current = aiOpen;
Expand All @@ -86,9 +105,8 @@ export function SelectionToolbar() {
const interacting =
aiOpenRef.current ||
(barRef.current !== null && barRef.current.contains(document.activeElement));
if (from === to) {
if (from === to && !aiOpenRef.current) {
setVisible(false);
setAiOpen(false);
setAlertOpen(false);
return;
}
Expand Down Expand Up @@ -137,12 +155,52 @@ export function SelectionToolbar() {
return () => document.removeEventListener('mousedown', onDown);
}, [visible]);

const runAi = (system: string, instruction?: string) => {
window.dispatchEvent(
new CustomEvent('dripnex:ai:edit', {
detail: { system, instruction },
})
);
useEffect(() => {
const onOpen = () => {
aiOpenRef.current = true;
setAlertOpen(false);
setAiError(null);
setAiOpen(true);
requestAnimationFrame(update);
};
window.addEventListener('dripnex:ai:open-inline', onOpen);
return () => window.removeEventListener('dripnex:ai:open-inline', onOpen);
}, [update]);

const runAi = async (instruction: string, keepFence = false) => {
const view = getEditorView();
if (!view || aiBusy) return;
const { from, to } = view.state.selection.main;
const initialContent = view.state.doc.toString();
setAiBusy(true);
setAiError(null);
const result = await requestInlineEdit({
content: initialContent,
from,
to,
title: '',
instruction,
keepFence,
});
setAiBusy(false);
if (!result.ok) {
setAiError(
result.reason === 'missing-key'
? 'Set up AI in Settings → AI'
: 'Could not edit the selection'
);
return;
}
if (view.state.doc.toString() !== initialContent) {
setAiError('Note changed — try again');
return;
}
if (from > view.state.doc.length || to > view.state.doc.length) return;
view.dispatch({
changes: { from, to, insert: result.text },
selection: { anchor: from, head: from + result.text.length },
});
Comment thread
tomymaritano marked this conversation as resolved.
view.focus();
setAiOpen(false);
setAiPrompt('');
};
Expand Down Expand Up @@ -304,30 +362,32 @@ export function SelectionToolbar() {
className={styles.aiForm}
onSubmit={event => {
event.preventDefault();
if (!aiPrompt.trim()) return;
runAi(
'Follow the user instruction on the selected markdown. Reply with the result only.',
aiPrompt.trim()
);
if (!aiPrompt.trim() || aiBusy) return;
void runAi(aiPrompt.trim());
}}
>
<input
className={styles.aiInput}
value={aiPrompt}
onChange={event => setAiPrompt(event.target.value)}
placeholder="Edit with AI…"
placeholder={aiBusy ? 'Editing…' : 'Edit with AI…'}
disabled={aiBusy}
autoFocus
/>
<button type="submit" className={styles.aiSend} aria-label="Run">
<button type="submit" className={styles.aiSend} aria-label="Run" disabled={aiBusy}>
<ArrowUp size={14} />
</button>
</form>
{aiError ? <p className={styles.aiError}>{aiError}</p> : null}
{AI_ACTIONS.map(action => (
<button
key={action.id}
type="button"
className={styles.aiItem}
onClick={() => runAi(action.system)}
disabled={aiBusy}
onClick={() =>
void runAi(action.instruction, 'keepFence' in action && action.keepFence)
}
>
{action.label}
</button>
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/src/renderer/editor/ai/collectChat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { LLMEvent } from '@dripnex/ai-core';
import type { AiAPI } from '../../../preload/api/ai';

export const AI_CHAT_TIMEOUT_MS = 8_000;

export type ChatApi = Pick<AiAPI, 'chat' | 'onEvent' | 'cancel'>;

/** Collect a streamed chat into one string. Does not log text (PHI). */
export function collectChat(
ai: ChatApi,
request: Parameters<AiAPI['chat']>[0],
timeoutMs = AI_CHAT_TIMEOUT_MS
): Promise<string | null> {
return new Promise((resolve, reject) => {
let requestId: string | null = null;
let text = '';
const buffered: Array<{ id: string; event: LLMEvent }> = [];
let settled = false;

function finish(value: string | null, error?: unknown): void {
if (settled) return;
settled = true;
clearTimeout(timeout);
off();
if (error) reject(error);
else resolve(value);
}

function apply(id: string, event: LLMEvent): void {
if (settled) return;
if (!requestId) {
buffered.push({ id, event });
return;
}
if (id !== requestId) return;
switch (event.type) {
case 'text':
text += event.delta;
break;
case 'error':
finish(null, new Error(event.error));
break;
case 'done':
finish(text);
break;
}
}

const off = ai.onEvent((id, raw) => apply(id, raw as LLMEvent));
const timeout = setTimeout(() => {
if (requestId) void ai.cancel(requestId);
finish(null);
}, timeoutMs);

void ai
.chat(request)
.then(result => {
if (settled) {
void ai.cancel(result.requestId);
return;
}
requestId = result.requestId;
for (const item of buffered) apply(item.id, item.event);
})
.catch(error => finish(null, error));
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { buildInlineEditPrompt, extractInlineReplacement, inlineEditContext } from '../parse';

describe('inlineEditContext', () => {
it('windows text around the selection', () => {
const content = 'aaa\nHello world\nzzz';
const from = content.indexOf('Hello');
const to = from + 'Hello'.length;
const ctx = inlineEditContext(content, from, to, 'Note', 'Proofread');
expect(ctx.selection).toBe('Hello');
expect(ctx.before).toBe('aaa\n');
expect(ctx.after).toBe(' world\nzzz');
expect(ctx.title).toBe('Note');
});
});

describe('buildInlineEditPrompt', () => {
it('includes the instruction and selection', () => {
const prompt = buildInlineEditPrompt({
title: 'Ship',
selection: 'teh list',
before: '',
after: '',
instruction: 'Proofread',
});
expect(prompt).toContain('TITLE: Ship');
expect(prompt).toContain('teh list');
expect(prompt).toContain('INSTRUCTION: Proofread');
});
});

describe('extractInlineReplacement', () => {
it('returns plain text', () => {
expect(extractInlineReplacement('Hello world', false)).toBe('Hello world');
});

it('unwraps a single markdown fence', () => {
expect(extractInlineReplacement('```markdown\nHello\n```', false)).toBe('Hello\n');
});

it('keeps a fence when asked', () => {
expect(extractInlineReplacement('```mermaid\ngraph TD\n```', true)).toBe(
'```mermaid\ngraph TD\n```'
);
});

it('preserves indented list items', () => {
expect(extractInlineReplacement(' - child', false)).toBe(' - child');
});

it('preserves indented code inside a wrapping fence', () => {
expect(extractInlineReplacement('```\n code\n```', false)).toBe(' code\n');
});

it('rejects empty output', () => {
expect(extractInlineReplacement(' ', false)).toBeNull();
});
});
Loading
Loading