diff --git a/src/frontend/styles.css b/src/frontend/styles.css
index 31f8b35..ca847ad 100644
--- a/src/frontend/styles.css
+++ b/src/frontend/styles.css
@@ -3387,6 +3387,20 @@ body[data-view="overview"] .toolbar { display: none; }
cursor: pointer;
}
.ws-pane-bm:hover { color: var(--accent-orange, #f59e0b); border-color: var(--accent-orange, #f59e0b); }
+/* Rename this terminal — same chrome as the bookmark button next to it. */
+.ws-pane-ren {
+ font-size: 12px;
+ line-height: 1;
+ width: 22px; height: 22px;
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ cursor: pointer;
+}
+.ws-pane-ren:hover { color: var(--accent, #3b82f6); border-color: var(--accent, #3b82f6); }
+/* The pane title doubles as a rename target (double-click). */
+.ws-pane-status { cursor: default; }
.ws-pane-close {
font-size: 15px;
line-height: 1;
diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js
index a6a1c93..e896e82 100644
--- a/src/frontend/workspace.js
+++ b/src/frontend/workspace.js
@@ -178,7 +178,7 @@ function _wsSaveSession() {
var snap = _wsCaptureLayout();
// Don't persist a lone empty pane — that's just the default blank state.
var meaningful = snap.tabs.some(function (t) {
- return t.panes.some(function (p) { return p.cmd || p.prefill || p.cwd || p.enteredCmd; });
+ return t.panes.some(function (p) { return p.cmd || p.prefill || p.cwd || p.enteredCmd || p.name; });
}) || snap.tabs.length > 1 || (snap.tabs[0] && snap.tabs[0].panes.length > 1);
var sig = meaningful ? JSON.stringify(snap) : '';
if (sig === _wsLastSessionSig) return;
@@ -210,7 +210,7 @@ function _wsRestoreTabsFromSession(sess) {
.slice(0, MAX_WS_PANES)
.map(function (p) {
var restoreCmd = (p && (p.cmd || p.enteredCmd || p.detectedCmd || p.prefill)) || '';
- return { id: 'p' + (++_wsPaneSeq), cmd: null, prefill: null, restoreCmd: restoreCmd || null, wantCwd: (p && p.cwd) || null };
+ return { id: 'p' + (++_wsPaneSeq), cmd: null, prefill: null, restoreCmd: restoreCmd || null, wantCwd: (p && p.cwd) || null, name: (p && p.name) || '' };
});
return { id: 't' + (++_wsTabSeq), name: t.name || ('Tab ' + (ti + 1)), panes: panes,
cols: Array.isArray(t.cols) ? t.cols.slice() : null, rows: Array.isArray(t.rows) ? t.rows.slice() : null };
@@ -529,6 +529,8 @@ function _wsShortCwd(cwd) {
// A short, human label for a pane's title bar: the running agent (if any),
// otherwise the folder name (e.g. "CoWork"), or "~" for the home directory.
function _wsPaneLabel(pane) {
+ // A name the user typed always wins — it's the whole point of renaming.
+ if (pane && pane.name) return pane.name;
if (pane && pane.cmd) {
// Strip leading `VAR=value` env assignments (value may be quoted and hold
// secrets, e.g. HTTPS_PROXY='http://user:pass@host') so the label is the
@@ -734,7 +736,11 @@ function _wsPaneMarkup(pane) {
return '' +
'
' +
'
' +
- 'connecting…' +
+ 'connecting…' +
+ '' +
'' +
'' +
@@ -1531,7 +1537,7 @@ function _wsBuildPanes(spec) {
: [{ cwd: spec.cwd, cmd: spec.cmd, prefill: spec.prefill }];
list = list.slice(0, MAX_WS_PANES);
return list.map(function (pc) {
- return { id: 'p' + (++_wsPaneSeq), cmd: pc.cmd || null, prefill: pc.prefill || null, wantCwd: pc.cwd || null };
+ return { id: 'p' + (++_wsPaneSeq), cmd: pc.cmd || null, prefill: pc.prefill || null, wantCwd: pc.cwd || null, name: pc.name || '' };
});
}
@@ -1815,7 +1821,7 @@ function _wsSerializeTab(tab) {
cols: Array.isArray(tab.cols) ? tab.cols.slice() : null,
rows: Array.isArray(tab.rows) ? tab.rows.slice() : null,
panes: tab.panes.map(function (p) {
- return { cmd: p.cmd || '', prefill: p.prefill || '', cwd: p.cwd || p.wantCwd || '', detectedCmd: p.detectedCmd || '', enteredCmd: p.enteredCmd || '' };
+ return { cmd: p.cmd || '', prefill: p.prefill || '', cwd: p.cwd || p.wantCwd || '', name: p.name || '', detectedCmd: p.detectedCmd || '', enteredCmd: p.enteredCmd || '' };
}),
};
}
@@ -1846,7 +1852,7 @@ function reopenLastClosedTab() {
.slice(0, MAX_WS_PANES)
.map(function (p) {
var cmd = (p && (p.cmd || p.enteredCmd || p.detectedCmd || p.prefill)) || '';
- return { id: 'p' + (++_wsPaneSeq), cmd: null, prefill: null, restoreCmd: cmd || null, wantCwd: (p && p.cwd) || null };
+ return { id: 'p' + (++_wsPaneSeq), cmd: null, prefill: null, restoreCmd: cmd || null, wantCwd: (p && p.cwd) || null, name: (p && p.name) || '' };
});
var tab = { id: 't' + (++_wsTabSeq), name: spec.name || 'Tab', panes: panes,
cols: Array.isArray(spec.cols) ? spec.cols.slice() : null, rows: Array.isArray(spec.rows) ? spec.rows.slice() : null };
@@ -1920,6 +1926,23 @@ function addWorkspacePane(cmd) {
_wsRenderPanes(); _wsSyncLayoutButtons();
}
+// Give a pane its own name, shown in the title bar instead of the folder/agent
+// label. Uses codbashPrompt, not window.prompt — the latter is a no-op in the
+// Electron shell. An empty answer clears the name and falls back to the
+// auto-label; the name round-trips through saved layouts and the session.
+function renameWorkspacePane(id) {
+ var pane = _wsFindPane(id);
+ if (!pane) return;
+ codbashPrompt('Terminal name:', pane.name || _wsPaneLabel(pane)).then(function (name) {
+ if (name === null) return; // cancelled — leave as-is
+ var next = String(name).trim().slice(0, 120); // matches MAX_NAME server-side
+ pane.name = next; // '' clears it → auto-label
+ var st = document.getElementById('wsStatus-' + pane.id);
+ if (st) st.textContent = _wsPaneLabel(pane);
+ _wsSaveSession();
+ });
+}
+
function closeWorkspacePane(id) {
for (var i = 0; i < _wsTabs.length; i++) {
var tab = _wsTabs[i];
@@ -2073,6 +2096,7 @@ function _wsCaptureLayout() {
cmd: p.cmd || '',
prefill: p.prefill || '',
cwd: p.cwd || p.wantCwd || '',
+ name: p.name || '', // user-chosen pane label
detectedCmd: p.detectedCmd || '',
enteredCmd: p.enteredCmd || '',
};
@@ -2145,6 +2169,7 @@ function applyWorkspaceLayout(id) {
cmd: (p && p.cmd) || null,
prefill: (p && p.prefill) || null,
wantCwd: (p && p.cwd) || null,
+ name: (p && p.name) || '',
};
});
return { id: 't' + (++_wsTabSeq), name: t.name || ('Tab ' + (ti + 1)), panes: panes };
diff --git a/src/workspace-layouts.js b/src/workspace-layouts.js
index 3134629..cd5a071 100644
--- a/src/workspace-layouts.js
+++ b/src/workspace-layouts.js
@@ -52,9 +52,14 @@ function sanitizePane(p) {
const cwdRaw = p && typeof p === 'object' && typeof p.cwd === 'string' ? p.cwd.trim() : '';
if (cwdRaw.length > MAX_COMMAND) return null;
if (cwdRaw && CONTROL_CHARS.test(cwdRaw)) return null;
+ // A user-chosen pane label. Kept short: it only has to fit the pane title bar.
+ const nameRaw = p && typeof p === 'object' && typeof p.name === 'string' ? p.name.trim() : '';
+ if (nameRaw.length > MAX_NAME) return null;
+ if (nameRaw && CONTROL_CHARS.test(nameRaw)) return null;
const out = { cmd };
if (prefillRaw) out.prefill = prefillRaw;
if (cwdRaw) out.cwd = cwdRaw;
+ if (nameRaw) out.name = nameRaw;
return out;
}
diff --git a/test/workspace-layouts.test.js b/test/workspace-layouts.test.js
index 7488d89..64ed75b 100644
--- a/test/workspace-layouts.test.js
+++ b/test/workspace-layouts.test.js
@@ -31,6 +31,34 @@ test('sanitizeLayout accepts a multi-tab, multi-pane layout', () => {
assert.equal(l.tabs[1].panes[0].cmd, '');
});
+test('sanitizeLayout preserves a user-chosen pane name', () => {
+ const { m } = freshModule();
+ const l = m.sanitizeLayout({
+ name: 'named panes',
+ tabs: [{ name: 'work', panes: [{ cmd: 'claude', name: 'API server', cwd: '/Users/me/proj' }] }],
+ });
+ assert.ok(l);
+ const p = l.tabs[0].panes[0];
+ assert.equal(p.name, 'API server');
+ assert.equal(p.cmd, 'claude');
+ assert.equal(p.cwd, '/Users/me/proj');
+});
+
+test('sanitizeLayout drops a pane name with control characters', () => {
+ const { m } = freshModule();
+ assert.equal(
+ m.sanitizeLayout({ name: 'bad', tabs: [{ name: 't', panes: [{ cmd: '', name: 'evil\x07name' }] }] }),
+ null
+ );
+});
+
+test('sanitizeLayout omits an empty pane name instead of storing it', () => {
+ const { m } = freshModule();
+ const l = m.sanitizeLayout({ name: 'blank', tabs: [{ name: 't', panes: [{ cmd: 'claude', name: ' ' }] }] });
+ assert.ok(l);
+ assert.equal('name' in l.tabs[0].panes[0], false);
+});
+
test('sanitizeLayout preserves per-pane prefill and cwd', () => {
const { m } = freshModule();
const l = m.sanitizeLayout({