diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 925319680d40d..ffca42c6d9178 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -7,7 +7,8 @@ import { useRetainedContextPayloads, } from "./chat/composerContextUndo"; -import type { ServerProviderSkill } from "@t3tools/contracts"; +import { matchComposerInlineSuggestion } from "@t3tools/client-runtime/composerInlineSuggestion"; +import { EnvironmentId, type ServerProviderSkill } from "@t3tools/contracts"; import type { Editor } from "@tiptap/core"; import { redoDepth, undoDepth } from "@tiptap/pm/history"; import { NodeSelection } from "@tiptap/pm/state"; @@ -36,12 +37,13 @@ function Composer({ richTextEnabled, skills, onChange, + ...inlineSuggestions }: { value: string; richTextEnabled: boolean; skills: ReadonlyArray; onChange?: ComposerPromptEditorProps["onChange"] | undefined; -}) { +} & Pick) { const [prompt, setPrompt] = useState({ value, cursor: value.length }); return ( {}} + {...inlineSuggestions} /> ); } @@ -233,6 +236,80 @@ it.each([false, true])( }, ); +it("draws a completion while typing, reports Tab, and keeps a dismissed one gone", async () => { + const candidate = { + kind: "environment", + environmentId: EnvironmentId.make("env-vps"), + label: "Hetzner VPS", + } as const; + const onAccept = vi.fn(); + await using screen = await render( + matchComposerInlineSuggestion(text, [candidate])} + onAcceptInlineSuggestion={onAccept} + />, + ); + const ghost = () => screen.container.querySelector(".composer-inline-suggestion")?.textContent; + await act(async () => editorIn(screen.container).view.focus()); + + await userEvent.keyboard("on hetz"); + await vi.waitFor(() => expect(ghost()).toBe("ner VPSTab")); + await userEvent.keyboard("{Tab}"); + expect(onAccept).toHaveBeenCalledWith({ candidate, typed: "hetz", remainder: "ner VPS" }); + + await userEvent.keyboard("{Escape}"); + await vi.waitFor(() => expect(ghost()).toBeUndefined()); + await userEvent.keyboard("n"); + expect(ghost()).toBeUndefined(); + + await userEvent.keyboard(" hetz"); + await vi.waitFor(() => expect(ghost()).toBe("ner VPSTab")); + await userEvent.keyboard("{ArrowLeft}"); + await vi.waitFor(() => expect(ghost()).toBeUndefined()); + expect(onAccept).toHaveBeenCalledTimes(1); +}); + +it("proposes no completion for pasted text or a name crossing formatting", async () => { + const candidate = { + kind: "environment", + environmentId: EnvironmentId.make("env-mini"), + label: "Mac mini", + } as const; + await using screen = await render( + matchComposerInlineSuggestion(text, [candidate])} + />, + ); + const editor = editorIn(screen.container); + const ghost = () => screen.container.querySelector(".composer-inline-suggestion"); + await act(async () => { + editor.view.focus(); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + }); + + await userEvent.keyboard("i"); + await vi.waitFor(() => expect(editor.state.doc.textContent).toContain("mi")); + expect(ghost()).toBeNull(); + + await userEvent.keyboard(" "); + await act(async () => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", "mac"); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + await vi.waitFor(() => expect(editor.state.doc.textContent).toContain("mi mac")); + expect(ghost()).toBeNull(); + + await userEvent.keyboard(" mac"); + await vi.waitFor(() => expect(ghost()).not.toBeNull()); +}); + it("lets composition replace selected text without surrounding it", async () => { await using screen = await mountEditor("word"); const { editor } = screen; diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index bb8f5a5a2211a..a75beb1b28adc 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,4 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; +import type { ComposerInlineSuggestion } from "@t3tools/client-runtime/composerInlineSuggestion"; +import type { EnvironmentId } from "@t3tools/contracts"; import { documentChipKindForFileName } from "~/pierre-icons"; import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills"; import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; @@ -183,6 +185,8 @@ export interface ComposerPromptEditorProps { ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: (key: string, event: KeyboardEvent, isTaskItem?: boolean) => boolean; + matchInlineSuggestion?: (textBeforeCaret: string) => ComposerEditorInlineSuggestion | null; + onAcceptInlineSuggestion?: (suggestion: ComposerEditorInlineSuggestion) => void; onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; @@ -714,6 +718,126 @@ const ComposerChipSelectionExtension = Extension.create({ }, }); +/** A name the composer's ghost text can complete into a chip. */ +export type ComposerInlineCandidate = { readonly label: string } & ( + | { readonly kind: "environment"; readonly environmentId: EnvironmentId } + | { readonly kind: "model"; readonly instanceId: string; readonly model: string } +); + +/** What the composer proposes after the caret; the editor only draws it and reports Tab. */ +export type ComposerEditorInlineSuggestion = ComposerInlineSuggestion; + +interface InlineSuggestionState { + readonly suggestion: ComposerEditorInlineSuggestion | null; + readonly from: number | null; + /** Where the suggestion Escape dismissed starts, so it stays gone while that word grows. */ + readonly dismissedFrom: number | null; +} + +interface InlineSuggestionStorage { + match: (textBeforeCaret: string) => ComposerEditorInlineSuggestion | null; +} + +const InlineSuggestionPluginKey = new PluginKey( + "composer-inline-suggestion", +); +const CHIP_PLACEHOLDER = "\ufffc"; + +function inlineSuggestionAtCaret( + { selection }: EditorState, + match: InlineSuggestionStorage["match"], +): Pick | null { + const { $from, empty } = selection; + const { parent, parentOffset } = $from; + if (!empty || !parent.isTextblock || parent.type.spec.code) return null; + const next = parent.textBetween( + parentOffset, + Math.min(parentOffset + 1, parent.content.size), + undefined, + CHIP_PLACEHOLDER, + ); + if (next !== "" && !/\s/u.test(next)) return null; + const textBeforeCaret = parent.textBetween(0, parentOffset, undefined, (node) => + node.type.name === "hardBreak" ? "\n" : CHIP_PLACEHOLDER, + ); + const suggestion = match(textBeforeCaret); + // Accepting replaces serialized Markdown, so the name must not span formatting markers. + if (!suggestion || suggestion.typed.length > ($from.nodeBefore?.text?.length ?? 0)) return null; + return { suggestion, from: selection.from - suggestion.typed.length }; +} + +function inlineSuggestionWidget(remainder: string): HTMLElement { + const root = document.createElement("span"); + root.className = "composer-inline-suggestion"; + root.setAttribute("aria-hidden", "true"); + root.append(remainder); + const key = document.createElement("kbd"); + key.className = "composer-inline-suggestion-key"; + key.textContent = "Tab"; + root.append(key); + return root; +} + +const ComposerInlineSuggestionExtension = Extension.create({ + name: "composerInlineSuggestion", + addStorage() { + return { match: () => null }; + }, + addProseMirrorPlugins() { + const storage = this.storage; + return [ + new Plugin({ + key: InlineSuggestionPluginKey, + state: { + init: () => ({ suggestion: null, from: null, dismissedFrom: null }), + apply: (tr, previous, _, state) => { + const dismissing = tr.getMeta(InlineSuggestionPluginKey) === "dismiss"; + if (!tr.docChanged && !tr.selectionSet && !dismissing) return previous; + const dismissedFrom = dismissing + ? previous.from + : previous.dismissedFrom === null + ? null + : tr.mapping.map(previous.dismissedFrom); + const none = { suggestion: null, from: null, dismissedFrom }; + // Follows typing only: a moved caret, a paste, an undo or a restored draft has no word + // in progress, and Tab should keep its usual meaning there. + if ( + !tr.docChanged || + tr.getMeta("uiEvent") || + tr.getMeta("history$") || + tr.getMeta("preventUpdate") || + tr.getMeta("composition") !== undefined + ) { + return none; + } + const found = inlineSuggestionAtCaret(state, storage.match); + return found === null || found.from === dismissedFrom + ? none + : { ...found, dismissedFrom }; + }, + }, + props: { + decorations: (state) => { + const suggestion = InlineSuggestionPluginKey.getState(state)?.suggestion; + if (!suggestion) return null; + return DecorationSet.create(state.doc, [ + Decoration.widget( + state.selection.head, + () => inlineSuggestionWidget(suggestion.remainder), + { + side: 1, + ignoreSelection: true, + key: `inline-suggestion:${suggestion.remainder}`, + }, + ), + ]); + }, + }, + }), + ]; + }, +}); + function chipSelectionDecorations({ doc, selection }: EditorState): DecorationSet { if (selection.empty) return DecorationSet.empty; const decorations: Decoration[] = []; @@ -818,6 +942,7 @@ function createComposerExtensions(richText: boolean) { ComposerCitationExtension, ComposerContextReferenceExtension, ComposerChipSelectionExtension, + ComposerInlineSuggestionExtension, ...(richText ? [ ComposerMarkdownSourceExtension, @@ -926,6 +1051,8 @@ function ComposerPromptEditorTiptapInner( onChange, onVisibleSelectionChange, onCommandKeyDown, + matchInlineSuggestion, + onAcceptInlineSuggestion, onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, @@ -952,6 +1079,8 @@ function ComposerPromptEditorTiptapInner( onChange, onVisibleSelectionChange, onCommandKeyDown, + matchInlineSuggestion, + onAcceptInlineSuggestion, isPathQueryActive, importContextFragment, })); @@ -1094,9 +1223,32 @@ function ComposerPromptEditorTiptapInner( isKnownModel: (instanceId, model) => modelKeys.has(modelMentionKey(instanceId, model)), }), editable: !disabled, + onBeforeCreate: ({ editor: created }) => { + const storage = created.storage as unknown as { + composerInlineSuggestion: InlineSuggestionStorage; + }; + storage.composerInlineSuggestion.match = (textBeforeCaret) => + readCallbacks().matchInlineSuggestion?.(textBeforeCaret) ?? null; + }, editorProps: { attributes: editorAttributes, handleKeyDown: (view, event) => { + const inlineSuggestion = InlineSuggestionPluginKey.getState(view.state)?.suggestion; + if ( + inlineSuggestion && + (event.key === "Tab" || event.key === "Escape") && + !event.isComposing && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey + ) { + event.preventDefault(); + event.stopPropagation(); + if (event.key === "Tab") readCallbacks().onAcceptInlineSuggestion?.(inlineSuggestion); + else view.dispatch(view.state.tr.setMeta(InlineSuggestionPluginKey, "dismiss")); + return true; + } // In a right-to-left line the start is on the right. const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft"; const boundary = event.metaKey diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 65eb3896f48ce..b05515a175a6d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -9,7 +9,7 @@ import { resolveRuntimeModeOptions, } from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; -import { usePrimaryEnvironmentId } from "../../state/environments"; +import { useEnvironmentIdentities, usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; import { @@ -44,6 +44,8 @@ import { ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { matchComposerInlineSuggestion } from "@t3tools/client-runtime/composerInlineSuggestion"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Schema from "effect/Schema"; @@ -154,6 +156,7 @@ import { type ComposerCitationCommentRequest, type ComposerPromptEditorHandle, ComposerPromptEditorTiptap as ComposerPromptEditor, + type ComposerEditorInlineSuggestion, } from "../ComposerPromptEditorTiptap"; import { ComposerContextActionsContext, @@ -185,7 +188,7 @@ import { import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; import { readThreadShell } from "~/state/entities"; -import { useLinkedEnvironments } from "~/state/peerLinks"; +import { offersLinkedEnvironmentsAtom, useLinkedEnvironments } from "~/state/peerLinks"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import type { ComposerContextRecord, ThreadPullRequestLink } from "@t3tools/contracts"; import { threadContextAttachment } from "~/lib/threadContextAttachment"; @@ -1086,6 +1089,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0; const linkedEnvironments = useLinkedEnvironments(environmentId); + const environmentIdentities = useEnvironmentIdentities(); + const offersMachines = useAtomValue(offersLinkedEnvironmentsAtom(environmentId)); const { items: composerMenuItems, isLoading: isComposerMenuLoading, @@ -2030,6 +2035,69 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const onSelectComposerItemStable = useStableCallback(onSelectComposerItem); + const matchInlineSuggestion = (textBeforeCaret: string) => + isLiteralPendingAnswer || questionAttachmentTarget !== null + ? null + : matchComposerInlineSuggestion(textBeforeCaret, [ + ...(offersMachines + ? environmentIdentities.map(({ environmentId: mentionedId, label }) => ({ + kind: "environment" as const, + environmentId: mentionedId, + label, + })) + : []), + // The thread's own provider wins when two offer a model of the same name. + ...[...modelOptionsByInstance] + .toSorted(([left], [right]) => + left === selectedInstanceId ? -1 : right === selectedInstanceId ? 1 : 0, + ) + .flatMap(([instanceId, options]) => + options + // "Default" and "Auto" are ordinary words first, and name no particular model. + .filter( + (option) => + option.isUnavailable !== true && !/^(?:default|auto)$/iu.test(option.name), + ) + .map((option) => ({ + kind: "model" as const, + instanceId, + model: option.slug, + label: option.name, + })), + ), + ]); + const matchInlineSuggestionStable = useStableCallback(matchInlineSuggestion); + + const onAcceptInlineSuggestion = (accepted: ComposerEditorInlineSuggestion) => { + const { candidate } = accepted; + const snapshot = readComposerSnapshot(); + const rangeStart = snapshot.expandedCursor - accepted.typed.length; + // A model becomes the same mention the @ menu inserts; a machine becomes a context record. + let record: EnvironmentContextRecord | null = null; + let reference: string; + if (candidate.kind === "model") { + reference = formatComposerModelMention(candidate.instanceId, candidate.model); + } else { + record = environmentContextRecord(candidate.environmentId, candidate.label); + reference = formatInlineContextReference(environmentContextReference(record)); + } + const replacement = `${reference} `; + const rangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + snapshot.expandedCursor, + replacement, + ); + const applied = applyPromptReplacement(rangeStart, rangeEnd, replacement, { + expectedText: snapshot.value.slice(rangeStart, rangeEnd), + }); + if (applied && record) { + addComposerDraftEnvironmentContexts(composerDraftTarget, [record], { + appendReference: false, + }); + } + }; + const onAcceptInlineSuggestionStable = useStableCallback(onAcceptInlineSuggestion); + const onComposerMenuItemHighlighted = (itemId: string | null) => { setComposerHighlightedItemId(itemId); setComposerHighlightedSearchKey(composerMenuSearchKey); @@ -3462,6 +3530,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className={cn(showMobilePendingAnswerActions && "max-sm:pb-12")} onChange={onPromptChangeStable} onCommandKeyDown={onComposerCommandKeyStable} + matchInlineSuggestion={matchInlineSuggestionStable} + onAcceptInlineSuggestion={onAcceptInlineSuggestionStable} onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b24265d02197e..12f780642e690 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2482,6 +2482,25 @@ code { position: relative; } +/* Ghost text after the caret; Tab turns it into a chip. */ +.composer-tiptap .composer-inline-suggestion { + color: var(--muted-foreground); + opacity: 0.7; + pointer-events: none; + user-select: none; + white-space: pre; +} + +.composer-tiptap .composer-inline-suggestion-key { + margin-inline-start: 0.5em; + padding: 0 0.35em; + border: 1px solid var(--border); + border-radius: 4px; + font-family: inherit; + font-size: 0.7em; + vertical-align: 0.1em; +} + .composer-tiptap.ProseMirror-focused .composer-chip-range-selected::after { content: ""; position: absolute; diff --git a/docs/user/composer.md b/docs/user/composer.md index 32de36e13b842..a5b8da7e071fe 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -347,6 +347,11 @@ current title and opens it when selected. Your prompt only carries a reference: history on demand, so attaching a long thread costs nothing until the agent looks. Attaching a thread does not change it, and the agent cannot send messages to it unless you ask. +On web and desktop, the composer also completes the names of your models and, while the Linked +environments beta is on, your machines. Type three letters or more of a name, without `@`, and the +rest of the name appears as gray text after the cursor. Press Tab to turn it into a chip that tells +the agent exactly which one you mean, or Esc to dismiss it. + A thread from a linked machine, picked, pasted or dropped, stays a reference that the agent reads on that machine. A pasted link to a thread on any other machine stays a link. Dropping a thread from any other environment attaches a saved transcript file. The source diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0e4b701aed3aa..d5bde331e2940 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -19,6 +19,10 @@ "types": "./src/workspaceBasenameLookup.ts", "default": "./src/workspaceBasenameLookup.ts" }, + "./composerInlineSuggestion": { + "types": "./src/composerInlineSuggestion.ts", + "default": "./src/composerInlineSuggestion.ts" + }, "./composerThreadItems": { "types": "./src/composerThreadItems.ts", "default": "./src/composerThreadItems.ts" diff --git a/packages/client-runtime/src/composerInlineSuggestion.test.ts b/packages/client-runtime/src/composerInlineSuggestion.test.ts new file mode 100644 index 0000000000000..bf68d2436fcbc --- /dev/null +++ b/packages/client-runtime/src/composerInlineSuggestion.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { matchComposerInlineSuggestion } from "./composerInlineSuggestion.ts"; + +const candidates = [ + { label: "Hetzner VPS" }, + { label: "Mac mini" }, + { label: "Claude Opus 5.5" }, + { label: "dev-box" }, +]; + +const match = (text: string) => { + const found = matchComposerInlineSuggestion(text, candidates); + return found && [found.candidate.label, found.typed, found.remainder]; +}; + +describe("matchComposerInlineSuggestion", () => { + it.each([ + ["run this on hetz", ["Hetzner VPS", "hetz", "ner VPS"]], + ["run this on HETZNER V", ["Hetzner VPS", "HETZNER V", "PS"]], + ["run on mac mi", ["Mac mini", "mac mi", "ni"]], + ["have opus", ["Claude Opus 5.5", "opus", " 5.5"]], + ["(hetz", ["Hetzner VPS", "hetz", "ner VPS"]], + ["first line\nhetz", ["Hetzner VPS", "hetz", "ner VPS"]], + ["try box", ["dev-box", "box", " (dev-box)"]], + ["use Hetzner VPS", ["Hetzner VPS", "Hetzner VPS", ""]], + ])("completes %j", (text, expected) => { + expect(match(text)).toEqual(expected); + }); + + it.each([ + ["he", "too short"], + ["hetz ", "a finished word"], + ["@hetz", "an @ mention, which has its own menu"], + ["/model opus", "a command, which has its own menu"], + ["5.5", "a run that does not start with a letter"], + ["sohetz", "the middle of a word"], + ["etzner", "the middle of a label's word"], + ["", "an empty line"], + ])("leaves %j alone: %s", (text) => { + expect(match(text)).toBeNull(); + }); + + it("prefers the earliest candidate when several match", () => { + const found = matchComposerInlineSuggestion("mac", [ + { label: "Mac mini" }, + { label: "MacBook" }, + ]); + expect(found?.candidate.label).toBe("Mac mini"); + }); +}); diff --git a/packages/client-runtime/src/composerInlineSuggestion.ts b/packages/client-runtime/src/composerInlineSuggestion.ts new file mode 100644 index 0000000000000..55a8d880272d5 --- /dev/null +++ b/packages/client-runtime/src/composerInlineSuggestion.ts @@ -0,0 +1,68 @@ +export interface ComposerInlineSuggestionCandidate { + readonly label: string; +} + +export interface ComposerInlineSuggestion { + readonly candidate: T; + /** The text before the caret that the suggestion replaces. */ + readonly typed: string; + /** What the caret is followed by: the rest of the label, or the label when `typed` ends it. */ + readonly remainder: string; +} + +// Under three letters, ordinary words like "he" or "ma" would keep suggesting names. +const MIN_TYPED_LENGTH = 3; +// Enough for the longest label a user would type out: "Hetzner VPS", "Claude Opus 5.5". +const MAX_TYPED_WORDS = 4; +const WORD_BOUNDARY = /[\s([{"'`]/u; +const LABEL_WORD_BOUNDARY = /[\s\-_/]/u; + +function typedRunStarts(line: string): number[] { + const starts: number[] = []; + for (let index = line.length - 1; index >= 0; index -= 1) { + if (index > 0 && !WORD_BOUNDARY.test(line[index - 1] ?? "")) continue; + if (WORD_BOUNDARY.test(line[index] ?? "")) continue; + starts.push(index); + if (starts.length === MAX_TYPED_WORDS) break; + } + return starts.reverse(); +} + +function labelWordMatch(label: string, typed: string): number { + for (let index = 0; index + typed.length <= label.length; index += 1) { + if (index > 0 && !LABEL_WORD_BOUNDARY.test(label[index - 1] ?? "")) continue; + if (label.startsWith(typed, index)) return index; + } + return -1; +} + +/** + * The name the user is typing, for the composer's ghost text. The longest run of words before + * the caret wins, then the earliest candidate. A run can start at any word of a label, so "opus" + * finds "Claude Opus 5.5". Commands and `@` mentions have their own menus, so they stay out. + */ +export function matchComposerInlineSuggestion( + textBeforeCaret: string, + candidates: ReadonlyArray, +): ComposerInlineSuggestion | null { + const line = textBeforeCaret.slice(textBeforeCaret.lastIndexOf("\n") + 1); + if (line.startsWith("/") || line.length === 0 || /\s$/u.test(line)) return null; + for (const start of typedRunStarts(line)) { + const typed = line.slice(start); + if (typed.length < MIN_TYPED_LENGTH || !/^\p{L}/u.test(typed)) continue; + const folded = typed.toLowerCase(); + for (const candidate of candidates) { + const label = candidate.label.toLowerCase(); + const index = labelWordMatch(label, folded); + if (index >= 0) { + const rest = candidate.label.slice(index + typed.length); + return { + candidate, + typed, + remainder: rest === "" && index > 0 ? ` (${candidate.label})` : rest, + }; + } + } + } + return null; +}