feat: customizable delete session command template

Symmetric with create template. Default: 'tmux kill-session -t {name}'.
Configurable in Settings → Commands tab (renamed from 'New Session').
DELETE /api/sessions/{name} reads template from settings, substitutes
{name}, runs synchronously via subprocess.run with 30s timeout.

Enables custom teardown workflows like:
  amplifier-dev ~/dev/{name} --destroy

Changes:
- settings.py: add delete_session_template to DEFAULT_SETTINGS
- main.py: DELETE endpoint uses subprocess.run + template instead of run_tmux
- index.html: add delete template textarea + reset btn; rename tab to Commands
- app.js: DELETE_SESSION_DEFAULT_TEMPLATE constant, openSettings loads it,
  bindStaticEventListeners wires input/reset; constant exported

Tests added:
- test_settings.py: 3 tests for default, load, and patch of delete_session_template
- test_api.py: 2 tests for custom/default template substitution via subprocess mock;
  update existing test_delete_session_calls_kill_session to match new implementation
- test_frontend_html.py: 5 tests for textarea, placeholder, rows, reset btn, tab label
- test_app.mjs: 5 tests for constant definition, value, and wiring in openSettings/bindStaticEventListeners
This commit is contained in:
Brian Krabach
2026-03-30 17:17:34 -07:00
parent 2a30e88feb
commit 79576bcbfe
8 changed files with 383 additions and 19 deletions
+30 -3
View File
@@ -142,6 +142,7 @@ const DISPLAY_DEFAULTS = {
notificationPermission: 'default', notificationPermission: 'default',
}; };
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}'; const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
// ─── DOM helpers ────────────────────────────────────────────────────────────── // ─── DOM helpers ──────────────────────────────────────────────────────────────
function $(id) { function $(id) {
@@ -1221,11 +1222,17 @@ function openSettings() {
autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true; 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'); const templateEl = $('setting-template');
if (templateEl) { if (templateEl) {
templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE; 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; var _templateDebounceTimer;
on($('setting-template'), 'input', function() { on($('setting-template'), 'input', function() {
clearTimeout(_templateDebounceTimer); clearTimeout(_templateDebounceTimer);
@@ -1697,12 +1704,29 @@ function bindStaticEventListeners() {
}, 500); }, 500);
}); });
// New Session tab — reset button restores default template // Commands tab — create template reset button restores default
on($('setting-template-reset'), 'click', function() { on($('setting-template-reset'), 'click', function() {
var el = $('setting-template'); var el = $('setting-template');
if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE; if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE;
patchServerSetting('new_session_template', 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 ──────────────────────────────────────────────────────── // ─── Test-only helpers ────────────────────────────────────────────────────────
@@ -1798,6 +1822,9 @@ if (typeof module !== 'undefined' && module.exports) {
createNewSession, createNewSession,
// Kill session // Kill session
killSession, killSession,
// Constants
NEW_SESSION_DEFAULT_TEMPLATE,
DELETE_SESSION_DEFAULT_TEMPLATE,
// Test-only helpers // Test-only helpers
_setCurrentSessions, _setCurrentSessions,
_setViewMode, _setViewMode,
+7 -1
View File
@@ -87,7 +87,7 @@
<button class="settings-tab settings-tab--active" data-tab="display">Display</button> <button class="settings-tab settings-tab--active" data-tab="display">Display</button>
<button class="settings-tab" data-tab="sessions">Sessions</button> <button class="settings-tab" data-tab="sessions">Sessions</button>
<button class="settings-tab" data-tab="notifications">Notifications</button> <button class="settings-tab" data-tab="notifications">Notifications</button>
<button class="settings-tab" data-tab="new-session">New Session</button> <button class="settings-tab" data-tab="new-session">Commands</button>
</nav> </nav>
<div class="settings-content"> <div class="settings-content">
<div class="settings-panel" data-tab="display"> <div class="settings-panel" data-tab="display">
@@ -169,6 +169,12 @@
<span class="settings-helper">{name} is replaced with the session name</span> <span class="settings-helper">{name} is replaced with the session name</span>
<button id="setting-template-reset" class="settings-action-btn">Reset to default</button> <button id="setting-template-reset" class="settings-action-btn">Reset to default</button>
</div> </div>
<div class="settings-field settings-field--column">
<label class="settings-label" for="setting-delete-template">Delete session command</label>
<textarea id="setting-delete-template" class="settings-textarea" rows="3" placeholder="tmux kill-session -t {name}"></textarea>
<span class="settings-helper">{name} is replaced with the session name</span>
<button id="setting-delete-template-reset" class="settings-action-btn">Reset to default</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+55
View File
@@ -2063,3 +2063,58 @@ test('pollSessions calls updateFaviconBadge', () => {
'pollSessions must call updateFaviconBadge — update favicon on every poll cycle' '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'
);
});
+28 -5
View File
@@ -411,9 +411,13 @@ async def delete_current_session() -> dict:
@app.delete("/api/sessions/{name}") @app.delete("/api/sessions/{name}")
async def delete_session(name: str) -> dict: 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). 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. 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: if known and name not in known:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found") 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: try:
await run_tmux("kill-session", "-t", name) result = subprocess.run(
except RuntimeError: command,
raise HTTPException(status_code=500, detail=f"Failed to kill session '{name}'") 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} return {"ok": True, "name": name}
+1
View File
@@ -17,6 +17,7 @@ DEFAULT_SETTINGS: dict = {
"window_size_largest": False, "window_size_largest": False,
"auto_open_created": True, "auto_open_created": True,
"new_session_template": "tmux new-session -d -s {name}", "new_session_template": "tmux new-session -d -s {name}",
"delete_session_template": "tmux kill-session -t {name}",
} }
+129 -10
View File
@@ -1044,20 +1044,34 @@ def test_delete_session_success(client, monkeypatch):
def test_delete_session_calls_kill_session(client, monkeypatch): def test_delete_session_calls_kill_session(client, monkeypatch):
"""DELETE /api/sessions/{name} calls tmux kill-session -t {name}.""" """DELETE /api/sessions/{name} runs 'tmux kill-session -t {name}' via subprocess (default template)."""
from unittest.mock import AsyncMock from unittest.mock import MagicMock, patch
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"]) 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 def mock_run(cmd, **kwargs):
args = mock_run_tmux.call_args[0] captured.append(cmd)
assert args[0] == "kill-session" result = MagicMock()
assert "-t" in args result.returncode = 0
assert "my-session" in args 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): 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, ( assert hostname in response.text, (
f"Expected hostname '{hostname}' in title of login page" 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}"
)
+91
View File
@@ -1062,3 +1062,94 @@ def test_html_settings_close_btn_exists() -> None:
assert dialog.find(id="settings-close-btn") is not None, ( assert dialog.find(id="settings-close-btn") is not None, (
"#settings-close-btn must be a descendant of #settings-dialog" "#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 <textarea id='setting-delete-template'> inside new-session panel"
)
classes = textarea.get("class") or []
assert "settings-textarea" in classes, (
f"#setting-delete-template must have class 'settings-textarea', has: {classes}"
)
def test_html_delete_template_textarea_placeholder() -> None:
"""#setting-delete-template must have placeholder 'tmux kill-session -t {name}'."""
soup = _SOUP
textarea = soup.find("textarea", id="setting-delete-template")
assert textarea is not None, "Missing #setting-delete-template textarea"
placeholder = textarea.get("placeholder")
assert placeholder == "tmux kill-session -t {name}", (
f"#setting-delete-template placeholder must be 'tmux kill-session -t {{name}}', "
f"got: {placeholder!r}"
)
def test_html_delete_template_textarea_rows() -> None:
"""#setting-delete-template textarea must have rows=3."""
soup = _SOUP
textarea = soup.find("textarea", id="setting-delete-template")
assert textarea is not None, "Missing #setting-delete-template textarea"
rows = textarea.get("rows")
assert rows == "3", f"#setting-delete-template must have rows='3', got: {rows!r}"
def test_html_delete_template_reset_button_exists() -> None:
"""New Session (Commands) panel must contain #setting-delete-template-reset button."""
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"
reset_btn = new_session_panel.find(id="setting-delete-template-reset")
assert reset_btn is not None, (
"Missing #setting-delete-template-reset inside new-session panel"
)
classes = reset_btn.get("class") or []
assert "settings-action-btn" in classes, (
f"#setting-delete-template-reset must have class 'settings-action-btn', has: {classes}"
)
def test_html_commands_tab_label() -> None:
"""The new-session tab button must be labeled 'Commands' (not 'New Session')."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
tabs_container = dialog.find("nav", class_="settings-tabs")
assert tabs_container is not None, "Missing nav.settings-tabs"
tab_btn = tabs_container.find("button", attrs={"data-tab": "new-session"})
assert tab_btn is not None, "Missing tab button with data-tab='new-session'"
label = tab_btn.get_text(strip=True)
assert label == "Commands", (
f"Tab button data-tab='new-session' must be labeled 'Commands', got: {label!r}"
)
def test_html_new_session_tab_controls_with_delete() -> None:
"""New Session (Commands) tab must contain create template, delete template, and both reset buttons."""
soup = _SOUP
for id_ in (
"setting-template",
"setting-template-reset",
"setting-delete-template",
"setting-delete-template-reset",
):
assert soup.find(id=id_), f"Missing element with id='{id_}'"
+42
View File
@@ -142,3 +142,45 @@ def test_load_propagates_non_json_errors(monkeypatch):
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
load_settings() load_settings()
# ============================================================
# Delete session template (task: customizable delete command)
# ============================================================
def test_default_settings_include_delete_template():
"""DEFAULT_SETTINGS must include delete_session_template with default tmux kill-session value."""
assert "delete_session_template" in DEFAULT_SETTINGS, (
"DEFAULT_SETTINGS must include 'delete_session_template'"
)
assert (
DEFAULT_SETTINGS["delete_session_template"] == "tmux kill-session -t {name}"
), (
f"delete_session_template default must be 'tmux kill-session -t {{name}}', "
f"got: {DEFAULT_SETTINGS['delete_session_template']!r}"
)
def test_delete_session_template_returned_by_load_settings():
"""load_settings() must return delete_session_template with default value."""
result = load_settings()
assert "delete_session_template" in result, (
"load_settings() must include 'delete_session_template'"
)
assert result["delete_session_template"] == "tmux kill-session -t {name}", (
f"load_settings() delete_session_template must default to 'tmux kill-session -t {{name}}', "
f"got: {result['delete_session_template']!r}"
)
def test_delete_session_template_patchable():
"""patch_settings() must accept and persist delete_session_template."""
custom = "amplifier-dev ~/dev/{name} --destroy"
result = patch_settings({"delete_session_template": custom})
assert result["delete_session_template"] == custom, (
f"patch_settings() must accept delete_session_template, got: {result['delete_session_template']!r}"
)
# Verify it was persisted
loaded = load_settings()
assert loaded["delete_session_template"] == custom