diff --git a/README.md b/README.md index 9193913..a852460 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,9 @@ claude-mode preset rm claude-mode set-key [ref] [key] store a key (hidden prompt; [key] for scripts) claude-mode models [filter] models available from the active provider +claude-mode models --preset ...from that preset's provider (and server) instead +claude-mode models --preset --refresh only update the cached list the panel uses +claude-mode models --preset --json the cached list for that provider, as JSON claude-mode doctor verify auth, endpoint, model ids, stray env vars claude-mode preflight [preset] can this mode actually serve? (no switch) @@ -201,6 +204,19 @@ claude-mode --force switch even when preflight says no Omitting the preset uses a **fixed** per-provider default, not "most recently used" — so `claude-mode openrouter` always means `default`. +Editing the active preset re-applies it straight away. For `preset set` and +`preset all` that happens **without** asking about running sessions: a switch +endangers them by moving their endpoint or key, and a tier edit moves neither. +`preset url` and `preset auth` do move them, so those still ask. If the re-apply +fails (a preflight refusal, say), the edit is still saved, and the last line says +so and names the command that finishes the job. + +Every catalogue fetch — `models`, `doctor`, `setup`, the menu's picker — leaves +a copy in `~/.claude-mode/models-cache.json`, one entry per provider with its own +timestamp. A failed fetch keeps the previous list and marks it failed, and an LM +Studio list is tied to the server it came from. Model ids, context lengths and +prices only; no key or key name is ever written there. + ## Presets shipped One per mode, so `claude-mode ` is never ambiguous and there is no @@ -751,9 +767,13 @@ refresh it; the command exists for anything that wants to force it. An icon in the Omarchy top bar showing which provider the next `claude` launch will use, and a panel that switches it without a terminal. Install is -[up top](#omarchy-bar-widget). Both install steps hot-reload, so nothing needs -restarting — except after a change to `Modes.js`, which the QML engine caches -for the life of the process as a `.pragma library` (`omarchy restart shell`). +[up top](#omarchy-bar-widget). The shell notices the new files and reloads the +plugin, but on an **upgrade** that is not enough: it clears Qt's component cache +while the old widget is still alive, so the old compiled panel survives, and +`omarchy-shell shell rescanPlugins` does not shift it either. Run +`omarchy restart shell` after upgrading. The same goes for any change to +`Modes.js`, which the QML engine caches for the life of the process as a +`.pragma library`. The icon is the mode, and it is the provider's own logo: the Claude burst, the OpenRouter arrow, the Z.AI Z, the LM Studio mark. They are drawn as vector paths @@ -798,9 +818,9 @@ CLI does, and both can stop it: …* opens a terminal and runs the whole first-run flow there (a bar popup can host neither a hidden key prompt nor a filter-select model list), *Store the key…* opens a terminal for just the prompt, *Check again* re-runs the - preflight, and *Server settings…* opens the form below. The gear on any LM - Studio preset row opens the same form without waiting for a failure — per - row, because two LM Studio presets can point at two different machines. + preflight, and *Server settings…* opens the form below. For an LM Studio + preset the same form is also one click inside the preset editor (below), + without waiting for a failure. The form holds the base URL, a *Use local default* reset, and a switch for whether that server needs an API key. Saving rewrites the preset and drops @@ -811,6 +831,19 @@ CLI does, and both can stop it: strictly **after** the write — restarting first would only bring them back up on the provider you just left. +- **The gear** on any preset row opens that preset in an editor: its four tiers, + each with the model it maps to. Clicking a tier opens one field that filters + the provider's models as you type (every word must match; ↓/↑ and Enter work), + listed with context length and price. That list comes from + the cache the CLI leaves behind, so the panel never touches the network on + its own. *Fetch models* / *Refresh list* runs `models --refresh` when it is + empty or old. Any id can also be typed by hand. The gear sits on each row + rather than each provider, because two presets of one provider map tiers + differently, and two LM Studio presets can point at two different machines. + Editing the preset in use applies it at once; see + [Commands](#commands) for why that does not ask about running sessions. +- **Esc** closes the panel. While it is open the panel holds the keyboard, as + every other shell panel does, so its text fields can be typed into. - **Right-click** switches straight back to Anthropic. - **Middle-click** re-reads state. - **Hover** for mode, preset, and the opus/sonnet mapping. diff --git a/linux/claude-mode b/linux/claude-mode index aa03fb6..7158df5 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -17,6 +17,7 @@ CM_PRESETS="$CM_ROOT/presets" CM_BACKUPS="$CM_ROOT/backups" CM_STATE="$CM_ROOT/state.json" CM_IGNORED="$CM_ROOT/ignored-sessions.json" +CM_MODELS_CACHE="$CM_ROOT/models-cache.json" CM_SETTINGS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" CM_SETTINGS="$CM_SETTINGS_DIR/settings.json" CM_HELPER="$CM_BIN/claude-key-helper.sh" @@ -28,6 +29,7 @@ JSON="$CM_BIN/cm-json.py" . "$CM_BIN/cm-vault.sh" CM_FORCE=0 +CM_SAME_ENDPOINT=0 # set by reapply_if_active for a tier-only edit MODES=(anthropic openrouter zai lmstudio) TIERS=(opus sonnet haiku fable) @@ -272,6 +274,9 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio claude-mode set-key [ref] [key] store an API key (hidden prompt; key for scripts) claude-mode models [filter] models available from the active provider + claude-mode models --preset [--refresh|--json] + that preset's provider instead; --refresh only + updates the panel's cached list claude-mode doctor verify auth, endpoint, model ids, env claude-mode repair strip [1m] tags from cached model ids claude-mode health refresh health.json (machine-readable state) @@ -1132,8 +1137,32 @@ check_stray_env() { # Provider catalogues # --------------------------------------------------------------------------- +# Every fetch leaves a copy behind for a reader that cannot afford the round +# trip - the bar panel's model picker. It is written here, in the only places a +# catalogue is ever fetched, so every command that already pays for the network +# (models, doctor, setup, the menu's picker) keeps it fresh at no extra cost. +cm_cache_catalogue() { + "$PY" "$JSON" cache-models "$CM_MODELS_CACHE" "$1" "$2" "${3:-}" >/dev/null 2>&1 || true +} + or_catalogue() { - curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null + local out rc + out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null)" + rc=$? + cm_cache_catalogue openrouter "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" <<<"$out" + [ -n "$out" ] && printf '%s\n' "$out" + return "$rc" +} + +# Z.AI publishes no catalogue endpoint, so this list comes from its docs - and +# this is the one place it is kept. +zai_catalogue() { + local out + out="$(printf '%s\t%s\n' \ + glm-5.3 'flagship coding model - opus/sonnet tier' \ + glm-4.7 'fast/cheap tier - haiku')" + cm_cache_catalogue zai 1 <<<"$out" + printf '%s\n' "$out" } # The credential a preset would send. Empty when there is nothing to send. @@ -1153,9 +1182,13 @@ cm_preset_token() { # the catalogue comes back empty and every caller silently believes the server # has no models installed - on exactly the setups that need the list most. lms_catalogue() { - local base="${1%/}" token="${2:-}" - curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \ - "$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null + local base="${1%/}" token="${2:-}" out rc + out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \ + "$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)" + rc=$? + cm_cache_catalogue lmstudio "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out" + [ -n "$out" ] && printf '%s\n' "$out" + return "$rc" } # --------------------------------------------------------------------------- @@ -1221,30 +1254,73 @@ cmd_presets() { } cmd_models() { - local filter="${1:-}" mode preset pf - mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")" + local filter='' pname='' refresh=0 as_json=0 a pf='' provider base='' tsv rc + while [ $# -gt 0 ]; do + a="$1"; shift + case "$a" in + --preset) + [ $# -gt 0 ] || { err '--preset needs a preset name'; return 1; } + pname="$1"; shift ;; + --refresh) refresh=1 ;; + --json) as_json=1 ;; + -*) err "unknown option '$a'"; return 1 ;; + *) filter="$a" ;; + esac + done - case "$mode" in + # The active preset by default. The panel's editor names one instead, + # because the preset being edited is often not the one in use - and two + # LM Studio presets can point at two different servers. + if [ -z "$pname" ] && [ "$(state_mode)" != "anthropic" ]; then + pname="$(state_preset)" + fi + if [ -n "$pname" ]; then + pf="$(preset_path "$pname")" + [ -f "$pf" ] || { err "preset '$pname' not found"; return 1; } + provider="$(jget "$pf" provider)"; [ -z "$provider" ] && provider=openrouter + base="$(jget "$pf" baseUrl)" + else + provider=openrouter # on anthropic, the catalogue worth browsing + fi + + local quiet=$(( refresh || as_json )) + case "$provider" in lmstudio) - head_ "models installed in LM Studio at $(jget "$pf" baseUrl)" - lms_catalogue "$(jget "$pf" baseUrl)" "$(cm_preset_token "$pf")" | while IFS=$'\t' read -r id st ctx; do - [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac - printf ' %-58s %-11s %s\n' "$id" "$st" "$ctx" - done - ;; + [ "$quiet" -eq 1 ] || head_ "models installed in LM Studio at $base" + tsv="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"; rc=$? ;; zai) - head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' - say 'glm-5.3 - flagship coding model (opus/sonnet tier)' - say 'glm-4.7 - fast/cheap tier (haiku tier)' - ;; + [ "$quiet" -eq 1 ] || head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' + tsv="$(zai_catalogue)"; rc=$? ;; *) - head_ 'fetching https://openrouter.ai/api/v1/models ...' - or_catalogue | while IFS=$'\t' read -r id ctx pin pout; do - [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac - printf ' %-52s %10s $%-8s $%s\n' "$id" "$ctx" "$pin" "$pout" - done - ;; + [ "$quiet" -eq 1 ] || head_ 'fetching https://openrouter.ai/api/v1/models ...' + tsv="$(or_catalogue)"; rc=$? ;; esac + + if [ "$as_json" -eq 1 ]; then + jget "$CM_MODELS_CACHE" "providers.$provider" + return "$rc" + fi + + if [ "$refresh" -eq 1 ]; then + if [ "$rc" -ne 0 ]; then + err "could not fetch the $provider catalogue${base:+ from $base}; the cached list, if any, is kept" + return 1 + fi + ok "cached $(printf '%s\n' "$tsv" | grep -c .) $provider model(s)" + return 0 + fi + + [ "$rc" -eq 0 ] || { err "could not fetch the $provider catalogue${base:+ from $base}"; return 1; } + local id c2 c3 c4 + while IFS=$'\t' read -r id c2 c3 c4; do + [ -n "$id" ] || continue + [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac + case "$provider" in + lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;; + zai) say "$(printf '%-8s - %s' "$id" "$c2")" ;; + *) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;; + esac + done <<<"$tsv" } cmd_set_key() { @@ -1456,19 +1532,23 @@ cmd_preset() { set) local tier="${3:-}" model="${4:-}" [ -n "$name" ] && [ -n "$tier" ] && [ -n "$model" ] || { err 'usage: claude-mode preset set '; return 1; } + # set-tier saves whatever it loaded, so a mistyped name would + # otherwise quietly become a new, half-empty preset. + [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; } "$PY" "$JSON" set-tier "$(preset_path "$name")" "$tier" "$model" || return 1 ok "$name : $tier -> $model" - reapply_if_active "$name" + reapply_if_active "$name" models ;; all) local model="${3:-}" [ -n "$name" ] && [ -n "$model" ] || { err 'usage: claude-mode preset all '; return 1; } + [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; } local t for t in "${TIERS[@]}" subagent; do "$PY" "$JSON" set-tier "$(preset_path "$name")" "$t" "$model" || return 1 done ok "$name : all tiers + subagent -> $model" - reapply_if_active "$name" + reapply_if_active "$name" models ;; url) local url="${3:-}" @@ -1495,11 +1575,24 @@ cmd_preset() { esac } +# scope `models` marks an edit that changed tier mappings only. What makes a +# switch dangerous to running sessions is the endpoint or the key changing under +# them, and a tier edit changes neither - so there is nothing to ask them about, +# and the bar panel (which cannot answer a prompt) can edit the active preset. reapply_if_active() { - local name="$1" - if [ "$(state_mode)" != "anthropic" ] && [ "$(state_preset)" = "$name" ]; then + local name="$1" scope="${2:-}" mode + mode="$(state_mode)" + if [ "$mode" != "anthropic" ] && [ "$(state_preset)" = "$name" ]; then say 're-applying active preset...' - set_mode "$(state_mode)" "$name" + [ "$scope" = models ] && CM_SAME_ENDPOINT=1 + if ! set_mode "$mode" "$name"; then + CM_SAME_ENDPOINT=0 + # Last on stderr on purpose: it is the line the panel shows, and + # the file *was* written, which the failure above does not say. + err "saved, but re-applying the active preset failed - run: claude-mode $mode $name" + return 1 + fi + CM_SAME_ENDPOINT=0 fi } @@ -1769,8 +1862,10 @@ ui_pick_model() { done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")") ;; zai) - ids+=('glm-5.3'); ui_add_item 'glm-5.3' 'flagship coding model - opus/sonnet tier' - ids+=('glm-4.7'); ui_add_item 'glm-4.7' 'fast/cheap tier - haiku' + while IFS=$'\t' read -r id a; do + [ -n "$id" ] || continue + ids+=("$id"); ui_add_item "$id" "$a" + done < <(zai_catalogue) ;; esac @@ -2151,6 +2246,10 @@ cm_confirm_sessions() { CM_SESSION_ACTION=none CM_SESSION_ROWS='' + # Re-applying the active preset after a tier edit: same endpoint, same key, + # so running sessions are not at risk - see reapply_if_active. + [ "$CM_SAME_ENDPOINT" -eq 1 ] && return 0 + if ! cm_sessions_supported; then printf '\n' warn 'cannot list running sessions here (no /proc) - the switch will not wait for them' @@ -2584,7 +2683,7 @@ case "$cmd" in else cmd_set_key "$_ref" "$_secret" fi ;; - models) cmd_models "${1:-}" ;; + models) cmd_models "$@" ;; doctor) cmd_doctor ;; health) write_health "$(state_mode)" "$(state_preset)" ;; preflight) cmd_preflight "${1:-}" "${2:-}" ;; diff --git a/linux/cm-json.py b/linux/cm-json.py index b5e9213..69303fa 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -16,6 +16,7 @@ Subcommands: presets "nameproviderdesc" scan-sessions [max-age-days] [ignored] broken transcripts, as JSON ignore-session add|remove|clear|list [id] + cache-models <1|0> [baseUrl] store a TSV catalogue (stdin) """ import glob @@ -313,6 +314,76 @@ def cmd_or_models(argv): print("%s\t%s\t%s\t%s" % (m.get("id", ""), m.get("context_length", ""), per_m("prompt"), per_m("completion"))) +def cmd_cache_models(argv): + """cache-models <1|0> [baseUrl] TSV catalogue on stdin + + Keeps the last catalogue each provider returned, for a reader that cannot + afford a network round trip - the bar panel's model picker. One node per + provider with its own fetchedAt and ok, so a failed OpenRouter fetch never + invalidates a fresh LM Studio list. A failed fetch keeps the previous list + (a stale list beats none when the network blinks) and marks it ok=false. + + Model ids, context lengths and prices only: never a key reference or token. + Prints the number of models stored by this call. + """ + path, provider, ok = argv[0], argv[1], argv[2] == "1" + base = argv[3].rstrip("/") if len(argv) > 3 else "" + + def num(s, cast): + try: + return cast(s) + except (TypeError, ValueError): + return None + + rows = [] + for line in sys.stdin.read().splitlines(): + parts = line.split("\t") + mid = parts[0].strip() if parts else "" + if not mid: + continue + m = {"id": mid} + if provider == "openrouter": + ctx, pin, pout = (parts[1:] + ["", "", ""])[:3] + m["contextTokens"] = num(ctx, int) + m["priceIn"] = num(pin, float) + m["priceOut"] = num(pout, float) + elif provider == "lmstudio": + state, ctx = (parts[1:] + ["", ""])[:2] + m["state"] = state + m["contextTokens"] = num(ctx, int) + else: + m["note"] = parts[1] if len(parts) > 1 else "" + rows.append(m) + + try: + data = load(path, {}) + except (OSError, ValueError): + data = {} + providers = data.get("providers") if isinstance(data, dict) else None + if not isinstance(providers, dict): + providers = {} + node = providers.get(provider) + if not isinstance(node, dict): + node = {} + + now = __import__("datetime").datetime.now( + __import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + if ok: + node = {"fetchedAt": now, "ok": True, "models": rows} + if provider == "lmstudio": + node["baseUrl"] = base + else: + # A list from a different LM Studio server is not stale, it is wrong. + if provider == "lmstudio" and node.get("baseUrl", "") != base: + node = {"baseUrl": base, "models": []} + node["ok"] = False + node["failedAt"] = now + + providers[provider] = node + save(path, {"schema": 1, "providers": providers}) + print(len(rows) if ok else 0) + + def cmd_lms_models(argv): """LM Studio /api/v0/models on stdin -> 'idstatectx' lines.""" data = json.loads(sys.stdin.read()) @@ -1095,6 +1166,7 @@ COMMANDS = { "strip-tags": cmd_strip_tags, "or-models": cmd_or_models, "lms-models": cmd_lms_models, + "cache-models": cmd_cache_models, "apply": cmd_apply, "summary": cmd_summary, "models": cmd_models, diff --git a/omarchy/smoido.claude-mode/BarWidget.qml b/omarchy/smoido.claude-mode/BarWidget.qml index 2580e90..c785bb2 100644 --- a/omarchy/smoido.claude-mode/BarWidget.qml +++ b/omarchy/smoido.claude-mode/BarWidget.qml @@ -111,6 +111,21 @@ BarWidget { onLoadFailed: root.fallbackState = null } + // The model catalogue the panel's picker offers. claude-mode leaves it + // behind whenever it fetches a provider's list anyway, so reading it here + // costs no network - and nothing here polls a provider on a timer. + property var modelsCache: null + + FileView { + id: modelsFile + path: root.cmRoot + "/models-cache.json" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.parseInto("modelsCache", text()) + onLoadFailed: root.modelsCache = null + } + // claude-mode writes health.json itself, so a switch launched from the panel // lands back here through the FileView above. This only covers the case // where the file was never written at all. @@ -179,6 +194,7 @@ BarWidget { function refresh() { healthFile.reload() stateFile.reload() + modelsFile.reload() if (!root.health) seedProc.running = true root.scanSessions() } diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml index 45cb70e..d969615 100644 --- a/omarchy/smoido.claude-mode/Panel.qml +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -62,7 +62,7 @@ Panel { // Both questions are answered by claude-mode rather than re-derived here, so // the widget and the CLI can never disagree about whether a switch is allowed // or about what is running. - property string stage: "list" // list | blocked | confirm + property string stage: "list" // list | blocked | confirm | server | repair | preset | presetTier property var blocker: null // the preflight verdict, when it refused property var sessionInfo: null // sessions --json, when any were found property string pendingMode: "" @@ -90,8 +90,13 @@ Panel { return null } - function openServerSettings(presetName) { + // `from` is the stage to come back to - the preset editor, when opened from + // there; otherwise the list, or the switch that was blocked. + property string serverReturn: "" + + function openServerSettings(presetName, from) { var e = root.presetEntry(presetName) + root.serverReturn = from || "" root.serverPreset = presetName root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverLocalDefault root.serverNeedsKey = !!(e && String(e.authMode) === "vault") @@ -139,7 +144,7 @@ Panel { // Straight back into the switch that was blocked, if there was one - // the whole point of coming here was to unblock it. if (root.pendingMode !== "") root.retryPending() - else root.stage = "list" + else root.stage = root.serverReturn !== "" ? root.serverReturn : "list" } } @@ -225,6 +230,190 @@ Panel { } } + // ---- Edits + // + // One chain for every edit the panel makes: each step runs only if the one + // before it succeeded, the first failure stops it and its last stderr line + // becomes the error, and health.json is rewritten at the end either way, so + // the panel shows what the files now say rather than what was intended. + property var opQueue: [] + property string opLabel: "" + property string opLanding: "" + + function runOps(label, ops, landing) { + if (root.busy) return + root.busy = true + root.lastError = "" + root.opLabel = label + root.opLanding = landing + root.opQueue = ops.slice() + root.nextOp() + } + + function nextOp() { + var q = root.opQueue.slice() + var argv = q.length > 0 ? q.shift() : null + root.opQueue = q + opProc.failed = false + opProc.lastLine = "" + opProc.finalHealth = argv === null + opProc.command = root.cli(argv === null ? ["health"] : argv) + opProc.running = true + } + + Process { + id: opProc + running: false + property bool failed: false + property bool finalHealth: false + property string lastLine: "" + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: { + var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim() + var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" }) + opProc.lastLine = lines.length > 0 + ? lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim() : "" + // The stream can finish after the exit it belongs to. + if (opProc.failed && opProc.lastLine !== "") root.lastError = opProc.lastLine + } + } + onExited: function (code) { + if (opProc.finalHealth) { + root.busy = false + if (widget) widget.refresh() + if (root.lastError === "") root.stage = root.opLanding + return + } + if (code !== 0) { + opProc.failed = true + root.lastError = opProc.lastLine !== "" ? opProc.lastLine : root.opLabel + " failed" + root.opQueue = [] + } + Qt.callLater(root.nextOp) + } + } + + // ---- Preset editor + // + // Stages rather than one form, because the card clamps to its content instead + // of scrolling and a long form would simply be cut off: + // + // list -> preset -> presetTier -> preset + // + // The tier names live here rather than in Modes.js on purpose: that file is a + // .pragma library, cached until the shell restarts, and a stale copy without + // them would render an empty editor. + readonly property var tierNames: ["opus", "sonnet", "haiku", "fable"] + property string editPreset: "" + property string editTier: "" + property string editModel: "" + + readonly property var editEntry: root.editPreset !== "" ? root.presetEntry(root.editPreset) : null + readonly property string editProvider: editEntry ? String(editEntry.provider) : "" + readonly property bool editIsActive: root.mode !== "anthropic" && root.editPreset !== "" && root.editPreset === root.preset + + function editCurrent(tier) { + var e = root.editEntry + return e && e.models && e.models[tier] ? String(e.models[tier]) : "" + } + + function openPresetEditor(name) { + root.lastError = "" + root.editPreset = name + root.editTier = "" + root.stage = "preset" + } + + // The field starts empty rather than holding the current id: it doubles as + // the list's filter, and a pre-filled id would narrow the list to itself. + // The current mapping is shown on the line above instead. + function openTierEditor(tier) { + root.lastError = "" + root.editTier = tier + root.editModel = "" + // Typing breaks the field's binding, so a value left from another tier + // would otherwise survive into this one. + modelField.text = "" + modelList.currentIndex = -1 + root.stage = "presetTier" + Qt.callLater(function () { modelField.forceActiveFocus() }) + } + + function pickModel(id) { + root.editModel = id + modelField.text = id + modelField.forceActiveFocus() + } + + function saveTier() { + var m = root.editModel.trim() + if (root.editPreset === "" || root.editTier === "") return + if (m === "") { root.lastError = "Pick a model from the list, or type an id."; return } + root.runOps("saving", [["preset", "set", root.editPreset, root.editTier, m]], "preset") + } + + // Every word has to appear somewhere in the id or its description, so + // "deepseek flash" finds the flash variants without an exact substring. + readonly property var modelMatches: { + var words = root.editModel.trim().toLowerCase().split(/\s+/).filter(function (w) { return w !== "" }) + var all = root.modelOptions + if (words.length === 0) return all + var out = [] + for (var i = 0; i < all.length; i++) { + var hay = (all[i].label + " " + all[i].description).toLowerCase() + var hit = true + for (var j = 0; j < words.length && hit; j++) hit = hay.indexOf(words[j]) !== -1 + if (hit) out.push(all[i]) + } + return out + } + + function fetchModels() { + root.runOps("fetching models", [["models", "--preset", root.editPreset, "--refresh"]], "presetTier") + } + + // The cached catalogue for the preset being edited. An LM Studio list is + // only used when it came from the same server: two presets can point at two + // machines with different models loaded. + readonly property var catalogueNode: { + var all = widget && widget.modelsCache && widget.modelsCache.providers ? widget.modelsCache.providers : null + var n = all && root.editProvider !== "" ? all[root.editProvider] : null + if (!n) return null + if (root.editProvider === "lmstudio") { + var want = root.editEntry && root.editEntry.baseUrl ? String(root.editEntry.baseUrl).replace(/\/+$/, "") : "" + if (String(n.baseUrl || "") !== want) return null + } + return n + } + + readonly property var modelOptions: { + var n = root.catalogueNode + var ms = n && n.models ? n.models : [] + var out = [] + for (var i = 0; i < ms.length; i++) { + var m = ms[i] + var bits = [] + if (m.contextTokens) bits.push(Modes.contextLabel(m.contextTokens)) + if (m.priceIn !== undefined && m.priceIn !== null) + bits.push("$" + m.priceIn + " in / $" + m.priceOut + " out per 1M") + if (m.state) bits.push(String(m.state)) + if (m.note) bits.push(String(m.note)) + out.push({ value: String(m.id), label: String(m.id), description: bits.join(" · ") }) + } + return out + } + + function ageLabel(iso) { + var t = Date.parse(String(iso || "")) + if (isNaN(t)) return "" + var s = Math.max(0, (Date.now() - t) / 1000) + if (s < 90) return "just now" + if (s < 5400) return Math.round(s / 60) + " min ago" + if (s < 129600) return Math.round(s / 3600) + " h ago" + return Math.round(s / 86400) + " days ago" + } + function storeServerKey() { remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"]) remedyProc.running = true @@ -237,7 +426,10 @@ Panel { root.pendingMode = "" root.pendingPreset = "" root.serverPreset = "" + root.serverReturn = "" root.repairTarget = null + root.editPreset = "" + root.editTier = "" } function switchTo(mode, presetName) { @@ -457,12 +649,22 @@ Panel { } } - PopupCard { + // KeyboardPanel, not PopupCard. PopupCard is an xdg-popup, which only gets + // keys after focus is routed through its parent surface - so no text field + // in it could ever be typed into (the server URL form and the model search + // both looked clickable and took nothing). KeyboardPanel is a layer-shell + // surface that primes keyboard focus on open, which is why every shell panel + // with a text field is built on it. + KeyboardPanel { id: card anchorItem: root.anchorItem owner: root.barIdentity bar: root.bar open: root.opened + // The panel now holds the keyboard while open, so keys need somewhere to + // land when no field has focus - and Esc should close it, as it does + // every other shell panel. Esc inside a text field bubbles up here too. + focusTarget: column contentWidth: card.fittedContentWidth(Style.space(360)) contentHeight: card.fittedContentHeight(column.implicitHeight) @@ -470,6 +672,8 @@ Panel { id: column width: card.contentWidth - card.padding * 2 spacing: Style.space(10) + focus: true + Keys.onEscapePressed: root.close() // ---- Hero: what the next `claude` will actually use. Row { @@ -696,8 +900,9 @@ Panel { font.pixelSize: Style.space(10) } - // Clicking the row switches; the gear edits where that preset - // points instead. Per row rather than per provider, because + // Clicking the row switches; the gear opens that preset in the + // editor instead. Per row rather than per provider, because + // two presets of one provider can map tiers differently - and // two LM Studio presets can sit on two different machines. MouseArea { id: presetHover @@ -711,7 +916,7 @@ Panel { Item { id: gearButton - visible: modeEntry.thisMode === "lmstudio" + visible: true anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: Style.space(22) @@ -731,7 +936,7 @@ Panel { hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: !root.busy - onClicked: root.openServerSettings(String(presetRow.modelData.name)) + onClicked: root.openPresetEditor(String(presetRow.modelData.name)) } } } @@ -928,7 +1133,331 @@ Panel { spacing: Style.space(7) PillButton { label: "Save"; primary: true; onTriggered: root.saveServerSettings() } - PillButton { label: "Cancel"; onTriggered: root.resetFlow() } + PillButton { + label: root.serverReturn !== "" ? "Back" : "Cancel" + onTriggered: { + if (root.serverReturn !== "") root.stage = root.serverReturn + else root.resetFlow() + } + } + } + } + + // ---- One preset: its tier map, each row opening a picker. + Column { + width: parent.width + visible: root.stage === "preset" && root.editPreset !== "" + spacing: Style.space(6) + + Text { + width: parent.width + text: "'" + root.editPreset + "' · " + Modes.title(root.editProvider) + color: Color.popups.text + elide: Text.ElideRight + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(13) + font.bold: true + } + + Text { + width: parent.width + visible: text !== "" + text: root.editEntry && root.editEntry.description ? String(root.editEntry.description) : "" + color: Color.muted + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Text { + width: parent.width + visible: root.editIsActive + text: "In use now. A change is applied straight away and reaches sessions started after it." + color: Color.accent + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Column { + width: parent.width + spacing: Style.space(1) + + Repeater { + model: root.stage === "preset" ? root.tierNames : [] + + Item { + id: tierRow + required property var modelData + readonly property string tier: String(modelData) + readonly property string current: root.editCurrent(tier) + width: column.width + height: Style.space(26) + + Rectangle { + anchors.fill: parent + anchors.leftMargin: -Style.space(6) + anchors.rightMargin: -Style.space(6) + radius: Style.space(4) + color: tierArea.containsMouse + ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14) + : "transparent" + } + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: tierRow.tier + color: Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + } + + Text { + anchors.left: parent.left + anchors.leftMargin: Style.space(54) + anchors.right: tierChevron.left + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignRight + text: tierRow.current !== "" ? tierRow.current : "not set" + color: tierRow.current !== "" ? Color.popups.text : Color.muted + elide: Text.ElideLeft + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + } + + Text { + id: tierChevron + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: String.fromCodePoint(0xF0142) + color: tierArea.containsMouse ? Color.accent : Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.body + } + + MouseArea { + id: tierArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !root.busy + onClicked: root.openTierEditor(tierRow.tier) + } + } + } + } + + // Flow, not Row: these can total more than the card is wide, and a + // Row lays them straight past its right edge instead of wrapping. + Flow { + width: parent.width + spacing: Style.space(7) + + PillButton { + label: "Server settings…" + visible: root.editProvider === "lmstudio" + onTriggered: root.openServerSettings(root.editPreset, "preset") + } + PillButton { label: "Done"; primary: true; onTriggered: root.resetFlow() } + } + } + + // ---- One tier: pick from the cached catalogue, or type any id. + Column { + width: parent.width + visible: root.stage === "presetTier" + spacing: Style.space(7) + + Text { + width: parent.width + text: root.editTier + " · '" + root.editPreset + "'" + color: Color.popups.text + elide: Text.ElideRight + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(13) + font.bold: true + } + + Text { + width: parent.width + text: "now: " + (root.editCurrent(root.editTier) || "not set") + color: Color.muted + elide: Text.ElideLeft + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + // One field that both filters the catalogue and takes any id typed by + // hand, with the matches listed inline beneath it - rather than the + // shell's SearchableDropdown, whose list is a second popup layered on + // this card. Inline, the card's height is the only thing to manage, + // and the list can never be clipped by the card's edge. + TextField { + id: modelField + width: parent.width + text: root.editModel + placeholderText: root.modelOptions.length > 0 + ? "Search " + root.modelOptions.length + " models, or type any id" + : "Model id" + foreground: Color.popups.text + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + onTextChanged: { + root.editModel = text + modelList.currentIndex = -1 + } + // Enter takes the highlighted match if the arrows chose one, and + // saves what is in the field otherwise. + onAccepted: { + if (modelList.currentIndex >= 0 && modelList.currentIndex < root.modelMatches.length) + root.pickModel(String(root.modelMatches[modelList.currentIndex].value)) + else + root.saveTier() + } + Keys.onDownPressed: function (event) { + if (modelList.count > 0) { + modelList.currentIndex = Math.min(modelList.currentIndex + 1, modelList.count - 1) + modelList.positionViewAtIndex(modelList.currentIndex, ListView.Contain) + } + event.accepted = true + } + Keys.onUpPressed: function (event) { + if (modelList.currentIndex >= 0) { + modelList.currentIndex = modelList.currentIndex - 1 + if (modelList.currentIndex >= 0) modelList.positionViewAtIndex(modelList.currentIndex, ListView.Contain) + } + event.accepted = true + } + } + + // Scrolls inside a box of at most six rows: the card clamps to its + // content rather than scrolling, so an unbounded list would push the + // buttons off the bottom edge. + Rectangle { + id: modelListFrame + readonly property int rowHeight: Style.space(30) + width: parent.width + visible: root.modelOptions.length > 0 + height: rowHeight * Math.max(1, Math.min(modelList.count, 6)) + 2 + radius: Style.space(4) + color: "transparent" + border.width: 1 + border.color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.14) + + ListView { + id: modelList + anchors.fill: parent + anchors.margins: 1 + clip: true + boundsBehavior: Flickable.StopAtBounds + currentIndex: -1 + model: root.stage === "presetTier" ? root.modelMatches : [] + + delegate: Item { + id: matchRow + required property var modelData + required property int index + readonly property bool chosen: String(modelData.value) === root.editModel.trim() + width: modelList.width + height: modelListFrame.rowHeight + + Rectangle { + anchors.fill: parent + color: matchRow.index === modelList.currentIndex || matchArea.containsMouse + ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.16) + : (matchRow.chosen ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.10) : "transparent") + } + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Style.space(8) + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Text { + width: parent.width + text: String(matchRow.modelData.label) + color: matchRow.chosen ? Color.accent : Color.popups.text + elide: Text.ElideMiddle + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + font.bold: matchRow.chosen + } + + Text { + width: parent.width + visible: text !== "" + text: String(matchRow.modelData.description || "") + color: Color.muted + elide: Text.ElideRight + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(9) + } + } + + MouseArea { + id: matchArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.pickModel(String(matchRow.modelData.value)) + } + } + } + + Text { + anchors.centerIn: parent + visible: modelList.count === 0 + text: "No match. Save uses the id as typed." + color: Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + } + + Text { + width: parent.width + visible: root.modelOptions.length === 0 + text: root.catalogueNode && root.catalogueNode.ok === false + ? "The last fetch failed and no list is cached. Type an id, or try fetching again." + : "No model list cached for " + Modes.title(root.editProvider) + " yet. Type an id, or fetch the list." + color: Color.muted + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Text { + width: parent.width + visible: root.catalogueNode !== null && root.modelOptions.length > 0 + text: root.catalogueNode + ? root.modelOptions.length + " models cached, fetched " + root.ageLabel(root.catalogueNode.fetchedAt) + + (root.catalogueNode.ok === false ? " · the last refresh failed" : "") + : "" + color: Color.muted + opacity: 0.85 + elide: Text.ElideRight + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(9) + } + + Flow { + width: parent.width + spacing: Style.space(7) + + PillButton { label: "Save"; primary: true; onTriggered: root.saveTier() } + PillButton { + label: root.modelOptions.length > 0 ? "Refresh list" : "Fetch models" + onTriggered: root.fetchModels() + } + PillButton { + label: "Back" + onTriggered: { root.lastError = ""; root.stage = "preset" } + } } } @@ -1378,7 +1907,8 @@ Panel { Text { width: parent.width text: root.busy - ? (ignoreProc.running ? "updating…" : (repairProc.running ? "repairing…" : "switching…")) + ? (opProc.running ? root.opLabel + "…" + : (ignoreProc.running ? "updating…" : (repairProc.running ? "repairing…" : "switching…"))) : (root.known && root.stage === "list" ? "Restart claude to pick this up." : "") visible: text !== "" color: Color.muted