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) - ? " · " - + root.contextLabel(agentRow.modelData) + "" + text: (root.contextLabel(agentRow.modelData) + ? "" + + root.contextLabel(agentRow.modelData) + " · " : "") + + 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):