From 6511e69d7d9d074dc883da3d908f19990672d12d Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:51:33 +0300 Subject: [PATCH] feat(ui): prompt autocomplete for shortcut triggers (+ Editor toggle) Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/autocomplete/index.js | 49 ++++++++++++++++++++++ app/ui/features/autocomplete/match.js | 40 ++++++++++++++++++ app/ui/features/autocomplete/match.test.js | 25 +++++++++++ app/ui/modules.js | 1 + 4 files changed, 115 insertions(+) create mode 100644 app/ui/features/autocomplete/index.js create mode 100644 app/ui/features/autocomplete/match.js create mode 100644 app/ui/features/autocomplete/match.test.js diff --git a/app/ui/features/autocomplete/index.js b/app/ui/features/autocomplete/index.js new file mode 100644 index 00000000..70d1037b --- /dev/null +++ b/app/ui/features/autocomplete/index.js @@ -0,0 +1,49 @@ +// Prompt autocomplete: while typing in a prompt box, suggest the shortcut triggers that fit the word under the caret. +// Attaches to the prompt boxes by what they are called, when they are first focused; no other feature knows about it. +import { composer as c } from "../../composer/composer.js"; +import { accept, suggestionsAt } from "./match.js"; + +const BOXES = new Set(["Story", "Prompt"]); // aria-labels of the prompt fields + +export default { + id: "autocomplete", + mount: "menubar.menus", + needs: ["project", "api"], + setup({ app }) { + const p = app.project; + let library = [], fetched = 0, span = null; + const refresh = () => { if (Date.now() - fetched > 10000) { fetched = Date.now(); app.api.shortcuts().then((r) => { library = r.shortcuts || []; }).catch(() => {}); } }; + const live = new WeakMap(); + + function attach(box) { + if (live.has(box)) return; + const source = () => { + span = null; + if (!p.pref("autocomplete", true) || box.selectionStart !== box.selectionEnd) return []; + const found = suggestionsAt(library, box.value, box.selectionStart); + if (!found) return []; + span = found.span; + return found.items.map((e) => ({ label: e.trigger, hint: (e.sc.replacements || [])[0] || [e.sc.category, e.sc.sub_category].filter(Boolean).join(" ยท "), trigger: e.trigger })); + }; + const onPick = (item) => { + if (!span) return; + const done = accept(box.value, span, item.trigger); + box.value = done.text; box.setSelectionRange(done.caret, done.caret); + box.dispatchEvent(new Event("input", { bubbles: true })); + box.focus(); + }; + live.set(box, c.autocomplete.default({ input: box, source, onPick })); + } + + const onFocus = (e) => { + const t = e.target; + if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { refresh(); attach(t); } + }; + document.addEventListener("focusin", onFocus); + + app.editorSettings.push(() => c.region.stack({ gap: "sm", children: [ + c.toggle.default({ label: "Prompt autocomplete", hint: "Suggest matching shortcuts while you type in the Story and scene prompts. Shows the trigger and its prompt.", + checked: p.pref("autocomplete", true), onChange: (v) => p.setPref("autocomplete", v) })] })); + return () => document.removeEventListener("focusin", onFocus); + }, +}; diff --git a/app/ui/features/autocomplete/match.js b/app/ui/features/autocomplete/match.js new file mode 100644 index 00000000..b0cd2519 --- /dev/null +++ b/app/ui/features/autocomplete/match.js @@ -0,0 +1,40 @@ +// Which shortcut triggers fit the word under the caret. Pure. +const DELIMS = /[,\n;()[\]]/; // a trigger lives between delimiters; brackets wrap one: (trigger:1.5), [trigger@1-3] +const MIN_QUERY = 2; + +/** Triggers containing `query` (prefix matches first), one row per distinct trigger. `library`: the shortcuts. */ +export function matchTriggers(library, query) { + const q = query.toLowerCase(), seen = new Set(), scored = []; + for (const sc of library) { + if (sc.enabled === false) continue; + for (const raw of sc.triggers || []) { + const trigger = String(raw || "").trim(), at = trigger.toLowerCase().indexOf(q); + if (trigger && at >= 0) scored.push({ trigger, sc, rank: at === 0 ? 0 : 1, at }); + } + } + scored.sort((a, b) => a.rank - b.rank || a.at - b.at || a.trigger.length - b.trigger.length); + return scored.filter((e) => !seen.has(e.trigger.toLowerCase()) && seen.add(e.trigger.toLowerCase())); +} + +/** What to offer under the caret: the longest trailing run of words in the current token that matches any trigger. + * -> { span: {start, end}, items } or null. */ +export function suggestionsAt(library, text, caret) { + const head = text.slice(0, caret); + let from = caret; + while (from > 0 && !DELIMS.test(head[from - 1])) from -= 1; + const starts = []; + for (let i = from, space = true; i < caret; i += 1) { const ws = /\s/.test(head[i]); if (!ws && space) starts.push(i); space = ws; } + for (const start of starts) { + const run = head.slice(start, caret); + if (run.trim().length < MIN_QUERY) continue; + const items = matchTriggers(library, run); + if (items.length) return { span: { start, end: caret }, items }; + } + return null; +} + +/** `text` with the span replaced by the trigger and a space after it (none when a separator already follows). -> { text, caret } */ +export function accept(text, span, trigger) { + const after = text.slice(span.end), sep = /^(\s|[,;)\]:@])/.test(after) ? "" : " "; + return { text: text.slice(0, span.start) + trigger + sep + after, caret: span.start + trigger.length + sep.length }; +} diff --git a/app/ui/features/autocomplete/match.test.js b/app/ui/features/autocomplete/match.test.js new file mode 100644 index 00000000..fedf8460 --- /dev/null +++ b/app/ui/features/autocomplete/match.test.js @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { accept, matchTriggers, suggestionsAt } from "./match.js"; + +const lib = [{ name: "a", triggers: ["golden hour", "goldfish"], replacements: ["warm light"] }, { name: "b", triggers: ["rain"], enabled: false }, { name: "c", triggers: ["old gold"] }]; + +test("prefix matches come first, a disabled shortcut never shows", () => { + assert.deepEqual(matchTriggers(lib, "gold").map((e) => e.trigger), ["goldfish", "golden hour", "old gold"]); + assert.deepEqual(matchTriggers(lib, "rain"), []); +}); + +test("the longest trailing run of words that matches wins, within the current token", () => { + const text = "a man, walking in golden ho"; + const r = suggestionsAt(lib, text, text.length); + assert.equal(text.slice(r.span.start, r.span.end), "golden ho"); + assert.equal(r.items[0].trigger, "golden hour"); + assert.equal(suggestionsAt(lib, "x", 1), null); // under two letters: nothing + assert.equal(suggestionsAt(lib, "(zz", 3), null); +}); + +test("accepting replaces only the span and leaves the caret ready for the next word", () => { + const out = accept("a golden ho", { start: 2, end: 11 }, "golden hour"); + assert.deepEqual(out, { text: "a golden hour ", caret: 14 }); + assert.equal(accept("a gold, b", { start: 2, end: 6 }, "goldfish").text, "a goldfish, b"); // a separator already follows +}); diff --git a/app/ui/modules.js b/app/ui/modules.js index dde32086..bb44edf0 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -17,6 +17,7 @@ export default [ "./features/rating/index.js", "./features/upscale/index.js", "./features/revolver/index.js", + "./features/autocomplete/index.js", "./features/status/index.js", "./features/audio/index.js", "./features/montage/index.js",