Files
claude-mode/omarchy/smoido.claude-mode/Panel.qml
T
smoidoandClaude Opus 5 c5c3fc57d9 Choose which preset claude-mode <provider> picks
Omitting the preset used a fixed name per provider (default, zai,
lmstudio). It can now be chosen:

  claude-mode preset default                     what each picks, and why
  claude-mode preset default openrouter cheap
  claude-mode preset default openrouter --clear

The choice is stored in ~/.claude-mode/defaults.json and read first by
default_preset_for, while its file still exists. A choice whose file is gone
falls back to the built-in name, and if that is gone too, to the first
preset by name, as before. Renaming a chosen preset moves the choice with it,
and deleting it clears the choice. The terminal menu marks what
resolve_preset would actually pick.

health.json publishes defaultPresetFor (what each provider resolves to, in
the CLI's order) and defaultPresetChosen (the explicit choices only). The
panel sorts and tags from it, and the editor gains Make default / Clear
default. preset new, rename and rm now refresh health.json, so the bar
follows edits made in a terminal too.

This changes existing CLI behaviour only once a default is chosen. The
Windows build does not read defaults.json yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 00:34:14 +03:00

2335 lines
86 KiB
QML

import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Modes.js" as Modes
Panel {
id: root
moduleName: "smoido.claude-mode"
ipcTarget: "smoido.claude-mode"
property var anchorItem: null
property var hostWidget: null
property var widget: null
// The bar identifies a panel by the widget mounted in its slot, not by this
// nested panel, so the popout coordinator has to be handed that widget.
readonly property var barIdentity: hostWidget || root
readonly property var health: widget ? widget.health : null
readonly property string mode: widget ? widget.mode : ""
readonly property string preset: widget ? widget.preset : ""
readonly property bool known: widget ? widget.known : false
readonly property string cmRoot: widget ? widget.cmRoot : Modes.defaultRoot()
// Which gateway row has its preset list unfolded. Only one at a time, and
// never anthropic, which takes no preset.
property string expandedMode: ""
property bool busy: false
property string lastError: ""
function open() {
root.expandedMode = ""
root.lastError = ""
root.hiddenExpanded = false
root.resetFlow()
if (widget) widget.refresh()
root.controller.show()
}
function toggle() { root.opened ? root.close() : root.open() }
// A gateway mode needs a preset chosen before anything can be applied, so
// clicking one unfolds its list instead of switching blind. anthropic has
// nothing to choose and applies on the spot.
function activate(mode) {
if (!Modes.needsPreset(mode)) { switchTo(mode, ""); return }
root.expandedMode = (root.expandedMode === mode) ? "" : mode
}
// ---- Switch flow
//
// A switch is not one action. It rewrites settings.json, and two things have
// to be settled before the write: whether the target can actually serve
// requests, and what should happen to the sessions already running - which a
// switch breaks rather than leaves alone, because their credential is
// re-fetched on a timer and resolves to the new mode while their endpoint
// stays the old one.
//
// list -> preflight -> blocked
// -> sessions -> confirm -> switch -> list
//
// 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 | 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: ""
property string pendingPreset: ""
function cli(args) { return [root.cmRoot + "/bin/claude-mode"].concat(args) }
function parseJson(t) { try { return JSON.parse(String(t || "")) } catch (e) { return null } }
// ---- Server settings (LM Studio)
//
// LM Studio ships listening on loopback, but it does not have to be there: it
// can be another machine on the LAN, or something reached through a tunnel or
// a reverse proxy, and it can have authentication switched on. All three are
// just a baseUrl and an auth block in the preset, so this edits those two
// rather than pretending the local default is the only shape.
property string serverPreset: ""
property string serverUrl: ""
property bool serverNeedsKey: false
property string serverKeyRef: "lmstudio"
readonly property string serverLocalDefault: "http://127.0.0.1:1234"
function presetEntry(name) {
var all = (health && health.presets) ? health.presets : []
for (var i = 0; i < all.length; i++) if (String(all[i].name) === name) return all[i]
return null
}
// `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")
root.serverKeyRef = e && e.keyRef ? String(e.keyRef) : "lmstudio"
root.blocker = null
root.stage = "server"
}
// Two writes, sequenced: the URL first, then the auth block, then health.json
// is refreshed so the form and the rest of the panel agree about what the
// preset now says.
function saveServerSettings() {
if (root.busy || root.serverPreset === "") return
root.busy = true
urlProc.command = root.cli(["preset", "url", root.serverPreset, root.serverUrl.trim()])
urlProc.running = true
}
Process {
id: urlProc
running: false
onExited: function (code) {
authProc.command = root.cli(root.serverNeedsKey
? ["preset", "auth", root.serverPreset, "key", root.serverKeyRef]
: ["preset", "auth", root.serverPreset, "none"])
authProc.running = true
}
}
Process {
id: authProc
running: false
onExited: function (code) {
healthProc.command = root.cli(["health"])
healthProc.running = true
}
}
Process {
id: healthProc
running: false
onExited: function (code) {
root.busy = false
if (widget) widget.refresh()
// 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 = root.serverReturn !== "" ? root.serverReturn : "list"
}
}
// ---- Broken transcripts
//
// Repair truncates a transcript back to its last resumable point. That is
// destructive enough to confirm, and reversible enough to offer: the original
// is backed up, the cut turns are written out as Markdown, and the same text
// is handed back to the session so it still knows what it did.
readonly property var broken: widget && widget.brokenSessions ? widget.brokenSessions : []
property var repairTarget: null
function askRepair(entry) {
root.repairTarget = entry
root.stage = "repair"
}
function doRepair() {
if (root.busy || !root.repairTarget) return
root.busy = true
repairProc.command = root.cli(["repair-session", String(root.repairTarget.sessionId), "--apply"])
repairProc.running = true
}
Process {
id: repairProc
running: false
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: {
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
if (msg !== "") root.lastError = msg.split("\n").pop().trim()
}
}
onExited: function (code) {
root.busy = false
root.repairTarget = null
root.stage = "list"
// The scan is what drives the bar's warning dot, so re-run it rather than
// trusting this side to have guessed the new state.
if (widget) widget.scanSessions()
}
}
// ---- Hidden transcripts
//
// Ignoring only changes whether the scan counts a session - the transcript is
// not touched and Restore is one click away - so unlike Repair it needs no
// confirmation. Age-hidden ones are never called ignored: nobody chose that.
readonly property var ignored: widget && widget.ignoredSessions ? widget.ignoredSessions : []
readonly property var dismissedList: ignored.filter(function (e) { return String(e.reason) === "dismissed" })
readonly property var staleList: ignored.filter(function (e) { return String(e.reason) !== "dismissed" })
readonly property int ageDays: widget ? widget.ignoreAgeDays : 7
property bool hiddenExpanded: false
// The card clamps rather than scrolls, so a long list would be cut off at
// the bottom edge instead of becoming reachable. The CLI has the full list.
readonly property int hiddenCap: 4
function projectLabel(p) { return String(p).replace(/^-/, "").replace(/-/g, "/") }
function setIgnored(entry, on) {
if (root.busy || !entry) return
root.busy = true
root.lastError = ""
ignoreProc.command = root.cli(["repair-session", on ? "--ignore" : "--unignore", String(entry.sessionId)])
ignoreProc.running = true
}
Process {
id: ignoreProc
running: false
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() !== "" })
if (lines.length > 0) root.lastError = lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim()
}
}
onExited: function (code) {
root.busy = false
if (widget) widget.scanSessions()
}
}
// ---- 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: ""
property var opThen: null // run on success, before 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()
}
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()
var then = root.opThen
root.opThen = null
if (root.lastError === "") {
if (then) then()
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: ""
// Which preset `claude-mode <provider>` picks when none is named. The CLI
// publishes it now that it can be chosen; the built-in names in Modes.js
// are only the fallback for a health.json written by an older CLI.
readonly property var defaultPresets: {
var out = {}
var builtin = Modes.DEFAULT_PRESET
for (var b in builtin) out[b] = builtin[b]
var pub = health && health.defaultPresetFor ? health.defaultPresetFor : null
if (pub) for (var p in pub) if (pub[p]) out[p] = pub[p]
return out
}
readonly property var chosenDefaults: health && health.defaultPresetChosen ? health.defaultPresetChosen : ({})
readonly property var editEntry: root.editPreset !== "" ? root.presetEntry(root.editPreset) : null
readonly property bool editIsDefault: root.editPreset !== "" && root.defaultPresets[root.editProvider] === root.editPreset
readonly property bool editIsChosenDefault: root.editPreset !== "" && root.chosenDefaults[root.editProvider] === root.editPreset
function setDefault(on) {
root.runOps("saving", [on ? ["preset", "default", root.editProvider, root.editPreset]
: ["preset", "default", root.editProvider, "--clear"]], "preset")
}
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"
}
// ---- 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
}
function resetFlow() {
root.stage = "list"
root.blocker = null
root.sessionInfo = null
root.pendingMode = ""
root.pendingPreset = ""
root.serverPreset = ""
root.serverReturn = ""
root.repairTarget = null
root.editPreset = ""
root.editTier = ""
root.newProvider = ""
}
function switchTo(mode, presetName) {
if (root.busy) return
root.lastError = ""
root.pendingMode = mode
root.pendingPreset = presetName
root.busy = true
preflightProc.command = root.cli(presetName === ""
? ["preflight", mode] : ["preflight", mode, presetName])
preflightProc.running = true
}
// Nothing is written until this says so. A mode with no key stored, or a
// local server that is not running, would otherwise switch cleanly and leave
// every session started afterwards broken in a way that looks like Claude
// Code's fault rather than a missing key.
Process {
id: preflightProc
running: false
property var verdict: null
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: preflightProc.verdict = root.parseJson(text)
}
onExited: function (code) {
var v = preflightProc.verdict
preflightProc.verdict = null
if (v && v.ok === false) {
root.busy = false
root.blocker = v
root.stage = "blocked"
return
}
sessionsProc.command = root.cli(["sessions", "--json"])
sessionsProc.running = true
}
}
Process {
id: sessionsProc
running: false
property var info: null
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: sessionsProc.info = root.parseJson(text)
}
onExited: function (code) {
var info = sessionsProc.info
sessionsProc.info = null
root.busy = false
if (info && info.count > 0) {
root.sessionInfo = info
root.stage = "confirm"
return
}
root.applySwitch("none")
}
}
// sessionAction: none | stop | restart, applied to the sessions left on the
// old provider once the new one is written.
function applySwitch(sessionAction) {
root.busy = true
root.stage = "list"
switchProc.sessionAction = sessionAction
// --yes because the confirmation already happened, in the card above. The
// CLI now refuses a non-interactive switch while sessions are running, and
// without this the panel's switch would simply stop working.
switchProc.command = root.cli(root.pendingPreset === ""
? [root.pendingMode, "--yes"] : [root.pendingMode, root.pendingPreset, "--yes"])
switchProc.running = true
}
// claude-mode rewrites health.json as the last step of a switch, so the
// widget's FileView is what actually updates the display. This only has to
// report failures and then deal with the sessions.
Process {
id: switchProc
running: false
property string sessionAction: "none"
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() !== "" })
if (lines.length > 0) root.lastError = lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim()
}
}
onExited: function (code) {
root.busy = false
root.expandedMode = ""
root.blocker = null
root.sessionInfo = null
if (code === 0) {
root.lastError = ""
// Strictly after the write. A session reopened before it would come
// straight back up on the provider we just left.
if (switchProc.sessionAction !== "none") {
sessionActProc.command = root.cli(["sessions",
switchProc.sessionAction === "restart" ? "--restart" : "--stop", "--yes"])
sessionActProc.running = true
}
}
switchProc.sessionAction = "none"
root.pendingMode = ""
root.pendingPreset = ""
if (widget) widget.refresh()
}
}
// --yes because the confirmation already happened, in the card below. Without
// it the CLI would refuse rather than act on a decision nobody typed.
Process { id: sessionActProc; running: false }
// The remedy for a missing key is a hidden password prompt, which a bar popup
// has nowhere to host - so the CLI is asked to open a terminal for it.
Process { id: remedyProc; running: false }
function runRemedy() {
var b = root.blocker
if (!b) return
if (String(b.remedyKind) === "set-key") {
remedyProc.command = root.cli(["set-key", String(b.keyRef), "--terminal"])
remedyProc.running = true
}
}
// First-run setup asks for a key behind a hidden prompt and picks models from
// a filter-select list - neither of which a bar popup can host. The terminal
// gets the whole flow; this side just launches it and re-checks afterwards.
function runSetup() {
if (root.pendingMode === "") return
setupProc.command = root.cli(["setup", root.pendingMode, "--terminal"])
setupProc.running = true
}
Process { id: setupProc; running: false }
function retryPending() {
if (root.pendingMode === "") { root.resetFlow(); return }
var m = root.pendingMode, p = root.pendingPreset
root.blocker = null
root.stage = "list"
root.switchTo(m, p)
}
readonly property var modelRows: {
var models = health && health.models ? health.models : null
if (!models) return []
var tiers = ["opus", "sonnet", "haiku", "fable"]
var out = []
for (var i = 0; i < tiers.length; i++) {
if (models[tiers[i]]) out.push({ tier: tiers[i], id: String(models[tiers[i]]) })
}
return out
}
// Color.urgent derives from the theme's `red`, and monochrome themes define
// that as a desaturated slate - Solitude's is #565d60, dimmer than the panel
// text it is supposed to outrank, so an error would read as the quietest
// line on the card. Use the theme's colour only when it carries real hue.
readonly property color urgentColor: {
var c = Color.urgent
var mx = Math.max(c.r, c.g, c.b)
var mn = Math.min(c.r, c.g, c.b)
var lightness = (mx + mn) / 2
var denom = 1 - Math.abs(2 * lightness - 1)
var saturation = denom > 0.0001 ? (mx - mn) / denom : 0
return saturation >= 0.15 ? c : "#d2685f"
}
readonly property string footerText: {
if (!known) return "claude-mode not found in " + cmRoot
var bits = []
if (health && health.version) bits.push("claude-mode " + health.version)
if (health && health.keyBackend) bits.push("key: " + health.keyBackend)
var ctx = health && health.contextTokens ? Modes.contextLabel(health.contextTokens) : ""
if (ctx !== "") bits.push(ctx)
return bits.join(" · ")
}
component PillButton: Rectangle {
id: pb
property string label: ""
property bool primary: false
signal triggered()
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
? Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.30 : 0.14)
: Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.16 : 0.00)
border.width: 1
border.color: Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.75 : 0.30)
Text {
id: pbText
anchors.centerIn: parent
text: pb.label
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
font.bold: pb.primary
}
MouseArea {
id: pbArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: pb.triggered()
}
}
// 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)
Column {
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 {
id: heroRow
width: parent.width
spacing: Style.space(10)
Item {
id: heroGlyph
anchors.verticalCenter: parent.verticalCenter
width: Style.font.display
height: Style.font.display
readonly property color tint: root.known && root.mode !== "anthropic"
? Color.accent : Color.popups.text
BrandIcon {
anchors.centerIn: parent
visible: Modes.logo(root.mode) !== ""
pathData: Modes.logo(root.mode)
opticalScale: Modes.logoScale(root.mode)
color: heroGlyph.tint
iconSize: Style.font.display
}
Text {
anchors.centerIn: parent
visible: Modes.logo(root.mode) === ""
text: Modes.glyph(root.mode)
color: heroGlyph.tint
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.display
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: heroRow.width - heroGlyph.width - heroRow.spacing
spacing: Style.space(2)
Text {
width: parent.width
elide: Text.ElideRight
text: Modes.title(root.mode)
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(17)
}
Text {
width: parent.width
elide: Text.ElideRight
text: root.preset !== "" ? "preset · " + root.preset : Modes.blurb(root.mode)
color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
}
}
PanelSeparator { width: parent.width }
PanelSectionHeader {
text: "SWITCH TO"
visible: root.stage === "list"
foreground: Color.popups.text
fontFamily: root.bar ? root.bar.fontFamily : Style.font.family
}
// ---- The modes, current one marked rather than hidden. The CLI omits
// the active mode because a list you arrow through should not offer a
// no-op; here the list is also the status display, so it stays.
Column {
width: parent.width
visible: root.stage === "list"
spacing: Style.space(1)
Repeater {
model: root.stage === "list" ? Modes.ORDER : []
Column {
id: modeEntry
required property var modelData
width: column.width
readonly property string thisMode: String(modelData)
readonly property bool isCurrent: thisMode === root.mode
readonly property bool isExpanded: root.expandedMode === thisMode
readonly property var presetList: Modes.presetsFor(root.health, thisMode, root.defaultPresets)
Item {
width: parent.width
height: Style.space(30)
Rectangle {
anchors.fill: parent
anchors.leftMargin: -Style.space(6)
anchors.rightMargin: -Style.space(6)
radius: Style.space(4)
color: modeEntry.isCurrent
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.12)
: (modeHover.containsMouse
? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.07)
: "transparent")
}
BrandIcon {
id: modeGlyph
anchors.left: parent.left
anchors.leftMargin: Style.space(3)
anchors.verticalCenter: parent.verticalCenter
pathData: Modes.logo(modeEntry.thisMode)
opticalScale: Modes.logoScale(modeEntry.thisMode)
color: modeEntry.isCurrent ? Color.accent : Color.popups.text
iconSize: Style.space(16)
}
Column {
anchors.left: modeGlyph.right
anchors.leftMargin: Style.space(8)
anchors.right: modeMark.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
text: Modes.title(modeEntry.thisMode)
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body
font.bold: modeEntry.isCurrent
}
Text {
width: parent.width
text: Modes.blurb(modeEntry.thisMode)
color: Color.muted
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
}
// Checkmark on the active mode; a chevron on a gateway row that
// has a preset list waiting behind it.
Text {
id: modeMark
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: modeEntry.isCurrent
? String.fromCodePoint(0xF012C)
: (Modes.needsPreset(modeEntry.thisMode)
? String.fromCodePoint(modeEntry.isExpanded ? 0xF0140 : 0xF0142)
: "")
color: modeEntry.isCurrent ? Color.accent : Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body
}
MouseArea {
id: modeHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !root.busy
onClicked: root.activate(modeEntry.thisMode)
}
}
// ---- 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
spacing: Style.space(1)
Repeater {
model: modeEntry.isExpanded ? modeEntry.presetList : []
Item {
id: presetRow
required property var modelData
width: modeEntry.width
height: Style.space(26)
readonly property bool isActive: modeEntry.isCurrent && String(modelData.name) === root.preset
Rectangle {
anchors.fill: parent
anchors.leftMargin: Style.space(20)
anchors.rightMargin: -Style.space(6)
radius: Style.space(4)
color: presetHover.containsMouse
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
: "transparent"
}
Text {
id: presetName
anchors.left: parent.left
anchors.leftMargin: Style.space(30)
anchors.verticalCenter: parent.verticalCenter
text: presetRow.modelData.name
color: presetRow.isActive ? Color.accent : Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(12)
font.bold: presetRow.isActive
}
// Only worth saying when there is a choice to tell apart.
Text {
id: defaultTag
visible: modeEntry.presetList.length > 1
&& String(presetRow.modelData.name) === root.defaultPresets[modeEntry.thisMode]
anchors.left: presetName.right
anchors.leftMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
text: "default"
color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(9)
}
// The opus mapping is the one that tells you what you are
// about to be talking to; the rest is in the tooltip.
Text {
anchors.left: defaultTag.visible ? defaultTag.right : presetName.right
anchors.leftMargin: Style.space(8)
anchors.right: gearButton.visible ? gearButton.left : parent.right
anchors.rightMargin: Style.space(4)
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: presetRow.modelData.models && presetRow.modelData.models.opus
? String(presetRow.modelData.models.opus)
: ""
color: Color.muted
elide: Text.ElideLeft
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
// 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
anchors.fill: parent
anchors.rightMargin: gearButton.visible ? gearButton.width : 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !root.busy
onClicked: root.switchTo(modeEntry.thisMode, String(presetRow.modelData.name))
}
Item {
id: gearButton
visible: true
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: Style.space(22)
height: parent.height
Text {
anchors.centerIn: parent
text: String.fromCodePoint(0xF0493)
color: gearArea.containsMouse ? Color.accent : Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(12)
}
MouseArea {
id: gearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !root.busy
onClicked: root.openPresetEditor(String(presetRow.modelData.name))
}
}
}
}
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)
}
}
}
}
}
}
// ---- Active model map, only meaningful behind a gateway.
// ---- Preflight refused. Name the missing thing and offer the one action
// that fixes it, rather than failing the click silently.
Column {
width: parent.width
visible: root.stage === "blocked" && root.blocker !== null
spacing: Style.space(7)
Text {
width: parent.width
text: root.blocker ? String(root.blocker.title) : ""
color: root.urgentColor
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: root.blocker ? String(root.blocker.detail) : ""
color: Color.popups.text
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
Text {
width: parent.width
visible: root.blocker && String(root.blocker.remedy) !== ""
text: root.blocker ? "$ " + String(root.blocker.remedy) : ""
color: Color.muted
wrapMode: Text.WrapAnywhere
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
// 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: "Set up " + Modes.title(root.pendingMode) + "…"
primary: true
visible: root.blocker && String(root.blocker.remedyKind) === "setup"
onTriggered: root.runSetup()
}
PillButton {
label: "Store the key…"
primary: true
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
visible: root.pendingMode === "lmstudio"
&& !(root.blocker && String(root.blocker.remedyKind) === "setup")
onTriggered: root.openServerSettings(root.pendingPreset)
}
PillButton {
label: root.blocker && ["start-server", "setup"].indexOf(String(root.blocker.remedyKind)) >= 0
? "Check again" : "Try again"
primary: root.blocker && String(root.blocker.remedyKind) === "start-server"
onTriggered: root.retryPending()
}
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
}
}
// ---- Where the LM Studio server is, and whether it needs a key.
Column {
width: parent.width
visible: root.stage === "server"
spacing: Style.space(8)
Text {
width: parent.width
text: "Server for '" + root.serverPreset + "'"
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: "LM Studio does not have to be on this machine. Point this at a "
+ "LAN address, or anything reachable through a tunnel or proxy."
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
TextField {
id: urlField
width: parent.width
text: root.serverUrl
placeholderText: root.serverLocalDefault
foreground: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
onTextChanged: root.serverUrl = text
onAccepted: root.saveServerSettings()
}
// 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: "Use local default"
onTriggered: { root.serverUrl = root.serverLocalDefault; urlField.text = root.serverLocalDefault }
}
}
PanelSeparator { width: parent.width }
Row {
width: parent.width
spacing: Style.space(9)
ToggleSwitch {
id: authToggle
anchors.verticalCenter: parent.verticalCenter
checked: root.serverNeedsKey
foreground: Color.popups.text
accent: Color.accent
onToggled: root.serverNeedsKey = !root.serverNeedsKey
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - authToggle.width - parent.spacing
spacing: Style.space(1)
Text {
width: parent.width
text: "Server requires an API key"
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
Text {
width: parent.width
text: root.serverNeedsKey
? "Kept in the vault as '" + root.serverKeyRef + "', never in settings.json."
: "Sends LM Studio's placeholder token, which is not a secret."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
}
}
// 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)
visible: root.serverNeedsKey
PillButton { label: "Store the key…"; onTriggered: root.storeServerKey() }
}
PanelSeparator { width: parent.width }
// 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: "Save"; primary: true; onTriggered: root.saveServerSettings() }
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)
}
Text {
width: parent.width
visible: root.editIsDefault
text: "claude-mode " + root.editProvider + " picks this one when no preset is named"
+ (root.editIsChosenDefault ? "." : " (the built-in choice).")
color: Color.muted
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: "Make default"; visible: !root.editIsDefault; onTriggered: root.setDefault(true) }
PillButton { label: "Clear default"; visible: root.editIsChosenDefault; onTriggered: root.setDefault(false) }
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.
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" }
}
}
}
// ---- Sessions are running. They will keep the old provider until they
// are restarted, and one mid-request can lose that turn outright, so this
// is a decision rather than a notification.
Column {
width: parent.width
visible: root.stage === "confirm" && root.sessionInfo !== null
spacing: Style.space(7)
readonly property int total: root.sessionInfo ? Number(root.sessionInfo.count) : 0
readonly property int busyCount: root.sessionInfo ? Number(root.sessionInfo.busy) : 0
Text {
width: parent.width
text: parent.total === 1
? "1 Claude session is running"
: parent.total + " Claude sessions are running"
color: Color.popups.text
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(13)
font.bold: true
}
// Not "they will keep using the old provider". Their endpoint and model
// ids are fixed at startup, but the key is re-fetched on a timer and
// will resolve to the new mode, which the old endpoint refuses - so
// they fail rather than carry on.
Text {
width: parent.width
text: "They keep pointing at " + Modes.title(root.mode) + ", but their key is "
+ "re-fetched on a timer and will switch under them. Worse, if one takes a "
+ "reply from the new provider first, that provider's message-id format "
+ "goes into its transcript and Anthropic will refuse to resume it at all."
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
visible: parent.busyCount > 0
text: parent.busyCount === 1
? "One is working right now and will break mid-turn."
: parent.busyCount + " are working right now and will break mid-turn."
color: root.urgentColor
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.sessionInfo ? root.sessionInfo.sessions : []
Item {
required property var modelData
width: column.width
height: Style.space(17)
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: parent.modelData.tty
color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
anchors.left: parent.left
anchors.leftMargin: Style.space(52)
anchors.right: busyTag.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
text: parent.modelData.cwd
color: Color.popups.text
elide: Text.ElideLeft
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
id: busyTag
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: parent.modelData.busy === true
text: "working"
color: root.urgentColor
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
}
}
}
// 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)
// Restart is the only option that ends with every session on the mode
// the bar is now claiming, so it leads and it is the primary.
PillButton {
label: "Switch and restart"
primary: true
onTriggered: root.applySwitch("restart")
}
PillButton { label: "Switch and close"; onTriggered: root.applySwitch("stop") }
PillButton { label: "Switch only"; onTriggered: root.applySwitch("none") }
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
}
}
// ---- Sessions that a switch cut short. Shown in the list, because the
// point is to be noticed without going looking.
Column {
width: parent.width
visible: root.stage === "list" && (root.broken.length > 0 || root.ignored.length > 0)
spacing: Style.space(4)
PanelSeparator { width: parent.width }
Text {
width: parent.width
visible: root.broken.length > 0
text: root.broken.length === 1
? "1 session cannot be resumed"
: root.broken.length + " sessions cannot be resumed"
color: root.urgentColor
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
font.bold: true
}
Repeater {
model: root.stage === "list" ? root.broken : []
Item {
id: brokenRow
required property var modelData
width: column.width
height: Style.space(26)
Column {
anchors.left: parent.left
anchors.right: ignoreBtn.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
width: parent.width
text: String(brokenRow.modelData.sessionId).substring(0, 8)
+ " · " + root.projectLabel(brokenRow.modelData.project)
color: Color.popups.text
elide: Text.ElideMiddle
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: brokenRow.modelData.dropLines + " turn lines after the last good message"
color: Color.muted
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(9)
}
}
PillButton {
id: ignoreBtn
anchors.right: repairBtn.left
anchors.rightMargin: Style.space(5)
anchors.verticalCenter: parent.verticalCenter
label: "Ignore"
onTriggered: root.setIgnored(brokenRow.modelData, true)
}
PillButton {
id: repairBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: "Repair"
onTriggered: root.askRepair(brokenRow.modelData)
}
}
}
// ---- Hidden: dismissed here, or broken but untouched for longer than
// the age limit. Collapsed, because the point of hiding them was that
// they stop taking up attention - but never gone without a trace.
Item {
width: parent.width
height: Style.space(20)
visible: root.ignored.length > 0
Text {
id: hiddenLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "hidden (" + root.ignored.length + ")"
color: hiddenArea.containsMouse ? Color.popups.text : Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
anchors.left: hiddenLabel.right
anchors.leftMargin: Style.space(4)
anchors.verticalCenter: parent.verticalCenter
text: String.fromCodePoint(root.hiddenExpanded ? 0xF0140 : 0xF0142)
color: hiddenLabel.color
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
MouseArea {
id: hiddenArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.hiddenExpanded = !root.hiddenExpanded
}
}
Column {
width: parent.width
visible: root.hiddenExpanded && root.ignored.length > 0
spacing: Style.space(3)
Text {
visible: root.dismissedList.length > 0
text: "Ignored (" + root.dismissedList.length + ")"
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
font.bold: true
}
Repeater {
model: root.hiddenExpanded ? root.dismissedList.slice(0, root.hiddenCap) : []
Item {
id: dismissedRow
required property var modelData
width: column.width
height: Style.space(26)
Text {
anchors.left: parent.left
anchors.right: restoreBtn.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
text: String(dismissedRow.modelData.sessionId).substring(0, 8)
+ " · " + root.projectLabel(dismissedRow.modelData.project)
color: Color.muted
elide: Text.ElideMiddle
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
PillButton {
id: restoreBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: "Restore"
onTriggered: root.setIgnored(dismissedRow.modelData, false)
}
}
}
Text {
visible: root.dismissedList.length > root.hiddenCap
text: "… and " + (root.dismissedList.length - root.hiddenCap)
+ " more · claude-mode repair-session --ignored"
color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(9)
}
Text {
visible: root.staleList.length > 0
text: "Older than " + root.ageDays + " days (" + root.staleList.length + ")"
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
font.bold: true
}
Repeater {
model: root.hiddenExpanded ? root.staleList.slice(0, root.hiddenCap) : []
Text {
required property var modelData
width: column.width
text: String(modelData.sessionId).substring(0, 8)
+ " · " + Math.floor((Date.now() / 1000 - Number(modelData.mtime)) / 86400) + "d"
+ " · " + root.projectLabel(modelData.project)
color: Color.muted
elide: Text.ElideMiddle
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
}
Text {
width: parent.width
visible: root.staleList.length > 0
text: (root.staleList.length > root.hiddenCap
? "… and " + (root.staleList.length - root.hiddenCap) + " more. " : "")
+ "Not counted because nothing has touched them since. "
+ "claude-mode repair-session --all --max-age 0 lists them."
color: Color.muted
opacity: 0.85
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(9)
}
}
}
// ---- Confirming one repair.
Column {
width: parent.width
visible: root.stage === "repair" && root.repairTarget !== null
spacing: Style.space(7)
Text {
width: parent.width
text: "Repair " + (root.repairTarget ? String(root.repairTarget.sessionId).substring(0, 8) : "")
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: {
if (!root.repairTarget) return ""
var p = (root.repairTarget.providers || []).join(", ")
return "This session took " + (p !== "" ? p + "'s" : "another provider's")
+ " output while the mode was switched under it, and Anthropic will not "
+ "resume it. Rolling it back to its last good message drops "
+ root.repairTarget.dropLines + " lines."
}
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: "The original is backed up, the dropped turns are saved as Markdown, "
+ "and handed back to the session so it still knows what it did."
color: Color.muted
opacity: 0.85
wrapMode: Text.WordWrap
lineHeight: 1.2
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: "Repair it"; primary: true; onTriggered: root.doRepair() }
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
}
}
PanelSeparator { width: parent.width; visible: root.modelRows.length > 0 && root.stage === "list" }
Column {
width: parent.width
spacing: Style.space(1)
visible: root.modelRows.length > 0 && root.stage === "list"
PanelSectionHeader {
text: "MODEL MAP"
foreground: Color.popups.text
fontFamily: root.bar ? root.bar.fontFamily : Style.font.family
}
Repeater {
model: root.modelRows
Item {
required property var modelData
width: column.width
height: Style.space(19)
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: parent.modelData.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: parent.right
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: parent.modelData.id
color: Color.popups.text
elide: Text.ElideLeft
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
}
}
}
PanelSeparator { width: parent.width }
// ---- Failures worth surfacing: a preset with no key stored exits
// non-zero, and without this the click would just look inert.
Text {
width: parent.width
visible: root.lastError !== ""
text: root.lastError
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
text: root.busy
? (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
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: root.footerText
visible: text !== ""
color: Color.muted
opacity: 0.8
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
}
}
}