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',
};
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,
+7 -1
View File
@@ -87,7 +87,7 @@
<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="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>
<div class="settings-content">
<div class="settings-panel" data-tab="display">
@@ -169,6 +169,12 @@
<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>
</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>
+55
View File
@@ -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'
);
});