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 } // 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() 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) } } }