feat(mux): add Herdr backend (#3531)

* mux: add herdr backend

* mux: remove herdr service tests

* mux: include herdr in settings subtitle

* mux: add translator context to the multiplexers subtitle

* mux: drive the service from a backend table

Each backend (tmux, zellij, herdr) is one entry in MuxBackends.js with
its commands and parser, so the service no longer branches on muxType
in eight places and the settings dropdown lists the table keys. One
availability check runs for the selected backend and re-runs when the
backend changes. The herdr parser reads the real {sessions:[...]}
shape only.
This commit is contained in:
Matt Jiang
2026-09-21 15:26:56 -04:00
committed by GitHub
parent e3b4efc498
commit cc8a6053c5
6 changed files with 163 additions and 118 deletions
+77
View File
@@ -0,0 +1,77 @@
.pragma library
// Terminal multiplexer backends for the mux launcher. Each command is an argv
// array. attach and create run inside the user's terminal; the rest run
// headless. rename is optional: backends without it hide the rename action.
// Parsers turn `list` output into [{ name, windows, attached }].
function parseTmux(output) {
return output.trim().split("\n").map(line => line.trim().split("|")).filter(parts => parts.length >= 3 && parts[0]).map(parts => ({
name: parts[0],
windows: parts[1],
attached: parts[2] === "1"
}));
}
function parseZellij(output) {
return output.trim().split("\n").map(line => line.trim()).filter(line => line).map(line => {
const bracketIdx = line.indexOf(" [");
return {
name: (bracketIdx > 0 ? line.substring(0, bracketIdx) : line).trim(),
windows: "N/A",
attached: !line.includes("(EXITED")
};
});
}
function parseHerdr(output) {
return (JSON.parse(output).sessions ?? []).map(session => ({
name: session.name,
windows: "N/A",
attached: session.running === true
}));
}
// var, not const: QML only exposes var and function bindings from .js libraries
var BACKENDS = {
tmux: {
displayName: "Tmux",
list: ["tmux", "list-sessions", "-F", "#{session_name}|#{session_windows}|#{session_attached}"],
parse: parseTmux,
attach: name => ["tmux", "attach", "-t", name],
create: name => ["tmux", "new-session", "-s", name],
kill: name => ["tmux", "kill-session", "-t", name],
rename: (oldName, newName) => ["tmux", "rename-session", "-t", oldName, newName]
},
zellij: {
displayName: "Zellij",
list: ["zellij", "list-sessions", "--no-formatting"],
parse: parseZellij,
attach: name => ["zellij", "attach", name],
create: name => ["zellij", "-s", name],
kill: name => ["zellij", "kill-session", name]
},
herdr: {
displayName: "Herdr",
list: ["herdr", "session", "list", "--json"],
parse: parseHerdr,
attach: name => ["herdr", "session", "attach", name],
create: name => ["herdr", "--session", name],
// stop, not delete: the session stays listed so it can be reattached
kill: name => ["herdr", "session", "stop", name]
}
};
// filter is a comma separated list of exact names or /regex/ patterns.
function isSessionExcluded(name, filter) {
return filter.split(",").map(pattern => pattern.trim()).filter(pattern => pattern).some(pattern => {
if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) {
try {
return new RegExp(pattern.slice(1, -1)).test(name);
} catch (e) {
return false;
}
}
return name.toLowerCase() === pattern.toLowerCase();
});
}
+1 -1
View File
@@ -553,7 +553,7 @@ Singleton {
"text": I18n.tr("Multiplexers", "settings page name for terminal multiplexers like tmux"),
"icon": "terminal",
"tabIndex": 32,
"hint": I18n.tr("tmux and zellij sessions")
"hint": I18n.tr("tmux, zellij, and herdr sessions", "settings page description for terminal multiplexer sessions; tmux, zellij, and herdr are product names")
},
{
"id": "printers",
+4 -3
View File
@@ -3,24 +3,25 @@ import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
import "../../Common/MuxBackends.js" as MuxBackends
Item {
id: root
readonly property var muxTypeOptions: ["tmux", "zellij"]
readonly property var muxTypeOptions: Object.keys(MuxBackends.BACKENDS)
SettingsPage {
id: mainColumn
SettingsCard {
tab: "mux"
tags: ["mux", "multiplexer", "tmux", "zellij", "type"]
tags: ["mux", "multiplexer", "tmux", "zellij", "herdr", "type"]
title: I18n.tr("General")
iconName: "terminal"
SettingsDropdownRow {
tab: "mux"
tags: ["mux", "multiplexer", "tmux", "zellij", "type", "backend"]
tags: ["mux", "multiplexer", "tmux", "zellij", "herdr", "type", "backend"]
settingKey: "muxType"
text: I18n.tr("Type")
options: root.muxTypeOptions
+28 -113
View File
@@ -6,6 +6,7 @@ import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import "../Common/MuxBackends.js" as MuxBackends
Singleton {
id: root
@@ -13,13 +14,12 @@ Singleton {
property var sessions: []
property bool loading: false
property bool tmuxAvailable: false
property bool zellijAvailable: false
readonly property bool currentMuxAvailable: muxType === "zellij" ? zellijAvailable : tmuxAvailable
property bool currentMuxAvailable: false
readonly property string muxType: SettingsData.muxType
readonly property string displayName: muxType === "zellij" ? "Zellij" : "Tmux"
readonly property var backend: MuxBackends.BACKENDS[muxType] ?? MuxBackends.BACKENDS.tmux
readonly property string displayName: backend.displayName
readonly property bool supportsRename: !!backend.rename
readonly property var terminalFlags: ({
"ghostty": ["-e"],
@@ -46,28 +46,24 @@ Singleton {
}
Process {
id: tmuxCheckProcess
command: ["sh", "-c", "command -v tmux"]
id: availabilityCheck
command: ["sh", "-c", "command -v " + root.backend.list[0]]
running: false
onExited: code => {
root.tmuxAvailable = (code === 0);
}
}
Process {
id: zellijCheckProcess
command: ["sh", "-c", "command -v zellij"]
running: false
onExited: code => {
root.zellijAvailable = (code === 0);
root.currentMuxAvailable = (code === 0);
}
}
// Restart so a backend switch mid-check never inherits the old binary's result
function checkAvailability() {
tmuxCheckProcess.running = true;
zellijCheckProcess.running = true;
if (availabilityCheck.running)
availabilityCheck.running = false;
Qt.callLater(function () {
availabilityCheck.running = true;
});
}
onBackendChanged: checkAvailability()
Component.onCompleted: checkAvailability()
Process {
@@ -77,10 +73,7 @@ Singleton {
stdout: StdioCollector {
onStreamFinished: {
try {
if (root.muxType === "zellij")
root._parseZellijSessions(text);
else
root._parseTmuxSessions(text);
root.sessions = root.backend.parse(text).filter(session => !root._isSessionExcluded(session.name));
} catch (e) {
log.error("Error parsing sessions:", e);
root.sessions = [];
@@ -116,119 +109,41 @@ Singleton {
if (listProcess.running)
listProcess.running = false;
if (root.muxType === "zellij")
listProcess.command = ["zellij", "list-sessions", "--no-formatting"];
else
listProcess.command = ["tmux", "list-sessions", "-F", "#{session_name}|#{session_windows}|#{session_attached}"];
listProcess.command = root.backend.list;
Qt.callLater(function () {
listProcess.running = true;
});
}
function _isSessionExcluded(name) {
var filter = SettingsData.muxSessionFilter.trim();
if (filter.length === 0)
return false;
var parts = filter.split(",");
for (var i = 0; i < parts.length; i++) {
var pattern = parts[i].trim();
if (pattern.length === 0)
continue;
if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) {
try {
var re = new RegExp(pattern.slice(1, -1));
if (re.test(name))
return true;
} catch (e) {}
} else {
if (name.toLowerCase() === pattern.toLowerCase())
return true;
}
}
return false;
return MuxBackends.isSessionExcluded(name, SettingsData.muxSessionFilter);
}
function _parseTmuxSessions(output) {
var sessionList = [];
var lines = output.trim().split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (line.length === 0)
continue;
var parts = line.split('|');
if (parts.length >= 3 && !_isSessionExcluded(parts[0])) {
sessionList.push({
name: parts[0],
windows: parts[1],
attached: parts[2] === "1"
});
}
function _runInTerminal(name, argv) {
if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) {
Quickshell.execDetached([Paths.expandTilde(SettingsData.muxCustomCommand), name]);
return;
}
root.sessions = sessionList;
}
function _parseZellijSessions(output) {
var sessionList = [];
var lines = output.trim().split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (line.length === 0)
continue;
var exited = line.includes("(EXITED");
var bracketIdx = line.indexOf(" [");
var name = (bracketIdx > 0 ? line.substring(0, bracketIdx) : line).trim();
if (!_isSessionExcluded(name)) {
sessionList.push({
name: name,
windows: "N/A",
attached: !exited
});
}
}
root.sessions = sessionList;
Quickshell.execDetached(_terminalPrefix().concat(argv));
}
function attachToSession(name) {
if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) {
Quickshell.execDetached([Paths.expandTilde(SettingsData.muxCustomCommand), name]);
} else if (root.muxType === "zellij") {
Quickshell.execDetached(_terminalPrefix().concat(["zellij", "attach", name]));
} else {
Quickshell.execDetached(_terminalPrefix().concat(["tmux", "attach", "-t", name]));
}
_runInTerminal(name, root.backend.attach(name));
}
function createSession(name) {
if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) {
Quickshell.execDetached([Paths.expandTilde(SettingsData.muxCustomCommand), name]);
} else if (root.muxType === "zellij") {
Quickshell.execDetached(_terminalPrefix().concat(["zellij", "-s", name]));
} else {
Quickshell.execDetached(_terminalPrefix().concat(["tmux", "new-session", "-s", name]));
}
_runInTerminal(name, root.backend.create(name));
}
readonly property bool supportsRename: muxType !== "zellij"
function renameSession(oldName, newName) {
if (root.muxType === "zellij")
if (!root.supportsRename)
return;
Quickshell.execDetached(["tmux", "rename-session", "-t", oldName, newName]);
Quickshell.execDetached(root.backend.rename(oldName, newName));
Qt.callLater(refreshSessions);
}
function killSession(name) {
if (root.muxType === "zellij") {
Quickshell.execDetached(["zellij", "kill-session", name]);
} else {
Quickshell.execDetached(["tmux", "kill-session", "-t", name]);
}
Quickshell.execDetached(root.backend.kill(name));
Qt.callLater(refreshSessions);
}
}
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadScript } from "./qml-script.mjs";
const mux = loadScript(new URL("../Common/MuxBackends.js", import.meta.url));
const plain = value => JSON.parse(JSON.stringify(value));
test("tmux parser reads the pipe format and drops short lines", () => {
const sessions = mux.BACKENDS.tmux.parse("main|3|1\nwork|1|0\n\nbroken\n");
assert.deepEqual(plain(sessions), [
{ name: "main", windows: "3", attached: true },
{ name: "work", windows: "1", attached: false }
]);
});
test("zellij parser strips the bracket suffix and marks exited sessions", () => {
const sessions = mux.BACKENDS.zellij.parse("main [Created 2h ago]\nold [Created 1d ago] (EXITED - attach to resurrect)\n");
assert.deepEqual(plain(sessions), [
{ name: "main", windows: "N/A", attached: true },
{ name: "old", windows: "N/A", attached: false }
]);
});
test("herdr parser reads the sessions envelope and treats missing running as stopped", () => {
const output = JSON.stringify({ sessions: [{ name: "default", running: true, default: true }, { name: "stopped", running: false }, { name: "unknown" }] });
assert.deepEqual(plain(mux.BACKENDS.herdr.parse(output)), [
{ name: "default", windows: "N/A", attached: true },
{ name: "stopped", windows: "N/A", attached: false },
{ name: "unknown", windows: "N/A", attached: false }
]);
assert.deepEqual(plain(mux.BACKENDS.herdr.parse("{}")), []);
assert.throws(() => mux.BACKENDS.herdr.parse("not json"));
});
test("only tmux can rename and each backend builds argv from the session name unchanged", () => {
const name = "my session; echo nope";
assert.deepEqual(Object.keys(mux.BACKENDS).filter(key => mux.BACKENDS[key].rename), ["tmux"]);
assert.deepEqual(plain(mux.BACKENDS.herdr.attach(name)), ["herdr", "session", "attach", name]);
assert.deepEqual(plain(mux.BACKENDS.herdr.create(name)), ["herdr", "--session", name]);
assert.deepEqual(plain(mux.BACKENDS.herdr.kill(name)), ["herdr", "session", "stop", name]);
assert.deepEqual(plain(mux.BACKENDS.tmux.rename(name, "renamed")), ["tmux", "rename-session", "-t", name, "renamed"]);
});
test("session filter matches names case-insensitively and by /regex/, ignoring bad patterns", () => {
const filter = " Scratch, /^tmp_/, /[bad/ ";
assert.equal(mux.isSessionExcluded("scratch", filter), true);
assert.equal(mux.isSessionExcluded("tmp_build", filter), true);
assert.equal(mux.isSessionExcluded("main", filter), false);
assert.equal(mux.isSessionExcluded("main", ""), false);
});
@@ -9937,6 +9937,7 @@
"category": "Multiplexers",
"keywords": [
"general",
"herdr",
"integrations",
"multiplexer",
"multiplexers",
@@ -9956,6 +9957,7 @@
"tabIndex": 32,
"category": "Multiplexers",
"keywords": [
"herdr",
"integrations",
"multiplexers",
"sessions",
@@ -9966,7 +9968,7 @@
],
"icon": "terminal",
"parentLabel": "System & integrations",
"description": "tmux and zellij sessions"
"description": "tmux, zellij, and herdr sessions"
},
{
"section": "muxSessionFilter",