The repo was one machine's capture. It is now a set of profiles built from layers under profiles/: machines/ hp-laptop, macbook-air-m2, coder-desktop roles/ developer, devops, ai-agents features/ handoff (lan-mouse) shared base, personal, personal-desktop A profile resolves to its includes (depth first), any --roles, then itself; for a path several layers provide, the last wins. install.sh --show prints the layers and which one owns each file. Hyprland settings no longer share a file: base's hyprland.lua loads ~/.config/hypr/layers/*.lua with Omarchy's own require_all, and bindings.lua stays stock so nothing binds twice. Verified with Hyprland 0.56.2 --verify-config for all three profiles, plus a broken probe layer to prove the directory is loaded. packages.txt gains per-line arch (Arch Linux ARM lacks opencode, codex, mise, shellcheck) and cmd= (skip when dotfiles' ~/.local/bin already provides the command), and an [ignore] section that replaces capture.sh's hard-coded list. Tab-separated records would have collapsed empty fields under read, so package records use the unit separator. hp-laptop: re-captured. monitors.lua catch-all scale is now 1, shell.json has four new plugins, fix-stuck-displays has its connector-name fix, navbar-cat is gone, plugin-manager and omarchy-phone are pinned, the herdr-hud working tree is re-diffed and bin/herdr-web rides along. Its handoff peer is now the Air (192.168.1.49); the old default list was the HP's own addresses, written from the Air's side. macbook-air-m2: captured read-only over SSH as it is (no themes, plugins or bar changes). Its LAN_MOUSE_POINTER_SCALE=0.7 moves from the shared unit into a lan-mouse.service.d drop-in in its own layer. coder-desktop: the Omarchy settings covert-cloud's image used to patch in by hand (Virtual-1 at 1920x1080, no animations, stay-awake, Retro 82 once), for the image to apply at a pinned commit. Roles reach it per workspace via install.sh --roles ... --no-clobber. capture.sh writes each file back into the layer that owns it, and refuses to run before the first layered install unless --force, since the old file shapes would land in shared layers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153egowV1Xw2RNjZnSyZicc
1047 lines
46 KiB
Diff
1047 lines
46 KiB
Diff
diff --git i/HerdrHud.qml w/HerdrHud.qml
|
||
index 018311d..4a73ee0 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,14 @@ 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 webPath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-web")).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 +38,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
|
||
@@ -66,6 +195,10 @@ Item {
|
||
property bool stateReady: false
|
||
|
||
property string uiMode: "omarchy"
|
||
+ // "web" hands agents to the herdr web ui in a browser app window; "terminal" attaches
|
||
+ // the native terminal in the panel. The shell can't host a web view itself.
|
||
+ property string viewMode: "web"
|
||
+ readonly property bool webView: viewMode === "web"
|
||
readonly property bool wowMode: uiMode === "wow"
|
||
readonly property int cornerRadius: wowMode ? 4 : 0
|
||
readonly property string chromeFont: wowMode ? "Georgia" : Style.font.family
|
||
@@ -184,12 +317,16 @@ Item {
|
||
if (parsed && typeof parsed === "object") {
|
||
if (Number.isInteger(parsed.launcherStyle) && parsed.launcherStyle >= 0 && parsed.launcherStyle < 6) launcherStyle = parsed.launcherStyle
|
||
if (parsed.uiMode === "wow" || parsed.uiMode === "omarchy") uiMode = parsed.uiMode
|
||
+ if (parsed.viewMode === "web" || parsed.viewMode === "terminal") viewMode = parsed.viewMode
|
||
if (typeof parsed.alertsEnabled === "boolean") alertsEnabled = parsed.alertsEnabled
|
||
if (typeof parsed.overlayVisible === "boolean") overlayVisible = parsed.overlayVisible
|
||
if (parsed.positions && typeof parsed.positions === "object") positions = parsed.positions
|
||
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 = ({})
|
||
@@ -203,13 +340,17 @@ Item {
|
||
stateFile.setText(JSON.stringify({
|
||
version: 1,
|
||
uiMode: uiMode,
|
||
+ viewMode: viewMode,
|
||
alertsEnabled: alertsEnabled,
|
||
launcherStyle: launcherStyle,
|
||
overlayVisible: overlayVisible,
|
||
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")
|
||
}
|
||
|
||
@@ -367,6 +508,24 @@ Item {
|
||
})
|
||
}
|
||
|
||
+ function toggleViewMode() {
|
||
+ viewMode = webView ? "terminal" : "web"
|
||
+ saveState()
|
||
+ }
|
||
+
|
||
+ // Shows the agent in the herdr web ui and closes the panel, which would cover it.
|
||
+ function openInWeb(pane) {
|
||
+ if (demoMode || !pane) return
|
||
+ Quickshell.execDetached(["python3", webPath, "open", pane])
|
||
+ requestClose()
|
||
+ }
|
||
+
|
||
+ // A card click: in web view it also opens the agent there.
|
||
+ function activateAgent(pane) {
|
||
+ selectAgent(pane)
|
||
+ if (webView) openInWeb(pane)
|
||
+ }
|
||
+
|
||
// Keyboard agent switching, in the roster's displayed order.
|
||
function selectRelative(step) {
|
||
var list = sortedAgents
|
||
@@ -486,9 +645,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)
|
||
}
|
||
|
||
@@ -553,7 +713,8 @@ Item {
|
||
if (!live || live.terminal_id !== agent.terminal_id) { showNextAlert(); return }
|
||
clearAlerts()
|
||
selectAgent(String(agent.pane_id))
|
||
- openOnScreen(screenName)
|
||
+ if (webView) openInWeb(String(agent.pane_id))
|
||
+ else openOnScreen(screenName)
|
||
}
|
||
|
||
function previewAlert(_arg) {
|
||
@@ -581,6 +742,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 +757,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 +785,13 @@ 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)
|
||
+ if (webView && opened) openInWeb(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 +802,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)
|
||
@@ -637,8 +892,8 @@ Item {
|
||
parent: root.viewForScreen(root.panelScreenName)
|
||
? root.viewForScreen(root.panelScreenName).terminalContainer : root
|
||
anchors.fill: parent
|
||
- visible: root.opened && root.overlayVisible
|
||
- target: root.opened && root.overlayVisible && !root.demoMode
|
||
+ visible: root.opened && root.overlayVisible && !root.webView
|
||
+ target: root.opened && root.overlayVisible && !root.demoMode && !root.webView
|
||
? String(root.agentForPane(root.selectedPane)?.terminal_id || "") : ""
|
||
foreground: root.foreground
|
||
muted: root.muted
|
||
@@ -689,6 +944,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
|
||
@@ -786,6 +1056,11 @@ Item {
|
||
enabled: overlayWindow.panelVisible
|
||
onActivated: root.selectRelative(-1)
|
||
}
|
||
+ Shortcut {
|
||
+ sequences: ["Return", "Enter"]
|
||
+ enabled: overlayWindow.panelVisible && root.webView && !root.newAgentOpen && !!root.selectedPane
|
||
+ onActivated: root.openInWeb(root.selectedPane)
|
||
+ }
|
||
Instantiator {
|
||
model: 9
|
||
delegate: Shortcut {
|
||
@@ -991,6 +1266,31 @@ Item {
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
}
|
||
+ Button {
|
||
+ id: viewButton
|
||
+ text: root.webView ? "View: Web" : "View: Terminal"
|
||
+ Layout.preferredWidth: 132
|
||
+ Layout.fillHeight: true
|
||
+ hoverEnabled: true
|
||
+ focusPolicy: Qt.NoFocus
|
||
+ onClicked: root.toggleViewMode()
|
||
+ background: Rectangle {
|
||
+ radius: root.cornerRadius
|
||
+ color: viewButton.down ? root.alpha(root.accent, 0.28)
|
||
+ : root.alpha(root.accent, viewButton.hovered ? 0.18 : 0.08)
|
||
+ border.width: 1
|
||
+ border.color: viewButton.hovered ? root.accent : root.alpha(root.accent, 0.5)
|
||
+ }
|
||
+ contentItem: Text {
|
||
+ text: viewButton.text + " ⇄"
|
||
+ color: root.accent
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 12
|
||
+ font.bold: true
|
||
+ horizontalAlignment: Text.AlignHCenter
|
||
+ verticalAlignment: Text.AlignVCenter
|
||
+ }
|
||
+ }
|
||
Item { Layout.fillWidth: true }
|
||
Text {
|
||
visible: root.wowMode
|
||
@@ -1005,6 +1305,7 @@ Item {
|
||
}
|
||
Button {
|
||
id: disconnectTerminalButton
|
||
+ visible: !root.webView
|
||
text: terminalPane.clientRunning ? "Disconnect" : "Reconnect"
|
||
enabled: !!terminalPane.target && !terminalPane.stopping
|
||
focusPolicy: Qt.NoFocus
|
||
@@ -1034,6 +1335,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 +1532,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 +1595,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.activateAgent(agentRow.pane)
|
||
}
|
||
+ HoverHandler { id: rowHover }
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
@@ -1123,7 +1629,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 +1638,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)
|
||
@@ -1269,6 +1802,46 @@ Item {
|
||
id: terminalSlot
|
||
anchors.fill: parent
|
||
anchors.margins: 6
|
||
+
|
||
+ Column {
|
||
+ id: webPrompt
|
||
+ anchors.centerIn: parent
|
||
+ width: Math.min(parent.width - 32, 360)
|
||
+ spacing: 12
|
||
+ visible: root.webView
|
||
+ readonly property var agent: root.agentForPane(root.selectedPane)
|
||
+ Text {
|
||
+ width: parent.width
|
||
+ text: webPrompt.agent ? root.agentName(webPrompt.agent) : "No agent selected"
|
||
+ color: root.foreground
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 16
|
||
+ font.bold: true
|
||
+ horizontalAlignment: Text.AlignHCenter
|
||
+ elide: Text.ElideRight
|
||
+ }
|
||
+ Text {
|
||
+ width: parent.width
|
||
+ text: "Click an agent, or press Enter, to open it in the herdr web ui."
|
||
+ color: root.muted
|
||
+ font.family: root.chromeFont
|
||
+ font.pixelSize: 12
|
||
+ wrapMode: Text.Wrap
|
||
+ horizontalAlignment: Text.AlignHCenter
|
||
+ }
|
||
+ Row {
|
||
+ anchors.horizontalCenter: parent.horizontalCenter
|
||
+ spacing: 8
|
||
+ HudChip {
|
||
+ hud: root
|
||
+ text: "Open in web ui"
|
||
+ primary: true
|
||
+ enabled: !!webPrompt.agent && !root.demoMode
|
||
+ onClicked: root.openInWeb(root.selectedPane)
|
||
+ }
|
||
+ HudChip { hud: root; text: "Use native terminal"; onClicked: root.toggleViewMode() }
|
||
+ }
|
||
+ }
|
||
}
|
||
}
|
||
|
||
diff --git i/README.md w/README.md
|
||
index 685096b..cb5b0b9 100644
|
||
--- i/README.md
|
||
+++ w/README.md
|
||
@@ -38,12 +38,27 @@ rm -rf ~/.config/herdr-hud-native
|
||
|
||
## Features
|
||
|
||
+- **View: Web** (default) opens the clicked agent, or an alert's agent, in the
|
||
+ [herdr web ui](https://github.com/devswha/herdr-web-ui) as one floating browser app window
|
||
+ (`bin/herdr-web`). Picking another agent replaces it in the same place and size; the panel
|
||
+ closes so it doesn't cover it. The address comes from `~/.config/herdr-hud-native/web.json`
|
||
+ (`{"url": "https://…"}`), else the web ui's own `HOST`/`PORT`. Quickshell can't host a web
|
||
+ view (QtWebEngine aborts in the shell), hence the separate window. **View: Terminal** switches
|
||
+ back to the in-panel terminal
|
||
- Native VT terminal attached to the selected Herdr agent
|
||
- Shift+drag selects terminal text; releasing the mouse copies it to the clipboard and clears the highlight
|
||
- Ctrl+Shift+V or Shift+Insert pastes the clipboard into the focused terminal; Ctrl+V remains available to terminal applications
|
||
- 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):
|