No error is a dead end: crash, click, and your agent is already looking
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
// The whole story behind the bar's one number.
|
||||
//
|
||||
// The bar shows the fullest window across every agent, because that is the one
|
||||
// about to interrupt you. This is what that number is made of: each agent's
|
||||
// limits with their reset times, what today cost, and where the tokens went.
|
||||
//
|
||||
// A Popover anchored under the widget, the way TrayMenu hangs off a tray icon —
|
||||
// the house pattern for anything that belongs to a bar item rather than to the
|
||||
// shell. Clicking outside closes it; so does Escape, which Popover's focus grab
|
||||
// handles for every popover in the shell.
|
||||
//
|
||||
// Nothing in here animates on a timer. One 30-second tick advances the clock
|
||||
// that "updated 4 minutes ago" and the reset countdowns read, and it only runs
|
||||
// while the panel is open.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Popover {
|
||||
id: root
|
||||
|
||||
implicitWidth: Theme.popoverWidth
|
||||
implicitHeight: body.implicitHeight + contentPadding * 2
|
||||
|
||||
// Which agent's tab is showing. Empty means "whichever is first", so the
|
||||
// panel is never blank because a collector was switched off between opens.
|
||||
property string selectedId: ""
|
||||
|
||||
readonly property var agents: AgentUsage.readyRecords
|
||||
|
||||
readonly property var record: {
|
||||
const list = root.agents;
|
||||
if (list.length === 0)
|
||||
return null;
|
||||
for (const candidate of list)
|
||||
if (candidate.id === root.selectedId)
|
||||
return candidate;
|
||||
return list[0];
|
||||
}
|
||||
|
||||
// One clock for the whole panel, ticking only while it is open. Every
|
||||
// elapsed-time and countdown string in here reads this instead of calling
|
||||
// Date.now() in a binding, which would never invalidate.
|
||||
property double nowMs: Date.now()
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible) {
|
||||
root.nowMs = Date.now();
|
||||
// The local scans can be reused; the limits are what the panel is
|
||||
// being opened to read.
|
||||
AgentUsage.refreshLimits();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 30 * 1000
|
||||
running: root.visible
|
||||
repeat: true
|
||||
onTriggered: root.nowMs = Date.now()
|
||||
}
|
||||
|
||||
// ── Formatting ──────────────────────────────────────────────────────────
|
||||
|
||||
function tokenText(value: double): string {
|
||||
const n = Number(value) || 0;
|
||||
if (n >= 1e9)
|
||||
return (n / 1e9).toFixed(1) + "B";
|
||||
if (n >= 1e6)
|
||||
return (n / 1e6).toFixed(1) + "M";
|
||||
if (n >= 1e3)
|
||||
return Math.round(n / 1e3) + "k";
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
function percentText(fraction: real): string {
|
||||
return Math.round(Math.min(1, Math.max(0, Number(fraction) || 0)) * 100) + "%";
|
||||
}
|
||||
|
||||
function meterColor(fraction: real): color {
|
||||
const percent = (Number(fraction) || 0) * 100;
|
||||
if (percent >= 90)
|
||||
return Theme.danger;
|
||||
if (percent >= 75)
|
||||
return Theme.warn;
|
||||
return Theme.accent;
|
||||
}
|
||||
|
||||
function parseTime(iso: string): double {
|
||||
const parsed = Date.parse(String(iso ?? ""));
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
// "Updated 4 minutes ago". A record with no timestamp says so rather than
|
||||
// implying it is current.
|
||||
function agoText(iso: string): string {
|
||||
const at = root.parseTime(iso);
|
||||
if (at <= 0)
|
||||
return "Never collected";
|
||||
const minutes = Math.floor(Math.max(0, root.nowMs - at) / 60000);
|
||||
if (minutes < 1)
|
||||
return "Updated just now";
|
||||
if (minutes === 1)
|
||||
return "Updated 1 minute ago";
|
||||
if (minutes < 60)
|
||||
return `Updated ${minutes} minutes ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return hours === 1 ? "Updated 1 hour ago" : `Updated ${hours} hours ago`;
|
||||
}
|
||||
|
||||
// "resets 2:40 pm" for something today, "resets Thu" for something further
|
||||
// out. A window whose reset has passed says so rather than counting into
|
||||
// the negative — the collector keeps a cached limit only until its window
|
||||
// rolls over, so this is a record caught mid-rollover.
|
||||
function resetText(iso: string): string {
|
||||
const at = root.parseTime(iso);
|
||||
if (at <= 0)
|
||||
return "";
|
||||
if (at <= root.nowMs)
|
||||
return "resetting";
|
||||
const when = new Date(at);
|
||||
const sameDay = new Date(root.nowMs).toDateString() === when.toDateString();
|
||||
if (sameDay)
|
||||
return "resets " + when.toLocaleTimeString(Qt.locale(), "h:mm ap");
|
||||
return "resets " + when.toLocaleDateString(Qt.locale(), "ddd");
|
||||
}
|
||||
|
||||
function weekdayText(date: string): string {
|
||||
const parts = String(date ?? "").split("-");
|
||||
if (parts.length !== 3)
|
||||
return "";
|
||||
const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||
return when.toLocaleDateString(Qt.locale(), "ddd").slice(0, 2);
|
||||
}
|
||||
|
||||
function isToday(date: string): bool {
|
||||
const parts = String(date ?? "").split("-");
|
||||
if (parts.length !== 3)
|
||||
return false;
|
||||
const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||
return when.toDateString() === new Date(root.nowMs).toDateString();
|
||||
}
|
||||
|
||||
// ── Derived views of the selected record ────────────────────────────────
|
||||
|
||||
readonly property var limits: {
|
||||
const entries = root.record && Array.isArray(root.record.limits) ? root.record.limits : [];
|
||||
return entries.filter(entry => entry && Number.isFinite(Number(entry.percent)));
|
||||
}
|
||||
|
||||
readonly property var days: {
|
||||
const entries = root.record && Array.isArray(root.record.recentDays) ? root.record.recentDays : [];
|
||||
// recentDays.messageCount is a token total, despite the legacy name the
|
||||
// collectors inherited.
|
||||
return entries.map(day => ({
|
||||
date: String(day?.date ?? ""),
|
||||
tokens: Number(day?.messageCount) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
readonly property double dayPeak: {
|
||||
let peak = 0;
|
||||
for (const day of root.days)
|
||||
peak = Math.max(peak, day.tokens);
|
||||
return peak;
|
||||
}
|
||||
|
||||
// Top models by total tokens. Five rows is the whole point of the section:
|
||||
// more than that and it stops being a glance.
|
||||
readonly property var models: {
|
||||
const usage = root.record?.modelUsage;
|
||||
if (!usage || typeof usage !== "object")
|
||||
return [];
|
||||
const rows = [];
|
||||
for (const name of Object.keys(usage)) {
|
||||
const bucket = usage[name] || {};
|
||||
const total = (Number(bucket.inputTokens) || 0)
|
||||
+ (Number(bucket.outputTokens) || 0)
|
||||
+ (Number(bucket.cacheReadInputTokens) || 0)
|
||||
+ (Number(bucket.cacheCreationInputTokens) || 0);
|
||||
if (total > 0)
|
||||
rows.push({ name: name, tokens: total });
|
||||
}
|
||||
rows.sort((a, b) => b.tokens - a.tokens);
|
||||
return rows.slice(0, 5);
|
||||
}
|
||||
|
||||
readonly property double modelPeak: root.models.length > 0 ? root.models[0].tokens : 0
|
||||
|
||||
Column {
|
||||
id: body
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
// ── Agent tabs ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Only worth drawing when there is a choice to make.
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
visible: root.agents.length > 1
|
||||
|
||||
Repeater {
|
||||
model: root.agents
|
||||
|
||||
delegate: Rectangle {
|
||||
id: tab
|
||||
required property var modelData
|
||||
|
||||
readonly property bool current: root.record && root.record.id === tab.modelData.id
|
||||
|
||||
width: (body.width - 6 * (root.agents.length - 1)) / root.agents.length
|
||||
height: 28
|
||||
radius: 9
|
||||
border.width: tab.current ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.3)
|
||||
color: tab.current
|
||||
? Theme.alpha(Theme.accent, 0.14)
|
||||
: (tabMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.05))
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: tab.modelData.name || tab.modelData.id
|
||||
color: tab.current ? Theme.fg : Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: tabMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.selectedId = String(tab.modelData.id ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Who, and how current ────────────────────────────────────────────
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(heroGlyph.implicitHeight, heroName.implicitHeight, tierChip.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: heroGlyph
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F1719}" // md-robot-outline
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
Text {
|
||||
id: heroName
|
||||
anchors.left: heroGlyph.right
|
||||
anchors.leftMargin: 9
|
||||
anchors.right: tierChip.visible ? tierChip.left : parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.record?.name ?? "Agent usage"
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
// The plan, when the collector could name one. It is the only thing
|
||||
// from the credential store allowed into a record.
|
||||
Rectangle {
|
||||
id: tierChip
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: String(root.record?.tierLabel ?? "") !== ""
|
||||
implicitWidth: tierText.implicitWidth + 18
|
||||
implicitHeight: tierText.implicitHeight + 6
|
||||
radius: Theme.pillRadius
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.25)
|
||||
color: Theme.alpha(Theme.accent, 0.1)
|
||||
|
||||
Text {
|
||||
id: tierText
|
||||
anchors.centerIn: parent
|
||||
text: String(root.record?.tierLabel ?? "").toUpperCase()
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.agoText(root.record?.updatedAt)
|
||||
color: Theme.fgMuted
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
// ── An honest word when the numbers are not authoritative ───────────
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
visible: String(root.record?.usageStatusText ?? "") !== ""
|
||||
implicitHeight: statusColumn.implicitHeight + 18
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.warn, 0.12)
|
||||
|
||||
Column {
|
||||
id: statusColumn
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.record?.usageStatusText ?? ""
|
||||
color: Theme.warn
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: String(root.record?.authHelpText ?? "") !== ""
|
||||
text: root.record?.authHelpText ?? ""
|
||||
color: Theme.fgDim
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Limits ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Model-scoped windows sit in the same list as the flat ones: the
|
||||
// collector settles which window an entry belongs to and titles it, so
|
||||
// "Fable Weekly" reads beside "Weekly (7-day)" rather than under it.
|
||||
|
||||
Repeater {
|
||||
model: root.limits
|
||||
|
||||
delegate: Column {
|
||||
id: limitRow
|
||||
required property var modelData
|
||||
|
||||
width: body.width
|
||||
topPadding: 5
|
||||
spacing: 6
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: limitLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: limitLabel
|
||||
anchors.left: parent.left
|
||||
anchors.right: limitValue.left
|
||||
anchors.rightMargin: 8
|
||||
text: limitRow.modelData.label ?? "Limit"
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
id: limitValue
|
||||
anchors.right: parent.right
|
||||
anchors.baseline: limitLabel.baseline
|
||||
text: {
|
||||
const reset = root.resetText(limitRow.modelData.resetsAt);
|
||||
const percent = root.percentText(limitRow.modelData.percent);
|
||||
return reset ? reset + " · " + percent : percent;
|
||||
}
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.features: Theme.tabularFigures
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 8
|
||||
radius: 4
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Rectangle {
|
||||
width: Math.max(0, Math.min(1, Number(limitRow.modelData.percent) || 0)) * parent.width
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
border.width: 0
|
||||
color: root.meterColor(limitRow.modelData.percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
visible: root.days.length > 0 || root.models.length > 0
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
}
|
||||
|
||||
// ── Tokens, last seven days ─────────────────────────────────────────
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
visible: root.dayPeak > 0
|
||||
implicitHeight: dayHeader.implicitHeight + 8 + 56
|
||||
|
||||
Text {
|
||||
id: dayHeader
|
||||
anchors.left: parent.left
|
||||
text: "Tokens, last 7 days"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.baseline: dayHeader.baseline
|
||||
text: "today " + root.tokenText(root.record?.todayTotalTokens ?? 0)
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.features: Theme.tabularFigures
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 56
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: root.days
|
||||
|
||||
delegate: Column {
|
||||
id: dayColumn
|
||||
required property var modelData
|
||||
|
||||
readonly property bool today: root.isToday(dayColumn.modelData.date)
|
||||
|
||||
width: (body.width - 6 * 6) / 7
|
||||
spacing: 4
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 56 - 4 - dayLabel.implicitHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: Math.max(
|
||||
dayColumn.modelData.tokens > 0 ? 2 : 0,
|
||||
root.dayPeak > 0 ? (dayColumn.modelData.tokens / root.dayPeak) * parent.height : 0)
|
||||
radius: 4
|
||||
border.width: 0
|
||||
color: dayColumn.today ? Theme.accent : Theme.alpha(Theme.accent, 0.35)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: dayLabel
|
||||
width: parent.width
|
||||
text: root.weekdayText(dayColumn.modelData.date)
|
||||
color: dayColumn.today ? Theme.fgDim : Theme.fgMuted
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
|
||||
font.weight: dayColumn.today ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Where the tokens went ───────────────────────────────────────────
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.models.length > 0
|
||||
topPadding: 4
|
||||
text: "By model"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.models
|
||||
|
||||
delegate: Item {
|
||||
id: modelRow
|
||||
required property var modelData
|
||||
|
||||
width: body.width
|
||||
implicitHeight: 18
|
||||
|
||||
Text {
|
||||
id: modelName
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 108
|
||||
text: modelRow.modelData.name
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.left: modelName.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: modelValue.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 6
|
||||
|
||||
Rectangle {
|
||||
width: root.modelPeak > 0
|
||||
? Math.max(2, (modelRow.modelData.tokens / root.modelPeak) * parent.width)
|
||||
: 0
|
||||
height: parent.height
|
||||
radius: 3
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.accentAlt, 0.55)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: modelValue
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 52
|
||||
text: root.tokenText(modelRow.modelData.tokens)
|
||||
color: Theme.fgDim
|
||||
horizontalAlignment: Text.AlignRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.features: Theme.tabularFigures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sessions, when that is all there is ─────────────────────────────
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.dayPeak <= 0 && root.models.length === 0 && root.record !== null
|
||||
text: `Sessions today: ${root.record?.todaySessions ?? 0}`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.record === null
|
||||
text: "No collector has anything to report yet."
|
||||
color: Theme.fgDim
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// How much of the Claude subscription is gone, beside the other vitals.
|
||||
// How much of the busiest agent subscription is gone, beside the other vitals.
|
||||
//
|
||||
// One number: whichever window is closer to its limit, because that is the one
|
||||
// about to interrupt you.
|
||||
// One number: whichever window across every collected agent is closest to its
|
||||
// limit, because that is the one about to interrupt you.
|
||||
//
|
||||
// Hidden unless asked for AND the collector has real numbers. A bar indicator
|
||||
// Hidden unless asked for AND a collector has real numbers. A bar indicator
|
||||
// reading "unknown" is worse than an empty space, and this is off by default:
|
||||
// it is a coding-tool readout, not something a general-purpose desktop shows
|
||||
// without being asked.
|
||||
@@ -14,8 +14,8 @@
|
||||
// came out sitting off-centre against the rest of the bar.
|
||||
//
|
||||
// Clickable, because a readout you cannot ask anything of is furniture. Left
|
||||
// click opens the settings that govern it; hovering says which window the
|
||||
// number belongs to and when it resets.
|
||||
// click opens the panel behind the number; right click opens the settings that
|
||||
// govern it.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
@@ -27,7 +27,7 @@ Pill {
|
||||
|
||||
visible: Settings.showAgentUsage && AgentUsage.available
|
||||
|
||||
onActivated: ShellState.openSettings("bar")
|
||||
onActivated: panel.visible = !panel.visible
|
||||
onSecondaryActivated: ShellState.openSettings("bar")
|
||||
|
||||
Row {
|
||||
@@ -58,4 +58,11 @@ Pill {
|
||||
width: 30
|
||||
}
|
||||
}
|
||||
|
||||
// Hangs off this pill the way TrayMenu hangs off a tray icon. A PopupWindow
|
||||
// is not an Item, so it takes no space in Pill's layout Row.
|
||||
AgentUsagePanel {
|
||||
id: panel
|
||||
anchorItem: root
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,17 +90,43 @@ Rectangle {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Everything except the buttons: clicking the body runs the notification's
|
||||
// default action, which is what GNOME does.
|
||||
// A command the notification carried as data, in the `panama-exec` hint.
|
||||
// Panama's escalation ladder rides this: a crash watcher that has already
|
||||
// exited, or an install that failed in a terminal, still gets a clickable
|
||||
// "diagnose this with your agent" -- the command IS the notification, so
|
||||
// nothing has to stay alive to service an action and the click survives a
|
||||
// shell restart. Read once at delivery; see services/Notifs.qml for why
|
||||
// that is safe and what it deliberately does not promise.
|
||||
readonly property string execCommand: Notifs.execCommand(root.notification)
|
||||
|
||||
readonly property bool bodyActivates: root.defaultAction !== null || root.execCommand !== ""
|
||||
|
||||
// Clicking the body runs the notification's default action, which is what
|
||||
// GNOME does. The sender's own action wins when a notification carries
|
||||
// both: an application that registered one is asking for ITS handler, and
|
||||
// the hint exists for senders that cannot stay alive to serve one.
|
||||
//
|
||||
// The command runs through `sh -c` because it arrives as a single string
|
||||
// rather than an argv -- that is the shape the hint can carry. It runs
|
||||
// detached, so a notification click never blocks or outlives the shell.
|
||||
function activateBody(): void {
|
||||
if (root.defaultAction) {
|
||||
root.defaultAction.invoke();
|
||||
return;
|
||||
}
|
||||
if (root.execCommand === "")
|
||||
return;
|
||||
Quickshell.execDetached(["sh", "-c", root.execCommand]);
|
||||
root.dismissed();
|
||||
}
|
||||
|
||||
// Everything except the buttons.
|
||||
MouseArea {
|
||||
id: hover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.defaultAction)
|
||||
root.defaultAction.invoke();
|
||||
}
|
||||
cursorShape: root.bodyActivates ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.activateBody()
|
||||
}
|
||||
|
||||
IconImage {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// Agents.
|
||||
//
|
||||
// Which AI tool answers when the desktop offers to investigate something, what
|
||||
// the desktop is allowed to hand it, and how much of your subscription is left.
|
||||
//
|
||||
// The preferred agent ships as "none" and that is not a placeholder: until one
|
||||
// is chosen, every rung of the escalation ladder stays silent -- a crash
|
||||
// notification carries no action, System Health grows no button, a failed
|
||||
// reload says only that it failed. A desktop that volunteered a tool nobody
|
||||
// installed would be worse than one that says nothing.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "agents-page"
|
||||
|
||||
title: "Agents"
|
||||
lede: "Your AI tools, and what the desktop is allowed to hand them."
|
||||
|
||||
// The options come from the schema rather than from a list here, so this
|
||||
// page cannot offer an agent the preference would refuse.
|
||||
readonly property var agentOptions: PreferenceSchema.spec("preferredAgent")?.options ?? []
|
||||
readonly property string preferred: String(DesktopPreferences.get("preferredAgent") ?? "none")
|
||||
|
||||
// ── Install state, or no claim at all ───────────────────────────────────
|
||||
//
|
||||
// A tile says "Not installed" only once something has actually looked. Any
|
||||
// other order gets it wrong in the direction that matters: a page telling
|
||||
// somebody their agent is missing, when it is sitting right there, teaches
|
||||
// them not to believe the page.
|
||||
//
|
||||
// Probed through a LOGIN shell rather than this one. Quickshell is started
|
||||
// by systemd, whose PATH does not include ~/.local/bin -- where both of
|
||||
// these usually land -- and a login shell is the environment the launcher
|
||||
// hands the agent when it spawns a terminal. Asking with the shell's own
|
||||
// PATH would report "not installed" for an agent that starts perfectly.
|
||||
property var installed: ({})
|
||||
property bool probed: false
|
||||
|
||||
function absorbProbe(text: string): void {
|
||||
const found = {};
|
||||
for (const line of String(text).split("\n")) {
|
||||
const name = line.trim();
|
||||
if (name !== "")
|
||||
found[name] = true;
|
||||
}
|
||||
root.installed = found;
|
||||
root.probed = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: agentProbe
|
||||
|
||||
running: true
|
||||
command: ["bash", "-lc",
|
||||
"for agent in claude codex; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.absorbProbe(this.text)
|
||||
}
|
||||
}
|
||||
|
||||
function iconFor(value: string): string {
|
||||
switch (value) {
|
||||
case "claude": return "starred-symbolic";
|
||||
case "codex": return "utilities-terminal-symbolic";
|
||||
default: return "notifications-disabled-symbolic";
|
||||
}
|
||||
}
|
||||
|
||||
function tileDetail(value: string): string {
|
||||
if (value === "none")
|
||||
return "Stay quiet";
|
||||
if (!root.probed)
|
||||
return "";
|
||||
return root.installed[value] === true ? "Installed" : "Not installed";
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Preferred agent"
|
||||
subtitle: "Who answers when the desktop offers to investigate something. Until one is chosen, crash notifications carry no action — the desktop stays quiet rather than volunteering a tool you do not use."
|
||||
|
||||
// The same tile shape the power profiles use: three rows with the word
|
||||
// "Active" in one of them is a list you have to read to find out what
|
||||
// is set; three tiles with one lit answers that without reading.
|
||||
Flow {
|
||||
id: tiles
|
||||
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
bottomPadding: 12
|
||||
|
||||
readonly property int columns: tiles.width >= 460 ? 3 : 1
|
||||
readonly property real tileWidth:
|
||||
(tiles.width - tiles.spacing * (tiles.columns - 1)) / tiles.columns
|
||||
|
||||
Repeater {
|
||||
model: root.agentOptions
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string value: String(tile.modelData.value)
|
||||
readonly property bool selected: tile.value === root.preferred
|
||||
readonly property string detail: root.tileDetail(tile.value)
|
||||
|
||||
objectName: `agent-tile:${tile.value}`
|
||||
width: tiles.tileWidth
|
||||
implicitHeight: tileBody.implicitHeight + 24
|
||||
radius: Theme.cardRadius
|
||||
color: tile.selected
|
||||
? Theme.alpha(Theme.accent, 0.09)
|
||||
: Theme.alpha(Theme.fg, tileHover.hovered ? 0.08 : 0.04)
|
||||
border.width: tile.selected || tile.activeFocus ? 2 : 1
|
||||
border.color: tile.activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (tile.selected ? Theme.alpha(Theme.accent, 0.6) : Theme.alpha(Theme.fg, 0.08))
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.RadioButton
|
||||
Accessible.name: String(tile.modelData.label)
|
||||
Accessible.description: tile.detail
|
||||
Accessible.checked: tile.selected
|
||||
|
||||
// An agent this machine cannot start is still selectable:
|
||||
// the probe answers for THIS session's login shell, and
|
||||
// being wrong about that must not lock somebody out of a
|
||||
// choice they are entitled to make. The tile says what it
|
||||
// found; the person decides.
|
||||
function choose(): void {
|
||||
if (!tile.selected)
|
||||
SystemSettings.commitPreference("preferredAgent", tile.value);
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: tile.choose()
|
||||
Keys.onSpacePressed: tile.choose()
|
||||
|
||||
Column {
|
||||
id: tileBody
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 6
|
||||
|
||||
ThemedIcon {
|
||||
icon: root.iconFor(tile.value)
|
||||
iconFallback: "system-run-symbolic"
|
||||
size: 20
|
||||
tint: tile.selected ? Theme.accent : Theme.fgDim
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: String(tile.modelData.label)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: tile.detail !== ""
|
||||
text: tile.detail
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: tileHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
tile.choose();
|
||||
tile.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "When something breaks"
|
||||
subtitle: "Each of these is a failure that used to be a dead end. Every one still needs an agent chosen above before it offers anything."
|
||||
|
||||
ToggleRow { setting: "crashDiagnoseOffer" }
|
||||
ToggleRow { setting: "reloadFailureOffer" }
|
||||
ToggleRow { setting: "healthAgentHandoff" }
|
||||
ToggleRow { setting: "agentAutoApprove"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Usage in the bar"
|
||||
subtitle: "The bar shows the fullest limit — the one that stops your next prompt. Clicking it opens the whole picture."
|
||||
|
||||
// The same switch the Bar page carries, deliberately: this is the page
|
||||
// somebody is on when they wonder where the number went, and the Bar
|
||||
// page is the page they are on when they are choosing what the bar
|
||||
// contains. Declared as an intentional mirror in
|
||||
// tests/quickshell/settings-ownership-contract.
|
||||
ToggleRow { setting: "showAgentUsage" }
|
||||
ToggleRow { setting: "agentUsageClaude" }
|
||||
ToggleRow { setting: "agentUsageCodex" }
|
||||
SliderRow { setting: "agentUsageRefreshMinutes"; divider: false }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
@@ -66,6 +67,84 @@ SettingsPage {
|
||||
return detail + " · Repair runs: " + command;
|
||||
}
|
||||
|
||||
// ── The Health rung of the escalation ladder ────────────────────────────
|
||||
//
|
||||
// A red check with nothing to press is where this page used to end. It can
|
||||
// tell you the portals are down; it cannot tell you why, and the honest
|
||||
// next step -- read the journal, correlate against recent updates -- is
|
||||
// precisely the work an agent is good at. So the row grows one more button
|
||||
// carrying what the check knows.
|
||||
//
|
||||
// Offered only where it is the LAST resort. A check that offers a repair
|
||||
// has a better answer than a conversation, right up until that repair has
|
||||
// actually been run and failed.
|
||||
readonly property bool agentHandoffAvailable: {
|
||||
if (DesktopPreferences.get("healthAgentHandoff") !== true)
|
||||
return false;
|
||||
// No agent chosen is the shipped default, and it means what it says:
|
||||
// no button, no offer, the page exactly as it was.
|
||||
const agent = String(DesktopPreferences.get("preferredAgent") ?? "none");
|
||||
return agent !== "" && agent !== "none";
|
||||
}
|
||||
|
||||
// `repairFailed` is the row's own answer rather than a second computation
|
||||
// of it here: the status text beside the button already says "Repair
|
||||
// failed", and two independent readings of one fact is how a button starts
|
||||
// disagreeing with the words next to it.
|
||||
function canAskAgent(check: var, repairFailed: bool): bool {
|
||||
if (!root.agentHandoffAvailable || !check || check.status !== "error")
|
||||
return false;
|
||||
return check.action?.kind !== "repair" || repairFailed === true;
|
||||
}
|
||||
|
||||
// Built from the snapshot this page already holds rather than by shelling
|
||||
// the doctor a second time: these are the same fields `panama doctor check
|
||||
// <id>` returns, and asking twice would only create a way for the two to
|
||||
// disagree. The agent is handed that command anyway, so its first move is
|
||||
// a fresh reading rather than trust in ours.
|
||||
function agentPrompt(check: var, repairFailed: bool): string {
|
||||
const lines = [
|
||||
"A System Health check on this Panama machine is red and I want to know why.",
|
||||
"",
|
||||
"What panama doctor reported:",
|
||||
" check: " + String(check.id) + " (" + String(check.group) + ")",
|
||||
" title: " + String(check.title),
|
||||
" status: " + String(check.status),
|
||||
" detail: " + String(check.detail ?? "")
|
||||
];
|
||||
if (check.repairCommand)
|
||||
lines.push(" repair: " + String(check.repairCommand)
|
||||
+ (repairFailed ? " — run, and it failed" : " — offered, not yet run"));
|
||||
else
|
||||
lines.push(" repair: none offered");
|
||||
lines.push("");
|
||||
lines.push("Start with `panama doctor check " + String(check.id) + "` for the current");
|
||||
lines.push("snapshot, then find the cause: the journal first, then whether a recent");
|
||||
lines.push("package update or configuration change explains it.");
|
||||
lines.push("");
|
||||
lines.push("Diagnosis reads; it does not fix. Anything needing root goes through");
|
||||
lines.push("`panama-sudo --reason \"why\" -- <command>`, so the password prompt says why.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function askAgent(check: var, repairFailed: bool): void {
|
||||
if (!root.canAskAgent(check, repairFailed))
|
||||
return;
|
||||
// Reached by path rather than by name: the shell is started by systemd,
|
||||
// whose environment does not carry the repository's bin directory on
|
||||
// PATH. Same expansion the panama-crash-watch unit uses.
|
||||
Quickshell.execDetached(["sh", "-c",
|
||||
'"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-agent" --prompt '
|
||||
+ root.shellQuote(root.agentPrompt(check, repairFailed))]);
|
||||
}
|
||||
|
||||
// POSIX single-quoting: everything between the quotes is literal, and the
|
||||
// only character needing care is the quote itself. A check's detail is
|
||||
// helper output, not a command, and this keeps it that way.
|
||||
function shellQuote(text: string): string {
|
||||
return "'" + String(text).replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function checksForGroup(group: string): var {
|
||||
return Health.checks.filter(check => check.group === group
|
||||
&& (check.status === "ok" || check.status === "unconfigured"));
|
||||
@@ -160,7 +239,12 @@ SettingsPage {
|
||||
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
|
||||
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
|
||||
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
|
||||
const agentHandoffs = root.descendants(root, "health-ask-agent:").filter(button => button.visible);
|
||||
return {
|
||||
// Empty whenever no agent is chosen, which is the shipped default
|
||||
// and the state this page has to keep behaving exactly as it did.
|
||||
agentHandoffs: agentHandoffs.map(button =>
|
||||
String(button.objectName).slice("health-ask-agent:".length)),
|
||||
renderedRows: rows.map(row => {
|
||||
const objectName = String(row.objectName);
|
||||
const parts = objectName.split(":");
|
||||
@@ -295,16 +379,52 @@ SettingsPage {
|
||||
id: issueRepeater
|
||||
model: root.issueChecks
|
||||
|
||||
HealthCheckRow {
|
||||
// The row plus, where the check has run out of answers,
|
||||
// the handoff button beside it. The row keeps its own
|
||||
// layout and yields the width the button takes, so the
|
||||
// trailing controls never stack on top of each other.
|
||||
Item {
|
||||
id: issueEntry
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool offersAgent:
|
||||
root.canAskAgent(issueEntry.modelData, issueRow.repairFailed)
|
||||
|
||||
width: issueRows.width
|
||||
check: modelData
|
||||
detailText: root.repairDetail(modelData)
|
||||
issue: true
|
||||
divider: index < issueRepeater.count - 1
|
||||
onActionRequested: check => root.handleAction(check)
|
||||
implicitHeight: issueRow.implicitHeight
|
||||
|
||||
HealthCheckRow {
|
||||
id: issueRow
|
||||
|
||||
width: issueEntry.width
|
||||
- (issueEntry.offersAgent ? askAgentButton.width + 12 : 0)
|
||||
check: issueEntry.modelData
|
||||
detailText: root.repairDetail(issueEntry.modelData)
|
||||
issue: true
|
||||
divider: issueEntry.index < issueRepeater.count - 1
|
||||
onActionRequested: check => root.handleAction(check)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: askAgentButton
|
||||
|
||||
objectName: `health-ask-agent:${issueEntry.modelData.id}`
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: issueEntry.offersAgent
|
||||
text: "Ask the agent"
|
||||
enabled: visible && !Health.busy
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: root.askAgent(issueEntry.modelData, issueRow.repairFailed)
|
||||
Keys.onReturnPressed: if (enabled)
|
||||
root.askAgent(issueEntry.modelData, issueRow.repairFailed)
|
||||
Keys.onSpacePressed: if (enabled)
|
||||
root.askAgent(issueEntry.modelData, issueRow.repairFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ the owner instead of duplicating it.
|
||||
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. |
|
||||
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
|
||||
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
|
||||
| `showAgentUsage` | Bar | Agents | Bar decides what the bar contains; Agents holds the collectors and interval the switch governs, and a card you cannot turn off from itself is not a card. |
|
||||
|
||||
Lock-screen visuals belong only to **Appearance**: background source, blur,
|
||||
clock, date, user name, and password-field presentation. **Power** owns when
|
||||
|
||||
@@ -203,6 +203,7 @@ Rectangle {
|
||||
case "containers": return containersPage;
|
||||
case "ssh-keys": return sshKeysPage;
|
||||
case "services": return healthPage;
|
||||
case "agents": return agentsPage;
|
||||
case "manual": return manualPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
@@ -275,6 +276,7 @@ Rectangle {
|
||||
Component { id: privacyPage; PrivacyPage {} }
|
||||
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||
Component { id: healthPage; HealthPage {} }
|
||||
Component { id: agentsPage; AgentsPage {} }
|
||||
Component { id: manualPage; ManualPage {} }
|
||||
Component { id: aboutPage; AboutPage {} }
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ FocusAllowChips 1.0 FocusAllowChips.qml
|
||||
PasswordRow 1.0 PasswordRow.qml
|
||||
PrintersPage 1.0 PrintersPage.qml
|
||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||
AgentsPage 1.0 AgentsPage.qml
|
||||
HealthPage 1.0 HealthPage.qml
|
||||
HealthSummary 1.0 HealthSummary.qml
|
||||
HealthCheckRow 1.0 HealthCheckRow.qml
|
||||
|
||||
Reference in New Issue
Block a user