Files
omarchy-setup/plugin-patches/indie.herdr-hud-worktree.patch
noconnorandClaude Opus 5 9ac198da26 Capture this laptop's Omarchy customizations
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>
2026-10-10 12:07:15 -04:00

862 lines
38 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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):