Files
claude-mode/omarchy/smoido.claude-mode/BarWidget.qml
T
smoidoandClaude Opus 5 b5824c6611 Providers as data; add Ollama and Custom endpoints
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>
2026-09-15 01:20:33 +03:00

398 lines
14 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 !== ""
// ---- Providers
//
// claude-mode publishes its providers in health.json - how to draw each one
// and which panel features it has - so one added to providers.json appears
// here without this file or Modes.js changing (and Modes.js cannot change
// without a shell restart). Modes.js stays the fallback: for anthropic,
// which is the native login and never in the list, and for a health.json
// written before the list existed. The panel asks through these, too.
readonly property var providerList: health && health.providers ? health.providers : []
readonly property var modeOrder: {
if (providerList.length === 0) return Modes.ORDER
var out = ["anthropic"]
for (var i = 0; i < providerList.length; i++) out.push(String(providerList[i].id))
return out
}
function providerInfo(m) {
for (var i = 0; i < providerList.length; i++) {
if (String(providerList[i].id) === m) return providerList[i]
}
return null
}
function modeTitle(m) { var p = providerInfo(m); return p && p.title ? String(p.title) : Modes.title(m) }
function modeBlurb(m) { var p = providerInfo(m); return p ? String(p.blurb || "") : Modes.blurb(m) }
function modeLogo(m) { var p = providerInfo(m); return p ? String(p.logo || "") : Modes.logo(m) }
function modeLogoScale(m) { var p = providerInfo(m); return p && p.logoScale ? Number(p.logoScale) : Modes.logoScale(m) }
function modeGlyph(m) {
var p = providerInfo(m)
if (p && p.glyph) return String.fromCodePoint(parseInt(String(p.glyph), 16))
return Modes.glyph(m)
}
// 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: modeLogo(mode)
readonly property string glyph: modeGlyph(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: root.modeLogoScale(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 = [root.modeTitle(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)
}
}
}