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 } } // ---- Providers, as the widget publishes them // // The widget owns the lookup, and the fallback to Modes.js for anthropic and // for an older health.json. This side only asks, so nothing here needs to // know which providers exist. readonly property var modeOrder: widget ? widget.modeOrder : Modes.ORDER function pInfo(m) { return widget ? widget.providerInfo(m) : null } function pTitle(m) { return widget ? widget.modeTitle(m) : "" } function pBlurb(m) { return widget ? widget.modeBlurb(m) : "" } function pLogo(m) { return widget ? widget.modeLogo(m) : "" } function pLogoScale(m) { return widget ? widget.modeLogoScale(m) : 1.0 } function pGlyph(m) { return widget ? widget.modeGlyph(m) : "" } // Before providers were published only LM Studio had either. function serverEditable(m) { var p = root.pInfo(m); return p ? p.serverEditable === true : m === "lmstudio" } function perServerCatalogue(m) { var p = root.pInfo(m); return p ? p.perServerCatalogue === true : m === "lmstudio" } // ---- Server settings // // A server provider (LM Studio, Ollama, a custom endpoint) ships pointed at // its usual local address, 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 of that is // 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 serverProvider: "" property string serverUrl: "" property bool serverNeedsKey: false property string serverKeyRef: "" property string serverDefault: "" // the provider's usual address; "" for custom property string serverHint: "" 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.serverProvider = e ? String(e.provider) : "" var p = root.pInfo(root.serverProvider) root.serverDefault = p ? String(p.defaultBaseUrl || "") : "http://127.0.0.1:1234" root.serverHint = p && p.serverHint ? String(p.serverHint) : "The server does not have to be on this machine. Point this at a LAN address, or anything reachable through a tunnel or proxy." root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverDefault root.serverNeedsKey = !!(e && String(e.authMode) === "vault") root.serverKeyRef = e && e.keyRef ? String(e.keyRef) : (p && p.defaultKeyRef ? String(p.defaultKeyRef) : (root.serverProvider || "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 ` 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.perServerCatalogue(root.editProvider)) { 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: root.pLogo(root.mode) !== "" pathData: root.pLogo(root.mode) opticalScale: root.pLogoScale(root.mode) color: heroGlyph.tint iconSize: Style.font.display } Text { anchors.centerIn: parent visible: root.pLogo(root.mode) === "" text: root.pGlyph(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: root.pTitle(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 : root.pBlurb(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" ? root.modeOrder : [] 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: root.pLogo(modeEntry.thisMode) opticalScale: root.pLogoScale(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: root.pTitle(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: root.pBlurb(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 " + root.pTitle(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 " + root.pTitle(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.serverEditable(root.pendingMode) && !(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 a server provider 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: root.serverHint 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.serverDefault !== "" ? root.serverDefault : "https://…" 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) // A custom endpoint has no usual address to go back to. PillButton { label: "Use local default" visible: root.serverDefault !== "" onTriggered: { root.serverUrl = root.serverDefault; urlField.text = root.serverDefault } } } 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 " + root.pTitle(root.serverProvider) + "'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 + "' · " + root.pTitle(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.serverEditable(root.editProvider) 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 " + root.pTitle(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 " + root.pTitle(root.editProvider) + " preset, so " + root.pTitle(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 " + root.pTitle(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 " + root.pTitle(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) } } } }