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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Most AI writing tools send your drafts to a remote backend. Draftside doesn't. E

## Features

- **Inline ghost-text completions** — pause briefly, press `Tab` to accept
- **Inline ghost-text completions** — pause briefly, press `Tab` to accept; choose Short, Medium, or Long in **More actions → Completion length**
- **Alternate wording** — highlight a phrase, click to swap
- **Rewrite** — shape a passage with the local Rewriter API
- **Classify** — read your own draft (form, intent, stance, friction, next move)
Expand Down Expand Up @@ -52,7 +52,7 @@ npx playwright install chromium
npm run test:e2e
```

The tests run the real editor in Chromium with deterministic responses at the built-in AI API boundary. They exercise ghost text, Tab acceptance, dismissal, and saved draft persistence without requiring a Gemini Nano download. Use `PLAYWRIGHT_BASE_URL` to test an already running development or production build, or `PLAYWRIGHT_PORT` to change the test server port.
The tests run the real editor in Chromium with deterministic responses at the built-in AI API boundary. They exercise ghost text, Tab acceptance, dismissal, saved draft persistence, and completion length preferences without requiring a Gemini Nano download. Use `PLAYWRIGHT_BASE_URL` to test an already running development or production build, or `PLAYWRIGHT_PORT` to change the test server port.

## Production build

Expand Down
33 changes: 33 additions & 0 deletions src/ai/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { CompletionLength } from "../lib/types";

export const COMPLETION_LENGTH_OPTIONS = [
{ value: "short", label: "Short", description: "3–10 words" },
{ value: "medium", label: "Medium", description: "15–30 words" },
{ value: "long", label: "Long", description: "40–80 words" },
] as const satisfies ReadonlyArray<{ value: CompletionLength; label: string; description: string }>;

export const COMPLETION_LENGTH_CONFIG: Record<CompletionLength, {
instruction: string;
maxSentences: number;
maxWords: number;
maxChars: number;
}> = {
short: {
instruction: "Continue only the unfinished sentence at the cursor. Keep it subtle: 3 to 10 words, at most one short clause.",
maxSentences: 1,
maxWords: 12,
maxChars: 96,
},
medium: {
instruction: "Continue the writing at the cursor with 15 to 30 words, completing the current sentence and adding one more sentence if useful. Return at most two sentences.",
maxSentences: 2,
maxWords: 40,
maxChars: 320,
},
long: {
instruction: "Continue the writing at the cursor with a developed paragraph of 40 to 80 words. Complete the current sentence and carry the idea forward in up to four sentences, matching the writer's voice.",
maxSentences: 4,
maxWords: 100,
maxChars: 800,
},
};
7 changes: 4 additions & 3 deletions src/ai/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChatMessage } from "../lib/types";
import type { ChatMessage, CompletionLength } from "../lib/types";
import { COMPLETION_LENGTH_CONFIG } from "./completion";
import { getCompletionPrefix } from "./text";

function formatChatHistory(history: ChatMessage[]) {
Expand Down Expand Up @@ -68,8 +69,8 @@ Draft:
"""${text}"""`;
}

export function buildCompletionPrompt(before: string) {
return `You are an inline autocomplete engine for a private writing editor. Continue only the unfinished sentence at the cursor. Return the completed sentence, starting with the exact unchanged prefix below, then add 3 to 10 words, at most one short clause. The cursor can be inside a word: finish that word without inserting a space. If the last word is already complete, separate the next word with a space. Preserve all existing spaces. No surrounding quotes, markdown, JSON, labels, or commentary.
export function buildCompletionPrompt(before: string, completionLength: CompletionLength = "short") {
return `You are an inline autocomplete engine for a private writing editor. ${COMPLETION_LENGTH_CONFIG[completionLength].instruction} Return the continuation, starting with the exact unchanged prefix below. The requested length counts only new words after that prefix. The cursor can be inside a word: finish that word without inserting a space. If the last word is already complete, separate the next word with a space. Preserve all existing spaces. No surrounding quotes, markdown, JSON, labels, or commentary.

Examples:
Prefix: "The quick brow"
Expand Down
13 changes: 13 additions & 0 deletions src/components/editor/DraftsideEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ export default function DraftsideEditor() {
vaultLocked,
activeSessionId: activeSession?.id,
completionTick,
completionLength: prefs.completionLength,
createLanguageModelTask,
});

Expand Down Expand Up @@ -607,6 +608,16 @@ export default function DraftsideEditor() {
[setPrefs],
);

const setCompletionLength = useCallback(
(completionLength: typeof prefs.completionLength) => {
setPrefs((current) => ({ ...current, completionLength }));
setPostMenuOpen(false);
// Focus synchronously so the completion effect sees the editor ready for the new length.
editor?.view.focus();
},
[editor, setPrefs],
);

const swapTranslation = useCallback(() => {
const source = aiTools.translationSource;
if (!source || source === prefs.translationTarget) return;
Expand Down Expand Up @@ -754,6 +765,8 @@ export default function DraftsideEditor() {
toggleFocusMode={toggleFocusMode}
editorFont={prefs.editorFont}
setEditorFont={setEditorFont}
completionLength={prefs.completionLength}
setCompletionLength={setCompletionLength}
aiSidebarOpen={prefs.aiSidebarOpen}
toggleAiSidebar={toggleAiSidebar}
onOpenDrafts={() => setDraftsDrawerOpen(true)}
Expand Down
9 changes: 6 additions & 3 deletions src/components/editor/hooks/useGhostCompletion.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/core";
import type { CompletionLength } from "../../../lib/types";
import {
cleanGhostCompletion,
clearEditorGhostCompletion,
Expand All @@ -17,6 +18,7 @@ interface UseGhostCompletionOptions {
vaultLocked: boolean;
activeSessionId?: string | null;
completionTick: number;
completionLength: CompletionLength;
createLanguageModelTask: (signal?: AbortSignal) => Promise<LanguageModel>;
}

Expand All @@ -29,6 +31,7 @@ export function useGhostCompletion({
vaultLocked,
activeSessionId,
completionTick,
completionLength,
createLanguageModelTask,
}: UseGhostCompletionOptions) {
const [ghostCompletionText, setGhostCompletionText] = useState("");
Expand Down Expand Up @@ -95,7 +98,7 @@ export function useGhostCompletion({
if (completionRequestRef.current !== requestId) return;

const result = await model.prompt([
{ role: "user", content: buildCompletionPrompt(liveContext.before) },
{ role: "user", content: buildCompletionPrompt(liveContext.before, completionLength) },
]);

const currentContext = getCompletionContext(editor);
Expand All @@ -109,7 +112,7 @@ export function useGhostCompletion({
return;
}

const completion = cleanGhostCompletion(result, currentContext);
const completion = cleanGhostCompletion(result, currentContext, completionLength);
if (!completion) return;

setEditorGhostCompletion(editor, completion, currentContext.pos);
Expand All @@ -128,7 +131,7 @@ export function useGhostCompletion({
if (completionTimerRef.current) window.clearTimeout(completionTimerRef.current);
completionRequestRef.current += 1;
};
}, [editor, enabled, expressionTargetActive, postMenuOpen, vaultLocked, selectionEmpty, activeSessionId, completionTick, createLanguageModelTask]);
}, [editor, enabled, expressionTargetActive, postMenuOpen, vaultLocked, selectionEmpty, activeSessionId, completionTick, completionLength, createLanguageModelTask]);

return {
ghostCompletionText,
Expand Down
5 changes: 4 additions & 1 deletion src/components/editor/hooks/useUiPrefs.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from "react";
import type { AiTab, EditorFont, EditorUiPrefs } from "../../../lib/types";
import type { AiTab, CompletionLength, EditorFont, EditorUiPrefs } from "../../../lib/types";
import { readStoredUiPrefs, writeStoredUiPrefs } from "../../../storage/prefs";

interface UiPrefsState {
aiSidebarOpen: boolean;
aiTab: AiTab;
editorFont: EditorFont;
completionLength: CompletionLength;
focusMode: boolean;
liveAnalysis: boolean;
translationTarget: string;
Expand All @@ -19,6 +20,7 @@ function initialPrefs(): UiPrefsState {
aiSidebarOpen: stored.aiSidebarOpen ?? isWideViewport,
aiTab: stored.aiTab ?? "tools",
editorFont: stored.editorFont ?? "geist",
completionLength: stored.completionLength ?? "short",
focusMode: stored.focusMode ?? false,
liveAnalysis: stored.liveAnalysis ?? true,
translationTarget: stored.translationTarget ?? "es",
Expand All @@ -30,6 +32,7 @@ const PERSIST_KEYS: ReadonlyArray<keyof EditorUiPrefs> = [
"aiSidebarOpen",
"aiTab",
"editorFont",
"completionLength",
"focusMode",
"liveAnalysis",
"translationTarget",
Expand Down
64 changes: 63 additions & 1 deletion src/components/editor/layout/EditorToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
PanelRightOpen,
Quote,
Redo2,
Sparkles,
Sun,
Trash2,
Type,
Expand All @@ -32,7 +33,8 @@ import {
} from "lucide-react";
import type { RefObject } from "react";
import type { Editor } from "@tiptap/core";
import type { AiAction, Capabilities, EditorFont, RecordingTarget, ThemeMode, VaultStatus, WriteSession } from "../../../lib/types";
import type { AiAction, Capabilities, CompletionLength, EditorFont, RecordingTarget, ThemeMode, VaultStatus, WriteSession } from "../../../lib/types";
import { COMPLETION_LENGTH_OPTIONS } from "../../../ai/completion";
import { cn } from "../../../lib/utils";
import { iconButton } from "../tailwind";
import { ToolbarButton } from "./ToolbarButton";
Expand All @@ -54,6 +56,8 @@ interface EditorToolbarProps {
toggleFocusMode: () => void;
editorFont: EditorFont;
setEditorFont: (font: EditorFont) => void;
completionLength: CompletionLength;
setCompletionLength: (length: CompletionLength) => void;
aiSidebarOpen: boolean;
toggleAiSidebar: () => void;
postMenuOpen: boolean;
Expand Down Expand Up @@ -87,6 +91,8 @@ export function EditorToolbar(props: EditorToolbarProps) {
toggleFocusMode,
editorFont,
setEditorFont,
completionLength,
setCompletionLength,
aiSidebarOpen,
toggleAiSidebar,
postMenuOpen,
Expand Down Expand Up @@ -190,6 +196,8 @@ export function EditorToolbar(props: EditorToolbarProps) {
</button>
{postMenuOpen ? (
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-50 grid w-max min-w-[14rem] max-w-[calc(100vw-1rem)] gap-0.5 rounded-xl bg-popover p-2 text-popover-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)),0_18px_46px_hsl(var(--shadow-color)/0.12)]" role="menu" aria-label="More actions">
<CompletionLengthPicker completionLength={completionLength} setCompletionLength={setCompletionLength} />
<div className="my-1 h-px bg-border/70" role="separator" />
<div className="px-2 pb-1 pt-1.5 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground" role="presentation">
Appearance
</div>
Expand Down Expand Up @@ -252,6 +260,60 @@ export function EditorToolbar(props: EditorToolbarProps) {
);
}

function CompletionLengthPicker({
completionLength,
setCompletionLength,
}: {
completionLength: CompletionLength;
setCompletionLength: (length: CompletionLength) => void;
}) {
const current = COMPLETION_LENGTH_OPTIONS.find((option) => option.value === completionLength)!;

return (
<div className="group/completion relative">
<button
type="button"
role="menuitem"
aria-haspopup="menu"
className="flex min-h-9 w-full items-center justify-between gap-3 rounded-md border-0 bg-transparent p-2 text-left text-sm font-medium leading-5 text-foreground transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
>
<span className="inline-flex items-center gap-2.5">
<Sparkles size={16} />
Completion length
</span>
<span className="text-[0.8125rem] font-normal text-muted-foreground">{current.label}</span>
</button>
<div
className="pointer-events-none invisible absolute right-full top-0 z-[60] -mt-2 mr-1 grid min-w-[12rem] gap-0.5 rounded-xl bg-popover p-2 text-popover-foreground opacity-0 shadow-[inset_0_0_0_1px_hsl(var(--border)),0_18px_46px_hsl(var(--shadow-color)/0.12)] transition-opacity duration-100 group-hover/completion:pointer-events-auto group-hover/completion:visible group-hover/completion:opacity-100 group-focus-within/completion:pointer-events-auto group-focus-within/completion:visible group-focus-within/completion:opacity-100 max-[640px]:right-auto max-[640px]:left-0 max-[640px]:top-full max-[640px]:mt-1 max-[640px]:mr-0"
role="menu"
aria-label="Completion length"
>
{COMPLETION_LENGTH_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={option.value === completionLength}
onClick={() => setCompletionLength(option.value)}
className={cn(
"grid grid-cols-[1rem_minmax(0,1fr)] items-center gap-2.5 rounded-md border-0 bg-transparent p-2 text-left text-sm leading-5 text-foreground transition-colors hover:bg-muted focus-visible:bg-muted",
option.value === completionLength && "bg-muted",
)}
>
<span className="inline-flex size-4 items-center justify-center" aria-hidden="true">
{option.value === completionLength ? <Check size={14} /> : null}
</span>
<span className="grid gap-0.5">
<span className="font-medium">{option.label}</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</span>
</button>
))}
</div>
</div>
);
}

const FONT_OPTIONS: Array<{ value: EditorFont; label: string; category: string; className: string }> = [
{ value: "geist", label: "Geist", category: "Sans", className: "font-sans" },
{ value: "inter", label: "Inter", category: "Sans", className: "editor-font-inter-preview" },
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type SaveState = "idle" | "saving" | "saved" | "error";
export type AiStatus = Availability | "idle" | "checking" | "creating" | "unsupported" | "error";
export type AiAction = "rewrite" | "chat" | "transcribe" | "translate" | null;
export type AiTab = "chat" | "tools";
export type CompletionLength = "short" | "medium" | "long";
export type AmbientStatus = "off" | "idle" | "tentative" | "stale" | "thinking" | "ready" | "error";
export type ThemeMode = "light" | "dark";
export type ChatRole = "user" | "assistant";
Expand All @@ -27,6 +28,7 @@ export interface EditorUiPrefs {
aiTab?: AiTab;
chatInput?: string;
editorFont?: EditorFont;
completionLength?: CompletionLength;
focusMode?: boolean;
liveAnalysis?: boolean;
translationTarget?: string;
Expand Down
4 changes: 4 additions & 0 deletions src/storage/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export function readStoredUiPrefs(): EditorUiPrefs {
aiTab: parsed.aiTab === "chat" || parsed.aiTab === "tools" ? parsed.aiTab : undefined,
chatInput: typeof parsed.chatInput === "string" ? parsed.chatInput : undefined,
editorFont: EDITOR_FONT_VALUES.has(parsed.editorFont as EditorFont) ? (parsed.editorFont as EditorFont) : undefined,
completionLength:
parsed.completionLength === "short" || parsed.completionLength === "medium" || parsed.completionLength === "long"
? parsed.completionLength
: undefined,
focusMode: typeof parsed.focusMode === "boolean" ? parsed.focusMode : undefined,
liveAnalysis: typeof parsed.liveAnalysis === "boolean" ? parsed.liveAnalysis : undefined,
translationTarget:
Expand Down
15 changes: 9 additions & 6 deletions src/tiptap/ghostCompletion.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Extension, type Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { CompletionContext, GhostCompletionState } from "../lib/types";
import type { CompletionContext, CompletionLength, GhostCompletionState } from "../lib/types";
import { countWords } from "../lib/session";
import { stripJsonFences } from "../lib/json";
import { getCompletionPrefix, truncateForModel } from "../ai/text";
import { COMPLETION_LENGTH_CONFIG } from "../ai/completion";

export const ghostCompletionKey = new PluginKey<GhostCompletionState>("draftsideGhostCompletion");

Expand Down Expand Up @@ -107,18 +108,20 @@ export function getCompletionContext(editor: Editor): CompletionContext | null {
};
}

export function cleanGhostCompletion(input: string, context: CompletionContext) {
export function cleanGhostCompletion(input: string, context: CompletionContext, completionLength: CompletionLength = "short") {
const limits = COMPLETION_LENGTH_CONFIG[completionLength];
const response = stripJsonFences(input).replace(/\s+/g, " ");
// The echoed prefix anchors the insertion boundary. Reject a rewritten or
// missing prefix instead of guessing whether the next token needs a space.
if (!response.startsWith(context.fragment)) return "";
let completion = response.slice(context.fragment.length).trimEnd();

const sentenceEnd = completion.search(/[.!?](?:\s|$)/);
if (sentenceEnd >= 0) completion = completion.slice(0, sentenceEnd + 1).trimEnd();
const sentenceEnds = Array.from(completion.matchAll(/[.!?](?=\s|$)/g));
const sentenceEnd = sentenceEnds[limits.maxSentences - 1]?.index;
if (sentenceEnd !== undefined) completion = completion.slice(0, sentenceEnd + 1).trimEnd();

const words = [...completion.matchAll(/\S+/g)];
if (words.length > 12) completion = completion.slice(0, words[12].index).trimEnd();
if (words.length > limits.maxWords) completion = completion.slice(0, words[limits.maxWords].index).trimEnd();

return completion.length > 96 ? completion.slice(0, 96).replace(/\s+\S*$/, "") : completion;
return completion.length > limits.maxChars ? completion.slice(0, limits.maxChars).replace(/\s+\S*$/, "") : completion;
}
Loading
Loading