From 5102dc00356b53dd0544f78d49f5e5f565db067f Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:53:51 +0300 Subject: [PATCH] feat(ui): Shortcut ideas bulb beside the focused prompt box (habits from saved projects + library browse, Editor toggle) Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/composer/index.js | 3 +- app/ui/features/ideas/ideas.js | 49 ++++++++++++++++ app/ui/features/ideas/ideas.test.js | 26 +++++++++ app/ui/features/ideas/index.js | 87 +++++++++++++++++++++++++++++ app/ui/modules.js | 1 + app/ui/shell/api.js | 1 + 6 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 app/ui/features/ideas/ideas.js create mode 100644 app/ui/features/ideas/ideas.test.js create mode 100644 app/ui/features/ideas/index.js diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index 83ade8b2..94330ba8 100644 --- a/app/ui/features/composer/index.js +++ b/app/ui/features/composer/index.js @@ -8,7 +8,6 @@ import { chat } from "./chat.js"; import { pickShortcut, templatesBar, variablesPanel } from "./storytools.js"; const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: "Shortcuts" }, { value: "cuts", label: "Cuts" }, { value: "enhance", label: "Enhance" }, { value: "chat", label: "Chat" }]; -const inert = (label, tone = "ghost") => c.button.sm({ label, tone, disabled: true }); // The box follows the scenes; typing in it rewrites them (after a short pause, or when focus leaves). const story = (app, own) => { @@ -41,7 +40,7 @@ const story = (app, own) => { if (!trigger) return; area.focus(); area.setRangeText(`${area.selectionStart && !/\s$/.test(area.value.slice(0, area.selectionStart)) ? " " : ""}${trigger} `, area.selectionStart, area.selectionEnd, "end"); area.dispatchEvent(new Event("input", { bubbles: true })); - } }), inert("💡")] }), + } })] }), box, warn, c.hint.default({ text: `Scenes are cut at the word “${marker}”. Edits apply to the scenes as you type; the anchor is its own field.` }), c.collapsible.default({ label: "+ Variables", body: variablesPanel(app) }), diff --git a/app/ui/features/ideas/ideas.js b/app/ui/features/ideas/ideas.js new file mode 100644 index 00000000..9d651908 --- /dev/null +++ b/app/ui/features/ideas/ideas.js @@ -0,0 +1,49 @@ +// What a prompt could still use, from the shortcut library and the person's own habits. Pure. A shortcut is keyed by its name. +const usable = (library) => library.filter((s) => s.enabled !== false && (s.triggers || []).length); +const isWord = (ch) => /[a-z0-9_]/.test(ch || ""); + +function hasTrigger(lc, trigger) { // as a whole token: word boundaries on both sides, spaces allowed inside + const t = String(trigger || "").trim().toLowerCase(); + for (let at = t ? lc.indexOf(t) : -1; at >= 0; at = lc.indexOf(t, at + 1)) if (!isWord(lc[at - 1]) && !isWord(lc[at + t.length])) return true; + return false; +} +const uses = (lc, sc) => sc.triggers.some((t) => hasTrigger(lc, t)); + +/** The shortcuts whose trigger is in `text`. */ +export const presentIn = (library, text) => { const lc = String(text || "").toLowerCase(); return lc.trim() ? usable(library).filter((s) => uses(lc, s)) : []; }; + +/** Library grouped by category: `used` have a trigger in the text, `missing` have none. */ +export function analyze(library, text) { + const lc = String(text || "").toLowerCase(), groups = new Map(); + for (const sc of usable(library)) { + const cat = sc.category || "other"; + if (!groups.has(cat)) groups.set(cat, { cat, used: false, items: [] }); + const g = groups.get(cat); + g.items.push(sc); + g.used = g.used || uses(lc, sc); + } + const all = [...groups.values()]; + return { used: all.filter((g) => g.used), missing: all.filter((g) => !g.used) }; +} + +/** Habit partners of the context shortcuts: `edges` is [[a, b, n]] (stats.pairs is symmetric once read both ways). + * -> [{ sc, n, with }] strongest first; one-offs (n < 2) are not a habit yet. */ +export function byHabit(library, edges, context, exclude, { both = false, limit = 4 } = {}) { + const byName = new Map(usable(library).map((s) => [s.name, s])), score = new Map(); + for (const [a, b, n] of edges) { + for (const [from, to] of both ? [[a, b], [b, a]] : [[a, b]]) { + if (!context.has(from) || exclude.has(to) || !byName.has(to)) continue; + const cur = score.get(to) || { n: 0, with: from, best: 0 }; + cur.n += n; + if (n > cur.best) { cur.best = n; cur.with = from; } + score.set(to, cur); + } + } + return [...score].filter(([, v]) => v.n >= 2).sort((x, y) => y[1].n - x[1].n).slice(0, limit).map(([name, v]) => ({ sc: byName.get(name), n: v.n, with: byName.get(v.with) })); +} + +export const shuffled = (list, random = Math.random) => { + const a = list.slice(); + for (let i = a.length - 1; i > 0; i -= 1) { const j = Math.floor(random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } + return a; +}; diff --git a/app/ui/features/ideas/ideas.test.js b/app/ui/features/ideas/ideas.test.js new file mode 100644 index 00000000..0504f823 --- /dev/null +++ b/app/ui/features/ideas/ideas.test.js @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { analyze, byHabit, presentIn } from "./ideas.js"; + +const lib = [ + { name: "rain", triggers: ["rain"], category: "weather" }, { name: "fog", triggers: ["fog"], category: "weather" }, + { name: "dolly", triggers: ["dolly in"], category: "camera" }, { name: "off", triggers: ["zoom"], category: "camera", enabled: false }, { name: "x", triggers: [], category: "none" }, +]; + +test("a trigger counts only as a whole token", () => { + assert.deepEqual(presentIn(lib, "a train in the rain").map((s) => s.name), ["rain"]); + assert.deepEqual(presentIn(lib, "constraint").map((s) => s.name), []); + assert.deepEqual(presentIn(lib, "a DOLLY IN shot").map((s) => s.name), ["dolly"]); +}); + +test("a category is used when any of its triggers is in the text; disabled and trigger-less shortcuts are ignored", () => { + const { used, missing } = analyze(lib, "rain"); + assert.deepEqual(used.map((g) => g.cat), ["weather"]); + assert.deepEqual(missing.map((g) => g.cat), ["camera"]); +}); + +test("habits: partners of what is here, strongest first, one-offs ignored, nothing already used", () => { + const edges = [["rain", "dolly", 3], ["rain", "fog", 1], ["fog", "dolly", 2]]; + const got = byHabit(lib, edges, new Set(["rain"]), new Set(["rain"]), { both: true }); + assert.deepEqual(got.map((g) => [g.sc.name, g.n, g.with.name]), [["dolly", 3, "rain"]]); +}); diff --git a/app/ui/features/ideas/index.js b/app/ui/features/ideas/index.js new file mode 100644 index 00000000..8e4e7a24 --- /dev/null +++ b/app/ui/features/ideas/index.js @@ -0,0 +1,87 @@ +// 💡 Shortcut ideas: a bulb beside the prompt box you are in. It never opens by itself; it glows while shortcut categories go unused, +// and its list mixes your own habits (what you paired before, what usually follows the previous shot) with browsing the library. +import { composer as c } from "../../composer/composer.js"; +import { claimFor } from "../../composer/internals/zlayer.js"; +import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js"; +import { analyze, byHabit, presentIn, shuffled } from "./ideas.js"; + +const BOXES = new Set(["Story", "Prompt"]); +const first = (sc) => String((sc.triggers || [])[0] || "").trim(); + +export default { + id: "ideas", + mount: "menubar.menus", + needs: ["project", "api", "selection"], + setup({ app }) { + const p = app.project, on = () => p.pref("suggestions", true); + let box = null, node = null, layer = null, pop = null, hold = 0; + const closeBulb = () => { if (pop) pop.close(); if (node) { node.remove(); layer.release(); node = layer = box = null; } }; + const prevText = () => { // the previous shot's prompt, for "your usual next shot" (scene prompt only) + const open = p.project; if (!open || box.getAttribute("aria-label") !== "Prompt") return ""; + const roots = open.scenes.filter((s) => isGenerative(s) && unitRoot(open, genUnitId(s)) === s), at = roots.findIndex((s) => s.id === app.selection.focus); + return at > 0 ? roots[at - 1].text || "" : ""; + }; + const insert = (text) => { + const v = box.value, s = box.selectionStart ?? v.length, e = box.selectionEnd ?? s, before = v.slice(0, s), after = v.slice(e); + const ins = `${before && !/\s$/.test(before) ? " " : ""}${text}${/^(\s|[,;])/.test(after) ? "" : " "}`; + box.value = before + ins + after; box.setSelectionRange(s + ins.length, s + ins.length); + box.dispatchEvent(new Event("input", { bubbles: true })); box.focus(); + }; + + async function openList() { + if (pop) return pop.close(); + let library = [], stats = null; + try { library = (await app.api.shortcuts()).shortcuts || []; } catch (err) { return c.toast.warn({ text: err.message }); } + try { stats = await app.api.suggestionStats(); } catch { /* habits are optional */ } + const text = box.value, { used, missing } = analyze(library, text), here = presentIn(library, text), hereNames = new Set(here.map((s) => s.name)); + const body = c.region.stack({ gap: "xs" }); + const row = (sc, why) => c.button.sm({ label: `${first(sc)}${(sc.replacements || [])[0] ? ` — ${sc.replacements[0].slice(0, 40)}` : ""}${why ? ` (${why})` : ""}`, tone: "ghost", onClick: () => { insert(first(sc)); pop && pop.close(); } }); + const draw = () => { + const parts = []; + if (stats && hereNames.size) { + const pairs = byHabit(library, stats.pairs || [], hereNames, hereNames, { both: true }); + if (pairs.length) parts.push(c.label.section({ text: "You've paired these before" }), ...pairs.map((i) => row(i.sc, `with ${first(i.with)} ×${i.n}`))); + } + const prev = new Set(presentIn(library, prevText()).map((s) => s.name)); + if (stats && prev.size) { + const next = byHabit(library, stats.follows || [], prev, hereNames); + if (next.length) parts.push(c.label.section({ text: "Your usual next shot" }), ...next.map((i) => row(i.sc, `after ${first(i.with)} ×${i.n}`))); + } + const more = used.map((g) => ({ cat: g.cat, items: g.items.filter((s) => !hereNames.has(s.name)) })).filter((g) => g.items.length); + if (more.length) parts.push(c.label.section({ text: "Continue the thread" }), ...shuffled(more).slice(0, 3).flatMap((g) => [c.hint.default({ text: `more ${g.cat}` }), ...shuffled(g.items).slice(0, 2).map((s) => row(s))])); + if (missing.length) parts.push(c.label.section({ text: "New directions" }), ...missing.sort((a, b) => a.cat.localeCompare(b.cat)).flatMap((g) => [c.hint.default({ text: `${g.cat} (${g.items.length})` }), ...shuffled(g.items).slice(0, 3).map((s) => row(s))])); + if (!parts.length) parts.push(c.hint.default({ text: "Every shortcut category already appears here — nothing left to browse." })); + body.set([c.toolbar.default({ items: [c.label.section({ text: "Ideas" })], trailing: [c.button.sm({ label: "⟳", tone: "ghost", title: "Shuffle the samples", onClick: draw })] }), ...parts]); + }; + draw(); + pop = c.popover.anchored({ anchor: node, body, side: "bottom", align: "end", onClose: () => { pop = null; } }); + } + + function show(target) { + if (box === target || !on()) return; + closeBulb(); + box = target; + const button = c.button.sm({ label: "💡", tone: "ghost", title: "Shortcut ideas", onClick: openList }); + node = button.node; + Object.assign(node.style, { position: "fixed" }); + document.body.append(node); layer = claimFor("popover", node); + place(); + } + function place() { + if (!box || !node) return; + const r = box.isConnected ? box.getBoundingClientRect() : null; + if (!r || !r.width) return closeBulb(); // gone, or hidden with its panel + Object.assign(node.style, { left: `${Math.max(4, r.right - 40)}px`, top: `${Math.max(4, r.top - 30)}px` }); + } + const onFocus = (e) => { const t = e.target; if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { clearTimeout(hold); show(t); } }; + const onBlur = (e) => { if (e.target === box) hold = setTimeout(() => { if (node && !node.contains(document.activeElement) && !pop) closeBulb(); }, 400); }; + const onMove = () => place(); + document.addEventListener("focusin", onFocus); document.addEventListener("focusout", onBlur); + window.addEventListener("scroll", onMove, true); window.addEventListener("resize", onMove); + + app.editorSettings.push(() => c.region.stack({ gap: "sm", children: [ + c.toggle.default({ label: "Shortcut ideas", hint: "A 💡 beside prompt boxes lights up when shortcut categories in your library aren't used in the prompt. Click it for insertable ideas; it never pops up on its own.", + checked: on(), onChange: (v) => { p.setPref("suggestions", v); if (!v) closeBulb(); } })] })); + return () => { closeBulb(); clearTimeout(hold); document.removeEventListener("focusin", onFocus); document.removeEventListener("focusout", onBlur); window.removeEventListener("scroll", onMove, true); window.removeEventListener("resize", onMove); }; + }, +}; diff --git a/app/ui/modules.js b/app/ui/modules.js index bb44edf0..218bf6c8 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -18,6 +18,7 @@ export default [ "./features/upscale/index.js", "./features/revolver/index.js", "./features/autocomplete/index.js", + "./features/ideas/index.js", "./features/status/index.js", "./features/audio/index.js", "./features/montage/index.js", diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 785a8f0a..92f83b5e 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -76,6 +76,7 @@ export const api = { const videos = Object.values(entry.outputs || {}).flatMap((o) => o.videos || []); return videos.length ? { videos } : { error: "no video came out, check the ComfyUI terminal" }; }, + suggestionStats: () => call("GET", "/api/shortcuts/suggestion_stats"), expandPrompt: (body) => call("POST", "/api/prompt/expand", { ...body, seed: 1 }), packs: () => call("GET", "/api/packs"), pack: (action, body) => call("POST", `/api/packs/${action}`, body || {}),