The gear on every preset row now opens an editor: the four tiers with the model each maps to, and for each one field that filters the provider's catalogue as you type (every word must match; arrows and Enter work), listed inline with context length and price, and that also takes any id typed by hand. LM Studio's server form moves one click inside the editor. The panel card moves from PopupCard to KeyboardPanel. PopupCard is an xdg-popup, which only receives keys after focus is routed through its parent surface, so no text field in it could ever be typed into - the existing server URL form included. KeyboardPanel primes layer-shell keyboard focus on open, which is why every shell panel with a text field uses it. Esc now closes the panel. The picker reads ~/.claude-mode/models-cache.json, which or_catalogue and lms_catalogue now write as a side effect, so models, doctor, setup and the menu's picker all keep it fresh and the panel never hits the network itself. One node per provider with its own fetchedAt/ok; a failed fetch keeps the old list, an LM Studio list is tied to its server, and no key or key name is ever stored. `models` gains --preset, --refresh and --json, and the Z.AI list now lives in one place. A tier edit to the active preset re-applies without the running-sessions prompt: that prompt guards against the endpoint or key moving, and a tier edit moves neither (preset url/auth still ask). A failed re-apply now says the edit was saved. `preset set` on an unknown name no longer creates it. Panel edits run through one chain that stops on the first failure and refreshes health.json at the end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
366 lines
12 KiB
QML
366 lines
12 KiB
QML
import QtQuick
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
import qs.Ui
|
|
import "Modes.js" as Modes
|
|
|
|
// Bar slot for claude-mode: shows which provider the next `claude` launch will
|
|
// use, and opens a panel that switches it.
|
|
//
|
|
// State comes from ~/.claude-mode/health.json, which claude-mode rewrites on
|
|
// every switch and on every `status`. Watching that file rather than polling
|
|
// the CLI keeps this widget at zero cost while idle, and means a switch made
|
|
// in a terminal shows up here without anything having to tell us.
|
|
BarWidget {
|
|
id: root
|
|
moduleName: "smoido.claude-mode"
|
|
|
|
readonly property string cmRoot: String(setting("root", Modes.defaultRoot()))
|
|
// Off by default - the bar is scarce horizontal space and the mark already
|
|
// says which provider is active. Turn it on per-instance in shell.json to get
|
|
// the preset name back beside the icon.
|
|
readonly property bool showLabel: setting("showLabel", false) === true
|
|
|
|
// Whole health.json, or null before the first successful read.
|
|
property var health: null
|
|
// state.json is the narrower fallback: it exists from install onward, where
|
|
// health.json only appears once claude-mode has run at least once. Without
|
|
// this the widget would render "unknown" on a fresh install until the first
|
|
// switch, which is precisely when someone is most likely to look at it.
|
|
property var fallbackState: null
|
|
|
|
readonly property string mode: {
|
|
if (health && health.mode) return String(health.mode)
|
|
if (fallbackState && fallbackState.mode) return String(fallbackState.mode)
|
|
return ""
|
|
}
|
|
readonly property string preset: {
|
|
if (mode === "anthropic") return ""
|
|
if (health && health.preset) return String(health.preset)
|
|
if (fallbackState && fallbackState.preset) return String(fallbackState.preset)
|
|
return ""
|
|
}
|
|
readonly property bool known: mode !== ""
|
|
|
|
// Measured against the neighbours: every stock bar glyph in this shell paints
|
|
// 11px of ink from a 13px font. These marks fill their box rather than
|
|
// carrying a font's internal padding, so the box itself has to be the smaller
|
|
// number or the widget sits visibly larger than everything beside it.
|
|
// Odd on purpose. The marks are radially symmetric, so their vertical and
|
|
// horizontal arms sit on the centre line - which lands on a pixel *centre* at
|
|
// an odd size and on the boundary between two pixels at an even one, where
|
|
// each arm splits its coverage and comes out grey on both sides.
|
|
readonly property int iconPx: {
|
|
var n = Math.round(Number(root.setting("iconSize", Style.bar.iconFont)))
|
|
return n % 2 === 0 ? n + 1 : n
|
|
}
|
|
readonly property string logo: Modes.logo(mode)
|
|
readonly property string glyph: Modes.glyph(mode)
|
|
readonly property string label: Modes.shortLabel(mode, preset)
|
|
|
|
// A gateway mode is spending money or leaning on a local server; native
|
|
// Anthropic is the resting state. Only the former earns the accent, so the
|
|
// bar stays quiet exactly when nothing unusual is configured.
|
|
readonly property bool gateway: known && mode !== "anthropic"
|
|
|
|
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 l = (mx + mn) / 2
|
|
var d = 1 - Math.abs(2 * l - 1)
|
|
return (d > 0.0001 ? (mx - mn) / d : 0) >= 0.15 ? c : "#d2685f"
|
|
}
|
|
|
|
readonly property color activeColor: !known
|
|
? Color.muted
|
|
: (gateway ? Color.accent : (bar ? bar.barForeground : Color.foreground))
|
|
|
|
// The bar's shared tooltip only paints for a target that reports itself
|
|
// hovered through this exact property.
|
|
readonly property bool tooltipHovered: visible && opacity > 0 && hoverHandler.hovered
|
|
|
|
function parseInto(prop, content) {
|
|
try {
|
|
var parsed = JSON.parse(String(content || ""))
|
|
root[prop] = (parsed && typeof parsed === "object") ? parsed : null
|
|
} catch (e) {
|
|
root[prop] = null
|
|
}
|
|
}
|
|
|
|
// `text()` is stale inside onFileChanged, so both first load and every later
|
|
// change are routed through reload() -> onLoaded to always parse fresh bytes.
|
|
FileView {
|
|
id: healthFile
|
|
path: root.cmRoot + "/health.json"
|
|
watchChanges: true
|
|
printErrors: false
|
|
onFileChanged: reload()
|
|
onLoaded: root.parseInto("health", text())
|
|
onLoadFailed: root.health = null
|
|
}
|
|
|
|
FileView {
|
|
id: stateFile
|
|
path: root.cmRoot + "/state.json"
|
|
watchChanges: true
|
|
printErrors: false
|
|
onFileChanged: reload()
|
|
onLoaded: root.parseInto("fallbackState", text())
|
|
onLoadFailed: root.fallbackState = null
|
|
}
|
|
|
|
// The model catalogue the panel's picker offers. claude-mode leaves it
|
|
// behind whenever it fetches a provider's list anyway, so reading it here
|
|
// costs no network - and nothing here polls a provider on a timer.
|
|
property var modelsCache: null
|
|
|
|
FileView {
|
|
id: modelsFile
|
|
path: root.cmRoot + "/models-cache.json"
|
|
watchChanges: true
|
|
printErrors: false
|
|
onFileChanged: reload()
|
|
onLoaded: root.parseInto("modelsCache", text())
|
|
onLoadFailed: root.modelsCache = null
|
|
}
|
|
|
|
// claude-mode writes health.json itself, so a switch launched from the panel
|
|
// lands back here through the FileView above. This only covers the case
|
|
// where the file was never written at all.
|
|
Process {
|
|
id: seedProc
|
|
command: [root.cmRoot + "/bin/claude-mode", "health"]
|
|
running: false
|
|
}
|
|
|
|
// ---- Broken transcripts
|
|
//
|
|
// A session cut short by a mode switch cannot be resumed, and nothing tells
|
|
// you until you try - by which time you have usually forgotten which session
|
|
// it was. So the bar checks periodically and marks itself when there is
|
|
// something to fix.
|
|
//
|
|
// The scan reads the tail of each transcript first and only opens the whole
|
|
// file when the tail already looks wrong, which is what makes it cheap enough
|
|
// to sit on a timer: ~60ms for 59 transcripts here, against 5s for the naive
|
|
// version that read every byte of every one.
|
|
property var brokenSessions: []
|
|
// Dismissed, or broken but untouched for longer than the age limit. Kept
|
|
// out of the dot on purpose: hiding one is how you tell the bar to stop
|
|
// asking about it.
|
|
property var ignoredSessions: []
|
|
property int ignoreAgeDays: 7
|
|
property bool rescanPending: false
|
|
|
|
Process {
|
|
id: scanProc
|
|
running: false
|
|
command: [root.cmRoot + "/bin/claude-mode", "repair-session", "--json"]
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var d = null
|
|
try { d = JSON.parse(String(text || "")) } catch (e) { d = null }
|
|
root.brokenSessions = (d && d.broken) ? d.broken : []
|
|
root.ignoredSessions = (d && d.ignored) ? d.ignored : []
|
|
if (d && d.maxAgeDays !== undefined) root.ignoreAgeDays = Number(d.maxAgeDays)
|
|
}
|
|
}
|
|
// A scan already under way was started before whatever asked for this
|
|
// one, so its answer may predate the change - run once more after it.
|
|
onExited: function (code) {
|
|
if (root.rescanPending) {
|
|
root.rescanPending = false
|
|
Qt.callLater(root.scanSessions)
|
|
}
|
|
}
|
|
}
|
|
|
|
function scanSessions() {
|
|
if (scanProc.running) root.rescanPending = true
|
|
else scanProc.running = true
|
|
}
|
|
|
|
Timer {
|
|
interval: 20 * 60 * 1000
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: root.scanSessions()
|
|
}
|
|
|
|
function refresh() {
|
|
healthFile.reload()
|
|
stateFile.reload()
|
|
modelsFile.reload()
|
|
if (!root.health) seedProc.running = true
|
|
root.scanSessions()
|
|
}
|
|
|
|
Component.onCompleted: Qt.callLater(root.refresh)
|
|
|
|
// ---- Bar content
|
|
|
|
readonly property int gap: Style.space(5)
|
|
|
|
readonly property real iconWidth: root.logo !== "" ? icon.implicitWidth : fallbackIcon.implicitWidth
|
|
|
|
implicitWidth: vertical
|
|
? barSize
|
|
: (iconWidth + (labelText.visible ? gap + labelText.implicitWidth : 0) + Style.space(10))
|
|
implicitHeight: vertical ? (iconPx + Style.space(10)) : barSize
|
|
|
|
Row {
|
|
anchors.centerIn: parent
|
|
spacing: root.gap
|
|
|
|
Item {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: icon.width
|
|
height: icon.height
|
|
visible: root.logo !== ""
|
|
|
|
BrandIcon {
|
|
id: icon
|
|
anchors.fill: parent
|
|
pathData: root.logo
|
|
opticalScale: Modes.logoScale(root.mode)
|
|
color: root.activeColor
|
|
iconSize: root.iconPx
|
|
}
|
|
|
|
// Recolouring the mark would misreport the mode, which is this widget's
|
|
// whole job, so the warning gets its own dot instead.
|
|
Rectangle {
|
|
visible: root.brokenSessions.length > 0
|
|
width: Math.max(4, Math.round(root.iconPx / 3.2))
|
|
height: width
|
|
radius: width / 2
|
|
color: root.urgentColor
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.rightMargin: -Math.round(width / 3)
|
|
anchors.topMargin: -Math.round(width / 4)
|
|
}
|
|
}
|
|
|
|
// Only reached when claude-mode is not installed - there is no brand to
|
|
// draw for "no answer", so the pictograph stands in.
|
|
Text {
|
|
id: fallbackIcon
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.logo === ""
|
|
text: root.glyph
|
|
color: root.activeColor
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.bar.iconFont
|
|
renderType: Text.NativeRendering
|
|
}
|
|
|
|
Text {
|
|
id: labelText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: !root.vertical && root.showLabel && root.label !== ""
|
|
text: root.label
|
|
color: root.activeColor
|
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
|
font.pixelSize: Style.bar.iconFont - 1
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
|
|
// ---- Interaction
|
|
|
|
// The bar decides a module is clickable - and gives it the pointing-hand
|
|
// cursor - purely by whether it exposes triggerPress().
|
|
function triggerPress(button) {
|
|
if (root.bar) root.bar.hideTooltip(root)
|
|
root.togglePanel()
|
|
}
|
|
|
|
function tooltipLabel() {
|
|
if (!root.known) return "claude-mode: not installed"
|
|
var lines = [Modes.title(root.mode)]
|
|
if (root.preset !== "") lines.push("preset " + root.preset)
|
|
var models = root.health && root.health.models ? root.health.models : null
|
|
if (models && models.opus) lines.push("opus " + models.opus)
|
|
if (models && models.sonnet) lines.push("sonnet " + models.sonnet)
|
|
if (root.brokenSessions.length > 0) {
|
|
lines.push(root.brokenSessions.length === 1
|
|
? "1 session needs repair"
|
|
: root.brokenSessions.length + " sessions need repair")
|
|
}
|
|
return lines.join(" · ")
|
|
}
|
|
|
|
function syncTooltip() {
|
|
if (!bar || !hoverHandler.hovered) return
|
|
var text = tooltipLabel()
|
|
if (bar.tooltipTarget === root) {
|
|
if (bar.tooltipText !== text) bar.tooltipText = text
|
|
} else {
|
|
bar.showTooltip(root, text)
|
|
}
|
|
}
|
|
|
|
onHealthChanged: syncTooltip()
|
|
onBrokenSessionsChanged: syncTooltip()
|
|
|
|
// Hover must come from a HoverHandler: once triggerPress() exists the bar's
|
|
// own slot MouseArea accepts hover events and swallows them before any
|
|
// MouseArea here would see them. The call is deferred one turn because
|
|
// `hovered` flips before tooltipHovered has propagated, and showTooltip()
|
|
// silently refuses a target that does not yet report itself hovered.
|
|
HoverHandler {
|
|
id: hoverHandler
|
|
onHoveredChanged: {
|
|
if (!root.bar) return
|
|
if (hovered) Qt.callLater(root.syncTooltip)
|
|
else root.bar.hideTooltip(root)
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.RightButton | Qt.MiddleButton
|
|
onClicked: function(mouse) {
|
|
if (mouse.button === Qt.MiddleButton) root.refresh()
|
|
else if (panelLoader.item) panelLoader.item.switchTo("anthropic", "")
|
|
}
|
|
}
|
|
|
|
// ---- Panel wiring
|
|
|
|
function injectPanel() {
|
|
var target = panelLoader.item
|
|
if (!target) return
|
|
if ("bar" in target) target.bar = root.bar
|
|
if ("settings" in target) target.settings = root.settings
|
|
if ("anchorItem" in target) target.anchorItem = root
|
|
if ("hostWidget" in target) target.hostWidget = root
|
|
if ("widget" in target) target.widget = root
|
|
}
|
|
|
|
function togglePanel() { if (panelLoader.item) panelLoader.item.toggle() }
|
|
|
|
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
|
|
function open() { if (panelLoader.item) panelLoader.item.open() }
|
|
function close() { if (panelLoader.item) panelLoader.item.close() }
|
|
|
|
onBarChanged: injectPanel()
|
|
onSettingsChanged: injectPanel()
|
|
|
|
Loader {
|
|
id: panelLoader
|
|
active: true
|
|
source: Qt.resolvedUrl("Panel.qml")
|
|
visible: false
|
|
onLoaded: {
|
|
root.injectPanel()
|
|
Qt.callLater(root.injectPanel)
|
|
}
|
|
}
|
|
}
|