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:
smoido
2026-09-15 00:14:30 +03:00
co-authored by Claude Opus 5
parent cbd3b1d8a9
commit 83b59f34a1
5 changed files with 797 additions and 47 deletions
+540 -10
View File
@@ -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