diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index 9acb4ca..ca51984 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -142,6 +142,7 @@ const DISPLAY_DEFAULTS = {
notificationPermission: 'default',
};
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
+const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
// ─── DOM helpers ──────────────────────────────────────────────────────────────
function $(id) {
@@ -1221,11 +1222,17 @@ function openSettings() {
autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true;
}
- // New Session tab - populate template textarea
+ // Commands tab - populate create template textarea
const templateEl = $('setting-template');
if (templateEl) {
templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE;
}
+
+ // Commands tab - populate delete template textarea
+ const deleteTemplateEl = $('setting-delete-template');
+ if (deleteTemplateEl) {
+ deleteTemplateEl.value = (ss && ss.delete_session_template) || DELETE_SESSION_DEFAULT_TEMPLATE;
+ }
});
}
@@ -1687,7 +1694,7 @@ function bindStaticEventListeners() {
});
});
- // New Session tab — template textarea with 500ms debounce
+ // Commands tab — create template textarea with 500ms debounce
var _templateDebounceTimer;
on($('setting-template'), 'input', function() {
clearTimeout(_templateDebounceTimer);
@@ -1697,12 +1704,29 @@ function bindStaticEventListeners() {
}, 500);
});
- // New Session tab — reset button restores default template
+ // Commands tab — create template reset button restores default
on($('setting-template-reset'), 'click', function() {
var el = $('setting-template');
if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE;
patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE);
});
+
+ // Commands tab — delete template textarea with 500ms debounce
+ var _deleteTemplateDebounceTimer;
+ on($('setting-delete-template'), 'input', function() {
+ clearTimeout(_deleteTemplateDebounceTimer);
+ var val = this.value;
+ _deleteTemplateDebounceTimer = setTimeout(function() {
+ patchServerSetting('delete_session_template', val);
+ }, 500);
+ });
+
+ // Commands tab — delete template reset button restores default
+ on($('setting-delete-template-reset'), 'click', function() {
+ var el = $('setting-delete-template');
+ if (el) el.value = DELETE_SESSION_DEFAULT_TEMPLATE;
+ patchServerSetting('delete_session_template', DELETE_SESSION_DEFAULT_TEMPLATE);
+ });
}
// ─── Test-only helpers ────────────────────────────────────────────────────────
@@ -1798,6 +1822,9 @@ if (typeof module !== 'undefined' && module.exports) {
createNewSession,
// Kill session
killSession,
+ // Constants
+ NEW_SESSION_DEFAULT_TEMPLATE,
+ DELETE_SESSION_DEFAULT_TEMPLATE,
// Test-only helpers
_setCurrentSessions,
_setViewMode,
diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html
index 56ca3fb..b96b453 100644
--- a/muxplex/frontend/index.html
+++ b/muxplex/frontend/index.html
@@ -87,7 +87,7 @@
-
+
@@ -169,6 +169,12 @@
{name} is replaced with the session name
+
+
+
+ {name} is replaced with the session name
+
+
diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs
index 11918b5..b1b8ead 100644
--- a/muxplex/frontend/tests/test_app.mjs
+++ b/muxplex/frontend/tests/test_app.mjs
@@ -2063,3 +2063,58 @@ test('pollSessions calls updateFaviconBadge', () => {
'pollSessions must call updateFaviconBadge — update favicon on every poll cycle'
);
});
+
+
+// --- Delete session template (task: customizable delete command) ---
+
+test('app.js defines DELETE_SESSION_DEFAULT_TEMPLATE constant', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ assert.ok(
+ source.includes('DELETE_SESSION_DEFAULT_TEMPLATE'),
+ 'app.js must define DELETE_SESSION_DEFAULT_TEMPLATE constant'
+ );
+});
+
+test('DELETE_SESSION_DEFAULT_TEMPLATE value is tmux kill-session -t {name}', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ assert.ok(
+ source.includes("'tmux kill-session -t {name}'") || source.includes('"tmux kill-session -t {name}"'),
+ "DELETE_SESSION_DEFAULT_TEMPLATE must be set to 'tmux kill-session -t {name}'"
+ );
+});
+
+test('openSettings loads delete_session_template from server settings', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ const fnStart = source.indexOf('function openSettings(');
+ assert.ok(fnStart !== -1, 'openSettings must exist');
+ const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
+ const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 3000);
+ assert.ok(
+ fnBody.includes('setting-delete-template'),
+ 'openSettings must populate #setting-delete-template from server settings'
+ );
+});
+
+test('bindStaticEventListeners wires delete template input to save', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ const fnStart = source.indexOf('function bindStaticEventListeners(');
+ assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
+ const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
+ const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
+ assert.ok(
+ fnBody.includes('setting-delete-template'),
+ 'bindStaticEventListeners must wire #setting-delete-template input event to save'
+ );
+});
+
+test('bindStaticEventListeners wires delete template reset button', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ const fnStart = source.indexOf('function bindStaticEventListeners(');
+ assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
+ const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
+ const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
+ assert.ok(
+ fnBody.includes('setting-delete-template-reset'),
+ 'bindStaticEventListeners must wire #setting-delete-template-reset click handler'
+ );
+});
diff --git a/muxplex/main.py b/muxplex/main.py
index 505e27e..61b36da 100644
--- a/muxplex/main.py
+++ b/muxplex/main.py
@@ -411,9 +411,13 @@ async def delete_current_session() -> dict:
@app.delete("/api/sessions/{name}")
async def delete_session(name: str) -> dict:
- """Kill a tmux session by name.
+ """Kill/destroy a tmux session using the delete_session_template from settings.
- Runs `tmux kill-session -t {name}`. Returns {ok: True, name: name}.
+ Reads delete_session_template, substitutes {name}, and runs it synchronously
+ (30s timeout) so the caller can rely on the session being gone on return.
+
+ Returns {ok: True, name: name}. Errors are logged as warnings — the endpoint
+ always returns 200 so the UI can refresh and reflect the gone session.
404 if session is not in the known session list (when non-empty).
Must be declared after DELETE /api/sessions/current so "current" routes correctly.
"""
@@ -421,10 +425,29 @@ async def delete_session(name: str) -> dict:
if known and name not in known:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
+ settings = load_settings()
+ command = settings.get(
+ "delete_session_template", "tmux kill-session -t {name}"
+ ).replace("{name}", name)
+
try:
- await run_tmux("kill-session", "-t", name)
- except RuntimeError:
- raise HTTPException(status_code=500, detail=f"Failed to kill session '{name}'")
+ result = subprocess.run(
+ command,
+ shell=True,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ _log.warning(
+ "Delete command failed (rc=%d): %s",
+ result.returncode,
+ result.stderr.strip(),
+ )
+ except subprocess.TimeoutExpired:
+ _log.warning("Delete command timed out after 30s: %r", command)
+ except Exception:
+ _log.warning("Delete command failed: %r", command)
return {"ok": True, "name": name}
diff --git a/muxplex/settings.py b/muxplex/settings.py
index 27ee91d..9336334 100644
--- a/muxplex/settings.py
+++ b/muxplex/settings.py
@@ -17,6 +17,7 @@ DEFAULT_SETTINGS: dict = {
"window_size_largest": False,
"auto_open_created": True,
"new_session_template": "tmux new-session -d -s {name}",
+ "delete_session_template": "tmux kill-session -t {name}",
}
diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py
index c12f67c..3aaade0 100644
--- a/muxplex/tests/test_api.py
+++ b/muxplex/tests/test_api.py
@@ -1044,20 +1044,34 @@ def test_delete_session_success(client, monkeypatch):
def test_delete_session_calls_kill_session(client, monkeypatch):
- """DELETE /api/sessions/{name} calls tmux kill-session -t {name}."""
- from unittest.mock import AsyncMock
+ """DELETE /api/sessions/{name} runs 'tmux kill-session -t {name}' via subprocess (default template)."""
+ from unittest.mock import MagicMock, patch
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"])
- mock_run_tmux = AsyncMock(return_value="")
- monkeypatch.setattr("muxplex.main.run_tmux", mock_run_tmux)
- client.delete("/api/sessions/my-session")
+ captured = []
- assert mock_run_tmux.called
- args = mock_run_tmux.call_args[0]
- assert args[0] == "kill-session"
- assert "-t" in args
- assert "my-session" in args
+ def mock_run(cmd, **kwargs):
+ captured.append(cmd)
+ result = MagicMock()
+ result.returncode = 0
+ result.stderr = ""
+ return result
+
+ with patch("muxplex.main.subprocess.run", side_effect=mock_run):
+ client.delete("/api/sessions/my-session")
+
+ assert len(captured) == 1, "subprocess.run must be called exactly once"
+ executed_cmd = captured[0]
+ assert "kill-session" in executed_cmd, (
+ f"Default command must include 'kill-session', got: {executed_cmd!r}"
+ )
+ assert "-t" in executed_cmd, (
+ f"Default command must include '-t', got: {executed_cmd!r}"
+ )
+ assert "my-session" in executed_cmd, (
+ f"Command must include session name 'my-session', got: {executed_cmd!r}"
+ )
def test_delete_session_not_found(client, monkeypatch):
@@ -1125,3 +1139,108 @@ def test_login_page_title_contains_hostname(client):
assert hostname in response.text, (
f"Expected hostname '{hostname}' in title of login page"
)
+
+
+# ---------------------------------------------------------------------------
+# DELETE /api/sessions/{name} — custom template (task: customizable delete command)
+# ---------------------------------------------------------------------------
+
+
+def test_delete_session_uses_template_command(client, monkeypatch, tmp_path):
+ """DELETE /api/sessions/{name} must execute the delete_session_template from settings.
+
+ The template {name} placeholder must be substituted with the session name.
+ The command must be run synchronously via subprocess.run (not run_tmux).
+ """
+ from unittest.mock import MagicMock, patch
+
+ # Make the session appear to exist so the 404 guard passes
+ monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["myworkspace"])
+
+ # Redirect settings to a temp path so we can write a custom template
+ import muxplex.settings as settings_mod
+
+ fake_settings_path = tmp_path / "settings.json"
+ monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path)
+
+ # Write a custom template
+ import json
+
+ fake_settings_path.write_text(
+ json.dumps(
+ {
+ "delete_session_template": "echo destroy {name}",
+ }
+ )
+ )
+
+ # Capture subprocess.run calls
+ captured_commands = []
+
+ def mock_run(cmd, **kwargs):
+ captured_commands.append(cmd)
+ result = MagicMock()
+ result.returncode = 0
+ result.stderr = ""
+ return result
+
+ with patch("muxplex.main.subprocess.run", side_effect=mock_run):
+ response = client.delete("/api/sessions/myworkspace")
+
+ assert response.status_code == 200, (
+ f"DELETE /api/sessions/myworkspace must return 200, got {response.status_code}"
+ )
+ data = response.json()
+ assert data.get("ok") is True, f"Response must have ok=True, got: {data}"
+ assert data.get("name") == "myworkspace", (
+ f"Response must have name='myworkspace', got: {data}"
+ )
+
+ # Verify template substitution happened
+ assert len(captured_commands) == 1, (
+ f"subprocess.run must be called exactly once, called {len(captured_commands)} times"
+ )
+ executed_cmd = captured_commands[0]
+ assert "myworkspace" in executed_cmd, (
+ f"Executed command must contain session name 'myworkspace', got: {executed_cmd!r}"
+ )
+ assert "echo destroy" in executed_cmd, (
+ f"Executed command must use the custom template, got: {executed_cmd!r}"
+ )
+
+
+def test_delete_session_default_template_is_tmux_kill(client, monkeypatch, tmp_path):
+ """DELETE /api/sessions/{name} uses 'tmux kill-session -t {name}' when no custom template is set."""
+ from unittest.mock import MagicMock, patch
+
+ monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["mysession"])
+
+ # Redirect settings to empty temp file (no settings file = use defaults)
+ import muxplex.settings as settings_mod
+
+ fake_settings_path = tmp_path / "settings.json"
+ monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path)
+ # Don't write any settings — defaults should be used
+
+ captured_commands = []
+
+ def mock_run(cmd, **kwargs):
+ captured_commands.append(cmd)
+ result = MagicMock()
+ result.returncode = 0
+ result.stderr = ""
+ return result
+
+ with patch("muxplex.main.subprocess.run", side_effect=mock_run):
+ response = client.delete("/api/sessions/mysession")
+
+ assert response.status_code == 200
+ assert len(captured_commands) == 1
+ executed_cmd = captured_commands[0]
+ # Default template substituted
+ assert "mysession" in executed_cmd, (
+ f"Default template must substitute session name, got: {executed_cmd!r}"
+ )
+ assert "kill-session" in executed_cmd, (
+ f"Default template must contain 'kill-session', got: {executed_cmd!r}"
+ )
diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py
index 007b045..ef0320a 100644
--- a/muxplex/tests/test_frontend_html.py
+++ b/muxplex/tests/test_frontend_html.py
@@ -1062,3 +1062,94 @@ def test_html_settings_close_btn_exists() -> None:
assert dialog.find(id="settings-close-btn") is not None, (
"#settings-close-btn must be a descendant of #settings-dialog"
)
+
+
+# ============================================================
+# Delete session template (task: customizable delete command)
+# ============================================================
+
+
+def test_html_delete_template_textarea_exists() -> None:
+ """New Session (Commands) panel must contain #setting-delete-template textarea."""
+ soup = _SOUP
+ dialog = soup.find(id="settings-dialog")
+ assert dialog is not None, "Missing #settings-dialog"
+ new_session_panel = dialog.find(
+ class_="settings-panel", attrs={"data-tab": "new-session"}
+ )
+ assert new_session_panel is not None, "Missing new-session settings-panel"
+ textarea = new_session_panel.find("textarea", id="setting-delete-template")
+ assert textarea is not None, (
+ "Missing