A broken session that will never be repaired kept the bar's warning dot lit forever. repair-session now takes --ignore/--unignore/--unignore-all/--ignored, recorded in ~/.claude-mode/ignored-sessions.json, and the scan hides broken transcripts older than --max-age days (default 7, 0 disables, CM_IGNORE_AGE_DAYS sets it). Hidden sessions move to a separate ignored[] list with a reason, and --all always names how many it held back. A repair clears the session's dismissal, and entries whose transcript is gone are pruned, so a session that breaks again is never silently hidden. The panel gets an Ignore button beside Repair and a collapsed "hidden (N)" section with Restore. The dot still counts broken sessions only. Bumps to 1.10.0 and fixes the widget manifest, which still said 1.8.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
350 lines
12 KiB
QML
350 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|