Files
claude-mode/omarchy/smoido.claude-mode/Panel.qml
T
smoido 9c301e10a1 Correct what a switch actually does to a running session
Every one of these said a switch leaves running sessions alone - that they keep
talking to the old provider until restarted. That is wrong, and reassuring in
exactly the wrong direction: a switch breaks them.

The static half of the config does behave as described. Base URL, model ids and
the env block are read once at startup and a running session keeps what it
started with.

The credential is not. It comes from running apiKeyHelper, which Claude Code
re-invokes on a timer - CLAUDE_CODE_API_KEY_HELPER_TTL_MS is present in the
2.1.251 binary - and claude-key-helper.sh answers for whatever state.json says at
that moment. So the switch reaches into a live session through the one part that
was never cached. Into anthropic, the helper returns nothing by design and the
next refresh comes back with no credential; into another provider, it hands over
the new key while the session is still pointed at the old base URL, which
rejects it. Either way the session starts failing calls whenever the TTL happens
to expire, mid-turn as easily as between turns.

One case does survive, and is now stated rather than glossed: switching between
two presets of the same provider that share a keyRef keeps the same key and the
same endpoint, so the session carries on with the model ids it started with.

The claim in "Restarting sessions" predates this work - it came in with the
Windows build - but it is wrong for the same reason and is corrected too.
2026-08-30 21:41:09 +03:00

1031 lines
36 KiB
QML

import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Modes.js" as Modes
Panel {
id: root
moduleName: "smoido.claude-mode"
ipcTarget: "smoido.claude-mode"
property var anchorItem: null
property var hostWidget: null
property var widget: null
// The bar identifies a panel by the widget mounted in its slot, not by this
// nested panel, so the popout coordinator has to be handed that widget.
readonly property var barIdentity: hostWidget || root
readonly property var health: widget ? widget.health : null
readonly property string mode: widget ? widget.mode : ""
readonly property string preset: widget ? widget.preset : ""
readonly property bool known: widget ? widget.known : false
readonly property string cmRoot: widget ? widget.cmRoot : Modes.defaultRoot()
// Which gateway row has its preset list unfolded. Only one at a time, and
// never anthropic, which takes no preset.
property string expandedMode: ""
property bool busy: false
property string lastError: ""
function open() {
root.expandedMode = ""
root.lastError = ""
root.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
property var blocker: null // the preflight verdict, when it refused
property var sessionInfo: null // sessions --json, when any were found
property string pendingMode: ""
property string pendingPreset: ""
function cli(args) { return [root.cmRoot + "/bin/claude-mode"].concat(args) }
function parseJson(t) { try { return JSON.parse(String(t || "")) } catch (e) { return null } }
// ---- Server settings (LM Studio)
//
// LM Studio ships listening on loopback, but it does not have to be there: it
// can be another machine on the LAN, or something reached through a tunnel or
// a reverse proxy, and it can have authentication switched on. All three are
// just a baseUrl and an auth block in the preset, so this edits those two
// rather than pretending the local default is the only shape.
property string serverPreset: ""
property string serverUrl: ""
property bool serverNeedsKey: false
property string serverKeyRef: "lmstudio"
readonly property string serverLocalDefault: "http://127.0.0.1:1234"
function presetEntry(name) {
var all = (health && health.presets) ? health.presets : []
for (var i = 0; i < all.length; i++) if (String(all[i].name) === name) return all[i]
return null
}
function openServerSettings(presetName) {
var e = root.presetEntry(presetName)
root.serverPreset = presetName
root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverLocalDefault
root.serverNeedsKey = !!(e && String(e.authMode) === "vault")
root.serverKeyRef = e && e.keyRef ? String(e.keyRef) : "lmstudio"
root.blocker = null
root.stage = "server"
}
// Two writes, sequenced: the URL first, then the auth block, then health.json
// is refreshed so the form and the rest of the panel agree about what the
// preset now says.
function saveServerSettings() {
if (root.busy || root.serverPreset === "") return
root.busy = true
urlProc.command = root.cli(["preset", "url", root.serverPreset, root.serverUrl.trim()])
urlProc.running = true
}
Process {
id: urlProc
running: false
onExited: function (code) {
authProc.command = root.cli(root.serverNeedsKey
? ["preset", "auth", root.serverPreset, "key", root.serverKeyRef]
: ["preset", "auth", root.serverPreset, "none"])
authProc.running = true
}
}
Process {
id: authProc
running: false
onExited: function (code) {
healthProc.command = root.cli(["health"])
healthProc.running = true
}
}
Process {
id: healthProc
running: false
onExited: function (code) {
root.busy = false
if (widget) widget.refresh()
// Straight back into the switch that was blocked, if there was one -
// the whole point of coming here was to unblock it.
if (root.pendingMode !== "") root.retryPending()
else root.stage = "list"
}
}
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 = ""
}
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
switchProc.command = root.cli(root.pendingPreset === ""
? [root.pendingMode] : [root.pendingMode, root.pendingPreset])
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
}
}
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)
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()
}
}
PopupCard {
id: card
anchorItem: root.anchorItem
owner: root.barIdentity
bar: root.bar
open: root.opened
contentWidth: card.fittedContentWidth(Style.space(360))
contentHeight: card.fittedContentHeight(column.implicitHeight)
Column {
id: column
width: card.contentWidth - card.padding * 2
spacing: Style.space(10)
// ---- Hero: what the next `claude` will actually use.
Row {
id: heroRow
width: parent.width
spacing: Style.space(10)
Item {
id: heroGlyph
anchors.verticalCenter: parent.verticalCenter
width: Style.font.display
height: Style.font.display
readonly property color tint: root.known && root.mode !== "anthropic"
? Color.accent : Color.popups.text
BrandIcon {
anchors.centerIn: parent
visible: Modes.logo(root.mode) !== ""
pathData: Modes.logo(root.mode)
opticalScale: Modes.logoScale(root.mode)
color: heroGlyph.tint
iconSize: Style.font.display
}
Text {
anchors.centerIn: parent
visible: Modes.logo(root.mode) === ""
text: Modes.glyph(root.mode)
color: heroGlyph.tint
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.display
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: heroRow.width - heroGlyph.width - heroRow.spacing
spacing: Style.space(2)
Text {
width: parent.width
elide: Text.ElideRight
text: Modes.title(root.mode)
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(17)
}
Text {
width: parent.width
elide: Text.ElideRight
text: root.preset !== "" ? "preset · " + root.preset : Modes.blurb(root.mode)
color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
}
}
PanelSeparator { width: parent.width }
PanelSectionHeader {
text: "SWITCH TO"
visible: root.stage === "list"
foreground: Color.popups.text
fontFamily: root.bar ? root.bar.fontFamily : Style.font.family
}
// ---- The modes, current one marked rather than hidden. The CLI omits
// the active mode because a list you arrow through should not offer a
// no-op; here the list is also the status display, so it stays.
Column {
width: parent.width
visible: root.stage === "list"
spacing: Style.space(1)
Repeater {
model: root.stage === "list" ? Modes.ORDER : []
Column {
id: modeEntry
required property var modelData
width: column.width
readonly property string thisMode: String(modelData)
readonly property bool isCurrent: thisMode === root.mode
readonly property bool isExpanded: root.expandedMode === thisMode
readonly property var presetList: Modes.presetsFor(root.health, thisMode, Modes.DEFAULT_PRESET)
Item {
width: parent.width
height: Style.space(30)
Rectangle {
anchors.fill: parent
anchors.leftMargin: -Style.space(6)
anchors.rightMargin: -Style.space(6)
radius: Style.space(4)
color: modeEntry.isCurrent
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.12)
: (modeHover.containsMouse
? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.07)
: "transparent")
}
BrandIcon {
id: modeGlyph
anchors.left: parent.left
anchors.leftMargin: Style.space(3)
anchors.verticalCenter: parent.verticalCenter
pathData: Modes.logo(modeEntry.thisMode)
opticalScale: Modes.logoScale(modeEntry.thisMode)
color: modeEntry.isCurrent ? Color.accent : Color.popups.text
iconSize: Style.space(16)
}
Column {
anchors.left: modeGlyph.right
anchors.leftMargin: Style.space(8)
anchors.right: modeMark.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
text: Modes.title(modeEntry.thisMode)
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body
font.bold: modeEntry.isCurrent
}
Text {
width: parent.width
text: Modes.blurb(modeEntry.thisMode)
color: Color.muted
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
}
// Checkmark on the active mode; a chevron on a gateway row that
// has a preset list waiting behind it.
Text {
id: modeMark
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: modeEntry.isCurrent
? String.fromCodePoint(0xF012C)
: (Modes.needsPreset(modeEntry.thisMode)
? String.fromCodePoint(modeEntry.isExpanded ? 0xF0140 : 0xF0142)
: "")
color: modeEntry.isCurrent ? Color.accent : Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body
}
MouseArea {
id: modeHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !root.busy
onClicked: root.activate(modeEntry.thisMode)
}
}
// ---- Presets for this provider, unfolded in place.
Column {
width: parent.width
visible: modeEntry.isExpanded && modeEntry.presetList.length > 0
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
}
// 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: 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 edits where that preset
// points instead. Per row rather than per provider, because
// 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: modeEntry.thisMode === "lmstudio"
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.openServerSettings(String(presetRow.modelData.name))
}
}
}
}
}
}
}
}
// ---- 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: "Store the key…"
primary: true
visible: root.blocker && String(root.blocker.remedyKind) === "set-key"
onTriggered: root.runRemedy()
}
PillButton {
label: "Server settings…"
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0
visible: root.pendingMode === "lmstudio"
onTriggered: root.openServerSettings(root.pendingPreset)
}
PillButton {
label: root.blocker && String(root.blocker.remedyKind) === "start-server"
? "Check again" : "Try again"
primary: root.blocker && String(root.blocker.remedyKind) === "start-server"
onTriggered: root.retryPending()
}
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
}
}
// ---- Where the LM Studio server is, and whether it needs a key.
Column {
width: parent.width
visible: root.stage === "server"
spacing: Style.space(8)
Text {
width: parent.width
text: "Server for '" + root.serverPreset + "'"
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: "LM Studio does not have to be on this machine. Point this at a "
+ "LAN address, or anything reachable through a tunnel or proxy."
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
TextField {
id: urlField
width: parent.width
text: root.serverUrl
placeholderText: root.serverLocalDefault
foreground: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
onTextChanged: root.serverUrl = text
onAccepted: root.saveServerSettings()
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
PillButton {
label: "Use local default"
onTriggered: { root.serverUrl = root.serverLocalDefault; urlField.text = root.serverLocalDefault }
}
}
PanelSeparator { width: parent.width }
Row {
width: parent.width
spacing: Style.space(9)
ToggleSwitch {
id: authToggle
anchors.verticalCenter: parent.verticalCenter
checked: root.serverNeedsKey
foreground: Color.popups.text
accent: Color.accent
onToggled: root.serverNeedsKey = !root.serverNeedsKey
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - authToggle.width - parent.spacing
spacing: Style.space(1)
Text {
width: parent.width
text: "Server requires an API key"
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
Text {
width: parent.width
text: root.serverNeedsKey
? "Kept in the vault as '" + root.serverKeyRef + "', never in settings.json."
: "Sends LM Studio's placeholder token, which is not a secret."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
}
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
visible: root.serverNeedsKey
PillButton { label: "Store the key…"; onTriggered: root.storeServerKey() }
}
PanelSeparator { width: parent.width }
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { label: "Save"; primary: true; onTriggered: root.saveServerSettings() }
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
}
}
// ---- Sessions are running. They will keep the old provider until they
// are restarted, and one mid-request can lose that turn outright, so this
// is a decision rather than a notification.
Column {
width: parent.width
visible: root.stage === "confirm" && root.sessionInfo !== null
spacing: Style.space(7)
readonly property int total: root.sessionInfo ? Number(root.sessionInfo.count) : 0
readonly property int busyCount: root.sessionInfo ? Number(root.sessionInfo.busy) : 0
Text {
width: parent.width
text: parent.total === 1
? "1 Claude session is running"
: parent.total + " Claude sessions are running"
color: Color.popups.text
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(13)
font.bold: true
}
// Not "they will keep using the old provider". Their endpoint and model
// ids are fixed at startup, but the key is re-fetched on a timer and
// will resolve to the new mode, which the old endpoint refuses - so
// they fail rather than carry on.
Text {
width: parent.width
text: "They keep pointing at " + Modes.title(root.mode) + ", but their key is "
+ "re-fetched on a timer and will switch under them. Their calls start "
+ "failing from that moment, not at a clean stop."
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)
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() }
}
}
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
? "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)
}
}
}
}