From 3798160656f2c8ddf47be28c4bb4571a76f100ed Mon Sep 17 00:00:00 2001 From: Colin Armstrong Date: Sun, 6 Sep 2026 12:47:22 -0400 Subject: [PATCH 1/3] Add persisted inline completion length setting --- README.md | 4 +- src/ai/completion.ts | 33 +++++ src/ai/prompts.ts | 7 +- src/components/editor/DraftsideEditor.tsx | 13 ++ .../editor/hooks/useGhostCompletion.ts | 9 +- src/components/editor/hooks/useUiPrefs.ts | 5 +- .../editor/layout/EditorToolbar.tsx | 64 +++++++- src/lib/types.ts | 2 + src/storage/prefs.ts | 4 + src/tiptap/ghostCompletion.ts | 15 +- tests/completion-length.spec.ts | 139 ++++++++++++++++++ 11 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 src/ai/completion.ts create mode 100644 tests/completion-length.spec.ts diff --git a/README.md b/README.md index d321de0..dc1b4c7 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/src/ai/completion.ts b/src/ai/completion.ts new file mode 100644 index 0000000..f4d6a3c --- /dev/null +++ b/src/ai/completion.ts @@ -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 = { + 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, + }, +}; diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index cb76a0a..fb021e3 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -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[]) { @@ -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" diff --git a/src/components/editor/DraftsideEditor.tsx b/src/components/editor/DraftsideEditor.tsx index 2132be7..c52b1fb 100644 --- a/src/components/editor/DraftsideEditor.tsx +++ b/src/components/editor/DraftsideEditor.tsx @@ -333,6 +333,7 @@ export default function DraftsideEditor() { vaultLocked, activeSessionId: activeSession?.id, completionTick, + completionLength: prefs.completionLength, createLanguageModelTask, }); @@ -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; @@ -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)} diff --git a/src/components/editor/hooks/useGhostCompletion.ts b/src/components/editor/hooks/useGhostCompletion.ts index a7b64e9..bfb75ff 100644 --- a/src/components/editor/hooks/useGhostCompletion.ts +++ b/src/components/editor/hooks/useGhostCompletion.ts @@ -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, @@ -17,6 +18,7 @@ interface UseGhostCompletionOptions { vaultLocked: boolean; activeSessionId?: string | null; completionTick: number; + completionLength: CompletionLength; createLanguageModelTask: (signal?: AbortSignal) => Promise; } @@ -29,6 +31,7 @@ export function useGhostCompletion({ vaultLocked, activeSessionId, completionTick, + completionLength, createLanguageModelTask, }: UseGhostCompletionOptions) { const [ghostCompletionText, setGhostCompletionText] = useState(""); @@ -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); @@ -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); @@ -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, diff --git a/src/components/editor/hooks/useUiPrefs.ts b/src/components/editor/hooks/useUiPrefs.ts index 9af4afe..64a35f6 100644 --- a/src/components/editor/hooks/useUiPrefs.ts +++ b/src/components/editor/hooks/useUiPrefs.ts @@ -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; @@ -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", @@ -30,6 +32,7 @@ const PERSIST_KEYS: ReadonlyArray = [ "aiSidebarOpen", "aiTab", "editorFont", + "completionLength", "focusMode", "liveAnalysis", "translationTarget", diff --git a/src/components/editor/layout/EditorToolbar.tsx b/src/components/editor/layout/EditorToolbar.tsx index a3202b2..e5bba8f 100644 --- a/src/components/editor/layout/EditorToolbar.tsx +++ b/src/components/editor/layout/EditorToolbar.tsx @@ -24,6 +24,7 @@ import { PanelRightOpen, Quote, Redo2, + Sparkles, Sun, Trash2, Type, @@ -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"; @@ -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; @@ -87,6 +91,8 @@ export function EditorToolbar(props: EditorToolbarProps) { toggleFocusMode, editorFont, setEditorFont, + completionLength, + setCompletionLength, aiSidebarOpen, toggleAiSidebar, postMenuOpen, @@ -190,6 +196,8 @@ export function EditorToolbar(props: EditorToolbarProps) { {postMenuOpen ? (
+ +
Appearance
@@ -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 ( +
+ +
+ {COMPLETION_LENGTH_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} + 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" }, diff --git a/src/lib/types.ts b/src/lib/types.ts index ba22b89..18bdb02 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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"; @@ -27,6 +28,7 @@ export interface EditorUiPrefs { aiTab?: AiTab; chatInput?: string; editorFont?: EditorFont; + completionLength?: CompletionLength; focusMode?: boolean; liveAnalysis?: boolean; translationTarget?: string; diff --git a/src/storage/prefs.ts b/src/storage/prefs.ts index cf5c00c..44f4f15 100644 --- a/src/storage/prefs.ts +++ b/src/storage/prefs.ts @@ -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: diff --git a/src/tiptap/ghostCompletion.ts b/src/tiptap/ghostCompletion.ts index 6461e7f..2057cf3 100644 --- a/src/tiptap/ghostCompletion.ts +++ b/src/tiptap/ghostCompletion.ts @@ -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("draftsideGhostCompletion"); @@ -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; } diff --git a/tests/completion-length.spec.ts b/tests/completion-length.spec.ts new file mode 100644 index 0000000..7f24e52 --- /dev/null +++ b/tests/completion-length.spec.ts @@ -0,0 +1,139 @@ +import { expect, test, type Page } from "@playwright/test"; + +const PREFIX = "On weekends our neighborhood meets"; +const RESPONSES = { + short: "to share a meal.", + medium: "to share a meal in the community garden, where everyone brings something fresh. The children help set the tables while their parents finish cooking together.", + long: "to share a meal in the community garden, where everyone brings something fresh and the tables fill with dishes from around the world. The children help carry plates while their parents exchange stories about the week and make plans for the next gathering. By the time the sun sets, even the newest neighbors feel like old friends who belong here.", +}; + +interface CompletionTestState { + completionPrompts: string[]; + resolveCompletion: Partial void>>; + destroyedCompletions: number; +} + +async function prepareEditor(page: Page, storedLength?: string, deferResponses = false) { + await page.addInitScript(({ responses, storedLength, deferResponses }) => { + const browser = window as typeof window & CompletionTestState; + browser.completionPrompts = []; + browser.resolveCompletion = {}; + browser.destroyedCompletions = 0; + localStorage.setItem("draftside.onboarded", JSON.stringify({ at: Date.now(), hadApi: true })); + if (!localStorage.getItem("draftside.uiPrefs")) { + localStorage.setItem("draftside.uiPrefs", JSON.stringify({ liveAnalysis: false, completionLength: storedLength })); + } + + // Deterministic Prompt API boundary: the real editor, hooks, cleanup, and storage still run. + class TestLanguageModel { + static async availability() { return "available"; } + static async params() { return {}; } + static async create() { return new TestLanguageModel(); } + async clone() { return new TestLanguageModel(); } + destroy() { browser.destroyedCompletions += 1; } + async prompt(messages: Array<{ content: string }>) { + const prompt = messages.map((message) => message.content).join("\n"); + browser.completionPrompts.push(prompt); + const length = prompt.includes("40 to 80 words") ? "long" : prompt.includes("15 to 30 words") ? "medium" : "short"; + if (deferResponses) { + await new Promise((resolve) => { browser.resolveCompletion[length] = resolve; }); + } + const prefix = JSON.parse(prompt.split("Required prefix (shown as a JSON string so spaces are visible):\n")[1].split("\n")[0]) as string; + return `${prefix} ${responses[length]}`; + } + } + Object.defineProperty(window, "LanguageModel", { configurable: true, value: TestLanguageModel }); + }, { responses: RESPONSES, storedLength, deferResponses }); + + await page.goto("/write"); + await expect(page.locator(".tiptap[contenteditable=true]")).toBeVisible(); +} + +async function openLengths(page: Page) { + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: /^Completion length/ }).click(); + await expect(page.getByRole("menu", { name: "Completion length", exact: true })).toHaveCSS("opacity", "1"); +} + +async function acceptCompletion(page: Page, response: string, instruction: string) { + const editor = page.locator(".tiptap[contenteditable=true]"); + await editor.fill(PREFIX); + await editor.press("End"); + const ghost = editor.locator(".ProseMirror-widget"); + await expect(ghost).toHaveText(` ${response}`); + const prompts = await page.evaluate(() => (window as typeof window & { completionPrompts: string[] }).completionPrompts); + expect(prompts.at(-1)).toContain(instruction); + await editor.press("Tab"); + await expect(ghost).toHaveCount(0); + await expect(editor).toHaveText(`${PREFIX} ${response}`); +} + +test("short remains the default and accepts its suggestion", async ({ page }) => { + await prepareEditor(page); + await openLengths(page); + await expect(page.getByRole("menuitemradio", { name: "Short 3–10 words" })).toHaveAttribute("aria-checked", "true"); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await acceptCompletion(page, RESPONSES.short, "3 to 10 words"); +}); + +for (const length of ["medium", "long"] as const) { + test(`${length} persists after reload and accepts the full continuation`, async ({ page }, testInfo) => { + await prepareEditor(page); + await openLengths(page); + const label = length === "medium" ? "Medium 15–30 words" : "Long 40–80 words"; + await page.getByRole("menuitemradio", { name: label }).click(); + await expect(page.getByRole("menu", { name: "More actions", exact: true })).toHaveCount(0); + await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("draftside.uiPrefs")!).completionLength)).toBe(length); + + await page.reload(); + await expect(page.locator(".tiptap[contenteditable=true]")).toBeVisible(); + await openLengths(page); + await expect(page.getByRole("menuitemradio", { name: label })).toHaveAttribute("aria-checked", "true"); + await page.screenshot({ path: testInfo.outputPath(`${length}-setting.png`) }); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + + await acceptCompletion(page, RESPONSES[length], length === "medium" ? "15 to 30 words" : "40 to 80 words"); + await page.screenshot({ path: testInfo.outputPath(`${length}-accepted.png`) }); + }); +} + +test("invalid stored completion length falls back to short", async ({ page }) => { + await prepareEditor(page, "unrecognized"); + await openLengths(page); + await expect(page.getByRole("menuitemradio", { name: "Short 3–10 words" })).toHaveAttribute("aria-checked", "true"); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await acceptCompletion(page, RESPONSES.short, "3 to 10 words"); +}); + +test("changing length discards a completion still being generated", async ({ page }) => { + await prepareEditor(page, undefined, true); + const editor = page.locator(".tiptap[contenteditable=true]"); + await editor.fill(PREFIX); + await editor.press("End"); + await expect.poll(() => page.evaluate(() => Boolean((window as typeof window & CompletionTestState).resolveCompletion.short))).toBe(true); + + await openLengths(page); + await page.getByRole("menuitemradio", { name: "Long 40–80 words" }).click(); + await expect.poll(() => page.evaluate(() => Boolean((window as typeof window & CompletionTestState).resolveCompletion.long))).toBe(true); + await page.evaluate(() => (window as typeof window & CompletionTestState).resolveCompletion.long!()); + const ghost = editor.locator(".ProseMirror-widget"); + await expect(ghost).toHaveText(` ${RESPONSES.long}`); + + await page.evaluate(() => (window as typeof window & CompletionTestState).resolveCompletion.short!()); + await expect.poll(() => page.evaluate(() => (window as typeof window & CompletionTestState).destroyedCompletions)).toBe(2); + await expect(ghost).toHaveText(` ${RESPONSES.long}`); + await editor.press("Tab"); + await expect(editor).toHaveText(`${PREFIX} ${RESPONSES.long}`); +}); + +test.describe("narrow touch screen", () => { + test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); + + test("completion length can be changed by touch", async ({ page }) => { + await prepareEditor(page); + await page.getByRole("button", { name: "More actions", exact: true }).tap(); + await page.getByRole("menuitem", { name: /^Completion length/ }).tap(); + await page.getByRole("menuitemradio", { name: "Long 40–80 words" }).tap(); + await acceptCompletion(page, RESPONSES.long, "40 to 80 words"); + }); +}); From e50731c541148069a77badb68923fd5b253deb61 Mon Sep 17 00:00:00 2001 From: Colin Armstrong Date: Sun, 6 Sep 2026 12:53:51 -0400 Subject: [PATCH 2/3] Update completion length fixture for prefix delimiters --- tests/completion-length.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/completion-length.spec.ts b/tests/completion-length.spec.ts index 7f24e52..74e1a85 100644 --- a/tests/completion-length.spec.ts +++ b/tests/completion-length.spec.ts @@ -38,7 +38,7 @@ async function prepareEditor(page: Page, storedLength?: string, deferResponses = if (deferResponses) { await new Promise((resolve) => { browser.resolveCompletion[length] = resolve; }); } - const prefix = JSON.parse(prompt.split("Required prefix (shown as a JSON string so spaces are visible):\n")[1].split("\n")[0]) as string; + const prefix = prompt.split("")[1].split("")[0]; return `${prefix} ${responses[length]}`; } } From 03b551b63ca77bed0a0b8e6f518bfbb884027618 Mon Sep 17 00:00:00 2001 From: Colin Armstrong Date: Sun, 6 Sep 2026 12:55:41 -0400 Subject: [PATCH 3/3] Test long completions at partial-word cursors --- tests/completion-length.spec.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/completion-length.spec.ts b/tests/completion-length.spec.ts index 74e1a85..b19749f 100644 --- a/tests/completion-length.spec.ts +++ b/tests/completion-length.spec.ts @@ -13,8 +13,8 @@ interface CompletionTestState { destroyedCompletions: number; } -async function prepareEditor(page: Page, storedLength?: string, deferResponses = false) { - await page.addInitScript(({ responses, storedLength, deferResponses }) => { +async function prepareEditor(page: Page, storedLength?: string, deferResponses = false, completionStart = " ") { + await page.addInitScript(({ responses, storedLength, deferResponses, completionStart }) => { const browser = window as typeof window & CompletionTestState; browser.completionPrompts = []; browser.resolveCompletion = {}; @@ -39,11 +39,11 @@ async function prepareEditor(page: Page, storedLength?: string, deferResponses = await new Promise((resolve) => { browser.resolveCompletion[length] = resolve; }); } const prefix = prompt.split("")[1].split("")[0]; - return `${prefix} ${responses[length]}`; + return `${prefix}${completionStart}${responses[length]}`; } } Object.defineProperty(window, "LanguageModel", { configurable: true, value: TestLanguageModel }); - }, { responses: RESPONSES, storedLength, deferResponses }); + }, { responses: RESPONSES, storedLength, deferResponses, completionStart }); await page.goto("/write"); await expect(page.locator(".tiptap[contenteditable=true]")).toBeVisible(); @@ -65,7 +65,7 @@ async function acceptCompletion(page: Page, response: string, instruction: strin expect(prompts.at(-1)).toContain(instruction); await editor.press("Tab"); await expect(ghost).toHaveCount(0); - await expect(editor).toHaveText(`${PREFIX} ${response}`); + await expect.poll(() => editor.textContent()).toBe(`${PREFIX} ${response}`); } test("short remains the default and accepts its suggestion", async ({ page }) => { @@ -105,6 +105,16 @@ test("invalid stored completion length falls back to short", async ({ page }) => await acceptCompletion(page, RESPONSES.short, "3 to 10 words"); }); +test("long completion finishes a partial word and preserves the full paragraph", async ({ page }) => { + await prepareEditor(page, "long", false, "ts "); + const editor = page.locator(".tiptap[contenteditable=true]"); + await editor.fill("On weekends our neighborhood mee"); + await editor.press("End"); + await expect(editor.locator(".ProseMirror-widget")).toHaveText(`ts ${RESPONSES.long}`); + await editor.press("Tab"); + await expect.poll(() => editor.textContent()).toBe(`${PREFIX} ${RESPONSES.long}`); +}); + test("changing length discards a completion still being generated", async ({ page }) => { await prepareEditor(page, undefined, true); const editor = page.locator(".tiptap[contenteditable=true]");