Everything on top of a stock Omarchy 4.0.4-1 install, gathered so a second machine can be brought to the same state: the ~/.config files that actually differ from the shipped defaults, the three hand-built themes, the ten shell plugins pinned to commits, the two custom scripts, two user units, and the /etc drop-ins. install.sh applies it all idempotently, backing up anything it overwrites. capture.sh runs the other way so the repo stays the source of truth rather than a snapshot that rots. indie.herdr-hud's ten local-only commits ship as a git bundle plus a working-tree patch, since they stay off the upstream PR deliberately. Secrets, the dotfiles repo's territory (shell/git/mise), the remote-access stack and aether-managed themes are all scoped out; README says why for each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
862 lines
38 KiB
Diff
862 lines
38 KiB
Diff
diff --git i/HerdrHud.qml w/HerdrHud.qml
|
||
index 018311d..d01449c 100644
|
||
--- i/HerdrHud.qml
|
||
+++ w/HerdrHud.qml
|
||
@@ -7,6 +7,7 @@ import Quickshell.Wayland
|
||
import qs.Commons
|
||
import qs.Ui as Ui
|
||
import "Alerts.js" as Alerts
|
||
+import "Roster.js" as Roster
|
||
import "."
|
||
|
||
Item {
|
||
@@ -21,12 +22,13 @@ Item {
|
||
readonly property string pluginId: (manifest && manifest.id) || "indie.herdr-hud"
|
||
readonly property string pluginDir: (manifest && manifest.__sourceDir) || ""
|
||
readonly property string bridgePath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-monitor")).replace(/^file:\/\//, ""))
|
||
+ readonly property string agentsPath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-agents")).replace(/^file:\/\//, ""))
|
||
readonly property string homeDir: Quickshell.env("HOME")
|
||
readonly property string configDir: homeDir + "/.config/herdr-hud-native"
|
||
readonly property string statePath: configDir + "/state.json"
|
||
|
||
property bool opened: false
|
||
- onOpenedChanged: if (opened) clearAlerts()
|
||
+ onOpenedChanged: if (opened) { clearAlerts(); resort() }
|
||
property bool overlayVisible: true
|
||
property bool openingRequested: false
|
||
property bool demoMode: false
|
||
@@ -35,10 +37,136 @@ Item {
|
||
property var agents: []
|
||
property string selectedPane: ""
|
||
property var unread: ({})
|
||
- // Keep Herdr's own order (workspace order). Re-sorting by attention moved
|
||
- // agents as soon as they were selected or changed status, which sent
|
||
- // prompts to the wrong agent; status glyphs and badges show attention.
|
||
- readonly property var sortedAgents: agents
|
||
+ // Herdr's own order (workspace order) by default. Re-sorting live by
|
||
+ // attention moved agents as soon as they were selected or changed status,
|
||
+ // which sent prompts to the wrong agent. So other sort orders are worked out
|
||
+ // only when the panel opens, the sort changes, or agents come or go; a row
|
||
+ // never moves while you look at it. Status glyphs and badges show attention.
|
||
+ property string sortMode: "herdr"
|
||
+ property var rosterOrder: []
|
||
+ property var activity: ({})
|
||
+ readonly property var sortedAgents: sortMode === "herdr" ? agents : Roster.arrange(agents, rosterOrder)
|
||
+
|
||
+ function resort() { rosterOrder = Roster.order(agents, sortMode, activity) }
|
||
+
|
||
+ function cycleSort(step) {
|
||
+ var index = Roster.SORTS.indexOf(sortMode)
|
||
+ sortMode = Roster.SORTS[(index + step + Roster.SORTS.length) % Roster.SORTS.length]
|
||
+ resort()
|
||
+ saveState()
|
||
+ }
|
||
+
|
||
+ // Starting and closing agents goes through bin/herdr-agents.
|
||
+ property bool newAgentOpen: false
|
||
+ property var spawnOptions: null
|
||
+ property string newKind: "claude"
|
||
+ property string newFolder: ""
|
||
+ property string newWorkspace: ""
|
||
+ property bool newAgentBusy: false
|
||
+ property string newAgentError: ""
|
||
+ property string lastKind: ""
|
||
+ property string lastFolder: ""
|
||
+ property string pendingPane: ""
|
||
+ property string confirmClosePane: ""
|
||
+ property string closingPane: ""
|
||
+
|
||
+ function tildePath(path) {
|
||
+ return homeDir && String(path).indexOf(homeDir) === 0 ? "~" + String(path).slice(homeDir.length) : String(path)
|
||
+ }
|
||
+
|
||
+ function expandPath(path) {
|
||
+ var text = String(path || "").trim()
|
||
+ return text === "~" || text.indexOf("~/") === 0 ? homeDir + text.slice(1) : text
|
||
+ }
|
||
+
|
||
+ // IPC entry point too: omarchy-shell shell call indie.herdr-hud newAgent '{}'
|
||
+ function newAgent(_arg) {
|
||
+ if (!(opened && overlayVisible)) open("{}")
|
||
+ openNewAgent()
|
||
+ }
|
||
+
|
||
+ function openNewAgent() {
|
||
+ if (demoMode || optionsProc.running) return
|
||
+ newAgentOpen = true
|
||
+ newAgentError = ""
|
||
+ spawnOptions = null
|
||
+ optionsProc.exec(["python3", agentsPath, "options"])
|
||
+ }
|
||
+
|
||
+ function applySpawnOptions(raw, error, exitCode) {
|
||
+ if (exitCode !== 0) {
|
||
+ newAgentError = String(error || "Couldn't list the agents you can start.").trim()
|
||
+ return
|
||
+ }
|
||
+ try {
|
||
+ var options = JSON.parse(String(raw || "{}"))
|
||
+ var kinds = options.kinds || []
|
||
+ newKind = kinds.some(function(k) { return k.kind === lastKind }) ? lastKind
|
||
+ : kinds.some(function(k) { return k.kind === "claude" }) ? "claude" : (kinds.length ? kinds[0].kind : "")
|
||
+ // Pick the folder before publishing the options; the form fills in from both.
|
||
+ setNewFolder(lastFolder || (options.folders || [])[0] || homeDir, options)
|
||
+ spawnOptions = options
|
||
+ } catch (parseError) {
|
||
+ newAgentError = "Herdr returned an unreadable response."
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Default to the workspace already working in that folder, else a new one.
|
||
+ function setNewFolder(path, options) {
|
||
+ newFolder = path
|
||
+ var dir = expandPath(path)
|
||
+ var known = options || spawnOptions
|
||
+ var match = (known ? known.workspaces : []).find(function(w) { return w.cwd === dir })
|
||
+ newWorkspace = match ? String(match.id) : ""
|
||
+ }
|
||
+
|
||
+ function folderSuggestions(text) {
|
||
+ if (!spawnOptions) return []
|
||
+ var dir = expandPath(text)
|
||
+ var query = dir.toLowerCase()
|
||
+ return spawnOptions.folders.filter(function(f) { return f !== dir && f.toLowerCase().indexOf(query) >= 0 }).slice(0, 6)
|
||
+ }
|
||
+
|
||
+ function startAgent(name, prompt) {
|
||
+ if (agentsProc.running || !newKind) return
|
||
+ if (!expandPath(newFolder)) { newAgentError = "Pick a folder."; return }
|
||
+ newAgentBusy = true
|
||
+ newAgentError = ""
|
||
+ lastKind = newKind
|
||
+ lastFolder = expandPath(newFolder)
|
||
+ saveState()
|
||
+ agentsProc.action = "start"
|
||
+ agentsProc.exec(["python3", agentsPath, "start", JSON.stringify({
|
||
+ kind: newKind, cwd: expandPath(newFolder), workspace: newWorkspace, name: String(name || ""), prompt: String(prompt || "")
|
||
+ })])
|
||
+ }
|
||
+
|
||
+ function closeAgent(pane) {
|
||
+ if (agentsProc.running) return
|
||
+ confirmClosePane = ""
|
||
+ closingPane = pane
|
||
+ agentsProc.action = "close"
|
||
+ agentsProc.exec(["python3", agentsPath, "close", pane])
|
||
+ }
|
||
+
|
||
+ function applyAgentsAction(action, raw, error, exitCode) {
|
||
+ var result = ({})
|
||
+ try { result = JSON.parse(String(raw || "{}")) } catch (parseError) { result = ({}) }
|
||
+ var message = String(error || "").trim()
|
||
+ if (action === "start") {
|
||
+ newAgentBusy = false
|
||
+ if (exitCode !== 0) { newAgentError = message || "The agent didn't start."; return }
|
||
+ newAgentOpen = false
|
||
+ pendingPane = String(result.pane_id || "")
|
||
+ noticeText = result.blocked ? "It's asking something before it starts. Answer it in the terminal."
|
||
+ : result.prompt_error ? "Started, but the first message didn't go through: " + result.prompt_error : ""
|
||
+ } else {
|
||
+ closingPane = ""
|
||
+ if (exitCode !== 0) noticeText = message || "Couldn't close that agent."
|
||
+ else noticeText = result.stopped_session ? "Stopped the Claude background session. Its conversation is kept." : ""
|
||
+ }
|
||
+ refreshRoster()
|
||
+ }
|
||
property var lastSequence: ({})
|
||
property var alertQueue: []
|
||
property bool alertsEnabled: true
|
||
@@ -190,6 +318,9 @@ Item {
|
||
if (Number(parsed.rosterWidth) > 0) rosterWidth = clamp(Number(parsed.rosterWidth), 150, 330)
|
||
if (Number(parsed.panelWidth) > 0) panelWidth = Math.max(minPanelWidth, Number(parsed.panelWidth))
|
||
if (Number(parsed.panelHeight) > 0) panelHeight = Math.max(minPanelHeight, Number(parsed.panelHeight))
|
||
+ if (Roster.SORTS.indexOf(parsed.sortMode) >= 0) sortMode = parsed.sortMode
|
||
+ if (typeof parsed.lastKind === "string") lastKind = parsed.lastKind
|
||
+ if (typeof parsed.lastFolder === "string") lastFolder = parsed.lastFolder
|
||
}
|
||
} catch (error) {
|
||
positions = ({})
|
||
@@ -209,7 +340,10 @@ Item {
|
||
positions: positions,
|
||
rosterWidth: Math.round(rosterWidth),
|
||
panelWidth: Math.round(panelWidth),
|
||
- panelHeight: Math.round(panelHeight)
|
||
+ panelHeight: Math.round(panelHeight),
|
||
+ sortMode: sortMode,
|
||
+ lastKind: lastKind,
|
||
+ lastFolder: lastFolder
|
||
}, null, 2) + "\n")
|
||
}
|
||
|
||
@@ -486,9 +620,10 @@ Item {
|
||
return muted
|
||
}
|
||
|
||
- function agentName(agent) {
|
||
- if (!agent) return "Agent"
|
||
- var type = String(agent.agent || "agent")
|
||
+ function agentName(agent) { return Roster.displayName(agent) }
|
||
+
|
||
+ function agentKind(agent) {
|
||
+ var type = String(agent ? agent.agent || "agent" : "agent")
|
||
return type.charAt(0).toUpperCase() + type.slice(1)
|
||
}
|
||
|
||
@@ -581,6 +716,7 @@ Item {
|
||
var nextUnread = cloneObject(unread)
|
||
var nextSequence = ({})
|
||
var nextWorkingSince = ({})
|
||
+ var nextActivity = ({})
|
||
var live = ({})
|
||
var previousByPane = ({})
|
||
for (var p = 0; p < agents.length; p++) previousByPane[String(agents[p].pane_id || "")] = agents[p]
|
||
@@ -595,13 +731,20 @@ Item {
|
||
nextWorkingSince[identity] = workingSince[identity] || Date.now()
|
||
}
|
||
live[pane] = true
|
||
+ // Last activity: the session store's last write, else the last status change we saw.
|
||
+ var statusChanged = !!previousByPane[pane] && String(previousByPane[pane].agent_status || "") !== status
|
||
+ nextActivity[pane] = Math.max(Number(row.active_at) || 0, activity[pane] || 0, statusChanged ? Date.now() : 0)
|
||
nextSequence[pane] = sequence === null ? (previousSequence === undefined ? 0 : previousSequence) : sequence
|
||
if (status === "working" || (opened && pane === selectedPane)) delete nextUnread[pane]
|
||
else if (isUnreadTransition(previousByPane[pane], row, previousSequence, sequence)) nextUnread[pane] = true
|
||
}
|
||
for (var unreadPane in nextUnread) if (!live[unreadPane]) delete nextUnread[unreadPane]
|
||
var previousAgent = agentForPane(selectedPane)
|
||
+ var paneSetChanged = rows.length !== agents.length
|
||
+ || rows.some(function(row) { return !previousByPane[String(row.pane_id || "")] })
|
||
agents = rows
|
||
+ activity = nextActivity
|
||
+ if (!opened || paneSetChanged) resort()
|
||
workingSince = nextWorkingSince
|
||
activityNow = Date.now()
|
||
var currentAgent = agentForPane(selectedPane)
|
||
@@ -616,7 +759,12 @@ Item {
|
||
}
|
||
completed.forEach(function(agent) { root.queueAlert(agent) })
|
||
errorText = ""
|
||
- if (!agentForPane(selectedPane)) selectAgent(rows.length ? String(rows[0].pane_id || "") : "")
|
||
+ if (pendingPane && live[pendingPane]) {
|
||
+ selectAgent(pendingPane)
|
||
+ pendingPane = ""
|
||
+ }
|
||
+ if (confirmClosePane && !live[confirmClosePane]) confirmClosePane = ""
|
||
+ if (!agentForPane(selectedPane)) selectAgent(rows.length ? String(sortedAgents[0].pane_id || "") : "")
|
||
dataRevision++
|
||
} catch (parseError) {
|
||
alertBaseline = false
|
||
@@ -627,6 +775,86 @@ Item {
|
||
}
|
||
}
|
||
|
||
+ // A small text button for the roster and the new-agent form.
|
||
+ component HudChip: Rectangle {
|
||
+ id: chip
|
||
+ required property var hud
|
||
+ property string text: ""
|
||
+ property bool selected: false
|
||
+ property bool primary: false
|
||
+ property bool danger: false
|
||
+ signal clicked()
|
||
+ signal rightClicked()
|
||
+ implicitWidth: chipLabel.implicitWidth + 18
|
||
+ implicitHeight: 24
|
||
+ radius: hud.cornerRadius
|
||
+ opacity: enabled ? 1 : 0.5
|
||
+ readonly property color tone: danger ? hud.urgent : hud.accent
|
||
+ color: primary ? tone : selected ? hud.alpha(tone, 0.22)
|
||
+ : hud.alpha(hud.foreground, chipMouse.containsMouse ? 0.1 : 0.04)
|
||
+ border.width: 1
|
||
+ border.color: primary || selected || chipMouse.containsMouse ? tone : hud.alpha(hud.foreground, 0.16)
|
||
+ Text {
|
||
+ id: chipLabel
|
||
+ anchors.centerIn: parent
|
||
+ text: chip.text
|
||
+ color: chip.primary ? hud.contrastColor(chip.tone, hud.foreground, hud.background) : chip.selected || chip.danger ? chip.tone : hud.foreground
|
||
+ font.family: hud.chromeFont
|
||
+ font.pixelSize: 11
|
||
+ font.bold: chip.selected || chip.primary
|
||
+ }
|
||
+ MouseArea {
|
||
+ id: chipMouse
|
||
+ anchors.fill: parent
|
||
+ hoverEnabled: true
|
||
+ cursorShape: Qt.PointingHandCursor
|
||
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||
+ onClicked: function(mouse) { mouse.button === Qt.RightButton ? chip.rightClicked() : chip.clicked() }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ component HudField: Rectangle {
|
||
+ id: field
|
||
+ required property var hud
|
||
+ property alias text: input.text
|
||
+ property string placeholder: ""
|
||
+ property bool multiline: false
|
||
+ signal edited()
|
||
+ signal cancelled()
|
||
+ signal submitted()
|
||
+ implicitHeight: multiline ? 70 : 30
|
||
+ radius: hud.cornerRadius
|
||
+ color: hud.alpha(hud.foreground, 0.05)
|
||
+ border.width: 1
|
||
+ border.color: input.activeFocus ? hud.accent : hud.alpha(hud.foreground, 0.18)
|
||
+ clip: true
|
||
+ function focusInput() { input.forceActiveFocus() }
|
||
+ TextEdit {
|
||
+ id: input
|
||
+ anchors.fill: parent
|
||
+ anchors.margins: 7
|
||
+ color: hud.foreground
|
||
+ selectionColor: hud.alpha(hud.accent, 0.4)
|
||
+ font.family: hud.chromeFont
|
||
+ font.pixelSize: 13
|
||
+ wrapMode: field.multiline ? TextEdit.Wrap : TextEdit.NoWrap
|
||
+ selectByMouse: true
|
||
+ onTextChanged: field.edited()
|
||
+ Keys.onEscapePressed: field.cancelled()
|
||
+ // One-line fields submit on Enter; the message field takes newlines.
|
||
+ Keys.onReturnPressed: function(event) { if (field.multiline) event.accepted = false; else field.submitted() }
|
||
+ Keys.onEnterPressed: function(event) { if (field.multiline) event.accepted = false; else field.submitted() }
|
||
+ Text {
|
||
+ anchors.fill: parent
|
||
+ visible: !input.text && !input.activeFocus
|
||
+ text: field.placeholder
|
||
+ color: hud.muted
|
||
+ font: input.font
|
||
+ elide: Text.ElideRight
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
Component.onCompleted: {
|
||
mkdirProc.running = true
|
||
Qt.callLater(root.refreshRoster)
|
||
@@ -689,6 +917,21 @@ Item {
|
||
onExited: function(exitCode) { root.applyRoster(rosterOut.text, rosterErr.text, exitCode) }
|
||
}
|
||
|
||
+ Process {
|
||
+ id: optionsProc
|
||
+ stdout: StdioCollector { id: optionsOut; waitForEnd: true }
|
||
+ stderr: StdioCollector { id: optionsErr; waitForEnd: true }
|
||
+ onExited: function(exitCode) { root.applySpawnOptions(optionsOut.text, optionsErr.text, exitCode) }
|
||
+ }
|
||
+
|
||
+ Process {
|
||
+ id: agentsProc
|
||
+ property string action: ""
|
||
+ stdout: StdioCollector { id: agentsOut; waitForEnd: true }
|
||
+ stderr: StdioCollector { id: agentsErr; waitForEnd: true }
|
||
+ onExited: function(exitCode) { root.applyAgentsAction(action, agentsOut.text, agentsErr.text, exitCode) }
|
||
+ }
|
||
+
|
||
property string previewIdentity: ""
|
||
Process {
|
||
id: alertPreviewProc
|
||
@@ -1034,6 +1277,186 @@ Item {
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
|
||
+ // New agent: covers the roster and terminal until started or cancelled.
|
||
+ Rectangle {
|
||
+ id: newAgentPanel
|
||
+ anchors.fill: parent
|
||
+ z: 3
|
||
+ visible: root.newAgentOpen
|
||
+ color: root.panelFill
|
||
+ radius: root.cornerRadius
|
||
+ border.width: 1
|
||
+ border.color: root.alpha(root.foreground, 0.12)
|
||
+ onVisibleChanged: if (visible) Qt.callLater(function() { nameField.focusInput() })
|
||
+
|
||
+ Connections {
|
||
+ target: root
|
||
+ function onSpawnOptionsChanged() {
|
||
+ if (!root.spawnOptions) return
|
||
+ folderField.text = root.tildePath(root.newFolder)
|
||
+ nameField.text = ""
|
||
+ promptField.text = ""
|
||
+ }
|
||
+ }
|
||
+
|
||
+ MouseArea { anchors.fill: parent } // keep clicks off the terminal underneath
|
||
+
|
||
+ Flickable {
|
||
+ anchors.fill: parent
|
||
+ anchors.margins: 14
|
||
+ contentHeight: newAgentForm.implicitHeight
|
||
+ clip: true
|
||
+ boundsBehavior: Flickable.StopAtBounds
|
||
+ ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||
+
|
||
+ ColumnLayout {
|
||
+ id: newAgentForm
|
||
+ width: parent.width
|
||
+ spacing: 8
|
||
+
|
||
+ Text {
|
||
+ text: "New agent"
|
||
+ color: root.accent
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 15
|
||
+ font.bold: true
|
||
+ }
|
||
+ Text {
|
||
+ Layout.fillWidth: true
|
||
+ visible: !root.spawnOptions
|
||
+ text: root.newAgentError || "Loading…"
|
||
+ color: root.newAgentError ? root.urgent : root.muted
|
||
+ wrapMode: Text.Wrap
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 12
|
||
+ }
|
||
+
|
||
+ ColumnLayout {
|
||
+ Layout.fillWidth: true
|
||
+ visible: !!root.spawnOptions
|
||
+ spacing: 8
|
||
+
|
||
+ Text { text: "AGENT"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||
+ Flow {
|
||
+ Layout.fillWidth: true
|
||
+ spacing: 6
|
||
+ Repeater {
|
||
+ model: root.spawnOptions ? root.spawnOptions.kinds : []
|
||
+ delegate: HudChip {
|
||
+ required property var modelData
|
||
+ hud: root
|
||
+ text: modelData.label
|
||
+ selected: root.newKind === modelData.kind
|
||
+ onClicked: root.newKind = modelData.kind
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ Text { text: "FOLDER"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||
+ HudField {
|
||
+ id: folderField
|
||
+ hud: root
|
||
+ Layout.fillWidth: true
|
||
+ placeholder: "~/Work/project"
|
||
+ onEdited: root.setNewFolder(text)
|
||
+ onCancelled: root.newAgentOpen = false
|
||
+ onSubmitted: nameField.focusInput()
|
||
+ }
|
||
+ Flow {
|
||
+ Layout.fillWidth: true
|
||
+ spacing: 6
|
||
+ Repeater {
|
||
+ model: root.folderSuggestions(folderField.text)
|
||
+ delegate: HudChip {
|
||
+ required property var modelData
|
||
+ hud: root
|
||
+ text: root.tildePath(modelData)
|
||
+ onClicked: {
|
||
+ root.setNewFolder(modelData)
|
||
+ folderField.text = root.tildePath(modelData)
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ Text { text: "WORKSPACE"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||
+ Flow {
|
||
+ Layout.fillWidth: true
|
||
+ spacing: 6
|
||
+ HudChip {
|
||
+ hud: root
|
||
+ text: "New workspace"
|
||
+ selected: root.newWorkspace === ""
|
||
+ onClicked: root.newWorkspace = ""
|
||
+ }
|
||
+ Repeater {
|
||
+ model: root.spawnOptions ? root.spawnOptions.workspaces : []
|
||
+ delegate: HudChip {
|
||
+ required property var modelData
|
||
+ hud: root
|
||
+ text: String(modelData.label)
|
||
+ selected: root.newWorkspace === String(modelData.id)
|
||
+ onClicked: root.newWorkspace = String(modelData.id)
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ Text { text: "NAME · OPTIONAL"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||
+ HudField {
|
||
+ id: nameField
|
||
+ hud: root
|
||
+ Layout.fillWidth: true
|
||
+ placeholder: "e.g. Fix login bug"
|
||
+ onCancelled: root.newAgentOpen = false
|
||
+ onSubmitted: promptField.focusInput()
|
||
+ }
|
||
+
|
||
+ Text { text: "FIRST MESSAGE · OPTIONAL"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
|
||
+ HudField {
|
||
+ id: promptField
|
||
+ hud: root
|
||
+ Layout.fillWidth: true
|
||
+ multiline: true
|
||
+ placeholder: "What should it do?"
|
||
+ onCancelled: root.newAgentOpen = false
|
||
+ }
|
||
+
|
||
+ Text {
|
||
+ Layout.fillWidth: true
|
||
+ visible: !!root.newAgentError
|
||
+ text: root.newAgentError
|
||
+ color: root.urgent
|
||
+ wrapMode: Text.Wrap
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 12
|
||
+ }
|
||
+
|
||
+ RowLayout {
|
||
+ Layout.fillWidth: true
|
||
+ Layout.topMargin: 4
|
||
+ spacing: 8
|
||
+ Text {
|
||
+ Layout.fillWidth: true
|
||
+ text: "Opens as a HERDR tab."
|
||
+ color: root.muted
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 11
|
||
+ elide: Text.ElideRight
|
||
+ }
|
||
+ HudChip { hud: root; text: "Cancel"; onClicked: root.newAgentOpen = false }
|
||
+ HudChip {
|
||
+ hud: root
|
||
+ primary: true
|
||
+ text: root.newAgentBusy ? "Starting…" : "Start agent"
|
||
+ enabled: !root.newAgentBusy && !!root.newKind
|
||
+ onClicked: root.startAgent(nameField.text, promptField.text)
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
spacing: 0
|
||
@@ -1051,15 +1474,36 @@ Item {
|
||
anchors.margins: 8
|
||
spacing: 8
|
||
|
||
- Text {
|
||
+ RowLayout {
|
||
Layout.fillWidth: true
|
||
- text: root.demoMode ? "HERDR · PREVIEW" : root.connected
|
||
- ? "HERDR · " + root.agents.length + " AGENTS" : "HERDR · OFFLINE"
|
||
- color: root.muted
|
||
- font.family: root.chromeFont
|
||
- font.pixelSize: 11
|
||
- font.bold: true
|
||
- leftPadding: 5
|
||
+ spacing: 6
|
||
+ Text {
|
||
+ Layout.fillWidth: true
|
||
+ text: root.demoMode ? "HERDR · PREVIEW" : root.connected
|
||
+ ? "HERDR · " + root.agents.length + " AGENTS" : "HERDR · OFFLINE"
|
||
+ color: root.muted
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 11
|
||
+ font.bold: true
|
||
+ leftPadding: 5
|
||
+ elide: Text.ElideRight
|
||
+ }
|
||
+ HudChip {
|
||
+ hud: root
|
||
+ text: "+ New"
|
||
+ enabled: root.connected && !root.demoMode
|
||
+ selected: root.newAgentOpen
|
||
+ onClicked: root.newAgentOpen ? root.newAgentOpen = false : root.openNewAgent()
|
||
+ }
|
||
+ }
|
||
+
|
||
+ HudChip {
|
||
+ // Click for the next order, right-click for the previous one.
|
||
+ hud: root
|
||
+ Layout.leftMargin: 4
|
||
+ text: "Sort: " + Roster.SORT_LABELS[root.sortMode] + " ⇅"
|
||
+ onClicked: root.cycleSort(1)
|
||
+ onRightClicked: root.cycleSort(-1)
|
||
}
|
||
|
||
ListView {
|
||
@@ -1093,12 +1537,16 @@ Item {
|
||
z: 1
|
||
}
|
||
|
||
+ readonly property string pane: String(modelData.pane_id || "")
|
||
+ readonly property bool confirming: root.confirmClosePane === pane
|
||
+
|
||
MouseArea {
|
||
id: agentMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
- onClicked: root.selectAgent(String(agentRow.modelData.pane_id || ""))
|
||
+ onClicked: root.selectAgent(agentRow.pane)
|
||
}
|
||
+ HoverHandler { id: rowHover }
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
@@ -1123,7 +1571,7 @@ Item {
|
||
|
||
Text {
|
||
Layout.fillWidth: true
|
||
- text: String(agentRow.modelData.workspace_label || "Untitled space")
|
||
+ text: root.agentName(agentRow.modelData)
|
||
color: root.accent
|
||
font.family: root.chromeFont
|
||
font.pixelSize: 15
|
||
@@ -1132,32 +1580,59 @@ Item {
|
||
}
|
||
Text {
|
||
Layout.fillWidth: true
|
||
- // Short line, so the context size isn't elided in a narrow roster.
|
||
+ // Context size first, so a narrow roster elides the workspace instead.
|
||
textFormat: Text.StyledText
|
||
- text: root.escapeHtml(root.agentName(agentRow.modelData))
|
||
- + (root.contextLabel(agentRow.modelData)
|
||
- ? " · <font color=\"" + root.contextColor(agentRow.modelData) + "\"><b>"
|
||
- + root.contextLabel(agentRow.modelData) + "</b></font>"
|
||
+ text: (root.contextLabel(agentRow.modelData)
|
||
+ ? "<font color=\"" + root.contextColor(agentRow.modelData) + "\"><b>"
|
||
+ + root.contextLabel(agentRow.modelData) + "</b></font> · "
|
||
: "")
|
||
+ + root.escapeHtml(String(agentRow.modelData.workspace_label || "Untitled space"))
|
||
+ + (root.agentName(agentRow.modelData) !== root.agentKind(agentRow.modelData)
|
||
+ ? " · " + root.escapeHtml(root.agentKind(agentRow.modelData)) : "")
|
||
color: root.muted
|
||
font.family: root.chromeFont
|
||
font.pixelSize: 12
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
+ visible: !agentRow.confirming
|
||
Layout.fillWidth: true
|
||
- text: (root.unread[String(agentRow.modelData.pane_id || "")] ? "Unseen update · " : "")
|
||
- + root.statusLabel(agentRow.modelData)
|
||
- color: root.unread[String(agentRow.modelData.pane_id || "")] ? root.success : root.muted
|
||
+ text: root.closingPane === agentRow.pane ? "Closing…"
|
||
+ : (root.unread[agentRow.pane] ? "Unseen update · " : "") + root.statusLabel(agentRow.modelData)
|
||
+ color: root.unread[agentRow.pane] ? root.success : root.muted
|
||
font.family: root.chromeFont
|
||
font.pixelSize: 11
|
||
elide: Text.ElideRight
|
||
}
|
||
+ RowLayout {
|
||
+ visible: agentRow.confirming
|
||
+ spacing: 6
|
||
+ HudChip { hud: root; text: "Close agent"; primary: true; danger: true; onClicked: root.closeAgent(agentRow.pane) }
|
||
+ HudChip { hud: root; text: "Keep"; onClicked: root.confirmClosePane = "" }
|
||
+ }
|
||
}
|
||
|
||
+ Text {
|
||
+ // Close, shown on hover; asks before closing.
|
||
+ visible: (rowHover.hovered || agentRow.confirming) && !root.demoMode && root.closingPane !== agentRow.pane
|
||
+ Layout.alignment: Qt.AlignTop
|
||
+ text: "\u00d7"
|
||
+ color: closeMouse.containsMouse || agentRow.confirming ? root.urgent : root.muted
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 16
|
||
+ font.bold: true
|
||
+ MouseArea {
|
||
+ id: closeMouse
|
||
+ anchors.fill: parent
|
||
+ anchors.margins: -4
|
||
+ hoverEnabled: true
|
||
+ cursorShape: Qt.PointingHandCursor
|
||
+ onClicked: root.confirmClosePane = agentRow.confirming ? "" : agentRow.pane
|
||
+ }
|
||
+ }
|
||
Text {
|
||
// Matches the Alt+1-9 shortcut for this agent.
|
||
- visible: agentRow.index < 9
|
||
+ visible: agentRow.index < 9 && !rowHover.hovered && !agentRow.confirming
|
||
Layout.alignment: Qt.AlignTop
|
||
Layout.topMargin: 2
|
||
text: String(agentRow.index + 1)
|
||
diff --git i/README.md w/README.md
|
||
index 685096b..37df3e5 100644
|
||
--- i/README.md
|
||
+++ w/README.md
|
||
@@ -44,6 +44,14 @@ rm -rf ~/.config/herdr-hud-native
|
||
- Approval-dialog keyboard input and TUI rendering
|
||
- Omarchy and WoW designs
|
||
- Agent switching, launcher, alerts, hidden-panel notifications
|
||
+- Agent cards lead with the agent's name (Claude's session name, else the Herdr agent name)
|
||
+- Sort the roster by Herdr order, status, recent activity, name, or workspace (click to cycle,
|
||
+ right-click to go back). Orders other than Herdr's are worked out when the panel opens, the sort
|
||
+ changes, or agents come or go, so a row never moves while you are looking at it
|
||
+- **+ New** starts any installed agent Herdr supports in a new tab: pick the agent, folder, and
|
||
+ workspace, with an optional name and first message. Also `omarchy-shell shell call indie.herdr-hud newAgent '{}'`
|
||
+- Hover a card and click × to close its agent (asks first). A `claude attach` pane's background
|
||
+ session is stopped too, keeping its conversation
|
||
- Safe reconnect, disconnect, bounded read-only monitoring, and dependency errors
|
||
|
||
The plugin supports Omarchy only. It does not provide an HTML renderer, prompt bridge, legacy plugin migration, or independent chat history. Herdr owns terminal controller, resize, scroll, and alternate-screen behavior.
|
||
diff --git i/Roster.js w/Roster.js
|
||
index f88af7c..51ba497 100644
|
||
--- i/Roster.js
|
||
+++ w/Roster.js
|
||
@@ -13,3 +13,53 @@ function sorted(agents, unread) {
|
||
return a.priority - b.priority || a.index - b.index
|
||
}).map(function(item) { return item.agent })
|
||
}
|
||
+
|
||
+// The agent's own name: Claude's session name (its terminal title, or the
|
||
+// claude_name token herdr-web reports), else the Herdr agent name, else its kind.
|
||
+function displayName(agent) {
|
||
+ if (!agent) return "Agent"
|
||
+ var token = agent.tokens && agent.tokens.claude_name
|
||
+ if (token) return String(token)
|
||
+ var title = String(agent.terminal_title_stripped || "").trim()
|
||
+ if (agent.agent === "claude" && title && !/^[^\s@]+@[^\s:]+:/.test(title) && title !== "Claude Code") return title
|
||
+ if (agent.name) return String(agent.name)
|
||
+ var type = String(agent.agent || "agent")
|
||
+ return type.charAt(0).toUpperCase() + type.slice(1)
|
||
+}
|
||
+
|
||
+var SORTS = ["herdr", "status", "recent", "name", "workspace"]
|
||
+var SORT_LABELS = { herdr: "Herdr", status: "Status", recent: "Recent", name: "Name", workspace: "Workspace" }
|
||
+var STATUS_RANK = { blocked: 0, working: 1, done: 2, idle: 3 }
|
||
+
|
||
+// Pane ids in display order. activity maps pane id -> last-activity time (ms).
|
||
+// Ties keep Herdr's order.
|
||
+function order(agents, mode, activity) {
|
||
+ var rows = agents.map(function(agent, index) { return { agent: agent, index: index } })
|
||
+ var name = function(row) { return displayName(row.agent).toLowerCase() }
|
||
+ var compare = {
|
||
+ status: function(a, b) {
|
||
+ var ra = STATUS_RANK[a.agent.agent_status], rb = STATUS_RANK[b.agent.agent_status]
|
||
+ return (ra === undefined ? 4 : ra) - (rb === undefined ? 4 : rb)
|
||
+ },
|
||
+ recent: function(a, b) {
|
||
+ return (activity[b.agent.pane_id] || 0) - (activity[a.agent.pane_id] || 0)
|
||
+ },
|
||
+ name: function(a, b) { return name(a) < name(b) ? -1 : name(a) > name(b) ? 1 : 0 },
|
||
+ workspace: function(a, b) {
|
||
+ var wa = String(a.agent.workspace_label || "").toLowerCase(), wb = String(b.agent.workspace_label || "").toLowerCase()
|
||
+ return wa < wb ? -1 : wa > wb ? 1 : 0
|
||
+ },
|
||
+ }[mode]
|
||
+ if (compare) rows.sort(function(a, b) { return compare(a, b) || a.index - b.index })
|
||
+ return rows.map(function(row) { return String(row.agent.pane_id || "") })
|
||
+}
|
||
+
|
||
+// Agents in a saved order; agents the order doesn't know yet go last, in Herdr order.
|
||
+function arrange(agents, paneOrder) {
|
||
+ var position = {}
|
||
+ paneOrder.forEach(function(pane, index) { position[pane] = index })
|
||
+ return agents.map(function(agent, index) {
|
||
+ var at = position[String(agent.pane_id || "")]
|
||
+ return { agent: agent, key: at === undefined ? paneOrder.length + index : at }
|
||
+ }).sort(function(a, b) { return a.key - b.key }).map(function(row) { return row.agent })
|
||
+}
|
||
diff --git i/bin/herdr-monitor w/bin/herdr-monitor
|
||
index 1944808..aac81b3 100755
|
||
--- i/bin/herdr-monitor
|
||
+++ w/bin/herdr-monitor
|
||
@@ -113,6 +113,7 @@ def roster() -> None:
|
||
item["workspace_label"] = labels.get(workspace_id, workspace_id or "Untitled space")
|
||
item["tab_label"] = tab_labels.get(str(row.get("tab_id", "")), str(row.get("tab_id") or ""))
|
||
item["context_tokens"], item["context_limit"] = context_usage(row)
|
||
+ item["active_at"] = last_active(row)
|
||
merged.append(item)
|
||
response({"ok": True, "agents": merged})
|
||
|
||
@@ -328,6 +329,18 @@ CONTEXT_READERS = {
|
||
}
|
||
|
||
|
||
+def last_active(row: dict) -> float:
|
||
+ """When the agent last wrote to its session store (epoch ms); 0 when unknown."""
|
||
+ session = (row.get("agent_session") or {}).get("value")
|
||
+ if row.get("agent") != "claude" or not session:
|
||
+ return 0
|
||
+ try:
|
||
+ transcript = claude_transcript(str(session))
|
||
+ return transcript.stat().st_mtime * 1000 if transcript else 0
|
||
+ except OSError:
|
||
+ return 0
|
||
+
|
||
+
|
||
def context_usage(row: dict) -> tuple[int, int]:
|
||
"""(context tokens, context window) for an agent; zeros when unknown."""
|
||
reader = CONTEXT_READERS.get(str(row.get("agent", "")))
|
||
diff --git i/tests/test-alerts.mjs w/tests/test-alerts.mjs
|
||
index 9a98741..988cf9c 100644
|
||
--- i/tests/test-alerts.mjs
|
||
+++ w/tests/test-alerts.mjs
|
||
@@ -72,7 +72,8 @@ function unreadContext() {
|
||
Alerts: alerts, demoMode: false, alertBaseline: false, agents: [], unread: {},
|
||
lastSequence: {}, workingSince: {}, opened: false, selectedPane: '',
|
||
activeAlert: null, activeAlertNeedsInput: false, connected: false, errorText: '',
|
||
- dataRevision: 0,
|
||
+ dataRevision: 0, activity: {}, pendingPane: '', confirmClosePane: '', resort() {},
|
||
+ get sortedAgents() { return context.agents; },
|
||
agentForPane(pane) {
|
||
for (const item of context.agents) if (String(item.pane_id || '') === pane) return item;
|
||
return null;
|
||
diff --git i/tests/test-roster.mjs w/tests/test-roster.mjs
|
||
index 9ea8eef..b92cdb2 100644
|
||
--- i/tests/test-roster.mjs
|
||
+++ w/tests/test-roster.mjs
|
||
@@ -22,3 +22,30 @@ test('attention first, then working, then read; stable within groups', () => {
|
||
test('reading a reply moves it down; blocked agents still need attention', () => {
|
||
assert.deepEqual(order({}), ['blocked', 'busy', 'read', 'unread', 'idle']);
|
||
});
|
||
+
|
||
+const crew = [
|
||
+ { pane_id: 'a', agent: 'codex', name: 'zeta', agent_status: 'idle', workspace_label: 'web' },
|
||
+ { pane_id: 'b', agent: 'claude', terminal_title_stripped: 'Alpha task', agent_status: 'working', workspace_label: 'api' },
|
||
+ { pane_id: 'c', agent: 'claude', terminal_title_stripped: 'me@host:~/x', tokens: { claude_name: 'Mid' }, agent_status: 'blocked', workspace_label: 'web' },
|
||
+ { pane_id: 'd', agent: 'hermes', agent_status: 'done', workspace_label: 'api' },
|
||
+];
|
||
+const by = (mode, activity = {}) => Array.from(roster.order(crew, mode, activity));
|
||
+
|
||
+test('display names prefer the Claude session name, then the Herdr name, then the kind', () => {
|
||
+ assert.deepEqual(crew.map(a => roster.displayName(a)), ['zeta', 'Alpha task', 'Mid', 'Hermes']);
|
||
+ assert.equal(roster.displayName({ agent: 'claude', terminal_title_stripped: 'me@host:~/w' }), 'Claude');
|
||
+});
|
||
+
|
||
+test('sort modes; ties keep Herdr order', () => {
|
||
+ assert.deepEqual(by('herdr'), ['a', 'b', 'c', 'd']);
|
||
+ assert.deepEqual(by('status'), ['c', 'b', 'd', 'a']);
|
||
+ assert.deepEqual(by('name'), ['b', 'd', 'c', 'a']);
|
||
+ assert.deepEqual(by('workspace'), ['b', 'd', 'a', 'c']);
|
||
+ assert.deepEqual(by('recent', { d: 30, a: 20, c: 10 }), ['d', 'a', 'c', 'b']);
|
||
+});
|
||
+
|
||
+test('arrange keeps a saved order and appends new agents', () => {
|
||
+ const ids = rows => Array.from(rows, a => a.pane_id);
|
||
+ assert.deepEqual(ids(roster.arrange(crew, ['d', 'b'])), ['d', 'b', 'a', 'c']);
|
||
+ assert.deepEqual(ids(roster.arrange(crew.slice(0, 2), ['d', 'b', 'a'])), ['b', 'a']);
|
||
+});
|
||
diff --git i/tests/test_native.py w/tests/test_native.py
|
||
index baa9589..bd36bab 100644
|
||
--- i/tests/test_native.py
|
||
+++ w/tests/test_native.py
|
||
@@ -260,9 +260,13 @@ class NativeTests(unittest.TestCase):
|
||
self.assertNotIn("QMLTermWidget", source)
|
||
|
||
def test_roster_keeps_herdr_order(self):
|
||
- # Re-sorting by attention moved agents under the cursor on selection.
|
||
+ # Re-sorting live by attention moved agents under the cursor on
|
||
+ # selection. Herdr's order is the default, and other sorts use a saved
|
||
+ # order that only changes on open, a sort change, or agents coming or going.
|
||
source = (NATIVE / "HerdrHud.qml").read_text()
|
||
- self.assertIn("readonly property var sortedAgents: agents\n", source)
|
||
+ self.assertIn('property string sortMode: "herdr"\n', source)
|
||
+ self.assertIn('sortedAgents: sortMode === "herdr" ? agents : Roster.arrange(agents, rosterOrder)\n', source)
|
||
+ self.assertIn("if (!opened || paneSetChanged) resort()", source)
|
||
self.assertNotIn("Roster.sorted(", source)
|
||
|
||
def test_release_structure(self):
|