diff --git a/app/ui/composer/elements/popover.js b/app/ui/composer/elements/popover.js index 95320fbd..949623e2 100644 --- a/app/ui/composer/elements/popover.js +++ b/app/ui/composer/elements/popover.js @@ -198,7 +198,10 @@ define("autocomplete", "default", ({ input, source, onPick, minChars = 1 } = {}) if (!live) return; if (e.key === "ArrowDown") { e.preventDefault(); index = Math.min(index + 1, current.length - 1); draw(current); } else if (e.key === "ArrowUp") { e.preventDefault(); index = Math.max(index - 1, 0); draw(current); } - else if (e.key === "Enter" && index >= 0) { e.preventDefault(); if (onPick) onPick(current[index]); close(); } + else if ((e.key === "Enter" && index >= 0) || (e.key === "Tab" && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey)) { + // Tab takes the highlighted suggestion, or the first one; it is a plain Tab only when nothing is offered. + e.preventDefault(); if (onPick) onPick(current[Math.max(index, 0)]); close(); + } }; if (field) { diff --git a/app/ui/composer/tests/overlays.test.js b/app/ui/composer/tests/overlays.test.js index ab3bc648..9a38e435 100644 --- a/app/ui/composer/tests/overlays.test.js +++ b/app/ui/composer/tests/overlays.test.js @@ -661,3 +661,22 @@ test("a toast with no words is not shown at all", () => { composer.toast.warn({ text: null }); assert.equal(document.querySelectorAll(".cx-toast").length, before); }); + +test("Tab takes the first suggestion (or the highlighted one); with no menu it stays a plain Tab", () => { + const picked = []; + const input = composer.input.md({}); + document.body.appendChild(input.node); + const ac = composer.autocomplete.default({ input, source: () => [{ label: "a" }, { label: "b" }], onPick: (i) => picked.push(i.label), minChars: 1 }); + const tab = () => { const e = new window.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }); input.node.dispatchEvent(e); return e.defaultPrevented; }; + assert.equal(tab(), false, "no menu: Tab is left alone"); + input.node.value = "x"; fire(input.node, "input"); + assert.equal(tab(), true); + assert.deepEqual(picked, ["a"]); + assert.equal(document.querySelector(".cx-autocomplete"), null, "the menu closed"); + input.node.value = "xy"; fire(input.node, "input"); + input.node.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + input.node.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + tab(); + assert.deepEqual(picked, ["a", "b"]); + ac.destroy(); input.node.remove(); +}); diff --git a/app/ui/features/autocomplete/index.js b/app/ui/features/autocomplete/index.js index 179b529a..ba90f8f2 100644 --- a/app/ui/features/autocomplete/index.js +++ b/app/ui/features/autocomplete/index.js @@ -3,7 +3,7 @@ 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 +const BOXES = new Set(["Story", "Prompt", "Anchor", "Postfix"]); // aria-labels of the prompt fields export default { id: "autocomplete", diff --git a/app/ui/features/settings/modules.js b/app/ui/features/settings/modules.js index 05c59f08..f8ed51c8 100644 --- a/app/ui/features/settings/modules.js +++ b/app/ui/features/settings/modules.js @@ -13,7 +13,10 @@ export const modules = (app) => function mount() { // The project remembers which add-ons it uses, so a new rental can offer them again on load. const remember = (id, on) => { const now = app.project.pref("addons", []) || [], next = on ? [...new Set([...now, id])] : now.filter((i) => i !== id); app.project.setPref("addons", next); }; const flip = async (a, on) => { - try { addons = await app.api.setAddon(a.id, on); remember(a.id, on); } catch (err) { c.toast.warn({ text: `Could not change ${a.title}: ${err.message}` }); } + try { + addons = await app.api.setAddon(a.id, on); remember(a.id, on); + if (on && addons.some((x) => x.id === a.id && x.state === "pending")) c.toast.info({ text: `${a.title} is not installed yet: restart ComfyUI to download and install it.`, duration: 10000 }); + } catch (err) { c.toast.warn({ text: `Could not change ${a.title}: ${err.message}` }); } draw(); }; const addonRow = (a) => { @@ -48,7 +51,9 @@ export const modules = (app) => function mount() { checked: ps.allOff(), onChange: (on) => ps.setAllOff(on).then(draw) }); const section = (label, rows) => rows.length ? [c.label.section({ text: label }), ...rows] : []; const installed = mine.filter((m) => !isOff(m)); + const waiting = addons.filter((a) => a.state === "pending"); page.set([notes.length ? c.banner.warn({ text: notes.join(" ") }) : null, + waiting.length ? c.banner.info({ text: `Restart ComfyUI to finish: ${waiting.map((a) => a.title).join(", ")} will be installed (their dependencies downloaded) while ComfyUI starts, and work after that.`, action: { label: "Restart ComfyUI", onClick: () => app.maintenance.restart() } }) : null, ...section("Available modules", offered.map(addonRow)), ...section("Installed modules", [...onAddons.map(addonRow), ...CATEGORIES.flatMap(([key]) => installed.filter((m) => (m.category || "") === key).map(row))]), ...section("Disabled modules", [...offAddons.map(addonRow), ...mine.filter(isOff).map(row)]), diff --git a/app/ui/shell/maintenance.js b/app/ui/shell/maintenance.js index 4905765a..0adeddcc 100644 --- a/app/ui/shell/maintenance.js +++ b/app/ui/shell/maintenance.js @@ -115,7 +115,9 @@ export function createMaintenance({ api, flush }) { return run("Switching torch to CUDA 13…", action, (r) => r.message || ""); }, async restart() { - if (!(await ask("Restart ComfyUI", "Restart now? The server is down for 10–40 s and any running generation is lost. This page reloads when it is back."))) return; + let waiting = []; try { waiting = (await api.addons()).filter((a) => a.state === "pending").map((a) => a.title); } catch { /* not knowing is not a reason to refuse */ } + const installs = waiting.length ? `\n\nOn the way up it will also download and install: ${waiting.join(", ")}. That can take a few minutes more.` : ""; + if (!(await ask("Restart ComfyUI", `Restart now? The server is down for 10–40 s and any running generation is lost. This page reloads when it is back.${installs}`))) return; const action = () => api.git("restart"); action.dropsConnection = true; return run("Restarting ComfyUI…", action); }, diff --git a/app/ui/shell/maintenance.test.js b/app/ui/shell/maintenance.test.js index 8789d823..19c0d2bd 100644 --- a/app/ui/shell/maintenance.test.js +++ b/app/ui/shell/maintenance.test.js @@ -2,10 +2,10 @@ import test from "node:test"; import assert from "node:assert/strict"; import { setupDom, teardownDom } from "../composer/tests/_dom.js"; +test.before(() => setupDom()); const tick = (ms = 30) => new Promise((r) => setTimeout(r, ms)); test("Restart ComfyUI says it is restarting once, not twice", async () => { - setupDom(); const { createMaintenance } = await import("./maintenance.js"); // The server answers that it is restarting, then stays down. const api = { git: async () => ({ ok: true, restarting: true }), health: async () => { throw new Error("down"); } }; @@ -15,8 +15,17 @@ test("Restart ComfyUI says it is restarting once, not twice", async () => { [...document.querySelectorAll("button")].find((b) => /Go ahead/.test(b.textContent)).click(); await tick(100); const said = (document.body.textContent.match(/Restarting ComfyUI…/g) || []).length; - teardownDom(); assert.equal(said, 1, "the working card repeats the restarting line"); }); +test("Restart ComfyUI says which modules it will install on the way up", async () => { + const { createMaintenance } = await import("./maintenance.js"); + const api = { addons: async () => [{ title: "Sharp Hands", state: "pending" }, { title: "Other", state: "installed" }], git: async () => ({ ok: true, restarting: true }), health: async () => { throw new Error("down"); } }; + createMaintenance({ api, flush: async () => {} }).restart(); + await tick(100); + const text = document.body.textContent; + [...document.querySelectorAll("button")].find((b) => /Go ahead/.test(b.textContent))?.click(); + assert.match(text, /download and install: Sharp Hands/); + assert.doesNotMatch(text, /Other/); +}); // The restart card polls the stopped server forever by design, so the process is ended when the test is done. -test.after(() => setTimeout(() => process.exit(0), 10)); +test.after(() => { teardownDom(); setTimeout(() => process.exit(0), 10); });