From 4d8e4edade46a03d071e435c2f4ef41078742923 Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Thu, 30 Jul 2026 15:09:30 +0300 Subject: [PATCH] feat(terminal): name a terminal pane yourself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pane title bar showed only an auto-label — the running agent, the project folder, or a bare '~' for home. With several panes open in the same folder (or several shells in $HOME) they were indistinguishable. You can now give a pane its own name: click the ✎ in its title bar, or double-click the title. An empty answer clears the name and restores the auto-label. Uses codbashPrompt (window.prompt is a no-op in the Electron shell). The name round-trips: it is captured into the session snapshot and saved layouts, restored at every pane-construction site, and sanitizePane now preserves it (reusing the existing MAX_NAME=120 cap and rejecting control characters, like the other pane fields). Co-Authored-By: Claude Opus 5 (1M context) --- src/frontend/styles.css | 14 +++++++++++++ src/frontend/workspace.js | 37 ++++++++++++++++++++++++++++------ src/workspace-layouts.js | 5 +++++ test/workspace-layouts.test.js | 28 +++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) 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({