Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions hermes-status/BarWidget.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.Bar.Extras
import qs.Services.UI
import qs.Widgets

Item {
id: root

property var pluginApi: null
property var hermesService: pluginApi?.mainInstance?.hermesService || null

property ShellScreen screen
property string widgetId: ""
property string section: ""
property int sectionWidgetIndex: -1
property int sectionWidgetsCount: 0

property var cfg: pluginApi?.pluginSettings || ({})
property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})

readonly property bool hideWhenIdle: cfg.hideWhenIdle ?? defaults.hideWhenIdle ?? false
readonly property string status: hermesService?.status ?? "loading"
readonly property var usage: hermesService?.usage ?? ({})

readonly property string screenName: screen ? screen.name : ""
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)

// ── Traffic light: icon + color per status ──
readonly property string currentIcon: {
switch (status) {
case "offline": return "power";
case "idle": return "circle-check";
case "busy": return "loader";
case "attention": return "bell-ringing";
case "degraded": return "alert-circle";
case "error": return "alert-triangle";
default: return "help-circle";
}
}

readonly property color iconColor: {
switch (status) {
case "offline": return Color.mError;
case "idle": return Color.mPrimary;
case "busy": return Color.mPrimary;
case "attention": return "#f59e0b";
case "degraded": return "#f97316";
case "error": return Color.mError;
default: return Color.mOnSurface;
}
}

readonly property string displayText: {
if (status === "attention") return "!";
if (status === "degraded") return "!";
// Conversation ended / idle: keep the green status icon, but hide stale
// token usage from the previous turn/session.
if (status === "idle") return "";
return root.usageText;
}

function formatTokens(value) {
var n = Number(value || 0);
if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1) + "k";
return String(Math.round(n));
}

function formatCost(value) {
if (value === undefined || value === null) return "";
var n = Number(value);
if (!isFinite(n) || n <= 0) return "";
if (n < 0.0001) return "$" + n.toFixed(6);
if (n < 0.01) return "$" + n.toFixed(4);
return "$" + n.toFixed(2);
}

readonly property string usageText: {
if (!usage || !usage.available || !usage.total_tokens) return "";
var text = formatTokens(usage.total_tokens) + " tok";
var cost = formatCost(usage.actual_cost_usd ?? usage.estimated_cost_usd);
if (cost !== "") text += " · " + cost;
return text;
}

readonly property bool shouldHide: hideWhenIdle && status === "idle"

implicitWidth: shouldHide ? 0 : pill.width
implicitHeight: shouldHide ? 0 : pill.height
visible: !shouldHide

BarPill {
id: pill
screen: root.screen
oppositeDirection: BarService.getPillDirection(root)
icon: root.currentIcon
text: root.displayText
forceOpen: root.displayText !== ""
autoHide: true
customTextIconColor: root.iconColor

onClicked: {
if (pluginApi) {
if (hermesService && hermesService.needsAttention) {
hermesService.clearAttention();
}
pluginApi.openPanel(root.screen, root);
}
}

onRightClicked: {
PanelService.showContextMenu(contextMenu, root, screen);
}
}

NPopupContextMenu {
id: contextMenu

model: [
{
"label": pluginApi?.tr("menu.refresh"),
"action": "refresh",
"icon": "refresh"
},
{
"label": pluginApi?.tr("menu.clear-attention"),
"action": "clear-attention",
"icon": "bell-off"
}
]

onTriggered: function(action) {
contextMenu.close();
PanelService.closeContextMenu(screen);
if (action === "refresh") {
hermesService?.refresh();
} else if (action === "clear-attention") {
hermesService?.clearAttention();
}
}
}
}
171 changes: 171 additions & 0 deletions hermes-status/Main.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons

Item {
id: root

property var pluginApi: null

// Expose service to bar widget
property alias hermesService: hermesService

// Path to the status check script (~ is expanded by sh)
readonly property string scriptPath: {
var cfg = pluginApi?.pluginSettings || {};
var defaults = pluginApi?.manifest?.metadata?.defaultSettings || {};
return cfg.statusScript ?? defaults.statusScript ?? "~/.config/noctalia/hermes-status-check";
}

QtObject {
id: hermesService

property string status: "idle"
property string gatewayPid: ""
property string cliPid: ""
property bool cliActive: false
property bool needsAttention: false
property var platforms: ({})
property string fetchState: "idle"
property string errorMessage: ""
property string signalEvent: ""
property string signalTs: ""
property var usage: ({})

property bool hasError: {
for (var key in platforms) {
if (platforms[key] && platforms[key].state !== "connected") return true;
}
return false;
}

function refresh() {
fetchState = "loading";
// sh -c expands ~ to home directory
statusProcess.command = ["sh", "-c", root.scriptPath];
statusProcess.running = true;
}

function clearAttention() {
needsAttention = false;
clearAttentionProcess.command = ["rm", "-f", Quickshell.env("HOME") + "/.hermes/needs_attention"];
clearAttentionProcess.running = true;
}
}

function expandHome(path) {
if (!path) return path;
if (path === "~") return Quickshell.env("HOME") || path;
if (path.indexOf("~/") === 0) return (Quickshell.env("HOME") || "") + path.slice(1);
return path;
}

readonly property string signalFilePath: {
var cfg = pluginApi?.pluginSettings || {};
var defaults = pluginApi?.manifest?.metadata?.defaultSettings || {};
return expandHome(cfg.signalFile ?? defaults.signalFile ?? "~/.hermes/status_signal");
}

// Watch the hook signal file for near-instant UI refresh. The timer below is
// still kept as a low-frequency safety net for process/gateway changes that do
// not rewrite status_signal.
FileView {
id: signalFileView
path: root.signalFilePath
printErrors: false
watchChanges: true

onFileChanged: {
reload();
refreshDebounce.restart();
}

onLoaded: refreshDebounce.restart()
onLoadFailed: refreshDebounce.restart()
}

Timer {
id: refreshDebounce
interval: 100
repeat: false
onTriggered: hermesService.refresh()
}

// Status check process
Process {
id: statusProcess
stdout: StdioCollector {}

onExited: function(exitCode) {
if (exitCode !== 0) {
hermesService.fetchState = "error";
hermesService.status = "error";
hermesService.errorMessage = "Script failed (exit " + exitCode + ")";
return;
}

var response = stdout.text;
if (!response || response.trim() === "") {
hermesService.fetchState = "error";
hermesService.status = "error";
hermesService.errorMessage = "Empty response";
return;
}

try {
var data = JSON.parse(response);
hermesService.status = data.status || "unknown";
hermesService.gatewayPid = data.gateway_pid || "";
hermesService.cliPid = data.cli_pid || "";
hermesService.cliActive = data.cli_active || false;
hermesService.needsAttention = data.needs_attention || false;
hermesService.platforms = data.platforms || {};
hermesService.signalEvent = data.signal_event || "";
hermesService.signalTs = data.signal_ts || "";
hermesService.usage = data.usage || {};
hermesService.fetchState = "success";
hermesService.errorMessage = "";
} catch (e) {
hermesService.fetchState = "error";
hermesService.status = "error";
hermesService.errorMessage = "JSON parse error: " + e;
}
}
}

// Clear attention process
Process {
id: clearAttentionProcess
stdout: StdioCollector {}
}

// Poll timer
Timer {
id: pollTimer
repeat: true
running: true
triggeredOnStart: true
interval: {
var cfg = pluginApi?.pluginSettings || {};
var defaults = pluginApi?.manifest?.metadata?.defaultSettings || {};
var secs = cfg.pollInterval ?? defaults.pollInterval ?? 10;
return secs * 1000;
}
onTriggered: hermesService.refresh()
}

IpcHandler {
target: "plugin:hermes-status"
function refresh() {
hermesService.refresh();
}
function toggle() {
if (pluginApi) {
pluginApi.withCurrentScreen(function(screen) {
pluginApi.togglePanel(screen);
});
}
}
}
}
Loading