diff --git a/package.json b/package.json index ccb1b13..f058086 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codbash-app", - "version": "7.7.0", + "version": "7.8.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 be92d5e..03c7b03 100644 --- a/src/changelog.js +++ b/src/changelog.js @@ -1,6 +1,17 @@ 'use strict'; const CHANGELOG = [ + { + version: '7.8.0', + date: '2026-07-20', + title: 'Workspace — iTerm-like tabs & splits', + changes: [ + 'Workspace now has named tabs (double-click to rename) — organize your terminals like in iTerm', + 'Split each tab into 1–4 panes; every pane is an independent shell', + 'Per-pane "Launch" dropdown types an agent command (Claude Code, Codex, Qwen, OpenCode, Kiro, Gemini) straight into that pane', + 'Background tabs stay alive when you switch away — their shells keep running', + ], + }, { version: '7.7.0', date: '2026-07-19', diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 432a69c..06f01bc 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -2763,14 +2763,118 @@ body { background: var(--bg-secondary); } .workspace-title { font-weight: 600; color: var(--text-primary); font-size: 13px; } -.workspace-status { font-size: 12px; color: var(--text-muted); flex: 1; } -.workspace-term { +.ws-layouts { display: flex; gap: 4px; } +.ws-layouts .toolbar-btn { min-width: 34px; letter-spacing: 1px; } +.workspace-hint { font-size: 11px; color: var(--text-muted); } + +/* ── Tab bar (iTerm-like) ── */ +.ws-tabbar { + display: flex; + align-items: stretch; + gap: 2px; + padding: 4px 8px 0; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; +} +.ws-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + max-width: 200px; + border: 1px solid var(--border); + border-bottom: none; + border-radius: 8px 8px 0 0; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + user-select: none; +} +.ws-tab.active { background: var(--bg-card); color: var(--text-primary); border-color: var(--accent-blue); } +.ws-tab-name { overflow: hidden; text-overflow: ellipsis; } +.ws-tab-close { + font-size: 14px; line-height: 1; + background: transparent; border: none; color: var(--text-muted); + cursor: pointer; padding: 0 2px; border-radius: 4px; +} +.ws-tab-close:hover { color: var(--accent-red); } +.ws-tab-add { + align-self: center; + margin-left: 4px; + width: 26px; height: 26px; + font-size: 16px; line-height: 1; + background: transparent; border: 1px solid var(--border); + color: var(--text-secondary); border-radius: 6px; cursor: pointer; +} +.ws-tab-add:hover { color: var(--text-primary); border-color: var(--text-muted); } +.ws-tab-rename { + font-size: 12px; width: 110px; + background: var(--bg-input); color: var(--text-primary); + border: 1px solid var(--accent-blue); border-radius: 4px; padding: 2px 4px; +} + +.workspace-tabpanes { flex: 1; min-height: 0; display: flex; } + +/* Grid of terminal panes. 1–3 panes = equal columns; 4 = 2×2. Only the active + tab's grid is shown; hidden grids keep their ptys alive. */ +.workspace-grid { flex: 1; min-height: 0; - padding: 6px 8px; - background: #08090c; + display: none; + grid-gap: 6px; + gap: 6px; + padding: 6px; + background: var(--bg-primary); +} +.workspace-grid.active { display: grid; } +.workspace-grid[data-count="1"] { grid-template-columns: 1fr; } +.workspace-grid[data-count="2"] { grid-template-columns: 1fr 1fr; } +.workspace-grid[data-count="3"] { grid-template-columns: 1fr 1fr 1fr; } +.workspace-grid[data-count="4"] { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; } + +.ws-pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border: 1px solid var(--border); + border-radius: 8px; overflow: hidden; + background: #08090c; +} +.ws-pane-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px 4px 10px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} +.ws-pane-status { font-size: 11px; color: var(--text-muted); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ws-pane-launch { + font-size: 11px; + background: var(--bg-input); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 6px; + cursor: pointer; +} +.ws-pane-close { + font-size: 15px; + 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-close:hover { color: var(--accent-red); border-color: var(--accent-red); } +.ws-pane-term { flex: 1; min-height: 0; padding: 4px 6px; overflow: hidden; } .changelog-container { padding: 20px; max-width: 700px; } diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js index 0d5d9b0..15d7d78 100644 --- a/src/frontend/workspace.js +++ b/src/frontend/workspace.js @@ -1,35 +1,49 @@ -// Workspace — browser terminal (B-0 walking skeleton). +// Workspace — iTerm-like browser terminal: named tabs, each with 1–4 split +// panes (B-3). Every pane is an independent xterm + WebSocket + server-side pty. // -// Lazily loads the vendored xterm.js bundle (so the base dashboard never pays -// for it), fetches the per-process WS token from /api/terminal/status, then -// opens a single pty over a WebSocket to /ws/terminal. +// Model: +// _wsTabs = [{ id, name, panes: [pane] }] pane = { id, cmd, term, sock, fit, ro } +// _wsActiveTabId the visible tab +// Each tab owns a ; only the active one is +// shown (display:grid), the rest stay in the DOM hidden so their ptys live on +// across tab switches. Layout is a CSS grid of 1–4 panes. // // Wire protocol (mirror of src/terminal.js): // • binary frames = raw terminal bytes (stdin up / stdout down) // • text frames = JSON control ({t:'resize'|'ready'|'exit'|'error'}) -var _wsTerm = null; // xterm Terminal instance -var _wsSock = null; // WebSocket -var _wsFit = null; // FitAddon -var _wsResizeHandler = null; +var MAX_WS_PANES = 4; + +var WORKSPACE_AGENTS = [ + { label: 'Claude Code', cmd: 'claude' }, + { label: 'Codex', cmd: 'codex' }, + { label: 'Qwen', cmd: 'qwen' }, + { label: 'OpenCode', cmd: 'opencode' }, + { label: 'Kiro', cmd: 'kiro-cli' }, + { label: 'Gemini', cmd: 'gemini' } +]; + +var _wsTabs = []; +var _wsActiveTabId = null; +var _wsTabSeq = 0; +var _wsPaneSeq = 0; +var _wsToken = null; var _wsVendorLoaded = false; +// ── vendor ────────────────────────────────────────────────────────────────── function _loadWorkspaceVendor() { if (_wsVendorLoaded) return Promise.resolve(); return new Promise(function (resolve, reject) { - // CSS if (!document.querySelector('link[data-xterm]')) { var link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = '/vendor/xterm.css'; + link.rel = 'stylesheet'; link.href = '/vendor/xterm.css'; link.setAttribute('data-xterm', '1'); document.head.appendChild(link); } function loadScript(src) { return new Promise(function (res, rej) { var s = document.createElement('script'); - s.src = src; - s.onload = res; + s.src = src; s.onload = res; s.onerror = function () { rej(new Error('failed to load ' + src)); }; document.head.appendChild(s); }); @@ -41,131 +55,321 @@ function _loadWorkspaceVendor() { }); } -// Called from render() when leaving the Workspace view. -function teardownWorkspaceIfActive() { - if (_wsSock || _wsTerm) _teardownWorkspace(); -} - -function _teardownWorkspace() { - if (_wsResizeHandler) { window.removeEventListener('resize', _wsResizeHandler); _wsResizeHandler = null; } - if (_wsSock) { - try { _wsSock.onclose = null; _wsSock.close(); } catch (e) {} - _wsSock = null; - } - if (_wsTerm) { - try { _wsTerm.dispose(); } catch (e) {} - _wsTerm = null; +// ── lookups ───────────────────────────────────────────────────────────────── +function _wsTab(id) { return _wsTabs.find(function (t) { return t.id === id; }); } +function _wsActiveTab() { return _wsTab(_wsActiveTabId); } +function _wsFindPane(id) { + for (var i = 0; i < _wsTabs.length; i++) { + var p = _wsTabs[i].panes.find(function (x) { return x.id === id; }); + if (p) return p; } - _wsFit = null; + return null; } -async function renderWorkspace(container) { - _teardownWorkspace(); - container.innerHTML = '
Loading terminal…
'; - - var status; - try { - var resp = await fetch('/api/terminal/status'); - status = await resp.json(); - } catch (e) { - container.innerHTML = '
Could not reach the terminal service.
'; - return; - } - - if (!status.available) { - container.innerHTML = - '
' + - 'Terminal not available
' + - escHtml(status.hint || 'The optional terminal dependency is not installed.') + - (status.error ? '
' + escHtml(status.error) + '' : '') + - '
'; - return; - } - - container.innerHTML = - '
' + - '
' + - 'Workspace' + - 'connecting…' + - '' + - '
' + - '
' + - '
'; +// ── teardown ──────────────────────────────────────────────────────────────── +function _wsTeardownPane(pane) { + if (!pane) return; + if (pane.ro) { try { pane.ro.disconnect(); } catch (e) {} } + if (pane.sock) { try { pane.sock.onclose = null; pane.sock.close(); } catch (e) {} } + if (pane.term) { try { pane.term.dispose(); } catch (e) {} } + pane.sock = null; pane.term = null; pane.fit = null; pane.ro = null; +} +function teardownWorkspaceIfActive() { + if (!_wsTabs.length) return; + _wsTabs.forEach(function (t) { t.panes.forEach(_wsTeardownPane); }); + _wsTabs = []; _wsActiveTabId = null; +} - try { - await _loadWorkspaceVendor(); - } catch (e) { - container.innerHTML = '
Failed to load terminal assets.
'; - return; - } +// ── pane connection ───────────────────────────────────────────────────────── +function _wsShortCwd(cwd) { + if (!cwd) return 'ready'; + return cwd.replace(/^\/Users\/[^/]+/, '~').replace(/^\/home\/[^/]+/, '~'); +} - var host = document.getElementById('wsTerm'); - if (!host) return; // view switched away while loading +function _wsConnectPane(pane) { + var host = document.getElementById('wsTermHost-' + pane.id); + if (!host) return; var term = new Terminal({ - cursorBlink: true, - fontSize: 13, + cursorBlink: true, fontSize: 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', - theme: { background: '#08090c' }, - scrollback: 5000, - allowProposedApi: true + theme: { background: '#08090c' }, scrollback: 5000, allowProposedApi: true }); var fit = new FitAddon.FitAddon(); term.loadAddon(fit); term.open(host); try { fit.fit(); } catch (e) {} - _wsTerm = term; - _wsFit = fit; + pane.term = term; pane.fit = fit; var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; var url = proto + '//' + location.host + '/ws/terminal' + - '?token=' + encodeURIComponent(status.token) + - '&cols=' + term.cols + '&rows=' + term.rows; + '?token=' + encodeURIComponent(_wsToken) + '&cols=' + term.cols + '&rows=' + term.rows; var sock = new WebSocket(url); sock.binaryType = 'arraybuffer'; - _wsSock = sock; - - var statusEl = document.getElementById('wsStatus'); - function setStatus(txt) { if (statusEl) statusEl.textContent = txt; } + pane.sock = sock; var enc = new TextEncoder(); - var dec = new TextDecoder(); + function setStatus(txt) { var el = document.getElementById('wsStatus-' + pane.id); if (el) el.textContent = txt; } - sock.onopen = function () { - setStatus('connected'); - term.focus(); - }; + sock.onopen = function () { setStatus('connected'); }; sock.onmessage = function (ev) { if (typeof ev.data === 'string') { - var msg; - try { msg = JSON.parse(ev.data); } catch (e) { return; } - if (msg.t === 'ready') { setStatus(msg.cwd || 'ready'); } - else if (msg.t === 'exit') { setStatus('exited (' + msg.code + ')'); } + var msg; try { msg = JSON.parse(ev.data); } catch (e) { return; } + if (msg.t === 'ready') { + setStatus(_wsShortCwd(msg.cwd)); + if (pane.cmd) setTimeout(function () { if (sock.readyState === 1) sock.send(enc.encode(pane.cmd + '\r')); }, 120); + } else if (msg.t === 'exit') { setStatus('exited (' + msg.code + ')'); } else if (msg.t === 'error') { setStatus('error'); term.write('\r\n\x1b[31m' + (msg.message || 'error') + '\x1b[0m\r\n'); } return; } - // binary: raw terminal bytes term.write(new Uint8Array(ev.data)); }; sock.onclose = function () { setStatus('disconnected'); }; sock.onerror = function () { setStatus('connection error'); }; - // stdin -> server (binary) - term.onData(function (data) { - if (sock.readyState === WebSocket.OPEN) sock.send(enc.encode(data)); + term.onData(function (data) { if (sock.readyState === 1) sock.send(enc.encode(data)); }); + + var rt = null; + pane.ro = new ResizeObserver(function () { + clearTimeout(rt); + rt = setTimeout(function () { + try { fit.fit(); } catch (e) {} + if (sock.readyState === 1) sock.send(JSON.stringify({ t: 'resize', cols: term.cols, rows: term.rows })); + }, 100); + }); + try { pane.ro.observe(host.parentNode); } catch (e) {} +} + +// ── markup ────────────────────────────────────────────────────────────────── +function _wsPaneMarkup(pane) { + var opts = ''; + WORKSPACE_AGENTS.forEach(function (a) { opts += ''; }); + return '' + + '
' + + '
' + + 'connecting…' + + '' + + '' + + '
' + + '
' + + '
'; +} + +function _wsTabMarkup(tab) { + return '' + + '
' + + '' + escHtml(tab.name) + '' + + '' + + '
'; +} + +// ── tab bar + active-tab rendering ────────────────────────────────────────── +function _wsRenderTabbar() { + var bar = document.getElementById('wsTabbar'); + if (!bar) return; + bar.innerHTML = _wsTabs.map(_wsTabMarkup).join('') + + ''; +} + +function _wsRefitTab(tab) { + if (!tab) return; + tab.panes.forEach(function (p) { + if (p.fit) { try { p.fit.fit(); } catch (e) {} } + if (p.sock && p.sock.readyState === 1 && p.term) { + p.sock.send(JSON.stringify({ t: 'resize', cols: p.term.cols, rows: p.term.rows })); + } + }); +} + +// Ensure every tab has a grid div; reconcile the active tab's panes; show only +// the active grid. Live panes are never rebuilt — only new panes get connected. +function _wsRenderPanes() { + var area = document.getElementById('wsTabPanes'); + if (!area) return; + + // Remove grids for tabs that no longer exist. + var tabIds = {}; + _wsTabs.forEach(function (t) { tabIds[t.id] = true; }); + Array.prototype.slice.call(area.querySelectorAll('.workspace-grid')).forEach(function (g) { + if (!tabIds[g.getAttribute('data-tab-id')]) g.remove(); }); - // resize -> server (text control), debounced - var resizeTimer = null; - function doResize() { - try { fit.fit(); } catch (e) {} - if (sock.readyState === WebSocket.OPEN) { - sock.send(JSON.stringify({ t: 'resize', cols: term.cols, rows: term.rows })); + _wsTabs.forEach(function (tab) { + var grid = area.querySelector('.workspace-grid[data-tab-id="' + tab.id + '"]'); + if (!grid) { + area.insertAdjacentHTML('beforeend', '
'); + grid = area.querySelector('.workspace-grid[data-tab-id="' + tab.id + '"]'); } + var active = tab.id === _wsActiveTabId; + grid.classList.toggle('active', active); + grid.setAttribute('data-count', String(tab.panes.length)); + + // Reconcile panes within this tab's grid. + var present = {}; + tab.panes.forEach(function (p) { present[p.id] = true; }); + Array.prototype.slice.call(grid.querySelectorAll('.ws-pane')).forEach(function (el) { + if (!present[el.getAttribute('data-pane-id')]) el.remove(); + }); + tab.panes.forEach(function (p) { + if (!grid.querySelector('.ws-pane[data-pane-id="' + p.id + '"]')) { + grid.insertAdjacentHTML('beforeend', _wsPaneMarkup(p)); + _wsConnectPane(p); + } + }); + }); + + // Refit the active tab shortly after it becomes visible (0-size while hidden). + setTimeout(function () { _wsRefitTab(_wsActiveTab()); }, 60); +} + +function _wsSyncLayoutButtons() { + var tab = _wsActiveTab(); + var n = tab ? tab.panes.length : 0; + ['1', '2', '3', '4'].forEach(function (k) { + var b = document.getElementById('wsLayout-' + k); + if (b) b.classList.toggle('active', n === Number(k)); + }); + var add = document.getElementById('wsAddPane'); + if (add) add.disabled = n >= MAX_WS_PANES; +} + +function _wsRenderAll() { _wsRenderTabbar(); _wsRenderPanes(); _wsSyncLayoutButtons(); } + +// ── tab ops ───────────────────────────────────────────────────────────────── +function addWorkspaceTab() { + var tab = { id: 't' + (++_wsTabSeq), name: 'Tab ' + (_wsTabs.length + 1), panes: [{ id: 'p' + (++_wsPaneSeq), cmd: null }] }; + _wsTabs.push(tab); + _wsActiveTabId = tab.id; + _wsRenderAll(); +} + +function activateWorkspaceTab(id) { + if (id === _wsActiveTabId) return; + _wsActiveTabId = id; + _wsRenderAll(); + var tab = _wsActiveTab(); + if (tab && tab.panes[0] && tab.panes[0].term) tab.panes[0].term.focus(); +} + +function closeWorkspaceTab(id) { + var idx = _wsTabs.findIndex(function (t) { return t.id === id; }); + if (idx < 0) return; + _wsTabs[idx].panes.forEach(_wsTeardownPane); + _wsTabs.splice(idx, 1); + if (_wsTabs.length === 0) { addWorkspaceTab(); return; } + if (_wsActiveTabId === id) _wsActiveTabId = _wsTabs[Math.max(0, idx - 1)].id; + _wsRenderAll(); +} + +function renameWorkspaceTab(id) { + var tab = _wsTab(id); + if (!tab) return; + var el = document.querySelector('.ws-tab[data-tab-id="' + id + '"] .ws-tab-name'); + if (!el) return; + var input = document.createElement('input'); + input.className = 'ws-tab-rename'; + input.value = tab.name; + el.replaceWith(input); + input.focus(); input.select(); + function commit() { + var v = input.value.trim(); + if (v) tab.name = v; + _wsRenderTabbar(); } - _wsResizeHandler = function () { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(doResize, 120); - }; - window.addEventListener('resize', _wsResizeHandler); + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + else if (e.key === 'Escape') { _wsRenderTabbar(); } + }); + input.addEventListener('blur', commit); + input.addEventListener('click', function (e) { e.stopPropagation(); }); +} + +// ── pane ops (operate on the active tab) ──────────────────────────────────── +function setWorkspaceLayout(n) { + var tab = _wsActiveTab(); + if (!tab) return; + n = Math.max(1, Math.min(MAX_WS_PANES, n)); + while (tab.panes.length < n) tab.panes.push({ id: 'p' + (++_wsPaneSeq), cmd: null }); + while (tab.panes.length > n) _wsTeardownPane(tab.panes.pop()); + _wsRenderPanes(); _wsSyncLayoutButtons(); +} + +function addWorkspacePane(cmd) { + var tab = _wsActiveTab(); + if (!tab || tab.panes.length >= MAX_WS_PANES) return; + tab.panes.push({ id: 'p' + (++_wsPaneSeq), cmd: cmd || null }); + _wsRenderPanes(); _wsSyncLayoutButtons(); +} + +function closeWorkspacePane(id) { + for (var i = 0; i < _wsTabs.length; i++) { + var tab = _wsTabs[i]; + var idx = tab.panes.findIndex(function (p) { return p.id === id; }); + if (idx < 0) continue; + _wsTeardownPane(tab.panes[idx]); + tab.panes.splice(idx, 1); + if (tab.panes.length === 0) tab.panes.push({ id: 'p' + (++_wsPaneSeq), cmd: null }); + _wsRenderPanes(); _wsSyncLayoutButtons(); + return; + } +} + +function launchAgentInPane(id, cmd) { + if (!cmd) return; + var pane = _wsFindPane(id); + if (!pane || !pane.sock || pane.sock.readyState !== 1) return; + pane.sock.send(new TextEncoder().encode(cmd + '\r')); + if (pane.term) pane.term.focus(); +} + +// ── mount ─────────────────────────────────────────────────────────────────── +async function renderWorkspace(container) { + // Idempotent: background refreshes call render() while on this view. + if (_wsTabs.length && document.getElementById('wsTabPanes')) return; + + teardownWorkspaceIfActive(); + container.innerHTML = '
Loading terminal…
'; + + var status; + try { status = await (await fetch('/api/terminal/status')).json(); } + catch (e) { container.innerHTML = '
Could not reach the terminal service.
'; return; } + + if (!status.available) { + container.innerHTML = + '
' + + 'Terminal not available
' + + escHtml(status.hint || 'The optional terminal dependency is not installed.') + + (status.error ? '
' + escHtml(status.error) + '' : '') + + '
'; + return; + } + _wsToken = status.token; + + container.innerHTML = + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + 'Double-click a tab to rename' + + '
' + + '
' + + '
'; + + try { await _loadWorkspaceVendor(); } + catch (e) { container.innerHTML = '
Failed to load terminal assets.
'; return; } + if (!document.getElementById('wsTabPanes')) return; // switched away while loading + + _wsTabs = [{ id: 't' + (++_wsTabSeq), name: 'Tab 1', panes: [{ id: 'p' + (++_wsPaneSeq), cmd: null }] }]; + _wsActiveTabId = _wsTabs[0].id; + _wsRenderAll(); }