Every gateway provider is now an entry in providers.json - endpoint and auth template, how its model list is read, how it is probed before a switch, what setup asks, which doctor checks apply, and its title, colour and logo - installed next to the presets and read by all three consumers: the bash CLI (through cm-json.py), the Windows script, and the bar widget (through health.json). anthropic stays built in; it is the native login, not a gateway. Behaviour that differs in kind stays in code, chosen by name from the entry: catalogue parsers (openrouter, lmstudio, ollama, openai, static), probe rules (always, lenient, local), and named doctor checks. A provider that reuses them is an entry and a default preset, with no code. The widget draws providers from health.json, so a new one needs no QML change and no shell restart. The existing three are unchanged in behaviour: their blank presets come out byte-identical from the file, and setup, doctor, models and the picker run the same checks through the generic paths. Ollama: local server on :11434, placeholder token, one model for every tier, models from /api/tags. doctor reads the context each loaded model actually runs with (/api/ps) and its maximum (/api/show), because Ollama defaults to 4096 tokens unless OLLAMA_CONTEXT_LENGTH is set and silently truncates past it. A bare model name matches its :latest tag. Custom: any Anthropic-compatible endpoint. Ships with no address and is refused until it has one; key optional; models from /v1/models when the endpoint has a list, and a lenient probe so a proxy without one is not blocked. Also: preflight (and Set-ClaudeMode on Windows) refuses a preset with no server address; the server form, setup and set-auth use each provider's own default URL, key name and placeholder token instead of LM Studio's; the Windows build gains the no-models and no-address guards it never had. Tested on Linux against fake Ollama/Custom servers, and on Windows 5.1 in a USERPROFILE sandbox on winbox. 1.12.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2361 lines
87 KiB
QML
2361 lines
87 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 } }
|
|
|
|
// ---- Providers, as the widget publishes them
|
|
//
|
|
// The widget owns the lookup, and the fallback to Modes.js for anthropic and
|
|
// for an older health.json. This side only asks, so nothing here needs to
|
|
// know which providers exist.
|
|
readonly property var modeOrder: widget ? widget.modeOrder : Modes.ORDER
|
|
function pInfo(m) { return widget ? widget.providerInfo(m) : null }
|
|
function pTitle(m) { return widget ? widget.modeTitle(m) : "" }
|
|
function pBlurb(m) { return widget ? widget.modeBlurb(m) : "" }
|
|
function pLogo(m) { return widget ? widget.modeLogo(m) : "" }
|
|
function pLogoScale(m) { return widget ? widget.modeLogoScale(m) : 1.0 }
|
|
function pGlyph(m) { return widget ? widget.modeGlyph(m) : "" }
|
|
// Before providers were published only LM Studio had either.
|
|
function serverEditable(m) { var p = root.pInfo(m); return p ? p.serverEditable === true : m === "lmstudio" }
|
|
function perServerCatalogue(m) { var p = root.pInfo(m); return p ? p.perServerCatalogue === true : m === "lmstudio" }
|
|
|
|
// ---- Server settings
|
|
//
|
|
// A server provider (LM Studio, Ollama, a custom endpoint) ships pointed at
|
|
// its usual local address, but it does not have to be there: it can be
|
|
// another machine on the LAN, or something reached through a tunnel or a
|
|
// reverse proxy, and it can have authentication switched on. All of that is
|
|
// just a baseUrl and an auth block in the preset, so this edits those two
|
|
// rather than pretending the local default is the only shape.
|
|
property string serverPreset: ""
|
|
property string serverProvider: ""
|
|
property string serverUrl: ""
|
|
property bool serverNeedsKey: false
|
|
property string serverKeyRef: ""
|
|
property string serverDefault: "" // the provider's usual address; "" for custom
|
|
property string serverHint: ""
|
|
|
|
function presetEntry(name) {
|
|
var all = (health && health.presets) ? health.presets : []
|
|
for (var i = 0; i < all.length; i++) if (String(all[i].name) === name) return all[i]
|
|
return null
|
|
}
|
|
|
|
// `from` is the stage to come back to - the preset editor, when opened from
|
|
// there; otherwise the list, or the switch that was blocked.
|
|
property string serverReturn: ""
|
|
|
|
function openServerSettings(presetName, from) {
|
|
var e = root.presetEntry(presetName)
|
|
root.serverReturn = from || ""
|
|
root.serverPreset = presetName
|
|
root.serverProvider = e ? String(e.provider) : ""
|
|
var p = root.pInfo(root.serverProvider)
|
|
root.serverDefault = p ? String(p.defaultBaseUrl || "") : "http://127.0.0.1:1234"
|
|
root.serverHint = p && p.serverHint ? String(p.serverHint)
|
|
: "The server does not have to be on this machine. Point this at a LAN address, or anything reachable through a tunnel or proxy."
|
|
root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverDefault
|
|
root.serverNeedsKey = !!(e && String(e.authMode) === "vault")
|
|
root.serverKeyRef = e && e.keyRef ? String(e.keyRef)
|
|
: (p && p.defaultKeyRef ? String(p.defaultKeyRef) : (root.serverProvider || "lmstudio"))
|
|
root.blocker = null
|
|
root.stage = "server"
|
|
}
|
|
|
|
// Two writes, sequenced: the URL first, then the auth block, then health.json
|
|
// is refreshed so the form and the rest of the panel agree about what the
|
|
// preset now says.
|
|
function saveServerSettings() {
|
|
if (root.busy || root.serverPreset === "") return
|
|
root.busy = true
|
|
urlProc.command = root.cli(["preset", "url", root.serverPreset, root.serverUrl.trim()])
|
|
urlProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: urlProc
|
|
running: false
|
|
onExited: function (code) {
|
|
authProc.command = root.cli(root.serverNeedsKey
|
|
? ["preset", "auth", root.serverPreset, "key", root.serverKeyRef]
|
|
: ["preset", "auth", root.serverPreset, "none"])
|
|
authProc.running = true
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: authProc
|
|
running: false
|
|
onExited: function (code) {
|
|
healthProc.command = root.cli(["health"])
|
|
healthProc.running = true
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: healthProc
|
|
running: false
|
|
onExited: function (code) {
|
|
root.busy = false
|
|
if (widget) widget.refresh()
|
|
// Straight back into the switch that was blocked, if there was one -
|
|
// the whole point of coming here was to unblock it.
|
|
if (root.pendingMode !== "") root.retryPending()
|
|
else root.stage = root.serverReturn !== "" ? root.serverReturn : "list"
|
|
}
|
|
}
|
|
|
|
// ---- Broken transcripts
|
|
//
|
|
// Repair truncates a transcript back to its last resumable point. That is
|
|
// destructive enough to confirm, and reversible enough to offer: the original
|
|
// is backed up, the cut turns are written out as Markdown, and the same text
|
|
// is handed back to the session so it still knows what it did.
|
|
readonly property var broken: widget && widget.brokenSessions ? widget.brokenSessions : []
|
|
property var repairTarget: null
|
|
|
|
function askRepair(entry) {
|
|
root.repairTarget = entry
|
|
root.stage = "repair"
|
|
}
|
|
|
|
function doRepair() {
|
|
if (root.busy || !root.repairTarget) return
|
|
root.busy = true
|
|
repairProc.command = root.cli(["repair-session", String(root.repairTarget.sessionId), "--apply"])
|
|
repairProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: repairProc
|
|
running: false
|
|
stderr: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
|
|
if (msg !== "") root.lastError = msg.split("\n").pop().trim()
|
|
}
|
|
}
|
|
onExited: function (code) {
|
|
root.busy = false
|
|
root.repairTarget = null
|
|
root.stage = "list"
|
|
// The scan is what drives the bar's warning dot, so re-run it rather than
|
|
// trusting this side to have guessed the new state.
|
|
if (widget) widget.scanSessions()
|
|
}
|
|
}
|
|
|
|
// ---- Hidden transcripts
|
|
//
|
|
// Ignoring only changes whether the scan counts a session - the transcript is
|
|
// not touched and Restore is one click away - so unlike Repair it needs no
|
|
// confirmation. Age-hidden ones are never called ignored: nobody chose that.
|
|
readonly property var ignored: widget && widget.ignoredSessions ? widget.ignoredSessions : []
|
|
readonly property var dismissedList: ignored.filter(function (e) { return String(e.reason) === "dismissed" })
|
|
readonly property var staleList: ignored.filter(function (e) { return String(e.reason) !== "dismissed" })
|
|
readonly property int ageDays: widget ? widget.ignoreAgeDays : 7
|
|
property bool hiddenExpanded: false
|
|
// The card clamps rather than scrolls, so a long list would be cut off at
|
|
// the bottom edge instead of becoming reachable. The CLI has the full list.
|
|
readonly property int hiddenCap: 4
|
|
|
|
function projectLabel(p) { return String(p).replace(/^-/, "").replace(/-/g, "/") }
|
|
|
|
function setIgnored(entry, on) {
|
|
if (root.busy || !entry) return
|
|
root.busy = true
|
|
root.lastError = ""
|
|
ignoreProc.command = root.cli(["repair-session", on ? "--ignore" : "--unignore", String(entry.sessionId)])
|
|
ignoreProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: ignoreProc
|
|
running: false
|
|
stderr: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
|
|
var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" })
|
|
if (lines.length > 0) root.lastError = lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim()
|
|
}
|
|
}
|
|
onExited: function (code) {
|
|
root.busy = false
|
|
if (widget) widget.scanSessions()
|
|
}
|
|
}
|
|
|
|
// ---- Edits
|
|
//
|
|
// One chain for every edit the panel makes: each step runs only if the one
|
|
// before it succeeded, the first failure stops it and its last stderr line
|
|
// becomes the error, and health.json is rewritten at the end either way, so
|
|
// the panel shows what the files now say rather than what was intended.
|
|
property var opQueue: []
|
|
property string opLabel: ""
|
|
property string opLanding: ""
|
|
property var opThen: null // run on success, before landing
|
|
|
|
function runOps(label, ops, landing, then) {
|
|
if (root.busy) return
|
|
root.busy = true
|
|
root.lastError = ""
|
|
root.opLabel = label
|
|
root.opLanding = landing
|
|
root.opThen = then || null
|
|
root.opQueue = ops.slice()
|
|
root.nextOp()
|
|
}
|
|
|
|
function nextOp() {
|
|
var q = root.opQueue.slice()
|
|
var argv = q.length > 0 ? q.shift() : null
|
|
root.opQueue = q
|
|
opProc.failed = false
|
|
opProc.lastLine = ""
|
|
opProc.finalHealth = argv === null
|
|
opProc.command = root.cli(argv === null ? ["health"] : argv)
|
|
opProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: opProc
|
|
running: false
|
|
property bool failed: false
|
|
property bool finalHealth: false
|
|
property string lastLine: ""
|
|
stderr: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
|
|
var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" })
|
|
opProc.lastLine = lines.length > 0
|
|
? lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim() : ""
|
|
// The stream can finish after the exit it belongs to.
|
|
if (opProc.failed && opProc.lastLine !== "") root.lastError = opProc.lastLine
|
|
}
|
|
}
|
|
onExited: function (code) {
|
|
if (opProc.finalHealth) {
|
|
root.busy = false
|
|
if (widget) widget.refresh()
|
|
var then = root.opThen
|
|
root.opThen = null
|
|
if (root.lastError === "") {
|
|
if (then) then()
|
|
root.stage = root.opLanding
|
|
}
|
|
return
|
|
}
|
|
if (code !== 0) {
|
|
opProc.failed = true
|
|
root.lastError = opProc.lastLine !== "" ? opProc.lastLine : root.opLabel + " failed"
|
|
root.opQueue = []
|
|
}
|
|
Qt.callLater(root.nextOp)
|
|
}
|
|
}
|
|
|
|
// ---- Preset editor
|
|
//
|
|
// Stages rather than one form, because the card clamps to its content instead
|
|
// of scrolling and a long form would simply be cut off:
|
|
//
|
|
// list -> preset -> presetTier -> preset
|
|
//
|
|
// The tier names live here rather than in Modes.js on purpose: that file is a
|
|
// .pragma library, cached until the shell restarts, and a stale copy without
|
|
// them would render an empty editor.
|
|
readonly property var tierNames: ["opus", "sonnet", "haiku", "fable"]
|
|
property string editPreset: ""
|
|
property string editTier: ""
|
|
property string editModel: ""
|
|
|
|
// Which preset `claude-mode <provider>` picks when none is named. The CLI
|
|
// publishes it now that it can be chosen; the built-in names in Modes.js
|
|
// are only the fallback for a health.json written by an older CLI.
|
|
readonly property var defaultPresets: {
|
|
var out = {}
|
|
var builtin = Modes.DEFAULT_PRESET
|
|
for (var b in builtin) out[b] = builtin[b]
|
|
var pub = health && health.defaultPresetFor ? health.defaultPresetFor : null
|
|
if (pub) for (var p in pub) if (pub[p]) out[p] = pub[p]
|
|
return out
|
|
}
|
|
readonly property var chosenDefaults: health && health.defaultPresetChosen ? health.defaultPresetChosen : ({})
|
|
|
|
readonly property var editEntry: root.editPreset !== "" ? root.presetEntry(root.editPreset) : null
|
|
readonly property bool editIsDefault: root.editPreset !== "" && root.defaultPresets[root.editProvider] === root.editPreset
|
|
readonly property bool editIsChosenDefault: root.editPreset !== "" && root.chosenDefaults[root.editProvider] === root.editPreset
|
|
|
|
function setDefault(on) {
|
|
root.runOps("saving", [on ? ["preset", "default", root.editProvider, root.editPreset]
|
|
: ["preset", "default", root.editProvider, "--clear"]], "preset")
|
|
}
|
|
readonly property string editProvider: editEntry ? String(editEntry.provider) : ""
|
|
readonly property bool editIsActive: root.mode !== "anthropic" && root.editPreset !== "" && root.editPreset === root.preset
|
|
|
|
function editCurrent(tier) {
|
|
var e = root.editEntry
|
|
return e && e.models && e.models[tier] ? String(e.models[tier]) : ""
|
|
}
|
|
|
|
function openPresetEditor(name) {
|
|
root.lastError = ""
|
|
root.editPreset = name
|
|
root.editTier = ""
|
|
root.stage = "preset"
|
|
}
|
|
|
|
// The field starts empty rather than holding the current id: it doubles as
|
|
// the list's filter, and a pre-filled id would narrow the list to itself.
|
|
// The current mapping is shown on the line above instead.
|
|
function openTierEditor(tier) {
|
|
root.lastError = ""
|
|
root.editTier = tier
|
|
root.editModel = ""
|
|
// 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.perServerCatalogue(root.editProvider)) {
|
|
var want = root.editEntry && root.editEntry.baseUrl ? String(root.editEntry.baseUrl).replace(/\/+$/, "") : ""
|
|
if (String(n.baseUrl || "") !== want) return null
|
|
}
|
|
return n
|
|
}
|
|
|
|
readonly property var modelOptions: {
|
|
var n = root.catalogueNode
|
|
var ms = n && n.models ? n.models : []
|
|
var out = []
|
|
for (var i = 0; i < ms.length; i++) {
|
|
var m = ms[i]
|
|
var bits = []
|
|
if (m.contextTokens) bits.push(Modes.contextLabel(m.contextTokens))
|
|
if (m.priceIn !== undefined && m.priceIn !== null)
|
|
bits.push("$" + m.priceIn + " in / $" + m.priceOut + " out per 1M")
|
|
if (m.state) bits.push(String(m.state))
|
|
if (m.note) bits.push(String(m.note))
|
|
out.push({ value: String(m.id), label: String(m.id), description: bits.join(" · ") })
|
|
}
|
|
return out
|
|
}
|
|
|
|
function ageLabel(iso) {
|
|
var t = Date.parse(String(iso || ""))
|
|
if (isNaN(t)) return ""
|
|
var s = Math.max(0, (Date.now() - t) / 1000)
|
|
if (s < 90) return "just now"
|
|
if (s < 5400) return Math.round(s / 60) + " min ago"
|
|
if (s < 129600) return Math.round(s / 3600) + " h ago"
|
|
return Math.round(s / 86400) + " days ago"
|
|
}
|
|
|
|
// ---- Preset lifecycle: new, duplicate, rename, delete
|
|
//
|
|
// Names become file names, so the CLI's valid_preset_name rule is checked
|
|
// here too. The CLI checks again; this only says what is wrong while typing.
|
|
readonly property var presetNameRe: /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/
|
|
property string newProvider: ""
|
|
property string newFrom: "" // source preset to copy; "" = blank template
|
|
property string newName: ""
|
|
property string renameTo: ""
|
|
|
|
function presetsOf(provider) {
|
|
var all = (health && health.presets) ? health.presets : []
|
|
return all.filter(function (p) { return String(p.provider) === provider })
|
|
}
|
|
|
|
// `keep` is a name that is allowed to exist already - the preset's own,
|
|
// when renaming.
|
|
function nameProblem(name, keep) {
|
|
var n = String(name || "").trim()
|
|
if (n === "") return "Give it a name."
|
|
if (!root.presetNameRe.test(n)) return "Letters, digits, . _ and - only, not starting with a dot."
|
|
if (n !== keep && root.presetEntry(n)) return "There is already a preset called '" + n + "'."
|
|
return ""
|
|
}
|
|
|
|
function uniqueName(base) {
|
|
var n = base
|
|
for (var i = 2; root.presetEntry(n); i++) n = base + "-" + i
|
|
return n
|
|
}
|
|
|
|
// `from` null means "whatever makes sense": the provider's first preset,
|
|
// which carries a working server, key and context, else a blank template.
|
|
function openNewPreset(provider, from) {
|
|
root.lastError = ""
|
|
root.newProvider = provider
|
|
var sibs = root.presetsOf(provider)
|
|
root.newFrom = from !== null && from !== undefined ? from : (sibs.length > 0 ? String(sibs[0].name) : "")
|
|
root.newName = root.uniqueName(from ? from + "-copy" : provider + "-new")
|
|
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: root.pLogo(root.mode) !== ""
|
|
pathData: root.pLogo(root.mode)
|
|
opticalScale: root.pLogoScale(root.mode)
|
|
color: heroGlyph.tint
|
|
iconSize: Style.font.display
|
|
}
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
visible: root.pLogo(root.mode) === ""
|
|
text: root.pGlyph(root.mode)
|
|
color: heroGlyph.tint
|
|
font.family: root.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: root.pTitle(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 : root.pBlurb(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" ? root.modeOrder : []
|
|
|
|
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: root.pLogo(modeEntry.thisMode)
|
|
opticalScale: root.pLogoScale(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: root.pTitle(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: root.pBlurb(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 " + root.pTitle(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 " + root.pTitle(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.serverEditable(root.pendingMode)
|
|
&& !(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 a server provider 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: root.serverHint
|
|
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.serverDefault !== "" ? root.serverDefault : "https://…"
|
|
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)
|
|
|
|
// A custom endpoint has no usual address to go back to.
|
|
PillButton {
|
|
label: "Use local default"
|
|
visible: root.serverDefault !== ""
|
|
onTriggered: { root.serverUrl = root.serverDefault; urlField.text = root.serverDefault }
|
|
}
|
|
}
|
|
|
|
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 " + root.pTitle(root.serverProvider) + "'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 + "' · " + root.pTitle(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.serverEditable(root.editProvider)
|
|
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 " + root.pTitle(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 " + root.pTitle(root.editProvider) + " preset, so "
|
|
+ root.pTitle(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 " + root.pTitle(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 " + root.pTitle(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)
|
|
}
|
|
}
|
|
}
|
|
}
|