Files
claude-mode/omarchy/smoido.claude-mode/Panel.qml
T
smoido 4c36f4d00b Notice broken sessions in the bar, and offer to repair them there
Nothing tells you a session is unresumable until you try to resume it, and by
then you have usually forgotten which one it was. The widget now scans every
project on a timer and whenever the panel opens, marks its icon when something
needs fixing, and lists the affected sessions with a repair button that says
what it will drop and what it will keep before doing anything.

The scan had to get roughly eighty times cheaper first. Classifying a transcript
needs two facts - what its last message id is, and whether any Anthropic id
exists at all - and the first settles the common case alone. Reading the tail of
each file and only opening the whole thing when the tail already looks wrong
takes the sweep from ~5s to ~60ms across 59 transcripts, which is the difference
between something that can sit on a timer and something that cannot. `--all`
uses the same path, and `--json` exposes it.

The badge is a dot beside the mark rather than a recolouring of it: this widget's
job is to report which provider is active, and tinting it red to mean something
else entirely would be a lie about that.

Verified by planting a genuinely corrupted transcript, watching the scan find it
and the dot appear, then removing it and watching both clear.
2026-08-31 02:28:24 +03:00

1218 lines
43 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"
}
}
// ---- 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()
}
}
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.repairTarget = null
}
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)
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: "Set up " + Modes.title(root.pendingMode) + "…"
primary: true
visible: root.blocker && String(root.blocker.remedyKind) === "setup"
onTriggered: root.runSetup()
}
PillButton {
label: "Store the key…"
primary: true
visible: root.blocker && String(root.blocker.remedyKind) === "set-key"
onTriggered: root.runRemedy()
}
PillButton {
label: "Server settings…"
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0
visible: root.pendingMode === "lmstudio"
&& !(root.blocker && String(root.blocker.remedyKind) === "setup")
onTriggered: root.openServerSettings(root.pendingPreset)
}
PillButton {
label: root.blocker && ["start-server", "setup"].indexOf(String(root.blocker.remedyKind)) >= 0
? "Check again" : "Try again"
primary: root.blocker && String(root.blocker.remedyKind) === "start-server"
onTriggered: root.retryPending()
}
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
}
}
// ---- Where the LM Studio server is, and whether it needs a key.
Column {
width: parent.width
visible: root.stage === "server"
spacing: Style.space(8)
Text {
width: parent.width
text: "Server for '" + root.serverPreset + "'"
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: "LM Studio does not have to be on this machine. Point this at a "
+ "LAN address, or anything reachable through a tunnel or proxy."
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
TextField {
id: urlField
width: parent.width
text: root.serverUrl
placeholderText: root.serverLocalDefault
foreground: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
onTextChanged: root.serverUrl = text
onAccepted: root.saveServerSettings()
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
PillButton {
label: "Use local default"
onTriggered: { root.serverUrl = root.serverLocalDefault; urlField.text = root.serverLocalDefault }
}
}
PanelSeparator { width: parent.width }
Row {
width: parent.width
spacing: Style.space(9)
ToggleSwitch {
id: authToggle
anchors.verticalCenter: parent.verticalCenter
checked: root.serverNeedsKey
foreground: Color.popups.text
accent: Color.accent
onToggled: root.serverNeedsKey = !root.serverNeedsKey
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - authToggle.width - parent.spacing
spacing: Style.space(1)
Text {
width: parent.width
text: "Server requires an API key"
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
}
Text {
width: parent.width
text: root.serverNeedsKey
? "Kept in the vault as '" + root.serverKeyRef + "', never in settings.json."
: "Sends LM Studio's placeholder token, which is not a secret."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
}
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
visible: root.serverNeedsKey
PillButton { label: "Store the key…"; onTriggered: root.storeServerKey() }
}
PanelSeparator { width: parent.width }
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { label: "Save"; primary: true; onTriggered: root.saveServerSettings() }
PillButton { label: "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. 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
spacing: Style.space(4)
PanelSeparator { width: parent.width }
Text {
width: parent.width
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 {
required property var modelData
width: column.width
height: Style.space(26)
Column {
anchors.left: parent.left
anchors.right: repairBtn.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
width: parent.width
text: String(parent.parent.modelData.sessionId).substring(0, 8)
+ " · " + String(parent.parent.modelData.project).replace(/^-/, "").replace(/-/g, "/")
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: parent.parent.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: repairBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: "Repair"
onTriggered: root.askRepair(parent.modelData)
}
}
}
}
// ---- 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
? "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)
}
}
}
}