Create, duplicate, rename and delete presets from the panel

Each provider's preset list ends in "New ... preset...", and the preset
editor gains Duplicate, Rename and Delete. New asks for a name and what to
start from: a copy of one of that provider's presets, or a blank template.
Delete is greyed out, with the reason, on the preset in use, and warns when
it would leave a provider with no preset at all.

CLI:
- preset rename <name> <new>: the new name is hard-linked in, state.json is
  repointed, and only then does the old name go.
- preset new <name> --provider <p> [--blank]: with a provider and no source
  it copies that provider's own default instead of the OpenRouter-only
  `default`; --blank starts from the scaffold.
- valid_preset_name on every preset subcommand: no slash, no leading dot.
  `preset show ../../etc/passwd` used to print the file.
- preset rm warns when it removes a provider's last preset.
- preflight refuses a preset with every tier empty. Otherwise a blank preset
  would switch cleanly and Claude Code would ask the gateway for its default
  Anthropic models, billed at full price on OpenRouter. The panel's blocked
  card offers "Edit preset..." for it.

The key helper now reads the active preset in a single open (new cm-json
auth-of) and re-reads state.json once on a miss. Renaming the active preset
could otherwise catch a running session between reading the old name and
opening the file: 1 failure in 51 key fetches in a race test before, 0 in
118 across 60 renames after. It could also briefly hand out the openrouter
key for a preset that uses another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-15 00:25:39 +03:00
co-authored by Claude Opus 5
parent 83b59f34a1
commit de9f10a8c2
5 changed files with 527 additions and 22 deletions
+360 -5
View File
@@ -62,7 +62,8 @@ 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 | server | repair | preset | presetTier
property string stage: "list" // list | blocked | confirm | server | repair
// | preset | presetTier | presetNew | presetRename | presetDelete
property var blocker: null // the preflight verdict, when it refused
property var sessionInfo: null // sessions --json, when any were found
property string pendingMode: ""
@@ -239,13 +240,15 @@ Panel {
property var opQueue: []
property string opLabel: ""
property string opLanding: ""
property var opThen: null // run on success, before landing
function runOps(label, ops, landing) {
function runOps(label, ops, landing, then) {
if (root.busy) return
root.busy = true
root.lastError = ""
root.opLabel = label
root.opLanding = landing
root.opThen = then || null
root.opQueue = ops.slice()
root.nextOp()
}
@@ -282,7 +285,12 @@ Panel {
if (opProc.finalHealth) {
root.busy = false
if (widget) widget.refresh()
if (root.lastError === "") root.stage = root.opLanding
var then = root.opThen
root.opThen = null
if (root.lastError === "") {
if (then) then()
root.stage = root.opLanding
}
return
}
if (code !== 0) {
@@ -414,6 +422,80 @@ Panel {
return Math.round(s / 86400) + " days ago"
}
// ---- Preset lifecycle: new, duplicate, rename, delete
//
// Names become file names, so the CLI's valid_preset_name rule is checked
// here too. The CLI checks again; this only says what is wrong while typing.
readonly property var presetNameRe: /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/
property string newProvider: ""
property string newFrom: "" // source preset to copy; "" = blank template
property string newName: ""
property string renameTo: ""
function presetsOf(provider) {
var all = (health && health.presets) ? health.presets : []
return all.filter(function (p) { return String(p.provider) === provider })
}
// `keep` is a name that is allowed to exist already - the preset's own,
// when renaming.
function nameProblem(name, keep) {
var n = String(name || "").trim()
if (n === "") return "Give it a name."
if (!root.presetNameRe.test(n)) return "Letters, digits, . _ and - only, not starting with a dot."
if (n !== keep && root.presetEntry(n)) return "There is already a preset called '" + n + "'."
return ""
}
function uniqueName(base) {
var n = base
for (var i = 2; root.presetEntry(n); i++) n = base + "-" + i
return n
}
// `from` null means "whatever makes sense": the provider's first preset,
// which carries a working server, key and context, else a blank template.
function openNewPreset(provider, from) {
root.lastError = ""
root.newProvider = provider
var sibs = root.presetsOf(provider)
root.newFrom = from !== null && from !== undefined ? from : (sibs.length > 0 ? String(sibs[0].name) : "")
root.newName = root.uniqueName(from ? from + "-copy" : provider + "-new")
newNameField.text = root.newName
root.stage = "presetNew"
Qt.callLater(function () { newNameField.forceActiveFocus(); newNameField.selectAll() })
}
function createPreset() {
var n = root.newName.trim()
if (root.nameProblem(n, "") !== "") return
var argv = root.newFrom === ""
? ["preset", "new", n, "--provider", root.newProvider, "--blank"]
: ["preset", "new", n, root.newFrom]
root.runOps("creating", [argv], "preset", function () { root.editPreset = n })
}
function openRename() {
root.lastError = ""
root.renameTo = root.editPreset
renameField.text = root.editPreset
root.stage = "presetRename"
Qt.callLater(function () { renameField.forceActiveFocus(); renameField.selectAll() })
}
function renamePreset() {
var n = root.renameTo.trim()
if (n === root.editPreset) { root.stage = "preset"; return }
if (root.nameProblem(n, root.editPreset) !== "") return
var old = root.editPreset
root.runOps("renaming", [["preset", "rename", old, n]], "preset", function () { root.editPreset = n })
}
function deletePreset() {
if (root.editIsActive || root.editPreset === "") return
root.runOps("deleting", [["preset", "rm", root.editPreset]], "list", function () { root.editPreset = "" })
}
function storeServerKey() {
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
remedyProc.running = true
@@ -430,6 +512,7 @@ Panel {
root.repairTarget = null
root.editPreset = ""
root.editTier = ""
root.newProvider = ""
}
function switchTo(mode, presetName) {
@@ -622,6 +705,8 @@ Panel {
implicitWidth: pbText.implicitWidth + Style.space(20)
implicitHeight: Style.space(25)
radius: Style.space(4)
// `enabled: false` greys it out; the MouseArea inherits the flag.
opacity: enabled ? 1.0 : 0.4
readonly property color tint: primary ? Color.accent : Color.popups.text
color: pbArea.containsMouse
@@ -843,10 +928,11 @@ Panel {
}
}
// ---- Presets for this provider, unfolded in place.
// ---- Presets for this provider, unfolded in place. Shown even
// with none left, so "New preset…" is still there to click.
Column {
width: parent.width
visible: modeEntry.isExpanded && modeEntry.presetList.length > 0
visible: modeEntry.isExpanded
spacing: Style.space(1)
Repeater {
@@ -941,6 +1027,40 @@ Panel {
}
}
}
Item {
width: modeEntry.width
height: Style.space(24)
Rectangle {
anchors.fill: parent
anchors.leftMargin: Style.space(20)
anchors.rightMargin: -Style.space(6)
radius: Style.space(4)
color: newPresetArea.containsMouse
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
: "transparent"
}
Text {
anchors.left: parent.left
anchors.leftMargin: Style.space(30)
anchors.verticalCenter: parent.verticalCenter
text: "New " + Modes.title(modeEntry.thisMode) + " preset…"
color: newPresetArea.containsMouse ? Color.accent : Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
MouseArea {
id: newPresetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !root.busy
onClicked: root.openNewPreset(modeEntry.thisMode, null)
}
}
}
}
}
@@ -1003,6 +1123,18 @@ Panel {
visible: root.blocker && String(root.blocker.remedyKind) === "set-key"
onTriggered: root.runRemedy()
}
// A preset with every tier empty: the fix is one click away here,
// not in a terminal.
PillButton {
label: "Edit preset…"
primary: true
visible: root.blocker && String(root.blocker.remedyKind) === "edit-preset"
onTriggered: {
var p = root.pendingPreset
root.resetFlow()
root.openPresetEditor(p)
}
}
PillButton {
label: "Server settings…"
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0
@@ -1260,8 +1392,231 @@ Panel {
visible: root.editProvider === "lmstudio"
onTriggered: root.openServerSettings(root.editPreset, "preset")
}
PillButton { label: "Duplicate…"; onTriggered: root.openNewPreset(root.editProvider, root.editPreset) }
PillButton { label: "Rename…"; onTriggered: root.openRename() }
// The CLI refuses to delete the preset in use. A button that always
// fails would be worse than one that says why it is off.
PillButton {
label: "Delete…"
enabled: !root.editIsActive
onTriggered: { root.lastError = ""; root.stage = "presetDelete" }
}
PillButton { label: "Done"; primary: true; onTriggered: root.resetFlow() }
}
Text {
width: parent.width
visible: root.editIsActive
text: "In use, so it cannot be deleted. Switch to another preset first."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(9)
}
}
// ---- New preset: a name, and what to start from.
Column {
width: parent.width
visible: root.stage === "presetNew"
spacing: Style.space(7)
Text {
width: parent.width
text: "New " + Modes.title(root.newProvider) + " preset"
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
}
TextField {
id: newNameField
width: parent.width
text: root.newName
placeholderText: "name"
foreground: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
onTextChanged: root.newName = text
onAccepted: root.createPreset()
}
Text {
width: parent.width
readonly property string problem: root.stage === "presetNew" ? root.nameProblem(root.newName, "") : ""
visible: problem !== ""
text: problem
color: root.urgentColor
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
text: "Start from"
color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
// Only this provider's presets: a copy keeps the provider, and a
// preset cannot change provider afterwards.
Flow {
width: parent.width
spacing: Style.space(6)
Repeater {
model: root.stage === "presetNew" ? root.presetsOf(root.newProvider) : []
PillButton {
required property var modelData
label: "copy of " + String(modelData.name)
primary: root.newFrom === String(modelData.name)
onTriggered: root.newFrom = String(modelData.name)
}
}
PillButton {
label: "blank"
primary: root.newFrom === ""
onTriggered: root.newFrom = ""
}
}
Text {
id: newFromHint
width: parent.width
text: root.newFrom === ""
? "Every tier starts empty; the editor opens next to fill them in."
: "Same server, key and models as '" + root.newFrom + "'. The editor opens next to change them."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { label: "Create"; primary: true; onTriggered: root.createPreset() }
PillButton {
label: "Cancel"
onTriggered: {
root.lastError = ""
if (root.editPreset !== "") root.stage = "preset"
else root.resetFlow()
}
}
}
}
// ---- Rename: the file moves, and state.json follows if it is in use.
Column {
width: parent.width
visible: root.stage === "presetRename"
spacing: Style.space(7)
Text {
width: parent.width
text: "Rename '" + 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
}
TextField {
id: renameField
width: parent.width
text: root.renameTo
placeholderText: "new name"
foreground: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
onTextChanged: root.renameTo = text
onAccepted: root.renamePreset()
}
Text {
width: parent.width
readonly property string problem: root.stage === "presetRename" ? root.nameProblem(root.renameTo, root.editPreset) : ""
visible: problem !== ""
text: problem
color: root.urgentColor
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: "It is the preset in use. The switch follows the new name, and running sessions keep working."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { label: "Rename"; primary: true; onTriggered: root.renamePreset() }
PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } }
}
}
// ---- Delete: confirmed, and says so when it leaves a provider empty.
Column {
width: parent.width
visible: root.stage === "presetDelete"
spacing: Style.space(7)
readonly property bool lastOne: root.stage === "presetDelete"
&& root.presetsOf(root.editProvider).length <= 1
Text {
width: parent.width
text: "Delete '" + 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: "The preset file is removed, and there is no undo from here. Its key stays in the vault."
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: parent.lastOne
text: "It is the only " + Modes.title(root.editProvider) + " preset, so "
+ Modes.title(root.editProvider) + " will have nothing to switch to until you create another."
color: root.urgentColor
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { label: "Delete it"; primary: true; onTriggered: root.deletePreset() }
PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } }
}
}
// ---- One tier: pick from the cached catalogue, or type any id.