Files
claude-mode/omarchy/smoido.claude-mode/Panel.qml
T
smoidoandClaude Opus 5 bb01418ce0 Split the bar panel into one QML file per stage
Panel.qml keeps the state, the CLI calls and the stage switch (889 lines,
was 2360); each stage is its own file taking the panel as a required
property. The omarchy installer copies every QML/JS file and clears stale
ones. tests/static.sh filters qmllint on [syntax], not the word error.

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

890 lines
32 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: ""
readonly property string uiFont: root.bar ? root.bar.fontFamily : Style.font.family
// Each stage is its own file, and this panel cannot reach into one by id. So
// where an action has to touch a stage's field, it announces it and the
// stage does the rest (see the Connections in TierStage, NewPresetStage and
// RenameStage).
signal tierEditorOpened()
signal modelPicked(string modelId)
signal newPresetOpened()
signal renameOpened()
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 <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 = ""
root.stage = "presetTier"
root.tierEditorOpened()
}
function pickModel(id) {
root.editModel = id
root.modelPicked(id)
}
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")
root.stage = "presetNew"
root.newPresetOpened()
}
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
root.stage = "presetRename"
root.renameOpened()
}
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(" · ")
}
// 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.uiFont
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.uiFont
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.uiFont
font.pixelSize: Style.space(11)
}
}
}
PanelSeparator { width: parent.width }
// The stages, in the order they stack. Each is its own file drawing from
// this panel's state, and shows only when it is its turn.
ModeList { panel: root }
BlockedStage { panel: root }
ServerStage { panel: root }
PresetStage { panel: root }
NewPresetStage { panel: root }
RenameStage { panel: root }
DeleteStage { panel: root }
TierStage { panel: root }
SessionsStage { panel: root }
BrokenSessions { panel: root }
RepairStage { panel: root }
ModelMap { panel: root }
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.uiFont
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.uiFont
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.uiFont
font.pixelSize: Style.space(10)
}
}
}
}