The openrouter default moves its hot tiers: opus to z-ai/glm-5.3-flash and sonnet to deepseek/deepseek-v4-flash-0731. haiku and fable are unchanged. `cheap` and `lmstudio-qwen` are gone, leaving exactly one preset per mode so `claude-mode <mode>` is never ambiguous and there is no menu to read before the thing you asked for happens. The surviving lmstudio preset keeps the Qwen3.6 model rather than KAT-Coder: the two differed mainly in that KAT's chat template carries the message-order assertion this README already warns about, so between two presets that had to become one, the one that is known to work won. More presets are still a `preset new` away; the shipped set is a starting point, not a ceiling. Which is the other half of this. A shipped preset was never a working configuration - OpenRouter and Z.AI have no key stored, and lmstudio's model ids were whatever happened to be installed on the machine this was packaged on. That was left for the user to discover through a failure. Now the shipped presets carry `configured: false`, preflight blocks on it, and `claude-mode setup <mode>` walks through what is actually needed: key, server URL and auth for LM Studio, then models chosen from the provider's own catalogue rather than typed from memory. A switch that trips this in a terminal offers to run setup there and then instead of printing a command to type next. Absent means configured, deliberately: presets that predate this and any built by hand with `preset new` do not suddenly start demanding a wizard. The panel gets a "Set up <mode>…" button that hands the whole flow to a terminal, since a bar popup can host neither a hidden key prompt nor a filter-select list. Two bugs found while testing it, both real: ask_value printed its prompt to stdout while being called inside $( ), so the prompt text came back glued to the front of the answer and set-url rejected the result. Moved to stderr, which is why warn and err already go there. lms_catalogue never sent the API key. On a server with authentication switched on - the case just added support for - /api/v0/models answers 401 like anything else, so the catalogue came back empty and every caller silently concluded the server had no models installed. It now sends the preset's credential, as do the three other call sites that read it.
1049 lines
37 KiB
QML
1049 lines
37 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
|
|
}
|
|
}
|
|
|
|
// 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. 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)
|
|
}
|
|
}
|
|
}
|
|
}
|