diff --git a/desktop/package.json b/desktop/package.json index c8902b1..fa19b94 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "codbash-desktop", "productName": "codbash", - "version": "7.16.0", + "version": "7.17.0", "private": true, "description": "Desktop shell (Electron) for codbash — wraps the codbash server in a native window.", "main": "main.js", diff --git a/package.json b/package.json index b7ab9a6..89b60fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codbash-app", - "version": "7.16.0", + "version": "7.17.0", "description": "Dashboard + CLI for AI coding agents — Claude Code, Codex, Cursor, OpenCode, Kiro. View, search, resume, convert, sync sessions.", "bin": { "codbash": "./bin/cli.js", diff --git a/src/changelog.js b/src/changelog.js index f4beece..f802bc1 100644 --- a/src/changelog.js +++ b/src/changelog.js @@ -1,6 +1,15 @@ 'use strict'; const CHANGELOG = [ + { + version: '7.17.0', + date: '2026-07-30', + title: 'Name your terminals — and dimmed text is readable again', + changes: [ + 'Terminal panes can be named: click the ✎ in a pane\'s title bar (or double-click the title) to label it, e.g. "API" and "worker" for two shells in the same folder. An empty name restores the automatic label (agent → folder → ~), and the name survives reloads, saved layouts and restored sessions', + 'Fixed dimmed text on a filled background rendering as an empty highlighted bar with no text at all — some rows (e.g. the grey "message above" bars a coding agent draws) lost their text entirely in a codbash pane while staying legible in iTerm. Terminal panes now enforce a minimum contrast ratio, so a dimmed foreground is never washed out to invisible', + ], + }, { version: '7.16.0', date: '2026-07-30', 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..3c2b525 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 @@ -552,7 +554,16 @@ function _wsConnectPane(pane) { var term = new Terminal({ cursorBlink: _tp.cursorBlink, cursorStyle: _tp.cursorStyle, fontSize: _tp.fontSize, fontFamily: _tp.fontFamily, - theme: _wsTermTheme(), scrollback: 5000, allowProposedApi: true + theme: _wsTermTheme(), scrollback: 5000, allowProposedApi: true, + // Guarantee readable text against whatever background a TUI paints. + // Without this (xterm's default is 1 = no adjustment) *dimmed* text on a + // filled background renders as an apparently EMPTY highlighted bar — the + // WebGL renderer applies dim by cutting the foreground alpha, so e.g. the + // grey "message above" rows Claude Code draws lost their text entirely, + // while the same output stayed legible in iTerm. 4.5 is the WCAG AA ratio; + // xterm only nudges a foreground when it falls below it, so normal colors + // are left alone. + minimumContrastRatio: 4.5 }); var fit = new FitAddon.FitAddon(); term.loadAddon(fit); @@ -734,7 +745,11 @@ function _wsPaneMarkup(pane) { return '' + '
' + '
' + - 'connecting…' + + 'connecting…' + + '' + '' + '' + @@ -1531,7 +1546,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 +1830,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 +1861,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 +1935,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 +2105,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 +2178,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({