Files
claude-mode/omarchy/smoido.claude-mode/BarWidget.qml
T
smoido f68ca08009 Omarchy bar widget: show the active provider, and switch from it
A Quickshell plugin for the Omarchy shell (v4's bar is Quickshell, not Waybar).
The icon is the mode; clicking it opens a panel that switches.

State comes from watching health.json rather than polling the CLI, so the widget
costs nothing while idle and a switch made in a terminal shows up in the bar on
its own. state.json is the fallback, because it exists from install onward where
health.json only appears once claude-mode has run.

The marks are the providers' real logos, drawn as vector paths through
QtQuick.Shapes rather than set as font glyphs - three of the four have no Nerd
Font pictograph at all, and paths take the bar's foreground colour and follow
the theme. Claude, OpenRouter and LM Studio from simple-icons, Z.AI from
lobe-icons; trademarks belong to their owners. Zhipu's mark was rejected for
Z.AI: it is a dense lattice that is unreadable below ~40px.

Rendering them at 13px took three fixes. A layer rasterises the Shape at its own
size and then scales the texture, so a 24px buffer minified to 13 resampled two
pixels into one and the Claude burst lost rays; without the layer the scale is a
transform on the geometry and rasterisation happens once, at final resolution.
CurveRenderer replaces the tessellating default. And the size is forced odd,
because a radially symmetric mark puts its vertical and horizontal arms on the
centre line, which is a pixel centre at odd sizes and the seam between two
pixels at even ones, where each arm splits its coverage and greys out. Measured
against a cairo render at the same size, the result is now identical.

Sizing is measured rather than guessed. Every stock glyph in this bar paints
11px of ink; the marks fill their box instead of carrying a font's padding, so
the box is the smaller number. Each mark also carries an optical scale from two
measurements of a 200px render - LM Studio's filled container covers 69% of its
box against ~38% for the others, and Z.AI and OpenRouter are wide-but-short
marks spanning ~84% of the box height.

Choosing a target does not switch immediately. It runs the CLI's own preflight,
and if that refuses, the panel names what is missing and offers the fix - a
terminal for the hidden key prompt, a re-check once a server is up, or the
server form. If sessions are running it lists them by terminal and directory,
marks any mid-request, and asks whether to restart them, close them, or leave
them. The session action is applied strictly after the write, since restarting
first would only bring them back up on the provider just left.

The server form edits an LM Studio preset's base URL and whether it needs an API
key, reachable from the gear on any LM Studio preset row - per row, because two
presets can point at two different machines - and from the failure card. The
shipped local default is untouched unless it is changed.
2026-08-30 21:07:03 +03:00

258 lines
8.9 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 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
}
// 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
}
function refresh() {
healthFile.reload()
stateFile.reload()
if (!root.health) seedProc.running = true
}
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
BrandIcon {
id: icon
anchors.verticalCenter: parent.verticalCenter
visible: root.logo !== ""
pathData: root.logo
opticalScale: Modes.logoScale(root.mode)
color: root.activeColor
iconSize: root.iconPx
}
// 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)
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()
// 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)
}
}
}