diff --git a/stacks/terminal/files/index.html b/stacks/terminal/files/index.html
index 388ce373..bf382dc4 100644
--- a/stacks/terminal/files/index.html
+++ b/stacks/terminal/files/index.html
@@ -124,10 +124,14 @@
background: #1f1f33; color: #eee; font-family: inherit;
font-size: 11px; cursor: pointer;
}
+ .session-actions button.rename {
+ color: #a29bfe; border-color: rgba(162, 155, 254, 0.4);
+ }
.session-actions button.kill {
color: #e74c3c; border-color: rgba(231, 76, 60, 0.4);
}
.session-actions button:hover { filter: brightness(1.3); }
+ .session-card.dragging { opacity: 0.4; cursor: grabbing; }
.empty { color: #888; font-style: italic; padding: 12px 0; font-size: 12px; }
@media (max-width: 720px) {
@@ -259,6 +263,9 @@
const emptyEl = document.getElementById('lobby-empty');
let currentActive = null;
+ let dragSrcCard = null;
+ let isDragging = false;
+ const ORDER_KEY = 'tmux-session-order-' + whoami.osUser;
function relativeTime(epochSec) {
if (!epochSec) return '';
@@ -269,6 +276,41 @@
return Math.floor(diff / 86400) + 'd ago';
}
+ function getSavedOrder() {
+ try {
+ const raw = localStorage.getItem(ORDER_KEY);
+ if (!raw) return [];
+ const arr = JSON.parse(raw);
+ return Array.isArray(arr) ? arr.filter(n => typeof n === 'string') : [];
+ } catch (e) { return []; }
+ }
+ function saveOrder(names) {
+ try { localStorage.setItem(ORDER_KEY, JSON.stringify(names)); } catch (e) {}
+ }
+ function captureOrderFromDom() {
+ return Array.from(listEl.querySelectorAll('.session-card')).map(c => c.dataset.name);
+ }
+ function applySavedOrder(sessions) {
+ const saved = getSavedOrder();
+ const byName = new Map(sessions.map(s => [s.name, s]));
+ const out = [];
+ for (const name of saved) {
+ const s = byName.get(name);
+ if (s) { out.push(s); byName.delete(name); }
+ }
+ for (const s of sessions) {
+ if (byName.has(s.name)) out.push(s);
+ }
+ return out;
+ }
+ function renameInOrder(oldName, newName) {
+ const order = getSavedOrder();
+ const i = order.indexOf(oldName);
+ if (i < 0) return;
+ order[i] = newName;
+ saveOrder(order);
+ }
+
function updateActiveCard() {
listEl.querySelectorAll('.session-card').forEach(c => {
if (c.dataset.name === currentActive) c.classList.add('active');
@@ -324,6 +366,47 @@
}
}
+ async function renameSession(oldName) {
+ if (!NAME_RE.test(oldName)) { showToast('Invalid name', 'error'); return; }
+ const input = prompt('Rename "' + oldName + '" to:', oldName);
+ if (input === null) return;
+ const newName = input.trim();
+ if (!NAME_RE.test(newName)) {
+ showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
+ return;
+ }
+ if (newName === oldName) return;
+ try {
+ const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(oldName) + '/rename', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newName })
+ });
+ if (resp.status === 204) {
+ renameInOrder(oldName, newName);
+ showToast('Renamed to ' + newName, 'success');
+ if (currentActive === oldName) {
+ currentActive = newName;
+ frameEl.src = '/?arg=' + encodeURIComponent(newName);
+ history.replaceState(null, '', '#' + newName);
+ document.title = 'tmux: ' + whoami.osUser + '/' + newName;
+ }
+ renderLobby();
+ } else if (resp.status === 404) {
+ showToast('Session not found', 'error');
+ } else if (resp.status === 409) {
+ showToast('Name "' + newName + '" already taken', 'error', 4000);
+ } else if (resp.status === 400) {
+ showToast('Invalid name', 'error');
+ } else {
+ showToast('Rename failed: HTTP ' + resp.status, 'error');
+ }
+ } catch (err) {
+ showToast('Rename error: ' + err.message, 'error');
+ }
+ }
+
function renderCard(s) {
const card = document.createElement('div');
card.className = 'session-card';
@@ -331,6 +414,7 @@
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.setAttribute('aria-label', 'Activate session ' + s.name);
+ card.draggable = true;
if (s.name === currentActive) card.classList.add('active');
const meta = document.createElement('div');
@@ -348,9 +432,19 @@
const actions = document.createElement('div');
actions.className = 'session-actions';
+ const renameBtn = document.createElement('button');
+ renameBtn.className = 'rename';
+ renameBtn.textContent = 'Rename';
+ renameBtn.draggable = false;
+ renameBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ renameSession(s.name);
+ });
+ actions.appendChild(renameBtn);
const killBtn = document.createElement('button');
killBtn.className = 'kill';
killBtn.textContent = 'Kill';
+ killBtn.draggable = false;
killBtn.addEventListener('click', (e) => {
e.stopPropagation();
killSession(s.name);
@@ -366,10 +460,34 @@
}
});
+ card.addEventListener('dragstart', (e) => {
+ dragSrcCard = card;
+ isDragging = true;
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', s.name);
+ card.classList.add('dragging');
+ });
+ card.addEventListener('dragend', () => {
+ card.classList.remove('dragging');
+ if (dragSrcCard === card) saveOrder(captureOrderFromDom());
+ dragSrcCard = null;
+ isDragging = false;
+ });
+ card.addEventListener('dragover', (e) => {
+ if (!dragSrcCard || dragSrcCard === card) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ const rect = card.getBoundingClientRect();
+ const after = (e.clientY - rect.top) > rect.height / 2;
+ if (after) card.after(dragSrcCard);
+ else card.before(dragSrcCard);
+ });
+
return card;
}
async function renderLobby() {
+ if (isDragging) return;
try {
const resp = await fetch(SESSIONS_API, { credentials: 'same-origin' });
if (!resp.ok) {
@@ -386,8 +504,8 @@
listEl.appendChild(emptyState('No sessions yet.'));
return;
}
- sessions.sort((a, b) => b.lastActivity - a.lastActivity);
- for (const s of sessions) listEl.appendChild(renderCard(s));
+ const ordered = applySavedOrder(sessions);
+ for (const s of ordered) listEl.appendChild(renderCard(s));
} catch (err) {
clearChildren(listEl);
listEl.appendChild(emptyState('Error: ' + err.message));
diff --git a/stacks/terminal/tmux-api/main.go b/stacks/terminal/tmux-api/main.go
index c094eed2..0414dd61 100644
--- a/stacks/terminal/tmux-api/main.go
+++ b/stacks/terminal/tmux-api/main.go
@@ -181,21 +181,39 @@ func handleSessions(w http.ResponseWriter, r *http.Request) {
}
func handleSessionByName(w http.ResponseWriter, r *http.Request) {
- name := strings.TrimPrefix(r.URL.Path, "/sessions/")
- name = strings.TrimSuffix(name, "/")
+ path := strings.TrimPrefix(r.URL.Path, "/sessions/")
+ path = strings.TrimSuffix(path, "/")
+ parts := strings.Split(path, "/")
+ name := parts[0]
if !sessionNameRe.MatchString(name) {
http.Error(w, "invalid session name", http.StatusBadRequest)
return
}
- if r.Method != http.MethodDelete {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
osUser := resolveOSUser(w, r)
if osUser == "" {
return
}
+ if len(parts) == 1 {
+ if r.Method != http.MethodDelete {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ killSession(w, osUser, name)
+ return
+ }
+ if len(parts) == 2 && parts[1] == "rename" {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ renameSession(w, r, osUser, name)
+ return
+ }
+ http.Error(w, "not found", http.StatusNotFound)
+}
+
+func killSession(w http.ResponseWriter, osUser, name string) {
out, err := tmuxCmd(osUser, "kill-session", "-t", name).CombinedOutput()
if err != nil {
msg := string(out)
@@ -209,3 +227,39 @@ func handleSessionByName(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
+
+func renameSession(w http.ResponseWriter, r *http.Request, osUser, oldName string) {
+ var body struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid body", http.StatusBadRequest)
+ return
+ }
+ newName := strings.TrimSpace(body.Name)
+ if !sessionNameRe.MatchString(newName) {
+ http.Error(w, "invalid new name", http.StatusBadRequest)
+ return
+ }
+ if newName == oldName {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ out, err := tmuxCmd(osUser, "rename-session", "-t", oldName, newName).CombinedOutput()
+ if err != nil {
+ msg := string(out)
+ if strings.Contains(msg, "can't find session") || strings.Contains(msg, "no server running") {
+ http.Error(w, "session not found", http.StatusNotFound)
+ return
+ }
+ if strings.Contains(msg, "duplicate session") || strings.Contains(msg, "session already exists") {
+ http.Error(w, "target name already exists", http.StatusConflict)
+ return
+ }
+ log.Printf("rename-session %s→%s as %s failed: %v: %s", oldName, newName, osUser, err, msg)
+ http.Error(w, "rename-session failed", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}