Omitting the preset used a fixed name per provider (default, zai, lmstudio). It can now be chosen: claude-mode preset default what each picks, and why claude-mode preset default openrouter cheap claude-mode preset default openrouter --clear The choice is stored in ~/.claude-mode/defaults.json and read first by default_preset_for, while its file still exists. A choice whose file is gone falls back to the built-in name, and if that is gone too, to the first preset by name, as before. Renaming a chosen preset moves the choice with it, and deleting it clears the choice. The terminal menu marks what resolve_preset would actually pick. health.json publishes defaultPresetFor (what each provider resolves to, in the CLI's order) and defaultPresetChosen (the explicit choices only). The panel sorts and tags from it, and the editor gains Make default / Clear default. preset new, rename and rm now refresh health.json, so the bar follows edits made in a terminal too. This changes existing CLI behaviour only once a default is chosen. The Windows build does not read defaults.json yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2335 lines
86 KiB
QML
2335 lines
86 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.hiddenExpanded = false
|
|
root.resetFlow()
|
|
if (widget) widget.refresh()
|
|
root.controller.show()
|
|
}
|
|
function toggle() { root.opened ? root.close() : root.open() }
|
|
|
|
// A gateway mode needs a preset chosen before anything can be applied, so
|
|
// clicking one unfolds its list instead of switching blind. anthropic has
|
|
// nothing to choose and applies on the spot.
|
|
function activate(mode) {
|
|
if (!Modes.needsPreset(mode)) { switchTo(mode, ""); return }
|
|
root.expandedMode = (root.expandedMode === mode) ? "" : mode
|
|
}
|
|
|
|
// ---- Switch flow
|
|
//
|
|
// A switch is not one action. It rewrites settings.json, and two things have
|
|
// to be settled before the write: whether the target can actually serve
|
|
// requests, and what should happen to the sessions already running - which a
|
|
// switch breaks rather than leaves alone, because their credential is
|
|
// re-fetched on a timer and resolves to the new mode while their endpoint
|
|
// stays the old one.
|
|
//
|
|
// list -> preflight -> blocked
|
|
// -> sessions -> confirm -> switch -> list
|
|
//
|
|
// Both questions are answered by claude-mode rather than re-derived here, so
|
|
// the widget and the CLI can never disagree about whether a switch is allowed
|
|
// or about what is running.
|
|
property string stage: "list" // list | blocked | confirm | server | repair
|
|
// | preset | presetTier | presetNew | presetRename | presetDelete
|
|
property var blocker: null // the preflight verdict, when it refused
|
|
property var sessionInfo: null // sessions --json, when any were found
|
|
property string pendingMode: ""
|
|
property string pendingPreset: ""
|
|
|
|
function cli(args) { return [root.cmRoot + "/bin/claude-mode"].concat(args) }
|
|
function parseJson(t) { try { return JSON.parse(String(t || "")) } catch (e) { return null } }
|
|
|
|
// ---- 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
|
|
}
|
|
|
|
// `from` is the stage to come back to - the preset editor, when opened from
|
|
// there; otherwise the list, or the switch that was blocked.
|
|
property string serverReturn: ""
|
|
|
|
function openServerSettings(presetName, from) {
|
|
var e = root.presetEntry(presetName)
|
|
root.serverReturn = from || ""
|
|
root.serverPreset = presetName
|
|
root.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 = root.serverReturn !== "" ? root.serverReturn : "list"
|
|
}
|
|
}
|
|
|
|
// ---- Broken transcripts
|
|
//
|
|
// Repair truncates a transcript back to its last resumable point. That is
|
|
// destructive enough to confirm, and reversible enough to offer: the original
|
|
// is backed up, the cut turns are written out as Markdown, and the same text
|
|
// is handed back to the session so it still knows what it did.
|
|
readonly property var broken: widget && widget.brokenSessions ? widget.brokenSessions : []
|
|
property var repairTarget: null
|
|
|
|
function askRepair(entry) {
|
|
root.repairTarget = entry
|
|
root.stage = "repair"
|
|
}
|
|
|
|
function doRepair() {
|
|
if (root.busy || !root.repairTarget) return
|
|
root.busy = true
|
|
repairProc.command = root.cli(["repair-session", String(root.repairTarget.sessionId), "--apply"])
|
|
repairProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: repairProc
|
|
running: false
|
|
stderr: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
|
|
if (msg !== "") root.lastError = msg.split("\n").pop().trim()
|
|
}
|
|
}
|
|
onExited: function (code) {
|
|
root.busy = false
|
|
root.repairTarget = null
|
|
root.stage = "list"
|
|
// The scan is what drives the bar's warning dot, so re-run it rather than
|
|
// trusting this side to have guessed the new state.
|
|
if (widget) widget.scanSessions()
|
|
}
|
|
}
|
|
|
|
// ---- Hidden transcripts
|
|
//
|
|
// Ignoring only changes whether the scan counts a session - the transcript is
|
|
// not touched and Restore is one click away - so unlike Repair it needs no
|
|
// confirmation. Age-hidden ones are never called ignored: nobody chose that.
|
|
readonly property var ignored: widget && widget.ignoredSessions ? widget.ignoredSessions : []
|
|
readonly property var dismissedList: ignored.filter(function (e) { return String(e.reason) === "dismissed" })
|
|
readonly property var staleList: ignored.filter(function (e) { return String(e.reason) !== "dismissed" })
|
|
readonly property int ageDays: widget ? widget.ignoreAgeDays : 7
|
|
property bool hiddenExpanded: false
|
|
// The card clamps rather than scrolls, so a long list would be cut off at
|
|
// the bottom edge instead of becoming reachable. The CLI has the full list.
|
|
readonly property int hiddenCap: 4
|
|
|
|
function projectLabel(p) { return String(p).replace(/^-/, "").replace(/-/g, "/") }
|
|
|
|
function setIgnored(entry, on) {
|
|
if (root.busy || !entry) return
|
|
root.busy = true
|
|
root.lastError = ""
|
|
ignoreProc.command = root.cli(["repair-session", on ? "--ignore" : "--unignore", String(entry.sessionId)])
|
|
ignoreProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: ignoreProc
|
|
running: false
|
|
stderr: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
|
|
var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" })
|
|
if (lines.length > 0) root.lastError = lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim()
|
|
}
|
|
}
|
|
onExited: function (code) {
|
|
root.busy = false
|
|
if (widget) widget.scanSessions()
|
|
}
|
|
}
|
|
|
|
// ---- Edits
|
|
//
|
|
// One chain for every edit the panel makes: each step runs only if the one
|
|
// before it succeeded, the first failure stops it and its last stderr line
|
|
// becomes the error, and health.json is rewritten at the end either way, so
|
|
// the panel shows what the files now say rather than what was intended.
|
|
property var opQueue: []
|
|
property string opLabel: ""
|
|
property string opLanding: ""
|
|
property var opThen: null // run on success, before landing
|
|
|
|
function runOps(label, ops, landing, then) {
|
|
if (root.busy) return
|
|
root.busy = true
|
|
root.lastError = ""
|
|
root.opLabel = label
|
|
root.opLanding = landing
|
|
root.opThen = then || null
|
|
root.opQueue = ops.slice()
|
|
root.nextOp()
|
|
}
|
|
|
|
function nextOp() {
|
|
var q = root.opQueue.slice()
|
|
var argv = q.length > 0 ? q.shift() : null
|
|
root.opQueue = q
|
|
opProc.failed = false
|
|
opProc.lastLine = ""
|
|
opProc.finalHealth = argv === null
|
|
opProc.command = root.cli(argv === null ? ["health"] : argv)
|
|
opProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: opProc
|
|
running: false
|
|
property bool failed: false
|
|
property bool finalHealth: false
|
|
property string lastLine: ""
|
|
stderr: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
|
|
var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" })
|
|
opProc.lastLine = lines.length > 0
|
|
? lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim() : ""
|
|
// The stream can finish after the exit it belongs to.
|
|
if (opProc.failed && opProc.lastLine !== "") root.lastError = opProc.lastLine
|
|
}
|
|
}
|
|
onExited: function (code) {
|
|
if (opProc.finalHealth) {
|
|
root.busy = false
|
|
if (widget) widget.refresh()
|
|
var then = root.opThen
|
|
root.opThen = null
|
|
if (root.lastError === "") {
|
|
if (then) then()
|
|
root.stage = root.opLanding
|
|
}
|
|
return
|
|
}
|
|
if (code !== 0) {
|
|
opProc.failed = true
|
|
root.lastError = opProc.lastLine !== "" ? opProc.lastLine : root.opLabel + " failed"
|
|
root.opQueue = []
|
|
}
|
|
Qt.callLater(root.nextOp)
|
|
}
|
|
}
|
|
|
|
// ---- Preset editor
|
|
//
|
|
// Stages rather than one form, because the card clamps to its content instead
|
|
// of scrolling and a long form would simply be cut off:
|
|
//
|
|
// list -> preset -> presetTier -> preset
|
|
//
|
|
// The tier names live here rather than in Modes.js on purpose: that file is a
|
|
// .pragma library, cached until the shell restarts, and a stale copy without
|
|
// them would render an empty editor.
|
|
readonly property var tierNames: ["opus", "sonnet", "haiku", "fable"]
|
|
property string editPreset: ""
|
|
property string editTier: ""
|
|
property string editModel: ""
|
|
|
|
// Which preset `claude-mode <provider>` picks when none is named. The CLI
|
|
// publishes it now that it can be chosen; the built-in names in Modes.js
|
|
// are only the fallback for a health.json written by an older CLI.
|
|
readonly property var defaultPresets: {
|
|
var out = {}
|
|
var builtin = Modes.DEFAULT_PRESET
|
|
for (var b in builtin) out[b] = builtin[b]
|
|
var pub = health && health.defaultPresetFor ? health.defaultPresetFor : null
|
|
if (pub) for (var p in pub) if (pub[p]) out[p] = pub[p]
|
|
return out
|
|
}
|
|
readonly property var chosenDefaults: health && health.defaultPresetChosen ? health.defaultPresetChosen : ({})
|
|
|
|
readonly property var editEntry: root.editPreset !== "" ? root.presetEntry(root.editPreset) : null
|
|
readonly property bool editIsDefault: root.editPreset !== "" && root.defaultPresets[root.editProvider] === root.editPreset
|
|
readonly property bool editIsChosenDefault: root.editPreset !== "" && root.chosenDefaults[root.editProvider] === root.editPreset
|
|
|
|
function setDefault(on) {
|
|
root.runOps("saving", [on ? ["preset", "default", root.editProvider, root.editPreset]
|
|
: ["preset", "default", root.editProvider, "--clear"]], "preset")
|
|
}
|
|
readonly property string editProvider: editEntry ? String(editEntry.provider) : ""
|
|
readonly property bool editIsActive: root.mode !== "anthropic" && root.editPreset !== "" && root.editPreset === root.preset
|
|
|
|
function editCurrent(tier) {
|
|
var e = root.editEntry
|
|
return e && e.models && e.models[tier] ? String(e.models[tier]) : ""
|
|
}
|
|
|
|
function openPresetEditor(name) {
|
|
root.lastError = ""
|
|
root.editPreset = name
|
|
root.editTier = ""
|
|
root.stage = "preset"
|
|
}
|
|
|
|
// The field starts empty rather than holding the current id: it doubles as
|
|
// the list's filter, and a pre-filled id would narrow the list to itself.
|
|
// The current mapping is shown on the line above instead.
|
|
function openTierEditor(tier) {
|
|
root.lastError = ""
|
|
root.editTier = tier
|
|
root.editModel = ""
|
|
// Typing breaks the field's binding, so a value left from another tier
|
|
// would otherwise survive into this one.
|
|
modelField.text = ""
|
|
modelList.currentIndex = -1
|
|
root.stage = "presetTier"
|
|
Qt.callLater(function () { modelField.forceActiveFocus() })
|
|
}
|
|
|
|
function pickModel(id) {
|
|
root.editModel = id
|
|
modelField.text = id
|
|
modelField.forceActiveFocus()
|
|
}
|
|
|
|
function saveTier() {
|
|
var m = root.editModel.trim()
|
|
if (root.editPreset === "" || root.editTier === "") return
|
|
if (m === "") { root.lastError = "Pick a model from the list, or type an id."; return }
|
|
root.runOps("saving", [["preset", "set", root.editPreset, root.editTier, m]], "preset")
|
|
}
|
|
|
|
// Every word has to appear somewhere in the id or its description, so
|
|
// "deepseek flash" finds the flash variants without an exact substring.
|
|
readonly property var modelMatches: {
|
|
var words = root.editModel.trim().toLowerCase().split(/\s+/).filter(function (w) { return w !== "" })
|
|
var all = root.modelOptions
|
|
if (words.length === 0) return all
|
|
var out = []
|
|
for (var i = 0; i < all.length; i++) {
|
|
var hay = (all[i].label + " " + all[i].description).toLowerCase()
|
|
var hit = true
|
|
for (var j = 0; j < words.length && hit; j++) hit = hay.indexOf(words[j]) !== -1
|
|
if (hit) out.push(all[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
function fetchModels() {
|
|
root.runOps("fetching models", [["models", "--preset", root.editPreset, "--refresh"]], "presetTier")
|
|
}
|
|
|
|
// The cached catalogue for the preset being edited. An LM Studio list is
|
|
// only used when it came from the same server: two presets can point at two
|
|
// machines with different models loaded.
|
|
readonly property var catalogueNode: {
|
|
var all = widget && widget.modelsCache && widget.modelsCache.providers ? widget.modelsCache.providers : null
|
|
var n = all && root.editProvider !== "" ? all[root.editProvider] : null
|
|
if (!n) return null
|
|
if (root.editProvider === "lmstudio") {
|
|
var want = root.editEntry && root.editEntry.baseUrl ? String(root.editEntry.baseUrl).replace(/\/+$/, "") : ""
|
|
if (String(n.baseUrl || "") !== want) return null
|
|
}
|
|
return n
|
|
}
|
|
|
|
readonly property var modelOptions: {
|
|
var n = root.catalogueNode
|
|
var ms = n && n.models ? n.models : []
|
|
var out = []
|
|
for (var i = 0; i < ms.length; i++) {
|
|
var m = ms[i]
|
|
var bits = []
|
|
if (m.contextTokens) bits.push(Modes.contextLabel(m.contextTokens))
|
|
if (m.priceIn !== undefined && m.priceIn !== null)
|
|
bits.push("$" + m.priceIn + " in / $" + m.priceOut + " out per 1M")
|
|
if (m.state) bits.push(String(m.state))
|
|
if (m.note) bits.push(String(m.note))
|
|
out.push({ value: String(m.id), label: String(m.id), description: bits.join(" · ") })
|
|
}
|
|
return out
|
|
}
|
|
|
|
function ageLabel(iso) {
|
|
var t = Date.parse(String(iso || ""))
|
|
if (isNaN(t)) return ""
|
|
var s = Math.max(0, (Date.now() - t) / 1000)
|
|
if (s < 90) return "just now"
|
|
if (s < 5400) return Math.round(s / 60) + " min ago"
|
|
if (s < 129600) return Math.round(s / 3600) + " h ago"
|
|
return Math.round(s / 86400) + " days ago"
|
|
}
|
|
|
|
// ---- Preset lifecycle: new, duplicate, rename, delete
|
|
//
|
|
// Names become file names, so the CLI's valid_preset_name rule is checked
|
|
// here too. The CLI checks again; this only says what is wrong while typing.
|
|
readonly property var presetNameRe: /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/
|
|
property string newProvider: ""
|
|
property string newFrom: "" // source preset to copy; "" = blank template
|
|
property string newName: ""
|
|
property string renameTo: ""
|
|
|
|
function presetsOf(provider) {
|
|
var all = (health && health.presets) ? health.presets : []
|
|
return all.filter(function (p) { return String(p.provider) === provider })
|
|
}
|
|
|
|
// `keep` is a name that is allowed to exist already - the preset's own,
|
|
// when renaming.
|
|
function nameProblem(name, keep) {
|
|
var n = String(name || "").trim()
|
|
if (n === "") return "Give it a name."
|
|
if (!root.presetNameRe.test(n)) return "Letters, digits, . _ and - only, not starting with a dot."
|
|
if (n !== keep && root.presetEntry(n)) return "There is already a preset called '" + n + "'."
|
|
return ""
|
|
}
|
|
|
|
function uniqueName(base) {
|
|
var n = base
|
|
for (var i = 2; root.presetEntry(n); i++) n = base + "-" + i
|
|
return n
|
|
}
|
|
|
|
// `from` null means "whatever makes sense": the provider's first preset,
|
|
// which carries a working server, key and context, else a blank template.
|
|
function openNewPreset(provider, from) {
|
|
root.lastError = ""
|
|
root.newProvider = provider
|
|
var sibs = root.presetsOf(provider)
|
|
root.newFrom = from !== null && from !== undefined ? from : (sibs.length > 0 ? String(sibs[0].name) : "")
|
|
root.newName = root.uniqueName(from ? from + "-copy" : provider + "-new")
|
|
newNameField.text = root.newName
|
|
root.stage = "presetNew"
|
|
Qt.callLater(function () { newNameField.forceActiveFocus(); newNameField.selectAll() })
|
|
}
|
|
|
|
function createPreset() {
|
|
var n = root.newName.trim()
|
|
if (root.nameProblem(n, "") !== "") return
|
|
var argv = root.newFrom === ""
|
|
? ["preset", "new", n, "--provider", root.newProvider, "--blank"]
|
|
: ["preset", "new", n, root.newFrom]
|
|
root.runOps("creating", [argv], "preset", function () { root.editPreset = n })
|
|
}
|
|
|
|
function openRename() {
|
|
root.lastError = ""
|
|
root.renameTo = root.editPreset
|
|
renameField.text = root.editPreset
|
|
root.stage = "presetRename"
|
|
Qt.callLater(function () { renameField.forceActiveFocus(); renameField.selectAll() })
|
|
}
|
|
|
|
function renamePreset() {
|
|
var n = root.renameTo.trim()
|
|
if (n === root.editPreset) { root.stage = "preset"; return }
|
|
if (root.nameProblem(n, root.editPreset) !== "") return
|
|
var old = root.editPreset
|
|
root.runOps("renaming", [["preset", "rename", old, n]], "preset", function () { root.editPreset = n })
|
|
}
|
|
|
|
function deletePreset() {
|
|
if (root.editIsActive || root.editPreset === "") return
|
|
root.runOps("deleting", [["preset", "rm", root.editPreset]], "list", function () { root.editPreset = "" })
|
|
}
|
|
|
|
function storeServerKey() {
|
|
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
|
|
remedyProc.running = true
|
|
}
|
|
|
|
function resetFlow() {
|
|
root.stage = "list"
|
|
root.blocker = null
|
|
root.sessionInfo = null
|
|
root.pendingMode = ""
|
|
root.pendingPreset = ""
|
|
root.serverPreset = ""
|
|
root.serverReturn = ""
|
|
root.repairTarget = null
|
|
root.editPreset = ""
|
|
root.editTier = ""
|
|
root.newProvider = ""
|
|
}
|
|
|
|
function switchTo(mode, presetName) {
|
|
if (root.busy) return
|
|
root.lastError = ""
|
|
root.pendingMode = mode
|
|
root.pendingPreset = presetName
|
|
root.busy = true
|
|
preflightProc.command = root.cli(presetName === ""
|
|
? ["preflight", mode] : ["preflight", mode, presetName])
|
|
preflightProc.running = true
|
|
}
|
|
|
|
// Nothing is written until this says so. A mode with no key stored, or a
|
|
// local server that is not running, would otherwise switch cleanly and leave
|
|
// every session started afterwards broken in a way that looks like Claude
|
|
// Code's fault rather than a missing key.
|
|
Process {
|
|
id: preflightProc
|
|
running: false
|
|
property var verdict: null
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: preflightProc.verdict = root.parseJson(text)
|
|
}
|
|
onExited: function (code) {
|
|
var v = preflightProc.verdict
|
|
preflightProc.verdict = null
|
|
if (v && v.ok === false) {
|
|
root.busy = false
|
|
root.blocker = v
|
|
root.stage = "blocked"
|
|
return
|
|
}
|
|
sessionsProc.command = root.cli(["sessions", "--json"])
|
|
sessionsProc.running = true
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: sessionsProc
|
|
running: false
|
|
property var info: null
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: sessionsProc.info = root.parseJson(text)
|
|
}
|
|
onExited: function (code) {
|
|
var info = sessionsProc.info
|
|
sessionsProc.info = null
|
|
root.busy = false
|
|
if (info && info.count > 0) {
|
|
root.sessionInfo = info
|
|
root.stage = "confirm"
|
|
return
|
|
}
|
|
root.applySwitch("none")
|
|
}
|
|
}
|
|
|
|
// sessionAction: none | stop | restart, applied to the sessions left on the
|
|
// old provider once the new one is written.
|
|
function applySwitch(sessionAction) {
|
|
root.busy = true
|
|
root.stage = "list"
|
|
switchProc.sessionAction = sessionAction
|
|
// --yes because the confirmation already happened, in the card above. The
|
|
// CLI now refuses a non-interactive switch while sessions are running, and
|
|
// without this the panel's switch would simply stop working.
|
|
switchProc.command = root.cli(root.pendingPreset === ""
|
|
? [root.pendingMode, "--yes"] : [root.pendingMode, root.pendingPreset, "--yes"])
|
|
switchProc.running = true
|
|
}
|
|
|
|
// claude-mode rewrites health.json as the last step of a switch, so the
|
|
// widget's FileView is what actually updates the display. This only has to
|
|
// report failures and then deal with the sessions.
|
|
Process {
|
|
id: switchProc
|
|
running: false
|
|
property string sessionAction: "none"
|
|
stderr: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
|
|
var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" })
|
|
if (lines.length > 0) root.lastError = lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim()
|
|
}
|
|
}
|
|
onExited: function (code) {
|
|
root.busy = false
|
|
root.expandedMode = ""
|
|
root.blocker = null
|
|
root.sessionInfo = null
|
|
if (code === 0) {
|
|
root.lastError = ""
|
|
// Strictly after the write. A session reopened before it would come
|
|
// straight back up on the provider we just left.
|
|
if (switchProc.sessionAction !== "none") {
|
|
sessionActProc.command = root.cli(["sessions",
|
|
switchProc.sessionAction === "restart" ? "--restart" : "--stop", "--yes"])
|
|
sessionActProc.running = true
|
|
}
|
|
}
|
|
switchProc.sessionAction = "none"
|
|
root.pendingMode = ""
|
|
root.pendingPreset = ""
|
|
if (widget) widget.refresh()
|
|
}
|
|
}
|
|
|
|
// --yes because the confirmation already happened, in the card below. Without
|
|
// it the CLI would refuse rather than act on a decision nobody typed.
|
|
Process { id: sessionActProc; running: false }
|
|
|
|
// The remedy for a missing key is a hidden password prompt, which a bar popup
|
|
// has nowhere to host - so the CLI is asked to open a terminal for it.
|
|
Process { id: remedyProc; running: false }
|
|
|
|
function runRemedy() {
|
|
var b = root.blocker
|
|
if (!b) return
|
|
if (String(b.remedyKind) === "set-key") {
|
|
remedyProc.command = root.cli(["set-key", String(b.keyRef), "--terminal"])
|
|
remedyProc.running = true
|
|
}
|
|
}
|
|
|
|
// First-run setup asks for a key behind a hidden prompt and picks models from
|
|
// a filter-select list - neither of which a bar popup can host. The terminal
|
|
// gets the whole flow; this side just launches it and re-checks afterwards.
|
|
function runSetup() {
|
|
if (root.pendingMode === "") return
|
|
setupProc.command = root.cli(["setup", root.pendingMode, "--terminal"])
|
|
setupProc.running = true
|
|
}
|
|
|
|
Process { id: setupProc; running: false }
|
|
|
|
function retryPending() {
|
|
if (root.pendingMode === "") { root.resetFlow(); return }
|
|
var m = root.pendingMode, p = root.pendingPreset
|
|
root.blocker = null
|
|
root.stage = "list"
|
|
root.switchTo(m, p)
|
|
}
|
|
|
|
readonly property var modelRows: {
|
|
var models = health && health.models ? health.models : null
|
|
if (!models) return []
|
|
var tiers = ["opus", "sonnet", "haiku", "fable"]
|
|
var out = []
|
|
for (var i = 0; i < tiers.length; i++) {
|
|
if (models[tiers[i]]) out.push({ tier: tiers[i], id: String(models[tiers[i]]) })
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Color.urgent derives from the theme's `red`, and monochrome themes define
|
|
// that as a desaturated slate - Solitude's is #565d60, dimmer than the panel
|
|
// text it is supposed to outrank, so an error would read as the quietest
|
|
// line on the card. Use the theme's colour only when it carries real hue.
|
|
readonly property color urgentColor: {
|
|
var c = Color.urgent
|
|
var mx = Math.max(c.r, c.g, c.b)
|
|
var mn = Math.min(c.r, c.g, c.b)
|
|
var lightness = (mx + mn) / 2
|
|
var denom = 1 - Math.abs(2 * lightness - 1)
|
|
var saturation = denom > 0.0001 ? (mx - mn) / denom : 0
|
|
return saturation >= 0.15 ? c : "#d2685f"
|
|
}
|
|
|
|
readonly property string footerText: {
|
|
if (!known) return "claude-mode not found in " + cmRoot
|
|
var bits = []
|
|
if (health && health.version) bits.push("claude-mode " + health.version)
|
|
if (health && health.keyBackend) bits.push("key: " + health.keyBackend)
|
|
var ctx = health && health.contextTokens ? Modes.contextLabel(health.contextTokens) : ""
|
|
if (ctx !== "") bits.push(ctx)
|
|
return bits.join(" · ")
|
|
}
|
|
|
|
|
|
component PillButton: Rectangle {
|
|
id: pb
|
|
property string label: ""
|
|
property bool primary: false
|
|
signal triggered()
|
|
|
|
implicitWidth: pbText.implicitWidth + Style.space(20)
|
|
implicitHeight: Style.space(25)
|
|
radius: Style.space(4)
|
|
// `enabled: false` greys it out; the MouseArea inherits the flag.
|
|
opacity: enabled ? 1.0 : 0.4
|
|
|
|
readonly property color tint: primary ? Color.accent : Color.popups.text
|
|
color: pbArea.containsMouse
|
|
? Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.30 : 0.14)
|
|
: Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.16 : 0.00)
|
|
border.width: 1
|
|
border.color: Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.75 : 0.30)
|
|
|
|
Text {
|
|
id: pbText
|
|
anchors.centerIn: parent
|
|
text: pb.label
|
|
color: Color.popups.text
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
font.bold: pb.primary
|
|
}
|
|
|
|
MouseArea {
|
|
id: pbArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: pb.triggered()
|
|
}
|
|
}
|
|
|
|
// KeyboardPanel, not PopupCard. PopupCard is an xdg-popup, which only gets
|
|
// keys after focus is routed through its parent surface - so no text field
|
|
// in it could ever be typed into (the server URL form and the model search
|
|
// both looked clickable and took nothing). KeyboardPanel is a layer-shell
|
|
// surface that primes keyboard focus on open, which is why every shell panel
|
|
// with a text field is built on it.
|
|
KeyboardPanel {
|
|
id: card
|
|
anchorItem: root.anchorItem
|
|
owner: root.barIdentity
|
|
bar: root.bar
|
|
open: root.opened
|
|
// The panel now holds the keyboard while open, so keys need somewhere to
|
|
// land when no field has focus - and Esc should close it, as it does
|
|
// every other shell panel. Esc inside a text field bubbles up here too.
|
|
focusTarget: column
|
|
contentWidth: card.fittedContentWidth(Style.space(360))
|
|
contentHeight: card.fittedContentHeight(column.implicitHeight)
|
|
|
|
Column {
|
|
id: column
|
|
width: card.contentWidth - card.padding * 2
|
|
spacing: Style.space(10)
|
|
focus: true
|
|
Keys.onEscapePressed: root.close()
|
|
|
|
// ---- Hero: what the next `claude` will actually use.
|
|
Row {
|
|
id: heroRow
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
|
|
Item {
|
|
id: heroGlyph
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Style.font.display
|
|
height: Style.font.display
|
|
|
|
readonly property color tint: root.known && root.mode !== "anthropic"
|
|
? Color.accent : Color.popups.text
|
|
|
|
BrandIcon {
|
|
anchors.centerIn: parent
|
|
visible: 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, root.defaultPresets)
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: Style.space(30)
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: -Style.space(6)
|
|
anchors.rightMargin: -Style.space(6)
|
|
radius: Style.space(4)
|
|
color: modeEntry.isCurrent
|
|
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.12)
|
|
: (modeHover.containsMouse
|
|
? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.07)
|
|
: "transparent")
|
|
}
|
|
|
|
BrandIcon {
|
|
id: modeGlyph
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.space(3)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
pathData: 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. Shown even
|
|
// with none left, so "New preset…" is still there to click.
|
|
Column {
|
|
width: parent.width
|
|
visible: modeEntry.isExpanded
|
|
spacing: Style.space(1)
|
|
|
|
Repeater {
|
|
model: modeEntry.isExpanded ? modeEntry.presetList : []
|
|
|
|
Item {
|
|
id: presetRow
|
|
required property var modelData
|
|
width: modeEntry.width
|
|
height: Style.space(26)
|
|
|
|
readonly property bool isActive: modeEntry.isCurrent && String(modelData.name) === root.preset
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(20)
|
|
anchors.rightMargin: -Style.space(6)
|
|
radius: Style.space(4)
|
|
color: presetHover.containsMouse
|
|
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
|
|
: "transparent"
|
|
}
|
|
|
|
Text {
|
|
id: presetName
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.space(30)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: presetRow.modelData.name
|
|
color: presetRow.isActive ? Color.accent : Color.popups.text
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(12)
|
|
font.bold: presetRow.isActive
|
|
}
|
|
|
|
// Only worth saying when there is a choice to tell apart.
|
|
Text {
|
|
id: defaultTag
|
|
visible: modeEntry.presetList.length > 1
|
|
&& String(presetRow.modelData.name) === root.defaultPresets[modeEntry.thisMode]
|
|
anchors.left: presetName.right
|
|
anchors.leftMargin: Style.space(6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "default"
|
|
color: Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(9)
|
|
}
|
|
|
|
// The opus mapping is the one that tells you what you are
|
|
// about to be talking to; the rest is in the tooltip.
|
|
Text {
|
|
anchors.left: defaultTag.visible ? defaultTag.right : presetName.right
|
|
anchors.leftMargin: Style.space(8)
|
|
anchors.right: gearButton.visible ? gearButton.left : parent.right
|
|
anchors.rightMargin: Style.space(4)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
horizontalAlignment: Text.AlignRight
|
|
text: presetRow.modelData.models && presetRow.modelData.models.opus
|
|
? String(presetRow.modelData.models.opus)
|
|
: ""
|
|
color: Color.muted
|
|
elide: Text.ElideLeft
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
// Clicking the row switches; the gear opens that preset in the
|
|
// editor instead. Per row rather than per provider, because
|
|
// two presets of one provider can map tiers differently - and
|
|
// two LM Studio presets can sit on two different machines.
|
|
MouseArea {
|
|
id: presetHover
|
|
anchors.fill: parent
|
|
anchors.rightMargin: gearButton.visible ? gearButton.width : 0
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
enabled: !root.busy
|
|
onClicked: root.switchTo(modeEntry.thisMode, String(presetRow.modelData.name))
|
|
}
|
|
|
|
Item {
|
|
id: gearButton
|
|
visible: true
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Style.space(22)
|
|
height: parent.height
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: String.fromCodePoint(0xF0493)
|
|
color: gearArea.containsMouse ? Color.accent : Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(12)
|
|
}
|
|
|
|
MouseArea {
|
|
id: gearArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
enabled: !root.busy
|
|
onClicked: root.openPresetEditor(String(presetRow.modelData.name))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: modeEntry.width
|
|
height: Style.space(24)
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(20)
|
|
anchors.rightMargin: -Style.space(6)
|
|
radius: Style.space(4)
|
|
color: newPresetArea.containsMouse
|
|
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
|
|
: "transparent"
|
|
}
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.space(30)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "New " + Modes.title(modeEntry.thisMode) + " preset…"
|
|
color: newPresetArea.containsMouse ? Color.accent : Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
}
|
|
|
|
MouseArea {
|
|
id: newPresetArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
enabled: !root.busy
|
|
onClicked: root.openNewPreset(modeEntry.thisMode, null)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Active model map, only meaningful behind a gateway.
|
|
|
|
// ---- Preflight refused. Name the missing thing and offer the one action
|
|
// that fixes it, rather than failing the click silently.
|
|
Column {
|
|
width: parent.width
|
|
visible: root.stage === "blocked" && root.blocker !== null
|
|
spacing: Style.space(7)
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: root.blocker ? String(root.blocker.title) : ""
|
|
color: root.urgentColor
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(13)
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: root.blocker ? String(root.blocker.detail) : ""
|
|
color: Color.popups.text
|
|
wrapMode: Text.WordWrap
|
|
lineHeight: 1.2
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.blocker && String(root.blocker.remedy) !== ""
|
|
text: root.blocker ? "$ " + String(root.blocker.remedy) : ""
|
|
color: Color.muted
|
|
wrapMode: Text.WrapAnywhere
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
// Flow, not Row: these can total more than the card is wide, and a
|
|
// Row lays them straight past its right edge instead of wrapping.
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(7)
|
|
|
|
PillButton {
|
|
label: "Set up " + 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()
|
|
}
|
|
// A preset with every tier empty: the fix is one click away here,
|
|
// not in a terminal.
|
|
PillButton {
|
|
label: "Edit preset…"
|
|
primary: true
|
|
visible: root.blocker && String(root.blocker.remedyKind) === "edit-preset"
|
|
onTriggered: {
|
|
var p = root.pendingPreset
|
|
root.resetFlow()
|
|
root.openPresetEditor(p)
|
|
}
|
|
}
|
|
PillButton {
|
|
label: "Server settings…"
|
|
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0
|
|
visible: root.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: root.serverReturn !== "" ? "Back" : "Cancel"
|
|
onTriggered: {
|
|
if (root.serverReturn !== "") root.stage = root.serverReturn
|
|
else root.resetFlow()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- One preset: its tier map, each row opening a picker.
|
|
Column {
|
|
width: parent.width
|
|
visible: root.stage === "preset" && root.editPreset !== ""
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "'" + root.editPreset + "' · " + Modes.title(root.editProvider)
|
|
color: Color.popups.text
|
|
elide: Text.ElideRight
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(13)
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: text !== ""
|
|
text: root.editEntry && root.editEntry.description ? String(root.editEntry.description) : ""
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.editIsActive
|
|
text: "In use now. A change is applied straight away and reaches sessions started after it."
|
|
color: Color.accent
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.editIsDefault
|
|
text: "claude-mode " + root.editProvider + " picks this one when no preset is named"
|
|
+ (root.editIsChosenDefault ? "." : " (the built-in choice).")
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(1)
|
|
|
|
Repeater {
|
|
model: root.stage === "preset" ? root.tierNames : []
|
|
|
|
Item {
|
|
id: tierRow
|
|
required property var modelData
|
|
readonly property string tier: String(modelData)
|
|
readonly property string current: root.editCurrent(tier)
|
|
width: column.width
|
|
height: Style.space(26)
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: -Style.space(6)
|
|
anchors.rightMargin: -Style.space(6)
|
|
radius: Style.space(4)
|
|
color: tierArea.containsMouse
|
|
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
|
|
: "transparent"
|
|
}
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: tierRow.tier
|
|
color: Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
}
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.space(54)
|
|
anchors.right: tierChevron.left
|
|
anchors.rightMargin: Style.space(6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
horizontalAlignment: Text.AlignRight
|
|
text: tierRow.current !== "" ? tierRow.current : "not set"
|
|
color: tierRow.current !== "" ? Color.popups.text : Color.muted
|
|
elide: Text.ElideLeft
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
}
|
|
|
|
Text {
|
|
id: tierChevron
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: String.fromCodePoint(0xF0142)
|
|
color: tierArea.containsMouse ? Color.accent : Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
|
|
MouseArea {
|
|
id: tierArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
enabled: !root.busy
|
|
onClicked: root.openTierEditor(tierRow.tier)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flow, not Row: these can total more than the card is wide, and a
|
|
// Row lays them straight past its right edge instead of wrapping.
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(7)
|
|
|
|
PillButton {
|
|
label: "Server settings…"
|
|
visible: root.editProvider === "lmstudio"
|
|
onTriggered: root.openServerSettings(root.editPreset, "preset")
|
|
}
|
|
PillButton { label: "Make default"; visible: !root.editIsDefault; onTriggered: root.setDefault(true) }
|
|
PillButton { label: "Clear default"; visible: root.editIsChosenDefault; onTriggered: root.setDefault(false) }
|
|
PillButton { label: "Duplicate…"; onTriggered: root.openNewPreset(root.editProvider, root.editPreset) }
|
|
PillButton { label: "Rename…"; onTriggered: root.openRename() }
|
|
// The CLI refuses to delete the preset in use. A button that always
|
|
// fails would be worse than one that says why it is off.
|
|
PillButton {
|
|
label: "Delete…"
|
|
enabled: !root.editIsActive
|
|
onTriggered: { root.lastError = ""; root.stage = "presetDelete" }
|
|
}
|
|
PillButton { label: "Done"; primary: true; onTriggered: root.resetFlow() }
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.editIsActive
|
|
text: "In use, so it cannot be deleted. Switch to another preset first."
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(9)
|
|
}
|
|
}
|
|
|
|
// ---- New preset: a name, and what to start from.
|
|
Column {
|
|
width: parent.width
|
|
visible: root.stage === "presetNew"
|
|
spacing: Style.space(7)
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "New " + Modes.title(root.newProvider) + " preset"
|
|
color: Color.popups.text
|
|
elide: Text.ElideRight
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(13)
|
|
font.bold: true
|
|
}
|
|
|
|
TextField {
|
|
id: newNameField
|
|
width: parent.width
|
|
text: root.newName
|
|
placeholderText: "name"
|
|
foreground: Color.popups.text
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
onTextChanged: root.newName = text
|
|
onAccepted: root.createPreset()
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
readonly property string problem: root.stage === "presetNew" ? root.nameProblem(root.newName, "") : ""
|
|
visible: problem !== ""
|
|
text: problem
|
|
color: root.urgentColor
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
text: "Start from"
|
|
color: Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
// Only this provider's presets: a copy keeps the provider, and a
|
|
// preset cannot change provider afterwards.
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Repeater {
|
|
model: root.stage === "presetNew" ? root.presetsOf(root.newProvider) : []
|
|
|
|
PillButton {
|
|
required property var modelData
|
|
label: "copy of " + String(modelData.name)
|
|
primary: root.newFrom === String(modelData.name)
|
|
onTriggered: root.newFrom = String(modelData.name)
|
|
}
|
|
}
|
|
|
|
PillButton {
|
|
label: "blank"
|
|
primary: root.newFrom === ""
|
|
onTriggered: root.newFrom = ""
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: newFromHint
|
|
width: parent.width
|
|
text: root.newFrom === ""
|
|
? "Every tier starts empty; the editor opens next to fill them in."
|
|
: "Same server, key and models as '" + root.newFrom + "'. The editor opens next to change them."
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(7)
|
|
|
|
PillButton { label: "Create"; primary: true; onTriggered: root.createPreset() }
|
|
PillButton {
|
|
label: "Cancel"
|
|
onTriggered: {
|
|
root.lastError = ""
|
|
if (root.editPreset !== "") root.stage = "preset"
|
|
else root.resetFlow()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Rename: the file moves, and state.json follows if it is in use.
|
|
Column {
|
|
width: parent.width
|
|
visible: root.stage === "presetRename"
|
|
spacing: Style.space(7)
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "Rename '" + root.editPreset + "'"
|
|
color: Color.popups.text
|
|
elide: Text.ElideRight
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(13)
|
|
font.bold: true
|
|
}
|
|
|
|
TextField {
|
|
id: renameField
|
|
width: parent.width
|
|
text: root.renameTo
|
|
placeholderText: "new name"
|
|
foreground: Color.popups.text
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
onTextChanged: root.renameTo = text
|
|
onAccepted: root.renamePreset()
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
readonly property string problem: root.stage === "presetRename" ? root.nameProblem(root.renameTo, root.editPreset) : ""
|
|
visible: problem !== ""
|
|
text: problem
|
|
color: root.urgentColor
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.editIsActive
|
|
text: "It is the preset in use. The switch follows the new name, and running sessions keep working."
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(7)
|
|
|
|
PillButton { label: "Rename"; primary: true; onTriggered: root.renamePreset() }
|
|
PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } }
|
|
}
|
|
}
|
|
|
|
// ---- Delete: confirmed, and says so when it leaves a provider empty.
|
|
Column {
|
|
width: parent.width
|
|
visible: root.stage === "presetDelete"
|
|
spacing: Style.space(7)
|
|
|
|
readonly property bool lastOne: root.stage === "presetDelete"
|
|
&& root.presetsOf(root.editProvider).length <= 1
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "Delete '" + root.editPreset + "'?"
|
|
color: Color.popups.text
|
|
elide: Text.ElideRight
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(13)
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "The preset file is removed, and there is no undo from here. Its key stays in the vault."
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: parent.lastOne
|
|
text: "It is the only " + Modes.title(root.editProvider) + " preset, so "
|
|
+ Modes.title(root.editProvider) + " will have nothing to switch to until you create another."
|
|
color: root.urgentColor
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(7)
|
|
|
|
PillButton { label: "Delete it"; primary: true; onTriggered: root.deletePreset() }
|
|
PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } }
|
|
}
|
|
}
|
|
|
|
// ---- One tier: pick from the cached catalogue, or type any id.
|
|
Column {
|
|
width: parent.width
|
|
visible: root.stage === "presetTier"
|
|
spacing: Style.space(7)
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: root.editTier + " · '" + root.editPreset + "'"
|
|
color: Color.popups.text
|
|
elide: Text.ElideRight
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(13)
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "now: " + (root.editCurrent(root.editTier) || "not set")
|
|
color: Color.muted
|
|
elide: Text.ElideLeft
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
// One field that both filters the catalogue and takes any id typed by
|
|
// hand, with the matches listed inline beneath it - rather than the
|
|
// shell's SearchableDropdown, whose list is a second popup layered on
|
|
// this card. Inline, the card's height is the only thing to manage,
|
|
// and the list can never be clipped by the card's edge.
|
|
TextField {
|
|
id: modelField
|
|
width: parent.width
|
|
text: root.editModel
|
|
placeholderText: root.modelOptions.length > 0
|
|
? "Search " + root.modelOptions.length + " models, or type any id"
|
|
: "Model id"
|
|
foreground: Color.popups.text
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
onTextChanged: {
|
|
root.editModel = text
|
|
modelList.currentIndex = -1
|
|
}
|
|
// Enter takes the highlighted match if the arrows chose one, and
|
|
// saves what is in the field otherwise.
|
|
onAccepted: {
|
|
if (modelList.currentIndex >= 0 && modelList.currentIndex < root.modelMatches.length)
|
|
root.pickModel(String(root.modelMatches[modelList.currentIndex].value))
|
|
else
|
|
root.saveTier()
|
|
}
|
|
Keys.onDownPressed: function (event) {
|
|
if (modelList.count > 0) {
|
|
modelList.currentIndex = Math.min(modelList.currentIndex + 1, modelList.count - 1)
|
|
modelList.positionViewAtIndex(modelList.currentIndex, ListView.Contain)
|
|
}
|
|
event.accepted = true
|
|
}
|
|
Keys.onUpPressed: function (event) {
|
|
if (modelList.currentIndex >= 0) {
|
|
modelList.currentIndex = modelList.currentIndex - 1
|
|
if (modelList.currentIndex >= 0) modelList.positionViewAtIndex(modelList.currentIndex, ListView.Contain)
|
|
}
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
// Scrolls inside a box of at most six rows: the card clamps to its
|
|
// content rather than scrolling, so an unbounded list would push the
|
|
// buttons off the bottom edge.
|
|
Rectangle {
|
|
id: modelListFrame
|
|
readonly property int rowHeight: Style.space(30)
|
|
width: parent.width
|
|
visible: root.modelOptions.length > 0
|
|
height: rowHeight * Math.max(1, Math.min(modelList.count, 6)) + 2
|
|
radius: Style.space(4)
|
|
color: "transparent"
|
|
border.width: 1
|
|
border.color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.14)
|
|
|
|
ListView {
|
|
id: modelList
|
|
anchors.fill: parent
|
|
anchors.margins: 1
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
currentIndex: -1
|
|
model: root.stage === "presetTier" ? root.modelMatches : []
|
|
|
|
delegate: Item {
|
|
id: matchRow
|
|
required property var modelData
|
|
required property int index
|
|
readonly property bool chosen: String(modelData.value) === root.editModel.trim()
|
|
width: modelList.width
|
|
height: modelListFrame.rowHeight
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: matchRow.index === modelList.currentIndex || matchArea.containsMouse
|
|
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.16)
|
|
: (matchRow.chosen ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.10) : "transparent")
|
|
}
|
|
|
|
Column {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.leftMargin: Style.space(8)
|
|
anchors.rightMargin: Style.space(8)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 0
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: String(matchRow.modelData.label)
|
|
color: matchRow.chosen ? Color.accent : Color.popups.text
|
|
elide: Text.ElideMiddle
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
font.bold: matchRow.chosen
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: text !== ""
|
|
text: String(matchRow.modelData.description || "")
|
|
color: Color.muted
|
|
elide: Text.ElideRight
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(9)
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: matchArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.pickModel(String(matchRow.modelData.value))
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
visible: modelList.count === 0
|
|
text: "No match. Save uses the id as typed."
|
|
color: Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.modelOptions.length === 0
|
|
text: root.catalogueNode && root.catalogueNode.ok === false
|
|
? "The last fetch failed and no list is cached. Type an id, or try fetching again."
|
|
: "No model list cached for " + Modes.title(root.editProvider) + " yet. Type an id, or fetch the list."
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.catalogueNode !== null && root.modelOptions.length > 0
|
|
text: root.catalogueNode
|
|
? root.modelOptions.length + " models cached, fetched " + root.ageLabel(root.catalogueNode.fetchedAt)
|
|
+ (root.catalogueNode.ok === false ? " · the last refresh failed" : "")
|
|
: ""
|
|
color: Color.muted
|
|
opacity: 0.85
|
|
elide: Text.ElideRight
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(9)
|
|
}
|
|
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(7)
|
|
|
|
PillButton { label: "Save"; primary: true; onTriggered: root.saveTier() }
|
|
PillButton {
|
|
label: root.modelOptions.length > 0 ? "Refresh list" : "Fetch models"
|
|
onTriggered: root.fetchModels()
|
|
}
|
|
PillButton {
|
|
label: "Back"
|
|
onTriggered: { root.lastError = ""; root.stage = "preset" }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Sessions are running. They will keep the old provider until they
|
|
// are restarted, and one mid-request can lose that turn outright, so this
|
|
// is a decision rather than a notification.
|
|
Column {
|
|
width: parent.width
|
|
visible: root.stage === "confirm" && root.sessionInfo !== null
|
|
spacing: Style.space(7)
|
|
|
|
readonly property int total: root.sessionInfo ? Number(root.sessionInfo.count) : 0
|
|
readonly property int busyCount: root.sessionInfo ? Number(root.sessionInfo.busy) : 0
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: parent.total === 1
|
|
? "1 Claude session is running"
|
|
: parent.total + " Claude sessions are running"
|
|
color: Color.popups.text
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(13)
|
|
font.bold: true
|
|
}
|
|
|
|
// Not "they will keep using the old provider". Their endpoint and model
|
|
// ids are fixed at startup, but the key is re-fetched on a timer and
|
|
// will resolve to the new mode, which the old endpoint refuses - so
|
|
// they fail rather than carry on.
|
|
Text {
|
|
width: parent.width
|
|
text: "They keep pointing at " + 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 || root.ignored.length > 0)
|
|
spacing: Style.space(4)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.broken.length > 0
|
|
text: root.broken.length === 1
|
|
? "1 session cannot be resumed"
|
|
: root.broken.length + " sessions cannot be resumed"
|
|
color: root.urgentColor
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
font.bold: true
|
|
}
|
|
|
|
Repeater {
|
|
model: root.stage === "list" ? root.broken : []
|
|
|
|
Item {
|
|
id: brokenRow
|
|
required property var modelData
|
|
width: column.width
|
|
height: Style.space(26)
|
|
|
|
Column {
|
|
anchors.left: parent.left
|
|
anchors.right: ignoreBtn.left
|
|
anchors.rightMargin: Style.space(6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 0
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: String(brokenRow.modelData.sessionId).substring(0, 8)
|
|
+ " · " + root.projectLabel(brokenRow.modelData.project)
|
|
color: Color.popups.text
|
|
elide: Text.ElideMiddle
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: brokenRow.modelData.dropLines + " turn lines after the last good message"
|
|
color: Color.muted
|
|
elide: Text.ElideRight
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(9)
|
|
}
|
|
}
|
|
|
|
PillButton {
|
|
id: ignoreBtn
|
|
anchors.right: repairBtn.left
|
|
anchors.rightMargin: Style.space(5)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
label: "Ignore"
|
|
onTriggered: root.setIgnored(brokenRow.modelData, true)
|
|
}
|
|
|
|
PillButton {
|
|
id: repairBtn
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
label: "Repair"
|
|
onTriggered: root.askRepair(brokenRow.modelData)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Hidden: dismissed here, or broken but untouched for longer than
|
|
// the age limit. Collapsed, because the point of hiding them was that
|
|
// they stop taking up attention - but never gone without a trace.
|
|
Item {
|
|
width: parent.width
|
|
height: Style.space(20)
|
|
visible: root.ignored.length > 0
|
|
|
|
Text {
|
|
id: hiddenLabel
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "hidden (" + root.ignored.length + ")"
|
|
color: hiddenArea.containsMouse ? Color.popups.text : Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
anchors.left: hiddenLabel.right
|
|
anchors.leftMargin: Style.space(4)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: String.fromCodePoint(root.hiddenExpanded ? 0xF0140 : 0xF0142)
|
|
color: hiddenLabel.color
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
}
|
|
|
|
MouseArea {
|
|
id: hiddenArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.hiddenExpanded = !root.hiddenExpanded
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
visible: root.hiddenExpanded && root.ignored.length > 0
|
|
spacing: Style.space(3)
|
|
|
|
Text {
|
|
visible: root.dismissedList.length > 0
|
|
text: "Ignored (" + root.dismissedList.length + ")"
|
|
color: Color.popups.text
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
font.bold: true
|
|
}
|
|
|
|
Repeater {
|
|
model: root.hiddenExpanded ? root.dismissedList.slice(0, root.hiddenCap) : []
|
|
|
|
Item {
|
|
id: dismissedRow
|
|
required property var modelData
|
|
width: column.width
|
|
height: Style.space(26)
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.right: restoreBtn.left
|
|
anchors.rightMargin: Style.space(6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: String(dismissedRow.modelData.sessionId).substring(0, 8)
|
|
+ " · " + root.projectLabel(dismissedRow.modelData.project)
|
|
color: Color.muted
|
|
elide: Text.ElideMiddle
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
PillButton {
|
|
id: restoreBtn
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
label: "Restore"
|
|
onTriggered: root.setIgnored(dismissedRow.modelData, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
visible: root.dismissedList.length > root.hiddenCap
|
|
text: "… and " + (root.dismissedList.length - root.hiddenCap)
|
|
+ " more · claude-mode repair-session --ignored"
|
|
color: Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(9)
|
|
}
|
|
|
|
Text {
|
|
visible: root.staleList.length > 0
|
|
text: "Older than " + root.ageDays + " days (" + root.staleList.length + ")"
|
|
color: Color.popups.text
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
font.bold: true
|
|
}
|
|
|
|
Repeater {
|
|
model: root.hiddenExpanded ? root.staleList.slice(0, root.hiddenCap) : []
|
|
|
|
Text {
|
|
required property var modelData
|
|
width: column.width
|
|
text: String(modelData.sessionId).substring(0, 8)
|
|
+ " · " + Math.floor((Date.now() / 1000 - Number(modelData.mtime)) / 86400) + "d"
|
|
+ " · " + root.projectLabel(modelData.project)
|
|
color: Color.muted
|
|
elide: Text.ElideMiddle
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.staleList.length > 0
|
|
text: (root.staleList.length > root.hiddenCap
|
|
? "… and " + (root.staleList.length - root.hiddenCap) + " more. " : "")
|
|
+ "Not counted because nothing has touched them since. "
|
|
+ "claude-mode repair-session --all --max-age 0 lists them."
|
|
color: Color.muted
|
|
opacity: 0.85
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(9)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Confirming one repair.
|
|
Column {
|
|
width: parent.width
|
|
visible: root.stage === "repair" && root.repairTarget !== null
|
|
spacing: Style.space(7)
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "Repair " + (root.repairTarget ? String(root.repairTarget.sessionId).substring(0, 8) : "")
|
|
color: Color.popups.text
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(13)
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: {
|
|
if (!root.repairTarget) return ""
|
|
var p = (root.repairTarget.providers || []).join(", ")
|
|
return "This session took " + (p !== "" ? p + "'s" : "another provider's")
|
|
+ " output while the mode was switched under it, and Anthropic will not "
|
|
+ "resume it. Rolling it back to its last good message drops "
|
|
+ root.repairTarget.dropLines + " lines."
|
|
}
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
lineHeight: 1.2
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "The original is backed up, the dropped turns are saved as Markdown, "
|
|
+ "and handed back to the session so it still knows what it did."
|
|
color: Color.muted
|
|
opacity: 0.85
|
|
wrapMode: Text.WordWrap
|
|
lineHeight: 1.2
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(7)
|
|
|
|
PillButton { label: "Repair it"; primary: true; onTriggered: root.doRepair() }
|
|
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width; visible: root.modelRows.length > 0 && root.stage === "list" }
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(1)
|
|
visible: root.modelRows.length > 0 && root.stage === "list"
|
|
|
|
PanelSectionHeader {
|
|
text: "MODEL MAP"
|
|
foreground: Color.popups.text
|
|
fontFamily: root.bar ? root.bar.fontFamily : Style.font.family
|
|
}
|
|
|
|
Repeater {
|
|
model: root.modelRows
|
|
|
|
Item {
|
|
required property var modelData
|
|
width: column.width
|
|
height: Style.space(19)
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: parent.modelData.tier
|
|
color: Color.muted
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
}
|
|
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.space(54)
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
horizontalAlignment: Text.AlignRight
|
|
text: parent.modelData.id
|
|
color: Color.popups.text
|
|
elide: Text.ElideLeft
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(11)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
// ---- Failures worth surfacing: a preset with no key stored exits
|
|
// non-zero, and without this the click would just look inert.
|
|
Text {
|
|
width: parent.width
|
|
visible: root.lastError !== ""
|
|
text: root.lastError
|
|
color: root.urgentColor
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: root.busy
|
|
? (opProc.running ? root.opLabel + "…"
|
|
: (ignoreProc.running ? "updating…" : (repairProc.running ? "repairing…" : "switching…")))
|
|
: (root.known && root.stage === "list" ? "Restart claude to pick this up." : "")
|
|
visible: text !== ""
|
|
color: Color.muted
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: root.footerText
|
|
visible: text !== ""
|
|
color: Color.muted
|
|
opacity: 0.8
|
|
wrapMode: Text.WordWrap
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.space(10)
|
|
}
|
|
}
|
|
}
|
|
}
|