diff --git a/tests/test_loaded_model_selection.mjs b/tests/test_loaded_model_selection.mjs
new file mode 100644
index 0000000..ab9a21c
--- /dev/null
+++ b/tests/test_loaded_model_selection.mjs
@@ -0,0 +1,534 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { Window } from 'happy-dom';
+
+import {
+ initLoadedModelSelection,
+ updateLoadedModelSelectionState,
+ getVisibleModelChips,
+ getSelectedModelChips,
+ getSelectedModelTokens,
+ clearLoadedModelSelection,
+} from '../web/resolver/utils/loaded_model_selection.js';
+import {
+ groupLoadedModelsByCategory,
+ buildLoadedModelTokenStrings,
+} from '../web/resolver/utils/loaded_model_utils.js';
+
+const getSvgIcon = () => '';
+void groupLoadedModelsByCategory;
+void buildLoadedModelTokenStrings;
+void getSvgIcon;
+
+function setupDomEnvironment() {
+ const window = new Window();
+ const document = window.document;
+ globalThis.window = window;
+ globalThis.document = document;
+ try {
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ writable: true,
+ value: window.navigator,
+ });
+ } catch {
+ // Fallback for environments where navigator cannot be redefined
+ }
+
+ window.MLSetCopyMode = function(sectionId, mode) {
+ const section = document.getElementById(sectionId);
+ if (!section) return;
+ const normalizedMode = ['all', 'active', 'inactive', 'selected'].includes(mode) ? mode : 'all';
+ if (normalizedMode !== 'selected') {
+ section.dataset.mlPreviousMode = normalizedMode;
+ }
+ const codeEl = section.querySelector('.mr-copy-code');
+ const labelEl = section.querySelector('.mr-copy-label');
+ const metaEl = section.querySelector('.mr-copy-meta');
+ const dataKey = normalizedMode === 'all'
+ ? 'mlAll'
+ : normalizedMode === 'active'
+ ? 'mlActive'
+ : normalizedMode === 'inactive'
+ ? 'mlInactive'
+ : 'mlSelected';
+ const countKey = normalizedMode === 'all'
+ ? 'mlAllCount'
+ : normalizedMode === 'active'
+ ? 'mlActiveCount'
+ : normalizedMode === 'inactive'
+ ? 'mlInactiveCount'
+ : 'mlSelectedCount';
+ const labelMap = {
+ all: 'Copy all',
+ active: 'Copy active',
+ inactive: 'Copy inactive',
+ selected: 'Copy selected'
+ };
+
+ if (codeEl) codeEl.textContent = section.dataset[dataKey] || '';
+ if (labelEl) labelEl.textContent = labelMap[normalizedMode];
+ if (metaEl) {
+ const count = Number(section.dataset[countKey] || 0);
+ metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`;
+ }
+
+ section.querySelectorAll('.mr-copy-mode').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode);
+ });
+ };
+
+ window.MLSetCopyButtonFeedback = function(btn, text) {
+ if (!btn) return;
+ btn.textContent = text;
+ };
+
+ return { window, document };
+}
+
+function createLoadedModelsMarkup(copySectionId = 'mr-copy-test') {
+ return `
+
+
+
+
+
+
+ lora_a0.80
+ lora_b1.00
+ lora_c0.50
+
+
+
+
+
+
+ `;
+}
+
+test('single click selects only clicked chip and switches copy toolbar to selected mode', () => {
+ const { document } = setupDomEnvironment();
+ const container = document.createElement('div');
+ container.innerHTML = createLoadedModelsMarkup();
+ document.body.appendChild(container);
+
+ const cleanup = initLoadedModelSelection(container);
+
+ const chips = container.querySelectorAll('.mr-model-chip');
+ chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
+
+ assert.equal(chips[1].classList.contains('is-selected'), true);
+ assert.equal(chips[0].classList.contains('is-selected'), false);
+ assert.equal(chips[2].classList.contains('is-selected'), false);
+
+ const copySection = container.querySelector('.mr-copy-section');
+ assert.equal(copySection.dataset.mlSelected, '');
+ assert.equal(copySection.dataset.mlSelectedCount, '1');
+
+ const selectedBtn = copySection.querySelector('.mr-copy-mode-selected');
+ assert.equal(selectedBtn.style.display, '');
+ assert.equal(selectedBtn.classList.contains('active'), true);
+
+ const codeEl = copySection.querySelector('.mr-copy-code');
+ assert.equal(codeEl.textContent, '');
+
+ cleanup();
+});
+
+test('ctrl+click adds and toggles chips in multi-selection', () => {
+ const { document } = setupDomEnvironment();
+ const container = document.createElement('div');
+ container.innerHTML = createLoadedModelsMarkup();
+ document.body.appendChild(container);
+
+ const cleanup = initLoadedModelSelection(container);
+ const chips = container.querySelectorAll('.mr-model-chip');
+
+ // Select first chip with regular click
+ chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
+ assert.deepEqual(getSelectedModelTokens(container), ['']);
+
+ // Ctrl+click second chip
+ chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
+ assert.deepEqual(getSelectedModelTokens(container), [
+ '',
+ '',
+ ]);
+
+ const copySection = container.querySelector('.mr-copy-section');
+ assert.equal(copySection.dataset.mlSelected, ' ');
+ assert.equal(copySection.dataset.mlSelectedCount, '2');
+
+ // Ctrl+click to toggle off the first chip
+ chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
+ assert.deepEqual(getSelectedModelTokens(container), ['']);
+ assert.equal(copySection.dataset.mlSelected, '');
+
+ cleanup();
+});
+
+test('shift+click performs range selection from previous anchor', () => {
+ const { document } = setupDomEnvironment();
+ const container = document.createElement('div');
+ container.innerHTML = createLoadedModelsMarkup();
+ document.body.appendChild(container);
+
+ const cleanup = initLoadedModelSelection(container);
+ const chips = container.querySelectorAll('.mr-model-chip');
+
+ // Click on index 1 (lora_a) as anchor
+ chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
+ assert.deepEqual(getSelectedModelTokens(container), ['']);
+
+ // Shift+click on index 3 (lora_c) -> range 1..3 selected
+ chips[3].dispatchEvent(new window.MouseEvent('click', { bubbles: true, shiftKey: true }));
+ assert.deepEqual(getSelectedModelTokens(container), [
+ '',
+ '',
+ '',
+ ]);
+
+ // Shift+click again on index 2 (lora_b) from same anchor -> range 1..2 selected
+ chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, shiftKey: true }));
+ assert.deepEqual(getSelectedModelTokens(container), [
+ '',
+ '',
+ ]);
+
+ cleanup();
+});
+
+test('ctrl+shift+click expands existing selection with range', () => {
+ const { document } = setupDomEnvironment();
+ const container = document.createElement('div');
+ container.innerHTML = createLoadedModelsMarkup();
+ document.body.appendChild(container);
+
+ const cleanup = initLoadedModelSelection(container);
+ const chips = container.querySelectorAll('.mr-model-chip');
+
+ // Select index 0 (checkpoint)
+ chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
+
+ // Click index 2 as anchor with Ctrl
+ chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
+ assert.deepEqual(getSelectedModelTokens(container), [
+ '',
+ '',
+ ]);
+
+ // Ctrl+Shift+click index 4 (inactive lora_d) -> adds 2..4 to selection without removing index 0
+ chips[4].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true, shiftKey: true }));
+ assert.deepEqual(getSelectedModelTokens(container), [
+ '',
+ '',
+ '',
+ '',
+ ]);
+
+ cleanup();
+});
+
+test('clicking blank area deselects all chips and reverts copy mode', () => {
+ const { document } = setupDomEnvironment();
+ const container = document.createElement('div');
+ container.innerHTML = createLoadedModelsMarkup();
+ document.body.appendChild(container);
+
+ const cleanup = initLoadedModelSelection(container);
+ const chips = container.querySelectorAll('.mr-model-chip');
+
+ // Select a chip
+ chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
+ assert.equal(getSelectedModelChips(container).length, 1);
+
+ // Click on empty list space
+ const list = container.querySelector('.mr-models-list');
+ list.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
+
+ assert.equal(getSelectedModelChips(container).length, 0);
+ const copySection = container.querySelector('.mr-copy-section');
+ assert.equal(copySection.dataset.mlSelectedCount, '0');
+ assert.equal(copySection.querySelector('.mr-copy-mode-selected').style.display, 'none');
+
+ // Reverted back to 'all'
+ const activeModeBtn = copySection.querySelector('.mr-copy-mode.active');
+ assert.equal(activeModeBtn.dataset.mlCopyMode, 'all');
+
+ cleanup();
+});
+
+test('escape key clears selection', () => {
+ const { document } = setupDomEnvironment();
+ const container = document.createElement('div');
+ container.innerHTML = createLoadedModelsMarkup();
+ document.body.appendChild(container);
+
+ const cleanup = initLoadedModelSelection(container);
+ const chips = container.querySelectorAll('.mr-model-chip');
+
+ chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
+ assert.equal(getSelectedModelChips(container).length, 1);
+
+ document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ assert.equal(getSelectedModelChips(container).length, 0);
+
+ cleanup();
+});
+
+test('ctrl+a selects all visible chips', () => {
+ const { document } = setupDomEnvironment();
+ const container = document.createElement('div');
+ container.innerHTML = createLoadedModelsMarkup();
+ document.body.appendChild(container);
+
+ const cleanup = initLoadedModelSelection(container);
+ const chips = container.querySelectorAll('.mr-model-chip');
+
+ // Make inactive section hidden like MLFilterSwitch('active') does
+ const inactiveGroup = container.querySelector('.mr-model-group-inactive');
+ inactiveGroup.style.display = 'none';
+
+ document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true }));
+
+ const selected = getSelectedModelChips(container);
+ assert.equal(selected.length, 4); // Only the 4 visible active chips
+ assert.equal(chips[4].classList.contains('is-selected'), false);
+
+ cleanup();
+});
+
+test('ctrl+c writes selected tokens to clipboard with feedback', async () => {
+ const { document } = setupDomEnvironment();
+ const container = document.createElement('div');
+ container.innerHTML = createLoadedModelsMarkup();
+ document.body.appendChild(container);
+
+ let writtenText = '';
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ writable: true,
+ value: {
+ clipboard: {
+ writeText: async (text) => {
+ writtenText = text;
+ }
+ }
+ }
+ });
+
+ const notifications = [];
+ const dialog = {
+ activeTab: 'loaded',
+ showNotification: (msg, type) => notifications.push({ msg, type })
+ };
+
+ const cleanup = initLoadedModelSelection(container, dialog);
+ const chips = container.querySelectorAll('.mr-model-chip');
+
+ // Select two chips
+ chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
+ chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
+
+ // Press Ctrl+C
+ document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }));
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ assert.equal(writtenText, ' ');
+ assert.equal(notifications.length, 1);
+ assert.match(notifications[0].msg, /Copied 2 model tokens to clipboard/);
+
+ cleanup();
+});
+
+test('displayLoadedModels renders chips with data-ml-token and includes Selected mode button', () => {
+ const { document } = setupDomEnvironment();
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
+ const source = fs.readFileSync(path.join(__dirname, '../web/resolver/views/tabs_loaded_methods.js'), 'utf-8');
+
+ function extractMethod(sourceCode, methodName) {
+ const regex = new RegExp(`\\n\\s+${methodName}\\s*\\(([^)]*\\{[^}]*\\}|[^)]*)\\)\\s*\\{`);
+ const match = regex.exec(sourceCode);
+ assert.ok(match, `Could not find ${methodName}`);
+ const params = match[1];
+ const braceStart = match.index + match[0].length - 1;
+ let depth = 0;
+ for (let i = braceStart; i < sourceCode.length; i += 1) {
+ if (sourceCode[i] === '{') depth += 1;
+ if (sourceCode[i] === '}') depth -= 1;
+ if (depth === 0) {
+ return `function ${methodName}(${params}) ${sourceCode.slice(braceStart, i + 1)}`;
+ }
+ }
+ throw new Error(`Unmatched braces for ${methodName}`);
+ }
+
+ const displayLoadedModels = eval(`(${extractMethod(source, 'displayLoadedModels')})`);
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const dialog = {
+ getModelNameAndStrength: model => ({
+ name: model.name,
+ strength: model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null
+ }),
+ getFilenameFromPath: pathVal => pathVal,
+ stripModelExtension: name => name,
+ getCategoryTokenName: cat => cat.replace(/s$/, ''),
+ getModelToken: (model, cat) => {
+ const str = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00';
+ return `<${cat.replace(/s$/, '')}:${model.name}:${str}>`;
+ },
+ getLoadedModelDomKey: model => model.name,
+ getLoadedModelContext: model => model,
+ getContextMenuAttrs: () => '',
+ getModelPreviewTooltipAttrs: () => '',
+ getCategoryDisplayName: cat => cat,
+ escapeHtml: str => String(str),
+ escapeJsString: str => JSON.stringify(str),
+ bindLoadedModelSelection: () => {}
+ };
+
+ displayLoadedModels.call(dialog, container, {
+ loaded_models: [
+ { category: 'checkpoints', name: 'model_1', active: true, connected: true },
+ { category: 'loras', name: 'lora_1', strength: 0.8, active: true, connected: true },
+ { category: 'loras', name: 'lora_2', strength: 1.0, active: false, connected: true },
+ ],
+ total: 3
+ });
+
+ const chips = container.querySelectorAll('.mr-model-chip');
+ assert.equal(chips.length, 3);
+ assert.equal(chips[0].getAttribute('data-ml-token'), '');
+ assert.equal(chips[1].getAttribute('data-ml-token'), '');
+ assert.equal(chips[2].getAttribute('data-ml-token'), '');
+
+ const selectedBtn = container.querySelector('.mr-copy-mode-selected');
+ assert.ok(selectedBtn, 'Selected mode button should be present in DOM');
+ assert.equal(selectedBtn.getAttribute('data-ml-copy-mode'), 'selected');
+});
+
+test('ctrl+click works reliably in metadata tab even when loaded tab was previously bound', () => {
+ const { document } = setupDomEnvironment();
+ const contentElement = document.createElement('div');
+ contentElement.id = 'mr-content-element';
+ document.body.appendChild(contentElement);
+
+ const dialog = {
+ activeTab: 'loaded',
+ contentElement,
+ _mlSelectionCleanup: null,
+ bindLoadedModelSelection(container) {
+ if (typeof this._mlSelectionCleanup === 'function') {
+ this._mlSelectionCleanup();
+ this._mlSelectionCleanup = null;
+ }
+ const cleanup = initLoadedModelSelection(container, this);
+ this._mlSelectionCleanup = cleanup;
+ return cleanup;
+ }
+ };
+
+ // 1. User was first on "Loaded Models" tab
+ contentElement.innerHTML = createLoadedModelsMarkup('mr-copy-loaded');
+ dialog.bindLoadedModelSelection(contentElement);
+
+ // 2. User switches to "Metadata" tab
+ dialog.activeTab = 'metadata';
+ const metadataShell = document.createElement('div');
+ metadataShell.className = 'mr-image-inspector';
+ const metadataResult = document.createElement('div');
+ metadataResult.setAttribute('data-image-inspector-result', '');
+ const modelContainer = document.createElement('div');
+ modelContainer.id = 'mr-image-inspector-models-12345';
+ modelContainer.innerHTML = createLoadedModelsMarkup('mr-copy-metadata');
+ metadataResult.appendChild(modelContainer);
+ metadataShell.appendChild(metadataResult);
+ contentElement.appendChild(metadataShell);
+
+ dialog.bindLoadedModelSelection(modelContainer);
+
+ // 3. User Ctrl+clicks chips in Metadata tab
+ const metaChips = modelContainer.querySelectorAll('.mr-model-chip');
+ assert.equal(metaChips.length, 5);
+
+ // Ctrl+click first chip
+ metaChips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
+ assert.equal(metaChips[0].classList.contains('is-selected'), true, 'First chip must remain selected after Ctrl+click in metadata tab');
+ assert.deepEqual(getSelectedModelTokens(modelContainer), ['']);
+
+ // Ctrl+click second chip
+ metaChips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
+ assert.equal(metaChips[1].classList.contains('is-selected'), true, 'Second chip must remain selected after Ctrl+click');
+ assert.deepEqual(getSelectedModelTokens(modelContainer), [
+ '',
+ ''
+ ]);
+
+ // Ctrl+click first chip again to toggle it off
+ metaChips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
+ assert.equal(metaChips[0].classList.contains('is-selected'), false, 'First chip must be toggled off');
+ assert.deepEqual(getSelectedModelTokens(modelContainer), ['']);
+
+ const copySection = modelContainer.querySelector('.mr-copy-section');
+ assert.equal(copySection.dataset.mlSelected, '');
+ assert.equal(copySection.dataset.mlSelectedCount, '1');
+
+ dialog._mlSelectionCleanup?.();
+});
+
+test('chip click event propagation is stopped to prevent parent container interference', () => {
+ const { document } = setupDomEnvironment();
+ const parentContainer = document.createElement('div');
+ const childContainer = document.createElement('div');
+ childContainer.innerHTML = createLoadedModelsMarkup('mr-copy-nested');
+ parentContainer.appendChild(childContainer);
+ document.body.appendChild(parentContainer);
+
+ let parentClickFired = false;
+ parentContainer.addEventListener('click', () => {
+ parentClickFired = true;
+ });
+
+ const cleanup = initLoadedModelSelection(childContainer);
+ const chip = childContainer.querySelector('.mr-model-chip');
+ chip.dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
+
+ assert.equal(chip.classList.contains('is-selected'), true);
+ assert.equal(parentClickFired, false, 'Parent container should not receive click event due to stopPropagation');
+
+ cleanup();
+});
+
diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css
index 03d5360..4d5585a 100644
--- a/web/css/resolver-main.css
+++ b/web/css/resolver-main.css
@@ -4305,6 +4305,25 @@
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-md);
color: var(--mr-text);
+ cursor: pointer;
+ user-select: none;
+ outline: 1.5px solid transparent;
+ outline-offset: 1px;
+ transition: outline-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.mr-model-chip:hover {
+ background: #464646;
+}
+
+.mr-model-chip.is-selected {
+ outline-color: var(--mr-accent, #72d38a);
+ background: #3d453f;
+ box-shadow: 0 0 0 1px rgba(114, 211, 138, 0.25);
+}
+
+.mr-model-chip-list-inactive .mr-model-chip.is-selected {
+ opacity: 1;
}
.mr-model-chip-category {
diff --git a/web/resolver/globals.js b/web/resolver/globals.js
index f8c5dbd..7ac7579 100644
--- a/web/resolver/globals.js
+++ b/web/resolver/globals.js
@@ -27,16 +27,32 @@ export function registerGlobalHelpers() {
const section = document.getElementById(sectionId);
if (!section) return;
- const normalizedMode = ['all', 'active', 'inactive'].includes(mode) ? mode : 'all';
+ const normalizedMode = ['all', 'active', 'inactive', 'selected'].includes(mode) ? mode : 'all';
+ if (normalizedMode !== 'selected') {
+ section.dataset.mlPreviousMode = normalizedMode;
+ }
const codeEl = section.querySelector('.mr-copy-code');
const labelEl = section.querySelector('.mr-copy-label');
const metaEl = section.querySelector('.mr-copy-meta');
- const dataKey = normalizedMode === 'all' ? 'mlAll' : normalizedMode === 'active' ? 'mlActive' : 'mlInactive';
- const countKey = normalizedMode === 'all' ? 'mlAllCount' : normalizedMode === 'active' ? 'mlActiveCount' : 'mlInactiveCount';
+ const dataKey = normalizedMode === 'all'
+ ? 'mlAll'
+ : normalizedMode === 'active'
+ ? 'mlActive'
+ : normalizedMode === 'inactive'
+ ? 'mlInactive'
+ : 'mlSelected';
+ const countKey = normalizedMode === 'all'
+ ? 'mlAllCount'
+ : normalizedMode === 'active'
+ ? 'mlActiveCount'
+ : normalizedMode === 'inactive'
+ ? 'mlInactiveCount'
+ : 'mlSelectedCount';
const labelMap = {
all: 'Copy all',
active: 'Copy active',
- inactive: 'Copy inactive'
+ inactive: 'Copy inactive',
+ selected: 'Copy selected'
};
if (codeEl) codeEl.textContent = section.dataset[dataKey] || '';
@@ -61,16 +77,22 @@ export function registerGlobalHelpers() {
}, 1500);
};
- window.MLFilterSwitch = function(filter) {
- const filterBtn = document.getElementById('filter-' + filter);
+ window.MLFilterSwitch = function(filter, clickedBtn = null) {
+ const root = clickedBtn?.closest?.('.mr-loaded-models-header')?.parentNode
+ || document;
+ const filterBtn = clickedBtn || document.getElementById('filter-' + filter);
if (!filterBtn) return;
// Update button states
- document.querySelectorAll('.mr-btn-filter').forEach(b => b.classList.remove('active'));
+ root.querySelectorAll('.mr-btn-filter').forEach(b => {
+ if (b.id && b.id.startsWith('filter-')) {
+ b.classList.remove('active');
+ }
+ });
filterBtn.classList.add('active');
// Filter model sections
- document.querySelectorAll('.mr-model-section').forEach(s => {
+ root.querySelectorAll('.mr-model-section').forEach(s => {
const hasActive = s.getAttribute('data-ml-active') === 'true';
const hasInactive = s.getAttribute('data-ml-inactive') === 'true';
@@ -92,7 +114,8 @@ export function registerGlobalHelpers() {
}
});
- const copySection = document.querySelector('[id^="mr-copy-"]');
+ const copySection = root.querySelector?.('[id^="mr-copy-"]')
+ || document.querySelector('[id^="mr-copy-"]');
if (copySection) {
window.MLSetCopyMode(copySection.id, filter);
}
diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js
index 6f2d0a1..de2315c 100644
--- a/web/resolver/shell/lifecycle_graph_methods.js
+++ b/web/resolver/shell/lifecycle_graph_methods.js
@@ -114,6 +114,11 @@ export const lifecycleGraphMethods = {
this.backdrop.style.display = "none";
this.element.style.display = "none";
+ if (typeof this._mlSelectionCleanup === 'function') {
+ this._mlSelectionCleanup();
+ this._mlSelectionCleanup = null;
+ }
+
if (wasDocked && collapseSidebar) {
this.closeComfySidebar(dockContainer);
}
diff --git a/web/resolver/utils/loaded_model_selection.js b/web/resolver/utils/loaded_model_selection.js
new file mode 100644
index 0000000..bd0cf64
--- /dev/null
+++ b/web/resolver/utils/loaded_model_selection.js
@@ -0,0 +1,313 @@
+/**
+ * Multi-selection and copying helpers for model chips in Loaded Models and Metadata tabs.
+ */
+
+/**
+ * Checks whether a model chip is currently visible (not hidden by tab filters).
+ * @param {Element} chip
+ * @returns {boolean}
+ */
+export function isChipVisible(chip) {
+ if (!chip) return false;
+ if (chip.style?.display === 'none') return false;
+ const group = chip.closest?.('.mr-model-group');
+ if (group && group.style?.display === 'none') return false;
+ const section = chip.closest?.('.mr-model-section');
+ if (section && section.style?.display === 'none') return false;
+ return true;
+}
+
+/**
+ * Returns all currently visible model chips in the container.
+ * @param {Element} container
+ * @returns {Element[]}
+ */
+export function getVisibleModelChips(container) {
+ if (!container || typeof container.querySelectorAll !== 'function') return [];
+ return Array.from(container.querySelectorAll('.mr-model-chip')).filter(isChipVisible);
+}
+
+/**
+ * Returns all selected model chips in the container.
+ * @param {Element} container
+ * @returns {Element[]}
+ */
+export function getSelectedModelChips(container) {
+ if (!container || typeof container.querySelectorAll !== 'function') return [];
+ return Array.from(container.querySelectorAll('.mr-model-chip.is-selected'));
+}
+
+/**
+ * Returns an array of token strings for all selected model chips.
+ * @param {Element} container
+ * @returns {string[]}
+ */
+export function getSelectedModelTokens(container) {
+ const selected = getSelectedModelChips(container);
+ return selected.map(chip => chip.dataset?.mlToken || chip.textContent.trim()).filter(Boolean);
+}
+
+/**
+ * Clears selection across all model chips in the container.
+ * @param {Element} container
+ */
+export function clearLoadedModelSelection(container) {
+ if (!container || typeof container.querySelectorAll !== 'function') return;
+ const chips = container.querySelectorAll('.mr-model-chip.is-selected');
+ chips.forEach(chip => chip.classList.remove('is-selected'));
+ if (container._mlSelectionState) {
+ container._mlSelectionState.anchorChip = null;
+ }
+ updateLoadedModelSelectionState(container);
+}
+
+/**
+ * Synchronizes the selection dataset, copy toolbar, and code preview with current chip selection.
+ * @param {Element} container
+ */
+export function updateLoadedModelSelectionState(container) {
+ if (!container || typeof container.querySelector !== 'function') return;
+
+ const selectedChips = getSelectedModelChips(container);
+ const selectedTokens = selectedChips.map(chip => chip.dataset?.mlToken || chip.textContent.trim()).filter(Boolean);
+ const selectedString = selectedTokens.join(' ');
+ const selectedCount = selectedTokens.length;
+
+ container.dataset.mlSelectedString = selectedString;
+
+ const copySection = container.querySelector('.mr-copy-section');
+ if (!copySection) return;
+
+ copySection.dataset.mlSelected = selectedString;
+ copySection.dataset.mlSelectedCount = String(selectedCount);
+
+ const selectedBtn = copySection.querySelector('.mr-copy-mode-selected');
+ const selectedCountSpan = copySection.querySelector('.mr-copy-selected-count');
+ if (selectedCountSpan) {
+ selectedCountSpan.textContent = String(selectedCount);
+ }
+ if (selectedBtn) {
+ selectedBtn.style.display = selectedCount > 0 ? '' : 'none';
+ }
+
+ const activeModeBtn = copySection.querySelector('.mr-copy-mode.active');
+ const currentMode = activeModeBtn?.dataset?.mlCopyMode;
+
+ if (selectedCount > 0) {
+ if (currentMode !== 'selected') {
+ if (typeof window !== 'undefined' && typeof window.MLSetCopyMode === 'function') {
+ window.MLSetCopyMode(copySection.id, 'selected');
+ }
+ } else {
+ const codeEl = copySection.querySelector('.mr-copy-code');
+ const labelEl = copySection.querySelector('.mr-copy-label');
+ const metaEl = copySection.querySelector('.mr-copy-meta');
+ if (codeEl) codeEl.textContent = selectedString;
+ if (labelEl) labelEl.textContent = 'Copy selected';
+ if (metaEl) metaEl.textContent = `${selectedCount} token${selectedCount === 1 ? '' : 's'}`;
+ }
+ } else {
+ if (currentMode === 'selected') {
+ const fallbackMode = copySection.dataset.mlPreviousMode || 'all';
+ if (typeof window !== 'undefined' && typeof window.MLSetCopyMode === 'function') {
+ window.MLSetCopyMode(copySection.id, fallbackMode);
+ }
+ }
+ }
+}
+
+/**
+ * Initializes click, shift-click, ctrl-click, and keyboard shortcuts for model chips.
+ * @param {Element} container
+ * @param {object} [dialog]
+ * @returns {Function} cleanup function
+ */
+export function initLoadedModelSelection(container, dialog = null) {
+ if (!container || typeof container.addEventListener !== 'function') {
+ return () => {};
+ }
+
+ if (container._mlSelectionState?.cleanup) {
+ container._mlSelectionState.cleanup();
+ }
+
+ const state = {
+ anchorChip: null,
+ cleanup: null
+ };
+ container._mlSelectionState = state;
+
+ const handleChipClick = (chip, event) => {
+ const isCtrl = Boolean(event.ctrlKey || event.metaKey);
+ const isShift = Boolean(event.shiftKey);
+ const visibleChips = getVisibleModelChips(container);
+
+ if (isShift) {
+ const currentIndex = visibleChips.indexOf(chip);
+ const anchorIndex = state.anchorChip && visibleChips.includes(state.anchorChip)
+ ? visibleChips.indexOf(state.anchorChip)
+ : currentIndex;
+
+ const start = Math.min(anchorIndex, currentIndex);
+ const end = Math.max(anchorIndex, currentIndex);
+
+ if (!isCtrl) {
+ visibleChips.forEach((c, idx) => {
+ const inRange = idx >= start && idx <= end;
+ c.classList.toggle('is-selected', inRange);
+ });
+ } else {
+ visibleChips.forEach((c, idx) => {
+ if (idx >= start && idx <= end) {
+ c.classList.add('is-selected');
+ }
+ });
+ }
+ } else if (isCtrl) {
+ chip.classList.toggle('is-selected');
+ state.anchorChip = chip;
+ } else {
+ const allChips = container.querySelectorAll('.mr-model-chip');
+ allChips.forEach(c => {
+ if (c !== chip) c.classList.remove('is-selected');
+ });
+ chip.classList.add('is-selected');
+ state.anchorChip = chip;
+ }
+
+ updateLoadedModelSelectionState(container);
+ };
+
+ const handleContainerClick = (event) => {
+ if (event.button !== 0) return;
+
+ if (dialog?.activeTab) {
+ if (dialog.activeTab !== 'loaded' && dialog.activeTab !== 'metadata') {
+ return;
+ }
+ if (container === dialog.contentElement && dialog.activeTab !== 'loaded') {
+ return;
+ }
+ }
+
+ const chip = event.target?.closest?.('.mr-model-chip');
+ if (chip && container.contains(chip)) {
+ event.stopPropagation();
+ handleChipClick(chip, event);
+ return;
+ }
+
+ if (event.target?.closest?.('button, a, input, textarea, select, .mr-copy-code, .mr-copy-toolbar')) {
+ return;
+ }
+
+ if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {
+ clearLoadedModelSelection(container);
+ }
+ };
+
+ const handleKeyDown = (event) => {
+ const activeEl = typeof document !== 'undefined' ? document.activeElement : null;
+ if (activeEl && (
+ activeEl.tagName === 'INPUT' ||
+ activeEl.tagName === 'TEXTAREA' ||
+ activeEl.tagName === 'SELECT' ||
+ activeEl.isContentEditable
+ )) {
+ return;
+ }
+
+ if (!container.isConnected) return;
+ if (container.style?.display === 'none') return;
+
+ const modal = container.closest?.('#model-resolver-modal');
+ if (modal && (modal.style?.display === 'none' || modal.classList.contains('mr-hidden'))) {
+ return;
+ }
+
+ if (dialog?.activeTab) {
+ if (dialog.activeTab !== 'loaded' && dialog.activeTab !== 'metadata') {
+ return;
+ }
+ if (container === dialog.contentElement && dialog.activeTab !== 'loaded') {
+ return;
+ }
+ }
+
+ // Ctrl+C / Cmd+C (Copy selected)
+ if ((event.key === 'c' || event.key === 'C') && (event.ctrlKey || event.metaKey) && !event.altKey) {
+ const browserSelection = typeof window !== 'undefined' ? window.getSelection?.()?.toString() : '';
+ if (browserSelection && browserSelection.trim().length > 0) {
+ return;
+ }
+
+ const selectedChips = getSelectedModelChips(container);
+ if (selectedChips.length > 0) {
+ event.preventDefault();
+ const copySection = container.querySelector('.mr-copy-section');
+ const selectedText = copySection?.dataset?.mlSelected
+ || selectedChips.map(c => c.dataset?.mlToken || c.textContent.trim()).filter(Boolean).join(' ');
+
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ navigator.clipboard.writeText(selectedText).then(() => {
+ const copyBtn = copySection?.querySelector('.mr-copy-main-btn');
+ if (copyBtn && typeof window !== 'undefined' && typeof window.MLSetCopyButtonFeedback === 'function') {
+ window.MLSetCopyButtonFeedback(copyBtn, 'Copied');
+ }
+ if (typeof dialog?.showNotification === 'function') {
+ dialog.showNotification(
+ `Copied ${selectedChips.length} model token${selectedChips.length === 1 ? '' : 's'} to clipboard`,
+ 'info'
+ );
+ }
+ }).catch(err => {
+ console.error('Model Resolver: clipboard write failed', err);
+ });
+ }
+ return;
+ }
+ }
+
+ // Ctrl+A / Cmd+A (Select All visible chips)
+ if ((event.key === 'a' || event.key === 'A') && (event.ctrlKey || event.metaKey) && !event.altKey) {
+ const visibleChips = getVisibleModelChips(container);
+ if (visibleChips.length > 0) {
+ event.preventDefault();
+ visibleChips.forEach(c => c.classList.add('is-selected'));
+ if (!state.anchorChip && visibleChips.length) {
+ state.anchorChip = visibleChips[0];
+ }
+ updateLoadedModelSelectionState(container);
+ return;
+ }
+ }
+
+ // Escape (Deselect all)
+ if (event.key === 'Escape') {
+ const selectedChips = getSelectedModelChips(container);
+ if (selectedChips.length > 0) {
+ event.preventDefault();
+ event.stopPropagation();
+ clearLoadedModelSelection(container);
+ }
+ }
+ };
+
+ container.addEventListener('click', handleContainerClick);
+ if (typeof document !== 'undefined') {
+ document.addEventListener('keydown', handleKeyDown);
+ }
+
+ const cleanup = () => {
+ container.removeEventListener('click', handleContainerClick);
+ if (typeof document !== 'undefined') {
+ document.removeEventListener('keydown', handleKeyDown);
+ }
+ if (container._mlSelectionState === state) {
+ container._mlSelectionState = null;
+ }
+ };
+
+ state.cleanup = cleanup;
+ return cleanup;
+}
diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js
index d4f2302..488a5c2 100644
--- a/web/resolver/views/tabs_image_metadata_methods.js
+++ b/web/resolver/views/tabs_image_metadata_methods.js
@@ -538,6 +538,10 @@ export const imageMetadataMethods = {
},
clearImageMetadata() {
+ if (typeof this._mlSelectionCleanup === 'function') {
+ this._mlSelectionCleanup();
+ this._mlSelectionCleanup = null;
+ }
const state = getImageInspectorState(this);
state.requestToken = `cleared-${Date.now()}`;
state.metadata = null;
@@ -549,6 +553,10 @@ export const imageMetadataMethods = {
},
renderImageMetadataResult() {
+ if (typeof this._mlSelectionCleanup === 'function') {
+ this._mlSelectionCleanup();
+ this._mlSelectionCleanup = null;
+ }
const result = this.contentElement?.querySelector('[data-image-inspector-result]');
if (!result) return;
const state = getImageInspectorState(this);
diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js
index 650a508..1b58a70 100644
--- a/web/resolver/views/tabs_loaded_methods.js
+++ b/web/resolver/views/tabs_loaded_methods.js
@@ -8,6 +8,10 @@ import {
buildLoadedModelTokenStrings,
groupLoadedModelsByCategory,
} from "../utils/loaded_model_utils.js";
+import {
+ initLoadedModelSelection,
+ updateLoadedModelSelectionState,
+} from "../utils/loaded_model_selection.js";
export const tabsLoadedMethods = {
getTabButton(tab) {
return {
@@ -59,6 +63,12 @@ export const tabsLoadedMethods = {
this._loadedModelsLoadToken = null;
this._loadedModelsProgressToken = null;
}
+ if (this.activeTab !== 'loaded' && this.activeTab !== 'metadata') {
+ if (typeof this._mlSelectionCleanup === 'function') {
+ this._mlSelectionCleanup();
+ this._mlSelectionCleanup = null;
+ }
+ }
this.hideTooltip();
this.animateTabContentTransition();
this.updateTabButtonStates();
@@ -243,7 +253,11 @@ export const tabsLoadedMethods = {
?.dataset?.mlCopyMode || 'all';
const copyCode = copySection.querySelector?.('.mr-copy-code');
if (copyCode) {
- copyCode.textContent = strings[activeMode] || strings.all;
+ if (activeMode === 'selected') {
+ copyCode.textContent = copySection.dataset.mlSelected || '';
+ } else {
+ copyCode.textContent = strings[activeMode] || strings.all;
+ }
}
}
@@ -321,6 +335,9 @@ export const tabsLoadedMethods = {
if (strengthElement) {
strengthElement.textContent = entry.strength.toFixed(2);
}
+ if (chip && typeof this.getModelToken === 'function') {
+ chip.dataset.mlToken = this.getModelToken(model, model.category || 'unknown');
+ }
updated = true;
}
if (!updated) return false;
@@ -329,6 +346,7 @@ export const tabsLoadedMethods = {
this.contentElement,
this.cachedLoadedModelsData.loaded_models
);
+ this.updateLoadedModelSelectionState?.(this.contentElement);
const workflow = this.getCurrentWorkflow?.();
const workflowSignature = workflow
? this.getWorkflowSignature?.(workflow)
@@ -386,9 +404,9 @@ export const tabsLoadedMethods = {
${this.escapeHtml(modelSubtitle)}
-
-
-
+
+
+
@@ -427,7 +445,8 @@ export const tabsLoadedMethods = {
const { name, strength } = this.getModelNameAndStrength(model);
const fullName = model.original_path || model.name || name;
const modelKey = this.getLoadedModelDomKey(model);
- html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`;
+ const token = this.getModelToken(model, category);
+ html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`;
}
html += `
`;
}
@@ -446,7 +465,8 @@ export const tabsLoadedMethods = {
const { name, strength } = this.getModelNameAndStrength(model);
const fullName = model.original_path || model.name || name;
const modelKey = this.getLoadedModelDomKey(model);
- html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`;
+ const token = this.getModelToken(model, category);
+ html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`;
}
html += ``;
}
@@ -457,7 +477,7 @@ export const tabsLoadedMethods = {
const copySectionId = 'mr-copy-' + Date.now();
html += `
-
+
@@ -480,5 +501,23 @@ export const tabsLoadedMethods = {
container.dataset.mlActiveString = activeString;
container.dataset.mlInactiveString = inactiveString;
container.dataset.mlAllString = allString;
+
+ if (typeof container?.querySelector === 'function') {
+ this.bindLoadedModelSelection?.(container);
+ }
+ },
+
+ bindLoadedModelSelection(container) {
+ if (typeof this._mlSelectionCleanup === 'function') {
+ this._mlSelectionCleanup();
+ this._mlSelectionCleanup = null;
+ }
+ const cleanup = initLoadedModelSelection(container, this);
+ this._mlSelectionCleanup = cleanup;
+ return cleanup;
+ },
+
+ updateLoadedModelSelectionState(container) {
+ return updateLoadedModelSelectionState(container);
}
};