Edit a preset's tiers from the bar panel
The gear on every preset row now opens an editor: the four tiers with the model each maps to, and for each one field that filters the provider's catalogue as you type (every word must match; arrows and Enter work), listed inline with context length and price, and that also takes any id typed by hand. LM Studio's server form moves one click inside the editor. The panel card moves from PopupCard to KeyboardPanel. PopupCard is an xdg-popup, which only receives keys after focus is routed through its parent surface, so no text field in it could ever be typed into - the existing server URL form included. KeyboardPanel primes layer-shell keyboard focus on open, which is why every shell panel with a text field uses it. Esc now closes the panel. The picker reads ~/.claude-mode/models-cache.json, which or_catalogue and lms_catalogue now write as a side effect, so models, doctor, setup and the menu's picker all keep it fresh and the panel never hits the network itself. One node per provider with its own fetchedAt/ok; a failed fetch keeps the old list, an LM Studio list is tied to its server, and no key or key name is ever stored. `models` gains --preset, --refresh and --json, and the Z.AI list now lives in one place. A tier edit to the active preset re-applies without the running-sessions prompt: that prompt guards against the endpoint or key moving, and a tier edit moves neither (preset url/auth still ask). A failed re-apply now says the edit was saved. `preset set` on an unknown name no longer creates it. Panel edits run through one chain that stops on the first failure and refreshes health.json at the end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -188,6 +188,9 @@ claude-mode preset rm <name>
|
|||||||
|
|
||||||
claude-mode set-key [ref] [key] store a key (hidden prompt; [key] for scripts)
|
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 [filter] models available from the active provider
|
||||||
|
claude-mode models --preset <name> ...from that preset's provider (and server) instead
|
||||||
|
claude-mode models --preset <name> --refresh only update the cached list the panel uses
|
||||||
|
claude-mode models --preset <name> --json the cached list for that provider, as JSON
|
||||||
claude-mode doctor verify auth, endpoint, model ids, stray env vars
|
claude-mode doctor verify auth, endpoint, model ids, stray env vars
|
||||||
|
|
||||||
claude-mode preflight <mode> [preset] can this mode actually serve? (no switch)
|
claude-mode preflight <mode> [preset] can this mode actually serve? (no switch)
|
||||||
@@ -201,6 +204,19 @@ claude-mode <mode> --force switch even when preflight says no
|
|||||||
Omitting the preset uses a **fixed** per-provider default, not "most recently
|
Omitting the preset uses a **fixed** per-provider default, not "most recently
|
||||||
used" — so `claude-mode openrouter` always means `default`.
|
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
|
## Presets shipped
|
||||||
|
|
||||||
One per mode, so `claude-mode <mode>` is never ambiguous and there is no
|
One per mode, so `claude-mode <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
|
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
|
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
|
[up top](#omarchy-bar-widget). The shell notices the new files and reloads the
|
||||||
restarting — except after a change to `Modes.js`, which the QML engine caches
|
plugin, but on an **upgrade** that is not enough: it clears Qt's component cache
|
||||||
for the life of the process as a `.pragma library` (`omarchy restart shell`).
|
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
|
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
|
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:
|
|||||||
<mode>…* opens a terminal and runs the whole first-run flow there (a bar popup
|
<mode>…* 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
|
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
|
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
|
preflight, and *Server settings…* opens the form below. For an LM Studio
|
||||||
Studio preset row opens the same form without waiting for a failure — per
|
preset the same form is also one click inside the preset editor (below),
|
||||||
row, because two LM Studio presets can point at two different machines.
|
without waiting for a failure.
|
||||||
|
|
||||||
The form holds the base URL, a *Use local default* reset, and a switch for
|
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
|
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
|
strictly **after** the write — restarting first would only bring them back up
|
||||||
on the provider you just left.
|
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.
|
- **Right-click** switches straight back to Anthropic.
|
||||||
- **Middle-click** re-reads state.
|
- **Middle-click** re-reads state.
|
||||||
- **Hover** for mode, preset, and the opus/sonnet mapping.
|
- **Hover** for mode, preset, and the opus/sonnet mapping.
|
||||||
|
|||||||
+130
-31
@@ -17,6 +17,7 @@ CM_PRESETS="$CM_ROOT/presets"
|
|||||||
CM_BACKUPS="$CM_ROOT/backups"
|
CM_BACKUPS="$CM_ROOT/backups"
|
||||||
CM_STATE="$CM_ROOT/state.json"
|
CM_STATE="$CM_ROOT/state.json"
|
||||||
CM_IGNORED="$CM_ROOT/ignored-sessions.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_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
||||||
CM_SETTINGS="$CM_SETTINGS_DIR/settings.json"
|
CM_SETTINGS="$CM_SETTINGS_DIR/settings.json"
|
||||||
CM_HELPER="$CM_BIN/claude-key-helper.sh"
|
CM_HELPER="$CM_BIN/claude-key-helper.sh"
|
||||||
@@ -28,6 +29,7 @@ JSON="$CM_BIN/cm-json.py"
|
|||||||
. "$CM_BIN/cm-vault.sh"
|
. "$CM_BIN/cm-vault.sh"
|
||||||
|
|
||||||
CM_FORCE=0
|
CM_FORCE=0
|
||||||
|
CM_SAME_ENDPOINT=0 # set by reapply_if_active for a tier-only edit
|
||||||
|
|
||||||
MODES=(anthropic openrouter zai lmstudio)
|
MODES=(anthropic openrouter zai lmstudio)
|
||||||
TIERS=(opus sonnet haiku fable)
|
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 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 [filter] models available from the active provider
|
||||||
|
claude-mode models --preset <name> [--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 doctor verify auth, endpoint, model ids, env
|
||||||
claude-mode repair strip [1m] tags from cached model ids
|
claude-mode repair strip [1m] tags from cached model ids
|
||||||
claude-mode health refresh health.json (machine-readable state)
|
claude-mode health refresh health.json (machine-readable state)
|
||||||
@@ -1132,8 +1137,32 @@ check_stray_env() {
|
|||||||
# Provider catalogues
|
# 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() {
|
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.
|
# 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
|
# 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.
|
# has no models installed - on exactly the setups that need the list most.
|
||||||
lms_catalogue() {
|
lms_catalogue() {
|
||||||
local base="${1%/}" token="${2:-}"
|
local base="${1%/}" token="${2:-}" out rc
|
||||||
curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
|
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
|
"$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() {
|
cmd_models() {
|
||||||
local filter="${1:-}" mode preset pf
|
local filter='' pname='' refresh=0 as_json=0 a pf='' provider base='' tsv rc
|
||||||
mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")"
|
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)
|
lmstudio)
|
||||||
head_ "models installed in LM Studio at $(jget "$pf" baseUrl)"
|
[ "$quiet" -eq 1 ] || head_ "models installed in LM Studio at $base"
|
||||||
lms_catalogue "$(jget "$pf" baseUrl)" "$(cm_preset_token "$pf")" | while IFS=$'\t' read -r id st ctx; do
|
tsv="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"; rc=$? ;;
|
||||||
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
|
|
||||||
printf ' %-58s %-11s %s\n' "$id" "$st" "$ctx"
|
|
||||||
done
|
|
||||||
;;
|
|
||||||
zai)
|
zai)
|
||||||
head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)'
|
[ "$quiet" -eq 1 ] || head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)'
|
||||||
say 'glm-5.3 - flagship coding model (opus/sonnet tier)'
|
tsv="$(zai_catalogue)"; rc=$? ;;
|
||||||
say 'glm-4.7 - fast/cheap tier (haiku tier)'
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
head_ 'fetching https://openrouter.ai/api/v1/models ...'
|
[ "$quiet" -eq 1 ] || head_ 'fetching https://openrouter.ai/api/v1/models ...'
|
||||||
or_catalogue | while IFS=$'\t' read -r id ctx pin pout; do
|
tsv="$(or_catalogue)"; rc=$? ;;
|
||||||
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
|
|
||||||
printf ' %-52s %10s $%-8s $%s\n' "$id" "$ctx" "$pin" "$pout"
|
|
||||||
done
|
|
||||||
;;
|
|
||||||
esac
|
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() {
|
cmd_set_key() {
|
||||||
@@ -1456,19 +1532,23 @@ cmd_preset() {
|
|||||||
set)
|
set)
|
||||||
local tier="${3:-}" model="${4:-}"
|
local tier="${3:-}" model="${4:-}"
|
||||||
[ -n "$name" ] && [ -n "$tier" ] && [ -n "$model" ] || { err 'usage: claude-mode preset set <name> <tier> <model-id>'; return 1; }
|
[ -n "$name" ] && [ -n "$tier" ] && [ -n "$model" ] || { err 'usage: claude-mode preset set <name> <tier> <model-id>'; 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
|
"$PY" "$JSON" set-tier "$(preset_path "$name")" "$tier" "$model" || return 1
|
||||||
ok "$name : $tier -> $model"
|
ok "$name : $tier -> $model"
|
||||||
reapply_if_active "$name"
|
reapply_if_active "$name" models
|
||||||
;;
|
;;
|
||||||
all)
|
all)
|
||||||
local model="${3:-}"
|
local model="${3:-}"
|
||||||
[ -n "$name" ] && [ -n "$model" ] || { err 'usage: claude-mode preset all <name> <model-id>'; return 1; }
|
[ -n "$name" ] && [ -n "$model" ] || { err 'usage: claude-mode preset all <name> <model-id>'; return 1; }
|
||||||
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
local t
|
local t
|
||||||
for t in "${TIERS[@]}" subagent; do
|
for t in "${TIERS[@]}" subagent; do
|
||||||
"$PY" "$JSON" set-tier "$(preset_path "$name")" "$t" "$model" || return 1
|
"$PY" "$JSON" set-tier "$(preset_path "$name")" "$t" "$model" || return 1
|
||||||
done
|
done
|
||||||
ok "$name : all tiers + subagent -> $model"
|
ok "$name : all tiers + subagent -> $model"
|
||||||
reapply_if_active "$name"
|
reapply_if_active "$name" models
|
||||||
;;
|
;;
|
||||||
url)
|
url)
|
||||||
local url="${3:-}"
|
local url="${3:-}"
|
||||||
@@ -1495,11 +1575,24 @@ cmd_preset() {
|
|||||||
esac
|
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() {
|
reapply_if_active() {
|
||||||
local name="$1"
|
local name="$1" scope="${2:-}" mode
|
||||||
if [ "$(state_mode)" != "anthropic" ] && [ "$(state_preset)" = "$name" ]; then
|
mode="$(state_mode)"
|
||||||
|
if [ "$mode" != "anthropic" ] && [ "$(state_preset)" = "$name" ]; then
|
||||||
say 're-applying active preset...'
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1769,8 +1862,10 @@ ui_pick_model() {
|
|||||||
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
|
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
|
||||||
;;
|
;;
|
||||||
zai)
|
zai)
|
||||||
ids+=('glm-5.3'); ui_add_item 'glm-5.3' 'flagship coding model - opus/sonnet tier'
|
while IFS=$'\t' read -r id a; do
|
||||||
ids+=('glm-4.7'); ui_add_item 'glm-4.7' 'fast/cheap tier - haiku'
|
[ -n "$id" ] || continue
|
||||||
|
ids+=("$id"); ui_add_item "$id" "$a"
|
||||||
|
done < <(zai_catalogue)
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -2151,6 +2246,10 @@ cm_confirm_sessions() {
|
|||||||
CM_SESSION_ACTION=none
|
CM_SESSION_ACTION=none
|
||||||
CM_SESSION_ROWS=''
|
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
|
if ! cm_sessions_supported; then
|
||||||
printf '\n'
|
printf '\n'
|
||||||
warn 'cannot list running sessions here (no /proc) - the switch will not wait for them'
|
warn 'cannot list running sessions here (no /proc) - the switch will not wait for them'
|
||||||
@@ -2584,7 +2683,7 @@ case "$cmd" in
|
|||||||
else
|
else
|
||||||
cmd_set_key "$_ref" "$_secret"
|
cmd_set_key "$_ref" "$_secret"
|
||||||
fi ;;
|
fi ;;
|
||||||
models) cmd_models "${1:-}" ;;
|
models) cmd_models "$@" ;;
|
||||||
doctor) cmd_doctor ;;
|
doctor) cmd_doctor ;;
|
||||||
health) write_health "$(state_mode)" "$(state_preset)" ;;
|
health) write_health "$(state_mode)" "$(state_preset)" ;;
|
||||||
preflight) cmd_preflight "${1:-}" "${2:-}" ;;
|
preflight) cmd_preflight "${1:-}" "${2:-}" ;;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Subcommands:
|
|||||||
presets <dir> "name<TAB>provider<TAB>desc"
|
presets <dir> "name<TAB>provider<TAB>desc"
|
||||||
scan-sessions <projects> [max-age-days] [ignored] broken transcripts, as JSON
|
scan-sessions <projects> [max-age-days] [ignored] broken transcripts, as JSON
|
||||||
ignore-session <ignored> <projects> add|remove|clear|list [id]
|
ignore-session <ignored> <projects> add|remove|clear|list [id]
|
||||||
|
cache-models <cache> <provider> <1|0> [baseUrl] store a TSV catalogue (stdin)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import glob
|
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")))
|
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 <cache.json> <provider> <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):
|
def cmd_lms_models(argv):
|
||||||
"""LM Studio /api/v0/models on stdin -> 'id<TAB>state<TAB>ctx' lines."""
|
"""LM Studio /api/v0/models on stdin -> 'id<TAB>state<TAB>ctx' lines."""
|
||||||
data = json.loads(sys.stdin.read())
|
data = json.loads(sys.stdin.read())
|
||||||
@@ -1095,6 +1166,7 @@ COMMANDS = {
|
|||||||
"strip-tags": cmd_strip_tags,
|
"strip-tags": cmd_strip_tags,
|
||||||
"or-models": cmd_or_models,
|
"or-models": cmd_or_models,
|
||||||
"lms-models": cmd_lms_models,
|
"lms-models": cmd_lms_models,
|
||||||
|
"cache-models": cmd_cache_models,
|
||||||
"apply": cmd_apply,
|
"apply": cmd_apply,
|
||||||
"summary": cmd_summary,
|
"summary": cmd_summary,
|
||||||
"models": cmd_models,
|
"models": cmd_models,
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ BarWidget {
|
|||||||
onLoadFailed: root.fallbackState = null
|
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
|
// 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
|
// lands back here through the FileView above. This only covers the case
|
||||||
// where the file was never written at all.
|
// where the file was never written at all.
|
||||||
@@ -179,6 +194,7 @@ BarWidget {
|
|||||||
function refresh() {
|
function refresh() {
|
||||||
healthFile.reload()
|
healthFile.reload()
|
||||||
stateFile.reload()
|
stateFile.reload()
|
||||||
|
modelsFile.reload()
|
||||||
if (!root.health) seedProc.running = true
|
if (!root.health) seedProc.running = true
|
||||||
root.scanSessions()
|
root.scanSessions()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ Panel {
|
|||||||
// Both questions are answered by claude-mode rather than re-derived here, so
|
// 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
|
// the widget and the CLI can never disagree about whether a switch is allowed
|
||||||
// or about what is running.
|
// 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 blocker: null // the preflight verdict, when it refused
|
||||||
property var sessionInfo: null // sessions --json, when any were found
|
property var sessionInfo: null // sessions --json, when any were found
|
||||||
property string pendingMode: ""
|
property string pendingMode: ""
|
||||||
@@ -90,8 +90,13 @@ Panel {
|
|||||||
return null
|
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)
|
var e = root.presetEntry(presetName)
|
||||||
|
root.serverReturn = from || ""
|
||||||
root.serverPreset = presetName
|
root.serverPreset = presetName
|
||||||
root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverLocalDefault
|
root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverLocalDefault
|
||||||
root.serverNeedsKey = !!(e && String(e.authMode) === "vault")
|
root.serverNeedsKey = !!(e && String(e.authMode) === "vault")
|
||||||
@@ -139,7 +144,7 @@ Panel {
|
|||||||
// Straight back into the switch that was blocked, if there was one -
|
// Straight back into the switch that was blocked, if there was one -
|
||||||
// the whole point of coming here was to unblock it.
|
// the whole point of coming here was to unblock it.
|
||||||
if (root.pendingMode !== "") root.retryPending()
|
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() {
|
function storeServerKey() {
|
||||||
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
|
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
|
||||||
remedyProc.running = true
|
remedyProc.running = true
|
||||||
@@ -237,7 +426,10 @@ Panel {
|
|||||||
root.pendingMode = ""
|
root.pendingMode = ""
|
||||||
root.pendingPreset = ""
|
root.pendingPreset = ""
|
||||||
root.serverPreset = ""
|
root.serverPreset = ""
|
||||||
|
root.serverReturn = ""
|
||||||
root.repairTarget = null
|
root.repairTarget = null
|
||||||
|
root.editPreset = ""
|
||||||
|
root.editTier = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchTo(mode, presetName) {
|
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
|
id: card
|
||||||
anchorItem: root.anchorItem
|
anchorItem: root.anchorItem
|
||||||
owner: root.barIdentity
|
owner: root.barIdentity
|
||||||
bar: root.bar
|
bar: root.bar
|
||||||
open: root.opened
|
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))
|
contentWidth: card.fittedContentWidth(Style.space(360))
|
||||||
contentHeight: card.fittedContentHeight(column.implicitHeight)
|
contentHeight: card.fittedContentHeight(column.implicitHeight)
|
||||||
|
|
||||||
@@ -470,6 +672,8 @@ Panel {
|
|||||||
id: column
|
id: column
|
||||||
width: card.contentWidth - card.padding * 2
|
width: card.contentWidth - card.padding * 2
|
||||||
spacing: Style.space(10)
|
spacing: Style.space(10)
|
||||||
|
focus: true
|
||||||
|
Keys.onEscapePressed: root.close()
|
||||||
|
|
||||||
// ---- Hero: what the next `claude` will actually use.
|
// ---- Hero: what the next `claude` will actually use.
|
||||||
Row {
|
Row {
|
||||||
@@ -696,8 +900,9 @@ Panel {
|
|||||||
font.pixelSize: Style.space(10)
|
font.pixelSize: Style.space(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clicking the row switches; the gear edits where that preset
|
// Clicking the row switches; the gear opens that preset in the
|
||||||
// points instead. Per row rather than per provider, because
|
// 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.
|
// two LM Studio presets can sit on two different machines.
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: presetHover
|
id: presetHover
|
||||||
@@ -711,7 +916,7 @@ Panel {
|
|||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: gearButton
|
id: gearButton
|
||||||
visible: modeEntry.thisMode === "lmstudio"
|
visible: true
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: Style.space(22)
|
width: Style.space(22)
|
||||||
@@ -731,7 +936,7 @@ Panel {
|
|||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
enabled: !root.busy
|
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)
|
spacing: Style.space(7)
|
||||||
|
|
||||||
PillButton { label: "Save"; primary: true; onTriggered: root.saveServerSettings() }
|
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 {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: root.busy
|
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." : "")
|
: (root.known && root.stage === "list" ? "Restart claude to pick this up." : "")
|
||||||
visible: text !== ""
|
visible: text !== ""
|
||||||
color: Color.muted
|
color: Color.muted
|
||||||
|
|||||||
Reference in New Issue
Block a user