fix(custom-model): merge-time fixes for the promoted-model picker (#459)

The maintainer's promised follow-ups to opticon454's picker promotion,
applied on the landing branch after the merge (ecb95b5d):

- session-ui.js: the promotion tag ("Currently loaded" / "Last used") and
  the "Default" pill are two separate spans, so a promoted row that is
  also the endpoint's defaultModelId shows both instead of silently
  losing its Default marking; two tests pin it (both fail on the old
  exclusive-slot rendering).
- styles.css: a dedicated #customModelPickModal .set-scope rule, since
  the pill was only styled inside the three settings modals and rendered
  as plain body text here; same skin tokens, modal layout untouched.
- docs/wiki/Custom-Model-Endpoints.md: describe the promotion (currently
  loaded, else last used per device), the separate Default pill, and
  that nothing is ever auto-chosen.
- CLAUDE.md + docs/custom-model-endpoints.md: credit the real "Last used"
  writers (_runCustomModelEntryViaRestart and
  _quickStartWithCustomModelConfirm; runCustomModelEntry only dispatches
  since 88e5b7b2) and drop the now-wrong "both defer to Default" sentence.
- Not done: moving the one-shot "last used" write into
  _runCustomModelEntryOneShot, because the existing one-shot tests assert
  that _quickStartWithCustomModelConfirm writes the key itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0cb0f911adc14a852ba5c2951768a4aa87c25657)
This commit is contained in:
Codeman maintainer
2026-09-21 04:30:01 +02:00
parent 6ef71ec3b9
commit d3f2ec0220
6 changed files with 111 additions and 12 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+13 -7
View File
@@ -196,14 +196,20 @@ under your thumb:
- **"Last used"** — shown only when nothing is currently loaded: the model
actually launched last for this exact (harness, endpoint) pair, read
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
localStorage key. Written by `runCustomModelEntry` /
`_quickStartWithCustomModelConfirm` only once the model is actually
applied, never on the mere click — declining the context-window warning
means this exact model cannot work with this CLI at all, so promoting it
next time would be actively wrong, not just premature.
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
`runCustomModelEntry` entry point itself only dispatches between the
two) only once the model is actually applied, never on the mere click —
declining the context-window warning means this exact model cannot work
with this CLI at all, so promoting it next time would be actively wrong,
not just premature.
Neither tag reorders anything past that one promoted row, and both defer
to "Default" when neither applies.
Neither tag reorders anything past that one promoted row. The "Default"
pill is a separate span, not a third value of the same slot: a promoted
row that is also the endpoint's `defaultModelId` shows both tags (on a
single-purpose GPU box that is the common case, and an exclusive slot
silently dropped the Default marking for exactly that row), and a row
with neither promotion nor default shows no tag at all.
**How the launch itself applies the endpoint depends on the harness.** For
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
+7 -1
View File
@@ -50,7 +50,13 @@ pointed at.
the session launches straight away on that model — nothing to choose. With two or more, a
small dialog asks which one to use for this launch before starting the session; the
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
use a different one without changing the saved default.
use a different one without changing the saved default. The list is not raw discovery order
either: the model llama-swap reports loaded and ready is moved to the top and tagged
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
another device starts from its own history). The default model keeps its own **Default** pill
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
thumb.
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
straight onto the endpoint** — no restart, because the endpoint is applied before the
+8 -3
View File
@@ -825,12 +825,17 @@ Object.assign(CodemanApp.prototype, {
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
list.innerHTML = models
.map((m) => {
const isDefault = m === host.defaultModelId;
const tag = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : isDefault ? 'Default' : null;
// Two independent tags, never one exclusive slot: the promotion tag says what
// llama-swap (or this device's history) knows about the model, the Default pill
// says what the saved endpoint says about it, and on a single-purpose GPU box the
// promoted model IS the default more often than not. One slot holding whichever
// applied first silently dropped the Default marking for exactly that row.
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
const arg = escapeHtml(JSON.stringify(m));
return `
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tag ? ` <span class="set-scope">${escapeHtml(tag)}</span>` : ''}
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
</button>`;
})
.join('');
+21
View File
@@ -6869,6 +6869,27 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
min-height: 0;
}
/* The picker's row tags ("Currently loaded", "Last used", "Default") reuse the
settings surface's .set-scope pill, but that pill is styled only inside
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) (see the
settings-surface block), so in here it rendered as plain body text and
"qwen3 Currently loaded" read as one model name. Same pill, same skin
tokens (never a hardcoded colour: --text-muted / --border are what each
html[data-skin] block redefines), and nothing about the modal's layout or
z-index. The row is a flex container, so the pills sit after the name
with the row's own gap and the trailing whitespace collapses. */
#customModelPickModal .set-scope {
font-size: 0.52rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 1px 4px;
white-space: nowrap;
color: var(--text-muted);
border: 1px solid var(--border);
opacity: 0.8;
}
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
+61
View File
@@ -335,6 +335,67 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
expect(buttons[0].textContent).toContain('Currently loaded');
});
it('a currently-loaded row that is also the endpoint default shows BOTH tags, as two separate spans', async () => {
// The common case on a single-purpose GPU box: the one model that is loaded is the
// saved default too. An exclusive tag slot (promotion, else Default) silently dropped
// the Default marking for exactly that row.
const { win, app } = bootApp({
hosts: [
{
id: 'llama-box',
label: 'llama.cpp',
baseUrl: 'http://x',
models: ['qwen3', 'llama3', 'phi4'],
defaultModelId: 'phi4',
},
],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') {
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
}
return origApiJson(path);
};
await app.selectCustomModelEntry('claude', 'llama-box');
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('phi4');
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
expect(tags).toEqual(['Currently loaded', 'Default']);
// Rows with neither a promotion nor the default carry no tag at all.
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
});
it('a "last used" row that is also the endpoint default shows both tags too', async () => {
const { win, app } = bootApp({
hosts: [
{
id: 'llama-box',
label: 'llama.cpp',
baseUrl: 'http://x',
models: ['qwen3', 'llama3', 'phi4'],
defaultModelId: 'llama3',
},
],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
await app.selectCustomModelEntry('claude', 'llama-box');
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama3');
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
expect(tags).toEqual(['Last used', 'Default']);
});
it('is not fooled by a model llama-swap reports loaded but not yet ready, or one this host no longer lists', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],