diff --git a/.gitignore b/.gitignore index db0fb87..04d7058 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ node_modules/ hooks/ .tmp/ package-lock.json + +# Local dev screenshots / MCP scratch (not shipped) +.playwright-mcp/ +/*.png diff --git a/desktop/dev.sh b/desktop/dev.sh new file mode 100755 index 0000000..0ec0cc2 --- /dev/null +++ b/desktop/dev.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Relaunch the codbash desktop app FROM SOURCE with live frontend reload. +# +# Use this (not the installed DMG) to see uncommitted changes: +# • The window shows an amber "DEV" badge next to the version. +# • Frontend edits (src/frontend/**) → just press Cmd+R in the window. +# • Backend edits (src/*.js, bin/**) → re-run this script. +# +# It kills any prior dev instances first so you never end up staring at a stale +# window on the wrong port. +set -e +pkill -f "codbash/desktop" 2>/dev/null || true +pkill -f "bin/cli.js run" 2>/dev/null || true +sleep 1 +cd "$(dirname "$0")" +NODE_ENV=development exec npm start diff --git a/desktop/main.js b/desktop/main.js index b143652..b272059 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -35,6 +35,31 @@ function getFreePort() { }); } +// A STABLE loopback port keeps the window's origin (http://127.0.0.1:) +// constant across launches. This is REQUIRED for persistence: localStorage is +// scoped to the origin, so a random ephemeral port silently wiped EVERYTHING +// origin-scoped every launch — theme, starred sessions, tags, terminal prefs, +// and the saved Workspace session used to restore tabs/panes. We therefore +// prefer a fixed port and only fall back to an ephemeral one if it is genuinely +// occupied (rare — another instance or an unrelated listener). +const PREFERRED_PORT = 51763; +function isPortFree(port) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.unref(); + srv.once('error', () => resolve(false)); + srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true))); + }); +} +async function resolveStablePort() { + if (process.env.CODBASH_PORT) { + const p = parseInt(process.env.CODBASH_PORT, 10); + if (Number.isInteger(p) && p > 0 && await isPortFree(p)) return p; + } + if (await isPortFree(PREFERRED_PORT)) return PREFERRED_PORT; + return getFreePort(); +} + // Where the codbash server lives: repo layout in dev, bundled resources when packaged. function resolveServerEntry() { const dev = path.join(__dirname, '..', 'bin', 'cli.js'); @@ -135,6 +160,9 @@ function registerIpc() { if (res.canceled || !res.filePaths || !res.filePaths.length) return null; return res.filePaths[0]; }); + // The renderer decides a shortcut had no in-page meaning (e.g. Cmd+W outside + // the Workspace) and asks us to close the window instead. + ipcMain.on('codbash:close-window', function () { if (win) { try { win.close(); } catch (_e) {} } }); } async function createWindow() { @@ -160,6 +188,20 @@ async function createWindow() { return { action: 'allow' }; }); + // Cmd/Ctrl+W would hit the native menu's "Close Window" before the page ever + // sees the keystroke. Intercept it here so it can close the ACTIVE TAB instead + // (Chrome-like). We forward it to the renderer, which closes a tab or — if + // there's nothing to close — calls back to close the window. Cmd+T and + // Cmd+Shift+T are NOT default menu accelerators, so the page handles those. + win.webContents.on('before-input-event', function (event, input) { + if (input.type !== 'keyDown') return; + const mod = input.meta || input.control; + if (mod && !input.shift && !input.alt && (input.key === 'w' || input.key === 'W')) { + event.preventDefault(); + try { win.webContents.send('codbash:shortcut', 'close-tab'); } catch (_e) {} + } + }); + await win.loadURL('http://127.0.0.1:' + serverPort + '/'); if (SMOKE) { @@ -232,7 +274,7 @@ function initAutoUpdater() { app.whenReady().then(async function () { try { - serverPort = await getFreePort(); + serverPort = await resolveStablePort(); startServer(serverPort); await waitForServer(serverPort); await createWindow(); diff --git a/desktop/package.json b/desktop/package.json index 776f578..6c7e74e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "codbash-desktop", "productName": "codbash", - "version": "7.14.7", + "version": "7.15.0", "private": true, "description": "Desktop shell (Electron) for codbash — wraps the codbash server in a native window.", "main": "main.js", @@ -12,7 +12,8 @@ "smoke": "CODBASH_SMOKE=1 electron .", "dist:mac": "electron-builder --mac dmg", "release:mac": "electron-builder --mac dmg --publish always", - "pack": "electron-builder --dir" + "pack": "electron-builder --dir", + "dev": "bash dev.sh" }, "devDependencies": { "@electron/notarize": "^2.5.0", diff --git a/desktop/preload.js b/desktop/preload.js index 7472d54..da58087 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -9,4 +9,10 @@ contextBridge.exposeInMainWorld('codbashDesktop', { isDesktop: true, // Resolves to the chosen absolute folder path, or null if the user cancels. pickFolder: () => ipcRenderer.invoke('codbash:pick-folder'), + // Keyboard shortcuts the native menu would otherwise swallow (e.g. Cmd+W + // closing the whole window). Main intercepts them and forwards the name here + // so the page can act (close a tab instead). cb receives the shortcut name. + onShortcut: (cb) => ipcRenderer.on('codbash:shortcut', (_e, name) => cb(name)), + // Ask main to close the window (used when a shortcut has no in-page meaning). + closeWindow: () => ipcRenderer.send('codbash:close-window'), }); diff --git a/package.json b/package.json index fd53865..b734eb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codbash-app", - "version": "7.14.7", + "version": "7.15.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 cdd4736..7d60a62 100644 --- a/src/changelog.js +++ b/src/changelog.js @@ -1,6 +1,22 @@ 'use strict'; const CHANGELOG = [ + { + version: '7.15.0', + date: '2026-07-23', + title: 'Browser-like terminal: session resume, bookmarks & groups, settings, resizable panes — plus input-freeze & lost-dialog fixes', + changes: [ + 'Fixed the terminal freezing input intermittently — the running-agents and pane-cwd polls ran ps/lsof synchronously on the event loop (every 1–4s), stalling terminal I/O; they now run off the loop so typing stays smooth', + 'Fixed agents launched from codbash writing their conversation to the wrong place: panes no longer inherit the parent\'s agent-session vars (which made a nested "hidden folder" session) or npm\'s npm_config_prefix (which broke nvm), and a requested folder is never silently swapped for your home directory (which misfiled dialogs) — you\'re warned instead', + 'Resume where you left off (Chrome-like): reopening the app offers to continue each pane\'s last agent conversation in that folder with one click (claude --continue), keeping your proxy prefix', + 'Bookmarks bar with named groups/folders — drag a bookmark into a group; one click opens the folder and launches its agent', + 'Terminal settings (⚙): font family & size, theme (iTerm, Monokai, Dracula, Solarized, Light…), cursor style and blink — applied live to every pane', + 'Resizable split panes — drag the divider; sizes persist with the session and survive maximize / fullscreen', + 'Tabs: Cmd+T new tab, Cmd+W close tab, Cmd+Shift+T reopen the last closed tab (full layout), and drag to reorder', + 'Reclaimed vertical space by merging the tab bar and toolbar into one compact row', + 'Running Agents now lists only agents actually launched from codbash', + ], + }, { version: '7.14.7', date: '2026-07-21', diff --git a/src/data.js b/src/data.js index 601599b..987f452 100644 --- a/src/data.js +++ b/src/data.js @@ -1,7 +1,10 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execSync, execFileSync } = require('child_process'); +const { execSync, execFileSync, exec, execFile } = require('child_process'); +const { promisify } = require('util'); +const _execAsync = promisify(exec); +const _execFileAsync = promisify(execFile); const { atomicWriteJson } = require('./atomic'); // ── Constants ────────────────────────────────────────────── @@ -5398,31 +5401,98 @@ function _analyticsKey(sessions) { return sessions.length + ':' + newest; } -function getCostAnalytics(sessions) { - // Fast cache check — if sessions haven't changed, return cached result +// Background recompute guard: the key we're currently (re)computing off the +// request path, so overlapping Overview polls don't queue N duplicate 4s jobs. +let _analyticsRecomputeKey = null; + +function _persistAnalytics(key, result) { + try { fs.writeFileSync(ANALYTICS_CACHE_FILE, JSON.stringify({ _key: key, data: result })); } catch {} +} + +// Recompute analytics off the request path and refresh the in-memory + disk +// cache when done. Deduped by key so a burst of stale hits schedules one job. +// +// The expensive part of a cold recompute is ~O(sessions) computeSessionCost() +// calls, each of which may read+parse a JSONL file. Running them all in one +// synchronous tick would freeze the event loop for seconds — stalling every +// other request AND the terminal WebSocket data pump. So we warm those caches +// in small chunks, yielding to the loop between chunks; the final aggregate is +// then cache-warm and returns in milliseconds. +function _scheduleAnalyticsRecompute(key, sessions) { + if (_analyticsRecomputeKey === key) return; + _analyticsRecomputeKey = key; + const finish = () => { if (_analyticsRecomputeKey === key) _analyticsRecomputeKey = null; }; + (async () => { + try { + const CHUNK = 80; + for (let i = 0; i < sessions.length; i += CHUNK) { + const chunk = sessions.slice(i, i + CHUNK); + for (const s of chunk) { + // Skip formats that carry no token cost (cursor/kiro/copilot) — they + // short-circuit in computeSessionCost anyway, but avoid the call. + if (s.tool === 'cursor' || s.tool === 'kiro' || s.tool === 'copilot-chat') continue; + try { computeSessionCost(s.id, s.project); } catch {} + } + // Yield so terminal output and other API calls stay responsive. + await new Promise(r => setImmediate(r)); + } + const result = _computeCostAnalytics(sessions); // caches warm → fast, sync + _analyticsCacheResult = result; + _analyticsCacheKey = key; + _persistAnalytics(key, result); + } catch {} + finally { finish(); } + })(); +} + +// Cost analytics with stale-while-revalidate. The cache key is +// count+newest-mtime, so it invalidates after ANY session activity — which, +// with ~1500 sessions, meant a ~4s synchronous recompute on nearly every cold +// start / Overview open. Instead we return the last known result INSTANTLY and +// refresh in the background (opts.allowStale, set by the unfiltered Overview +// path). Only the very first computation, when nothing is cached yet, blocks. +// +// Date-filtered requests (from/to → allowStale falsy) must be exact, so they +// compute synchronously and are kept out of the shared Overview cache to avoid +// a filtered snapshot briefly leaking onto the unfiltered Overview. +function getCostAnalytics(sessions, opts) { + opts = opts || {}; const key = _analyticsKey(sessions); + + // Exact in-memory hit → fresh. if (_analyticsCacheResult && _analyticsCacheKey === key) return _analyticsCacheResult; - // Try disk cache + // Warm the in-memory cache from disk once (survives restarts). if (!_analyticsCacheResult) { try { if (fs.existsSync(ANALYTICS_CACHE_FILE)) { const cached = JSON.parse(fs.readFileSync(ANALYTICS_CACHE_FILE, 'utf8')); - if (cached._key === key) { + if (cached && cached.data) { _analyticsCacheResult = cached.data; - _analyticsCacheKey = key; - return cached.data; + _analyticsCacheKey = cached._key; + if (cached._key === key) return cached.data; } } } catch {} } + // Stale-while-revalidate for the unfiltered Overview/startup path: return the + // previous result now, refresh in the background. Never blocks after the very + // first ever computation. + if (opts.allowStale && _analyticsCacheResult) { + _scheduleAnalyticsRecompute(key, sessions); + return _analyticsCacheResult; + } + + // Cold path: first-ever computation, or an exact (date-filtered) request. const result = _computeCostAnalytics(sessions); - // Save to cache - _analyticsCacheResult = result; - _analyticsCacheKey = key; - try { fs.writeFileSync(ANALYTICS_CACHE_FILE, JSON.stringify({ _key: key, data: result })); } catch {} + // Only the unfiltered path owns the shared Overview cache. + if (opts.allowStale) { + _analyticsCacheResult = result; + _analyticsCacheKey = key; + _persistAnalytics(key, result); + } return result; } @@ -5767,7 +5837,11 @@ function findQwenSessionByPid(pid, cwd, allSessions) { return null; } -function getActiveSessions() { +// ASYNC on purpose: this shells out to `ps aux` (and sometimes `lsof`) and is +// polled by /api/active every second. The old synchronous execSync blocked the +// single Node event loop each tick, which stalled the Workspace pty's I/O — the +// "input freezes for a moment" bug. Async execFile keeps it off the loop. +async function getActiveSessions() { const active = []; const seenPids = new Set(); @@ -5800,10 +5874,10 @@ function getActiveSessions() { if (process.platform === 'win32') return active; try { - const psOut = execSync( + const psOut = (await _execAsync( 'ps aux 2>/dev/null | grep -E "claude|codex|qwen|omp|omp.cmd|(^|[[:space:]/])pi([[:space:]]|$)|opencode|kiro-cli|cursor-agent|kilo" | grep -v grep || true', - { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } - ); + { encoding: 'utf8', timeout: 3000, maxBuffer: 4 * 1024 * 1024 } + )).stdout; const allSessions = loadSessions(); @@ -5828,6 +5902,16 @@ function getActiveSessions() { // Skip node/npm/shell wrappers, MCP servers, plugins — only main agent processes if (cmd.includes('node bin/cli') || /(^|\s)npm(\s|$)/.test(cmd) || /(^|\s)grep(\s|$)/.test(cmd)) continue; + // Skip GUI desktop apps whose name/path contains an agent word but which + // are NOT CLI agents — most notably the Claude *Desktop* chat app, whose + // Electron helpers live under `/Applications/Claude.app/…/Claude Helper` + // and match the loose `\bclaude\b` pattern. Electron subprocesses are + // identified by their `--type=` + // flag; the bundle helpers by their `.app/Contents/{Frameworks,MacOS}/` + // path. Neither is ever a coding-agent session. + if (/--type=(renderer|utility|gpu-process|zygote|broker|crashpad|ppapi|sandbox)/.test(cmd)) continue; + if (/\.app\/Contents\/(Frameworks|MacOS)\//.test(cmd)) continue; + if (/(^|\/)(Claude|Cursor|Code) Helper/.test(cmd)) continue; if (cmd.includes('mcp-server') || cmd.includes('mcp_server') || cmd.includes('/mcp/') || cmd.includes('/mcp-servers/')) continue; if (cmd.includes('/plugins/') || cmd.includes('plugin-') || cmd.includes('app-server-broker')) continue; if (cmd.includes('.claude/') && !cmd.includes('claude ') && tool === 'claude') continue; @@ -5878,7 +5962,7 @@ function getActiveSessions() { } if (!cwd) { try { - const lsofOut = execSync(`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }); + const lsofOut = (await _execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 2000 })).stdout; // -F output is line-oriented; cwd path lives on a line starting with "n". // Anchor to start-of-line (multiline flag) so we don't depend on a // preceding newline, and tolerate non-path lines mixed in. @@ -5972,7 +6056,44 @@ function getActiveSessions() { if (entryScore > existingScore) deduped.set(key, entry); } - return Array.from(deduped.values()); + // Scope to agents launched from codbash — only those whose process tree + // descends from a codbash terminal pane (see pty-registry). Matches the + // "codbash-only" RUNNING AGENTS model: no panes open → nothing shown. + return await _scopeToCodbashAgents(Array.from(deduped.values())); +} + +// Keep only active entries whose process ancestry reaches a live codbash pty. +// Empty pty registry → empty result (nothing is running *in* codbash). If the +// ppid scan itself fails we fail OPEN (return the unfiltered list) rather than +// hiding genuine sessions on a transient `ps` error. +async function _scopeToCodbashAgents(active) { + let ptyRegistry; + try { ptyRegistry = require('./pty-registry'); } catch { return active; } + const livePids = ptyRegistry.all(); + if (!livePids.length) return []; + if (process.platform === 'win32') return active; // no ppid scan here + const live = new Set(livePids); + + // Build a pid→ppid map in one cheap (async, off-loop) call so we can walk each + // agent's ancestry without blocking the event loop. + const ppidOf = new Map(); + try { + const out = (await _execFileAsync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8', timeout: 3000, maxBuffer: 4 * 1024 * 1024 })).stdout; + for (const line of out.split('\n')) { + const m = line.trim().match(/^(\d+)\s+(\d+)$/); + if (m) ppidOf.set(parseInt(m[1], 10), parseInt(m[2], 10)); + } + } catch { return active; } // fail open + + return active.filter(a => { + let pid = a.pid, depth = 0; + while (pid && depth < 16) { + if (live.has(pid)) return true; + pid = ppidOf.get(pid); + depth++; + } + return false; + }); } // ── Leaderboard stats ───────────────────────────────────── diff --git a/src/frontend/app.js b/src/frontend/app.js index 57f0e69..0fd1f8d 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -3586,6 +3586,16 @@ async function checkForUpdates() { if (badge) { badge.textContent = 'v' + data.current; + // DEV badge: when running from source (NODE_ENV=development), flag it so + // it's obvious you're looking at the live-editing build with the latest + // changes — not the installed DMG. Removed automatically in a real build. + if (data.dev) { + badge.textContent = 'v' + data.current + ' · DEV'; + badge.classList.add('dev-build'); + badge.title = 'Running from source — live changes (not the installed app)'; + } else { + badge.classList.remove('dev-build'); + } } // Show "what's new" if version changed since last visit @@ -4509,6 +4519,14 @@ function _onProjectsHashChange() { setInterval(loadSessions, 60000); // refresh sessions + invalidate analytics cache every 60s startActivePolling(); + // Terminal-first landing (Chrome-like): if a previous workspace session was + // saved, open straight into Terminal and restore its tabs/panes instead of + // the Overview dashboard. Read localStorage directly so this doesn't depend on + // workspace.js having initialized yet. First run (no session) keeps Overview. + try { + if (localStorage.getItem('codbash-workspace-session')) currentView = 'workspace'; + } catch (e) { /* localStorage unavailable */ } + // Apply saved theme var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; setTheme(savedTheme); diff --git a/src/frontend/overview.js b/src/frontend/overview.js index 8d1888d..b07d04a 100644 --- a/src/frontend/overview.js +++ b/src/frontend/overview.js @@ -48,9 +48,14 @@ function _ovStat(num, label, sub) { } function renderOverview(container) { - // Make sure the data we lean on is available even on a cold landing. - if (typeof _wsLoadCommands === 'function' && (!window._wsSavedCommands || !_wsSavedCommands.length)) _wsLoadCommands(); - if (typeof _wsLoadLayouts === 'function' && (!window._wsSavedLayouts || !_wsSavedLayouts.length)) _wsLoadLayouts(); + // Make sure the data we lean on is available even on a cold landing. Fire the + // fetch exactly ONCE (guarded by a request flag) — NOT "when the list is + // empty": renderOverview runs once per second, and a user with zero saved + // layouts/commands has a permanently-empty [] which made the old "if empty" + // guard re-fetch /api/terminal/{layouts,commands} every single second. Saving + // a layout/command calls the loader directly, so it still refreshes. + if (typeof _wsLoadCommands === 'function' && !window._ovCommandsRequested) { window._ovCommandsRequested = true; _wsLoadCommands(); } + if (typeof _wsLoadLayouts === 'function' && !window._ovLayoutsRequested) { window._ovLayoutsRequested = true; _wsLoadLayouts(); } if (typeof _wsStartStatusLoop === 'function') _wsStartStatusLoop(); var sessions = (typeof allSessions !== 'undefined' && allSessions) ? allSessions : []; if (!sessions.length && typeof loadSessions === 'function') loadSessions(); @@ -100,8 +105,12 @@ function renderOverview(container) { groups[key].forEach(function (x) { var st = (typeof _wsPaneStatus === 'function') ? _wsPaneStatus(x.pane) : 'idle'; var meta = (window.WS_STATUS_META && WS_STATUS_META[st]) || { label: st, cls: st }; - var cmd = x.pane.cmd || 'shell'; - var masked = (typeof _wsMaskSecrets === 'function') ? _wsMaskSecrets(cmd) : cmd; + // Clean, human label ("claude") — _wsPaneLabel strips leading VAR=value + // env assignments (e.g. HTTPS_PROXY='http://user:pass@host') so we never + // surface a raw proxy string, and masks any remaining secrets. + var masked = (typeof _wsPaneLabel === 'function') + ? _wsPaneLabel(x.pane) + : ((typeof _wsMaskSecrets === 'function') ? _wsMaskSecrets(x.pane.cmd || 'shell') : (x.pane.cmd || 'shell')); html += '' + '' + '' + '
' + + '' + ''; } +// Turn a remembered launch command into the one that CONTINUES that agent's +// last conversation in this folder — the browser-tab-state metaphor: reopening +// restores the dialog, not a blank agent. We keep any `VAR=value` env prefix +// (so the proxy survives) and only append/insert the agent's continue form. +// If the command already resumes, or the agent has no known continue flag, it's +// returned unchanged (so the worst case is a fresh agent, never a broken cmd). +function _wsResumeVariant(cmd) { + var s = String(cmd || '').trim(); + if (!s) return s; + var env = ''; + var m = s.match(_WS_ENV_PREFIX_RE); + if (m) { env = m[0]; s = s.slice(m[0].length); } + // Already a resume/continue invocation — leave it be. + if (/(^|\s)(--continue|-c|--resume|resume)(\s|$)/.test(s)) return env + s; + var word = (s.split(/\s+/)[0] || '').toLowerCase(); + switch (word) { + // Claude: `--continue` resumes the most recent conversation in the cwd, + // keeping any other flags (e.g. --dangerously-skip-permissions). + case 'claude': + case 'claude-ext': return env + s + ' --continue'; + // Codex only resumes via a subcommand; safe when launched bare (`codex`). + case 'codex': return env + (s === 'codex' ? 'codex resume --last' : s); + default: return env + s; + } +} + +// Show the "resume where you left off" banner for a restored pane: the folder it +// opened in and a one-click button that CONTINUES the agent's last conversation +// there (incl. its proxy prefix). Nothing runs until the user clicks — reopening +// a tab shouldn't silently spend tokens. +function _wsShowRestoreBanner(pane) { + var el = document.getElementById('wsRestore-' + pane.id); + if (!el || !pane.restoreCmd) return; + pane._resumeCmd = _wsResumeVariant(pane.restoreCmd); + var label = (typeof _wsMaskSecrets === 'function') ? _wsMaskSecrets(pane._resumeCmd) : pane._resumeCmd; + var where = _wsShortCwd(pane.cwd || pane.wantCwd || ''); + var continues = pane._resumeCmd !== pane.restoreCmd; // did we add a continue form? + el.innerHTML = + '
' + + '
' + + '' + (continues ? 'Resume your conversation' : 'Reopen this terminal') + '' + + '' + escHtml(label) + ' in ' + escHtml(where) + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + el.hidden = false; +} + +// Run the resume command in the pane (user clicked Resume) — the continue +// variant when the agent supports it, otherwise the remembered command. +function wsRestorePaneCmd(paneId) { + var pane = _wsFindPane(paneId); + if (!pane || !pane.restoreCmd) return; + var cmd = pane._resumeCmd || _wsResumeVariant(pane.restoreCmd); + if (pane.sock && pane.sock.readyState === 1) { + pane.sock.send(new TextEncoder().encode(cmd + '\r')); + } + pane.cmd = cmd; // now it's the pane's running command (label, restore next time) + pane.restoreCmd = null; + pane._resumeCmd = null; + wsDismissRestore(paneId); + if (pane.term) pane.term.focus(); + var st = document.getElementById('wsStatus-' + paneId); + if (st) st.textContent = _wsPaneLabel(pane); + _wsSaveSession(); +} + +// Dismiss the restore banner without running anything. +function wsDismissRestore(paneId) { + var pane = _wsFindPane(paneId); + if (pane) pane.restoreCmd = null; + var el = document.getElementById('wsRestore-' + paneId); + if (el) { el.hidden = true; el.innerHTML = ''; } + _wsSaveSession(); +} + +// ── Bookmarks (Chrome-like bookmarks bar) ──────────────────────────────────── +// A bookmark is a saved "site" in the browser metaphor: a folder + the agent to +// run in it. One click opens a new terminal there and launches that agent. This +// is deliberately lighter than a saved Layout (a whole-workspace snapshot) — a +// bookmark is a single target you reach for constantly, like a pinned tab. +var _WS_BOOKMARKS_KEY = 'codbash-bookmarks'; +var _WS_BM_FOLDERS_KEY = 'codbash-bookmark-folders'; +var _wsBookmarks = []; +var _wsBmFolders = []; // [{ id, name, color }] — named groups, Chrome-like +var _WS_BM_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#ef4444', '#06b6d4', '#eab308']; + +function _wsLoadBookmarks() { + try { var a = JSON.parse(localStorage.getItem(_WS_BOOKMARKS_KEY)); _wsBookmarks = Array.isArray(a) ? a : []; } + catch (e) { _wsBookmarks = []; } + try { var f = JSON.parse(localStorage.getItem(_WS_BM_FOLDERS_KEY)); _wsBmFolders = Array.isArray(f) ? f : []; } + catch (e) { _wsBmFolders = []; } +} +function _wsSaveBookmarks() { + try { localStorage.setItem(_WS_BOOKMARKS_KEY, JSON.stringify(_wsBookmarks)); } catch (e) {} + try { localStorage.setItem(_WS_BM_FOLDERS_KEY, JSON.stringify(_wsBmFolders)); } catch (e) {} +} +function _wsBmNextColor() { + return _WS_BM_COLORS[_wsBookmarks.length % _WS_BM_COLORS.length]; +} +function _wsBmFolder(id) { return _wsBmFolders.find(function (f) { return f.id === id; }); } + +// The agent word a bookmark launches (for its label/icon), e.g. "claude". +function _wsBmAgentWord(cmd) { + var s = String(cmd || '').trim().replace(_WS_ENV_PREFIX_RE, ''); + return (s.split(/\s+/)[0] || '').toLowerCase(); +} + +// One bookmark chip (draggable — drop it on a folder to file it there). +function _wsBmChipHtml(b) { + var sub = b.cmd ? _wsBmAgentWord(b.cmd) : (_wsShortCwd(b.cwd) || 'shell'); + var tip = (b.cwd || '') + (b.cmd ? ' — ' + _wsMaskSecrets(b.cmd) : ''); + var id = escHtml(b.id); + return '' + + ''; +} + +function _wsRenderBookmarks() { + var bar = document.getElementById('wsBookmarks'); + if (!bar) return; + // Empty (no folders AND no bookmarks) → hide the strip. Add bookmarks via the + // ☆ in a pane's title bar, or make a folder with the 📁+ button. + if (!_wsBookmarks.length && !_wsBmFolders.length) { bar.style.display = 'none'; bar.innerHTML = ''; return; } + bar.style.display = 'flex'; + + // Folder chips (dropdowns) first, then loose bookmarks (no valid folder). + var folderChips = _wsBmFolders.map(function (f) { + var count = _wsBookmarks.filter(function (b) { return b.folderId === f.id; }).length; + var id = escHtml(f.id); + return '' + + ''; + }).join(''); + var loose = _wsBookmarks.filter(function (b) { return !b.folderId || !_wsBmFolder(b.folderId); }); + var looseChips = loose.map(_wsBmChipHtml).join(''); + + bar.innerHTML = + '' + + folderChips + looseChips + + '' + + ''; +} + +// Open a bookmark: a new tab in its folder, launching its agent (explicit click +// = intent to run, so this auto-runs — unlike passive session restore). +function openBookmark(id) { + _wsCloseBmMenu(); + var b = _wsBookmarks.find(function (x) { return x.id === id; }); + if (!b) return; + openInWorkspace({ name: b.label || _wsProjectBasename(b.cwd), cwd: b.cwd || null, cmd: b.cmd || null }); +} + +// Save a bookmark from a pane (its folder + the agent command it launched). +function _wsBookmarkFromPane(pane) { + if (!pane) return; + var cwd = pane.cwd || pane.wantCwd || ''; + var cmd = pane.cmd || pane.enteredCmd || pane.detectedCmd || ''; + if (!cwd && !cmd) { showToast('Nothing to bookmark yet — open a folder or run an agent first'); return; } + var suggested = _wsProjectBasename(cwd) || _wsBmAgentWord(cmd) || 'bookmark'; + codbashPrompt('Bookmark name:', suggested).then(function (name) { + if (name == null) return; + name = String(name).trim() || suggested; + _wsBookmarks.push({ id: 'bm' + (++_wsPaneSeq), label: name, cwd: cwd, cmd: cmd, color: _wsBmNextColor() }); + _wsSaveBookmarks(); + _wsRenderBookmarks(); + showToast('Bookmarked "' + name + '"'); + }); +} + +// Pane-bar star button → bookmark this pane. +function bookmarkPane(paneId) { _wsBookmarkFromPane(_wsFindPane(paneId)); } + +// Bar "+" → bookmark the focused pane (fallback: first pane of active tab). +function addBookmarkFromFocused() { + var pane = (_wsFocusedPaneId && _wsFindPane(_wsFocusedPaneId)); + if (!pane) { var t = _wsActiveTab(); pane = t && t.panes[0]; } + _wsBookmarkFromPane(pane); +} + +function removeBookmark(id) { + _wsBookmarks = _wsBookmarks.filter(function (x) { return x.id !== id; }); + _wsSaveBookmarks(); + _wsRenderBookmarks(); + _wsRefreshBmMenu(); +} + +function renameBookmark(id) { + var b = _wsBookmarks.find(function (x) { return x.id === id; }); + if (!b) return; + codbashPrompt('Rename bookmark:', b.label || '').then(function (name) { + if (name == null) return; + b.label = String(name).trim() || b.label; + _wsSaveBookmarks(); + _wsRenderBookmarks(); + _wsRefreshBmMenu(); + }); +} + +// ── Bookmark folders (named groups) ────────────────────────────────────────── +function createBmFolder() { + codbashPrompt('New bookmark group name:', 'Group ' + (_wsBmFolders.length + 1)).then(function (name) { + if (name == null) return; + name = String(name).trim(); + if (!name) return; + var color = _WS_BM_COLORS[_wsBmFolders.length % _WS_BM_COLORS.length]; + _wsBmFolders.push({ id: 'bf' + (++_wsPaneSeq), name: name, color: color }); + _wsSaveBookmarks(); + _wsRenderBookmarks(); + showToast('Created group "' + name + '"'); + }); +} +function renameBmFolder(id) { + var f = _wsBmFolder(id); + if (!f) return; + codbashPrompt('Rename group:', f.name || '').then(function (name) { + if (name == null) return; + f.name = String(name).trim() || f.name; + _wsSaveBookmarks(); + _wsRenderBookmarks(); + }); +} +// Delete a group. Its bookmarks are kept (moved back out to loose) — deleting a +// group should never silently drop the bookmarks inside it. +function deleteBmFolder(id) { + var f = _wsBmFolder(id); + if (!f) return; + var n = _wsBookmarks.filter(function (b) { return b.folderId === id; }).length; + if (!confirm('Delete group "' + f.name + '"?' + (n ? ' Its ' + n + ' bookmark' + (n === 1 ? '' : 's') + ' will move out, not be deleted.' : ''))) return; + _wsBookmarks.forEach(function (b) { if (b.folderId === id) b.folderId = null; }); + _wsBmFolders = _wsBmFolders.filter(function (x) { return x.id !== id; }); + _wsSaveBookmarks(); + _wsCloseBmMenu(); + _wsRenderBookmarks(); +} +function moveBookmarkToFolder(bmId, folderId) { + var b = _wsBookmarks.find(function (x) { return x.id === bmId; }); + if (!b) return; + b.folderId = folderId || null; + _wsSaveBookmarks(); + _wsRenderBookmarks(); + _wsRefreshBmMenu(); +} + +// Drag a bookmark chip onto a folder to file it there. +var _wsDragBmId = null; +function wsBmDragStart(e, id) { _wsDragBmId = id; try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); } catch (_e) {} } +function wsBmDragEnd(e) { + _wsDragBmId = null; + var bar = document.getElementById('wsBookmarks'); + if (bar) Array.prototype.slice.call(bar.querySelectorAll('.drop-target')).forEach(function (x) { x.classList.remove('drop-target'); }); +} +function wsBmDragOverFolder(e, fid) { + if (!_wsDragBmId) return; + e.preventDefault(); + try { e.dataTransfer.dropEffect = 'move'; } catch (_e) {} + if (e.currentTarget) e.currentTarget.classList.add('drop-target'); +} +function wsBmDragLeaveFolder(e) { if (e.currentTarget) e.currentTarget.classList.remove('drop-target'); } +function wsBmDropOnFolder(e, fid) { + e.preventDefault(); + if (e.currentTarget) e.currentTarget.classList.remove('drop-target'); + if (_wsDragBmId) { moveBookmarkToFolder(_wsDragBmId, fid); _wsDragBmId = null; } +} + +// Folder dropdown menu (contents of a group). +function _wsCloseBmMenu() { + var m = document.getElementById('wsBmMenu'); + if (m) m.remove(); +} +function _wsBmMenuHtml(f) { + var items = _wsBookmarks.filter(function (b) { return b.folderId === f.id; }); + var rows = items.length ? items.map(function (b) { + var sub = b.cmd ? _wsBmAgentWord(b.cmd) : (_wsShortCwd(b.cwd) || 'shell'); + var id = escHtml(b.id); + return '' + + '
' + + '' + + '' + escHtml(b.label || _wsProjectBasename(b.cwd) || 'shell') + '' + + '' + escHtml(sub) + '' + + '' + + '×' + + '
'; + }).join('') : '
Empty — drag bookmarks here
'; + return '
' + rows + '
' + + '
' + + '' + + '' + + '
'; +} +function _wsRefreshBmMenu() { + var m = document.getElementById('wsBmMenu'); + if (!m) return; + var fid = m.getAttribute('data-folder-id'); + var f = _wsBmFolder(fid); + if (!f) { m.remove(); return; } + m.innerHTML = _wsBmMenuHtml(f); +} +function toggleBookmarkFolder(ev, fid) { + if (ev) ev.stopPropagation(); + var existing = document.getElementById('wsBmMenu'); + if (existing && existing.getAttribute('data-folder-id') === fid) { existing.remove(); return; } + if (existing) existing.remove(); + var f = _wsBmFolder(fid); + if (!f) return; + var m = document.createElement('div'); + m.id = 'wsBmMenu'; + m.className = 'ws-bmf-menu'; + m.setAttribute('data-folder-id', fid); + m.innerHTML = _wsBmMenuHtml(f); + document.body.appendChild(m); + var chip = ev && ev.currentTarget; + if (chip && chip.getBoundingClientRect) { + var r = chip.getBoundingClientRect(); + m.style.top = (r.bottom + 6) + 'px'; + m.style.left = Math.min(r.left, window.innerWidth - 280) + 'px'; + } + setTimeout(function () { + function off(e) { if (m.contains(e.target)) return; if (e.target.closest && e.target.closest('.ws-bm-folder')) return; m.remove(); document.removeEventListener('mousedown', off); document.removeEventListener('keydown', esc); } + function esc(e) { if (e.key === 'Escape') { m.remove(); document.removeEventListener('mousedown', off); document.removeEventListener('keydown', esc); } } + document.addEventListener('mousedown', off); + document.addEventListener('keydown', esc); + }, 0); +} + +// ── Terminal settings (font / theme / cursor) ──────────────────────────────── +// Persisted appearance prefs applied live to every xterm instance. Themes are +// xterm theme objects; the "dark" default matches the app chrome. +var _WS_TERM_PREFS_KEY = 'codbash-term-prefs'; +var _WS_TERM_FONTS = [ + { v: 'Menlo, Monaco, "Courier New", monospace', label: 'Menlo' }, + { v: '"JetBrains Mono", Menlo, monospace', label: 'JetBrains Mono' }, + { v: '"Fira Code", Menlo, monospace', label: 'Fira Code' }, + { v: '"SF Mono", Menlo, monospace', label: 'SF Mono' }, + { v: '"Cascadia Code", Menlo, monospace', label: 'Cascadia Code' }, + { v: '"Source Code Pro", Menlo, monospace', label: 'Source Code Pro' }, +]; +var _WS_TERM_THEMES = { + // iTerm-like soft charcoal (Tomorrow Night palette) — the default. A warm dark + // grey instead of near-black, with a full ANSI palette so agent output (Claude + // Code's colors, diffs, spinners) looks like it does in iTerm2. + iterm: { + background: '#1d1f21', foreground: '#c5c8c6', cursor: '#c5c8c6', cursorAccent: '#1d1f21', + selectionBackground: '#373b41', + black: '#1d1f21', red: '#cc6666', green: '#b5bd68', yellow: '#f0c674', + blue: '#81a2be', magenta: '#b294bb', cyan: '#8abeb7', white: '#c5c8c6', + brightBlack: '#666666', brightRed: '#d54e53', brightGreen: '#b9ca4a', brightYellow: '#e7c547', + brightBlue: '#7aa6da', brightMagenta: '#c397d8', brightCyan: '#70c0b1', brightWhite: '#eaeaea', + }, + dark: { background: '#08090c', foreground: '#e6e6e6', cursor: '#e6e6e6', selectionBackground: '#264f78' }, + midnight: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff', selectionBackground: '#1f6feb55' }, + monokai: { background: '#272822', foreground: '#f8f8f2', cursor: '#f8f8f0', selectionBackground: '#49483e' }, + dracula: { background: '#282a36', foreground: '#f8f8f2', cursor: '#bd93f9', selectionBackground: '#44475a' }, + solarized: { background: '#002b36', foreground: '#93a1a1', cursor: '#93a1a1', selectionBackground: '#073642' }, + light: { background: '#ffffff', foreground: '#1f2328', cursor: '#1f2328', selectionBackground: '#add6ff' }, +}; +var _WS_TERM_THEME_LABELS = { iterm: 'iTerm', dark: 'Dark', midnight: 'Midnight', monokai: 'Monokai', dracula: 'Dracula', solarized: 'Solarized', light: 'Light' }; +var _wsTermPrefs = { fontFamily: _WS_TERM_FONTS[0].v, fontSize: 13, theme: 'iterm', cursorStyle: 'block', cursorBlink: true }; + +function _wsLoadTermPrefs() { + try { + var p = JSON.parse(localStorage.getItem(_WS_TERM_PREFS_KEY)); + if (p && typeof p === 'object') Object.assign(_wsTermPrefs, p); + } catch (e) {} + if (!_WS_TERM_THEMES[_wsTermPrefs.theme]) _wsTermPrefs.theme = 'iterm'; +} +function _wsSaveTermPrefs() { try { localStorage.setItem(_WS_TERM_PREFS_KEY, JSON.stringify(_wsTermPrefs)); } catch (e) {} } +function _wsTermTheme() { return _WS_TERM_THEMES[_wsTermPrefs.theme] || _WS_TERM_THEMES.iterm; } + +// Match the pane frame (the padding around xterm's canvas, and the container +// behind it) to the terminal theme's background so there's no dark seam around +// a lighter theme. +function _wsApplyPaneBg(pane) { + var host = document.getElementById('wsTermHost-' + pane.id); + if (!host) return; + var bg = _wsTermTheme().background; + host.style.background = bg; + var pel = host.closest ? host.closest('.ws-pane') : null; + if (pel) pel.style.background = bg; +} + +// Push current prefs onto one pane's terminal, then re-fit (font size changes +// the cell grid, so the pty must be resized to match). +function _wsApplyTermPrefsToPane(pane) { + if (!pane || !pane.term) return; + var t = pane.term, p = _wsTermPrefs; + try { + t.options.fontFamily = p.fontFamily; + t.options.fontSize = p.fontSize; + t.options.theme = _wsTermTheme(); + t.options.cursorStyle = p.cursorStyle; + t.options.cursorBlink = p.cursorBlink; + } catch (e) {} + _wsApplyPaneBg(pane); + if (pane.fit) { try { pane.fit.fit(); } catch (e) {} } + if (pane.sock && pane.sock.readyState === 1 && t) { + pane.sock.send(JSON.stringify({ t: 'resize', cols: t.cols, rows: t.rows })); + } +} +function _wsApplyTermPrefsAll() { _wsAllPanes().forEach(function (x) { _wsApplyTermPrefsToPane(x.pane); }); } + +// Change one setting → persist → apply to every open terminal immediately. +function setTermPref(key, value) { + if (key === 'fontSize') value = Math.max(9, Math.min(24, parseInt(value, 10) || 13)); + if (key === 'cursorBlink') value = !!value; + _wsTermPrefs[key] = value; + _wsSaveTermPrefs(); + _wsApplyTermPrefsAll(); + var fs = document.getElementById('wsSetFontSizeVal'); + if (fs && key === 'fontSize') fs.textContent = value + 'px'; +} + +// Build (once) and toggle the settings popover anchored under the gear button. +function toggleTerminalSettings(ev) { + if (ev) ev.stopPropagation(); + var pop = document.getElementById('wsSettingsPop'); + if (pop) { pop.remove(); return; } // toggle off + pop = document.createElement('div'); + pop.id = 'wsSettingsPop'; + pop.className = 'ws-settings-pop'; + var fontOpts = _WS_TERM_FONTS.map(function (f) { + return ''; + }).join(''); + var themeOpts = Object.keys(_WS_TERM_THEMES).map(function (k) { + return ''; + }).join(''); + var cursorOpts = ['block', 'bar', 'underline'].map(function (c) { + return ''; + }).join(''); + pop.innerHTML = + '
Terminal settings
' + + '' + + '' + + '' + + '' + + ''; + document.body.appendChild(pop); + // Anchor under the gear button. + var gear = ev && ev.currentTarget; + if (gear && gear.getBoundingClientRect) { + var r = gear.getBoundingClientRect(); + pop.style.top = (r.bottom + 6) + 'px'; + pop.style.right = Math.max(8, window.innerWidth - r.right) + 'px'; + } + // Dismiss on outside click / Escape. + setTimeout(function () { + function off(e) { + if (pop.contains(e.target)) return; + pop.remove(); + document.removeEventListener('mousedown', off); + document.removeEventListener('keydown', esc); + } + function esc(e) { if (e.key === 'Escape') { pop.remove(); document.removeEventListener('mousedown', off); document.removeEventListener('keydown', esc); } } + document.addEventListener('mousedown', off); + document.addEventListener('keydown', esc); + }, 0); +} + function _wsTabMarkup(tab) { + var id = escHtml(tab.id); return '' + - '
' + + '
' + '' + escHtml(tab.name) + '' + - '' + - '' + + '' + + '' + '
'; } +// ── Tab reordering (drag to sort, Chrome-like) ─────────────────────────────── +var _wsDragTabId = null; +function wsTabDragStart(e, id) { + _wsDragTabId = id; + try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); } catch (_e) {} + var el = e.currentTarget; if (el && el.classList) el.classList.add('dragging'); +} +function wsTabDragOver(e, id) { + if (!_wsDragTabId || _wsDragTabId === id) return; + e.preventDefault(); + try { e.dataTransfer.dropEffect = 'move'; } catch (_e) {} + // Show an insertion cue on the side the cursor is nearest. + var el = e.currentTarget; if (!el) return; + var r = el.getBoundingClientRect(); + var before = (e.clientX - r.left) < r.width / 2; + el.classList.toggle('drop-before', before); + el.classList.toggle('drop-after', !before); +} +function wsTabDragLeave(e, id) { + var el = e.currentTarget; if (el) el.classList.remove('drop-before', 'drop-after'); +} +function wsTabDrop(e, id) { + e.preventDefault(); + var el = e.currentTarget; + var before = el && el.classList.contains('drop-before'); + if (el) el.classList.remove('drop-before', 'drop-after'); + if (_wsDragTabId && _wsDragTabId !== id) _wsMoveTab(_wsDragTabId, id, before); + _wsDragTabId = null; +} +function wsTabDragEnd(e) { + _wsDragTabId = null; + var bar = document.getElementById('wsTabbar'); + if (bar) Array.prototype.slice.call(bar.querySelectorAll('.ws-tab')).forEach(function (t) { + t.classList.remove('dragging', 'drop-before', 'drop-after'); + }); +} +// Move tab `fromId` to sit before/after `toId`. Panes are keyed by tab id and +// reconciled on render, so live terminals survive the reorder untouched. +function _wsMoveTab(fromId, toId, before) { + var from = _wsTabs.findIndex(function (t) { return t.id === fromId; }); + var to = _wsTabs.findIndex(function (t) { return t.id === toId; }); + if (from < 0 || to < 0) return; + var moved = _wsTabs.splice(from, 1)[0]; + // Recompute target index after removal, then insert before/after it. + to = _wsTabs.findIndex(function (t) { return t.id === toId; }); + _wsTabs.splice(before ? to : to + 1, 0, moved); + _wsRenderTabbar(); + _wsSaveSession(); +} + // ── tab bar + active-tab rendering ────────────────────────────────────────── function _wsRenderTabbar() { var bar = document.getElementById('wsTabbar'); @@ -520,6 +1280,7 @@ function _wsRefitTab(tab) { p.sock.send(JSON.stringify({ t: 'resize', cols: p.term.cols, rows: p.term.rows })); } }); + _wsLayoutResizers(tab); // keep drag handles aligned with the gaps } // Ensure every tab has a grid div; reconcile the active tab's panes; show only @@ -557,10 +1318,12 @@ function _wsRenderPanes() { _wsConnectPane(p); } }); + _wsApplyGrid(tab); // honor any custom column/row fractions }); - // Refit the active tab shortly after it becomes visible (0-size while hidden). - setTimeout(function () { _wsRefitTab(_wsActiveTab()); }, 60); + // Refit the active tab shortly after it becomes visible (0-size while hidden), + // then place the drag handles over the (now laid-out) pane gaps. + setTimeout(function () { var at = _wsActiveTab(); _wsRefitTab(at); _wsLayoutResizers(at); }, 60); // Ensure a focused pane is always highlighted within the active tab. var at = _wsActiveTab(); @@ -570,6 +1333,124 @@ function _wsRenderPanes() { } } +// ── Draggable pane splitters (resize by dragging the divider) ──────────────── +// Each tab stores column/row fractions; a drag adjusts the two tracks either +// side of the divider, keeping their sum constant. Handles are absolutely +// positioned over the gaps (the grid itself stays pure panes). Fractions +// persist with the session so a restored layout keeps your sizes. +var _WS_MIN_PANE_PX = 140; + +function _wsGridEl(tab) { + var area = document.getElementById('wsTabPanes'); + return area ? area.querySelector('.workspace-grid[data-tab-id="' + tab.id + '"]') : null; +} +function _wsColRowCounts(n) { return n === 4 ? { cols: 2, rows: 2 } : { cols: n, rows: 1 }; } +function _wsEqualFr(k) { var a = []; for (var i = 0; i < k; i++) a.push(1); return a; } + +// Make sure tab.cols/tab.rows exist and match the current pane count. +function _wsEnsureSplit(tab) { + var c = _wsColRowCounts(tab.panes.length); + if (!Array.isArray(tab.cols) || tab.cols.length !== c.cols) tab.cols = _wsEqualFr(c.cols); + if (!Array.isArray(tab.rows) || tab.rows.length !== c.rows) tab.rows = _wsEqualFr(c.rows); +} + +function _wsApplyGrid(tab) { + var grid = _wsGridEl(tab); + if (!grid) return; + var n = tab.panes.length; + if (n <= 1) { grid.style.gridTemplateColumns = ''; grid.style.gridTemplateRows = ''; return; } + _wsEnsureSplit(tab); + var fr = function (a) { return a.map(function (x) { return x.toFixed(4) + 'fr'; }).join(' '); }; + grid.style.gridTemplateColumns = fr(tab.cols); + grid.style.gridTemplateRows = (tab.rows.length > 1) ? fr(tab.rows) : ''; +} + +var _wsRefitRaf = 0; +function _wsThrottleRefit(tab) { + if (_wsRefitRaf) return; + _wsRefitRaf = requestAnimationFrame(function () { + _wsRefitRaf = 0; + tab.panes.forEach(function (p) { if (p.fit) { try { p.fit.fit(); } catch (e) {} } }); + }); +} + +// Rebuild the drag handles for a tab's grid from live pane rects. +function _wsLayoutResizers(tab) { + if (!tab || tab.id !== _wsActiveTabId) return; + var grid = _wsGridEl(tab); + if (!grid) return; + Array.prototype.slice.call(grid.querySelectorAll('.ws-resizer')).forEach(function (e) { e.remove(); }); + var n = tab.panes.length; + if (n < 2) return; + _wsEnsureSplit(tab); + var gr = grid.getBoundingClientRect(); + var els = tab.panes.map(function (p) { return grid.querySelector('.ws-pane[data-pane-id="' + p.id + '"]'); }); + if (els.some(function (e) { return !e; })) return; + var rects = els.map(function (e) { return e.getBoundingClientRect(); }); + var ncols = _wsColRowCounts(n).cols; + + // Vertical dividers between adjacent columns (top-row panes give the x-span). + for (var j = 0; j < ncols - 1; j++) { + var x = ((rects[j].right + rects[j + 1].left) / 2) - gr.left; + _wsMakeResizer(grid, tab, 'v', x, gr, j); + } + // Horizontal divider between the two rows (count 4 only). + if (n === 4) { + var y = ((rects[0].bottom + rects[2].top) / 2) - gr.top; + _wsMakeResizer(grid, tab, 'h', y, gr, 0); + } +} + +function _wsMakeResizer(grid, tab, dir, pos, gr, idx) { + var h = document.createElement('div'); + h.className = 'ws-resizer ws-resizer-' + dir; + if (dir === 'v') { h.style.left = pos + 'px'; } + else { h.style.top = pos + 'px'; } + grid.appendChild(h); + h.addEventListener('pointerdown', function (e) { + e.preventDefault(); + var els = tab.panes.map(function (p) { return grid.querySelector('.ws-pane[data-pane-id="' + p.id + '"]'); }); + var gr2 = grid.getBoundingClientRect(); + var arr = (dir === 'v') ? tab.cols : tab.rows; + var a = idx, b = idx + 1; + var sum = arr[a] + arr[b]; + // Pixel span of the two tracks being resized. + var startPx, endPx; + if (dir === 'v') { + startPx = els[a].getBoundingClientRect().left; + endPx = els[b].getBoundingClientRect().right; // adjacent top-row panes (works for 2/3/4) + } else { + startPx = els[0].getBoundingClientRect().top; // top row + endPx = els[2].getBoundingClientRect().bottom; // bottom row (count 4) + } + var S = endPx - startPx; + if (S < 2 * _WS_MIN_PANE_PX) return; + h.classList.add('dragging'); + try { h.setPointerCapture(e.pointerId); } catch (_e) {} + function move(ev) { + var p = (dir === 'v') ? ev.clientX : ev.clientY; + var rel = Math.min(S - _WS_MIN_PANE_PX, Math.max(_WS_MIN_PANE_PX, p - startPx)); + arr[a] = sum * (rel / S); + arr[b] = sum - arr[a]; + _wsApplyGrid(tab); + if (dir === 'v') h.style.left = (p - gr2.left) + 'px'; + else h.style.top = (p - gr2.top) + 'px'; + _wsThrottleRefit(tab); + } + function up(ev) { + h.classList.remove('dragging'); + try { h.releasePointerCapture(e.pointerId); } catch (_e) {} + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + _wsRefitTab(tab); + _wsLayoutResizers(tab); + _wsSaveSession(); + } + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }); +} + function _wsSyncLayoutButtons() { var tab = _wsActiveTab(); var n = tab ? tab.panes.length : 0; @@ -684,12 +1565,30 @@ function activateWorkspaceTab(id) { if (tab && tab.panes[0] && tab.panes[0].term) tab.panes[0].term.focus(); } +// Stack of recently-closed tabs, so Cmd/Ctrl+Shift+T can reopen them (Chrome). +var _wsClosedTabs = []; +var _WS_CLOSED_MAX = 12; + +function _wsSerializeTab(tab) { + return { + name: tab.name, + 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 || '' }; + }), + }; +} + function closeWorkspaceTab(id) { var idx = _wsTabs.findIndex(function (t) { return t.id === id; }); if (idx < 0) return; var live = _wsTabs[idx].panes.filter(_wsPaneLive).length; if (live > 0 && !confirm('Close this tab and its ' + live + ' running terminal' + (live === 1 ? '' : 's') + '?')) return; + // Remember it for Cmd+Shift+T before tearing it down. + _wsClosedTabs.push(_wsSerializeTab(_wsTabs[idx])); + if (_wsClosedTabs.length > _WS_CLOSED_MAX) _wsClosedTabs.shift(); _wsTabs[idx].panes.forEach(_wsTeardownPane); _wsTabs.splice(idx, 1); if (_wsTabs.length === 0) { addWorkspaceTab(); return; } @@ -697,6 +1596,26 @@ function closeWorkspaceTab(id) { _wsRenderAll(); } +// Reopen the most recently closed tab (Cmd/Ctrl+Shift+T). The agent command +// comes back as a restore offer (banner + button), same as session restore — +// no auto-respawn. +function reopenLastClosedTab() { + if (!_wsClosedTabs.length) return; + var spec = _wsClosedTabs.pop(); + var panes = (spec.panes && spec.panes.length ? spec.panes : [{}]) + .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 }; + }); + 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 }; + _wsTabs.push(tab); + _wsActiveTabId = tab.id; + if (typeof setView === 'function' && currentView !== 'workspace') setView('workspace'); + _wsRenderAll(); +} + function renameWorkspaceTab(id) { var tab = _wsTab(id); if (!tab) return; @@ -766,6 +1685,10 @@ function closeWorkspacePane(id) { var tab = _wsTabs[i]; var idx = tab.panes.findIndex(function (p) { return p.id === id; }); if (idx < 0) continue; + // Closing a pane kills whatever runs in it (a shell, or a live agent), so + // confirm first when it's live — same guard as closing a whole tab. + if (_wsPaneLive(tab.panes[idx]) && + !confirm('Close this terminal? The running ' + _wsPaneLabel(tab.panes[idx]) + ' session will be terminated.')) return; _wsTeardownPane(tab.panes[idx]); tab.panes.splice(idx, 1); if (tab.panes.length === 0) tab.panes.push({ id: 'p' + (++_wsPaneSeq), cmd: null }); @@ -903,11 +1826,15 @@ function _wsCaptureLayout() { tabs: _wsTabs.map(function (t) { return { name: t.name, + cols: Array.isArray(t.cols) ? t.cols.slice() : null, + rows: Array.isArray(t.rows) ? t.rows.slice() : null, panes: t.panes.map(function (p) { return { cmd: p.cmd || '', prefill: p.prefill || '', cwd: p.cwd || p.wantCwd || '', + detectedCmd: p.detectedCmd || '', + enteredCmd: p.enteredCmd || '', }; }), }; @@ -1075,21 +2002,27 @@ async function renderWorkspace(container) { container.innerHTML = '
' + - '
' + - '
' + - '
' + - '' + - '' + - '' + - '' + + // One compact top row: tabs on the left (scrollable), tools on the right — + // Chrome-like. Merging the old tab bar + toolbar reclaims a whole strip. + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + '
' + - '' + - '' + - '' + - '' + - '' + '
' + + '
' + '
' + '
'; @@ -1101,16 +2034,115 @@ async function renderWorkspace(container) { _wsRoot = container.querySelector('.workspace-wrap'); // If something asked to open a project/session before the terminal had // mounted, seed that tab as the initial one (no throwaway "Tab 1"). + var _restoredSession = _wsLoadSession(); if (_wsPendingOpen) { var spec = _wsPendingOpen; _wsPendingOpen = null; _wsTabs = [{ id: 't' + (++_wsTabSeq), name: _wsTabName(spec), panes: _wsBuildPanes(spec) }]; + } else if (_restoredSession) { + // Reopen last session's tabs/panes (Chrome-like restore). + _wsRestoreTabsFromSession(_restoredSession); } else { _wsTabs = [{ id: 't' + (++_wsTabSeq), name: 'Tab 1', panes: [{ id: 'p' + (++_wsPaneSeq), cmd: null }] }]; } _wsActiveTabId = _wsTabs[0].id; + _wsLoadTermPrefs(); + _wsLoadBookmarks(); _wsRenderAll(); + _wsRenderBookmarks(); _wsLoadCommands(); _wsLoadLayouts(); _wsStartStatusLoop(); _wsUpdateStatusBar(); + _wsBindReopenShortcut(); + _wsBindWindowResize(); +} + +// Reposition split handles + refit terminals whenever the window resizes — +// maximize, enter/exit fullscreen, or a manual drag. The resizers are absolutely +// positioned by pixel offset over the pane gaps, so without this they keep their +// old coordinates after the window changes size and drift off the dividers. +// Bound once; debounced so it fires after the resize (incl. the fullscreen +// animation) settles. +var _wsResizeBound = false; +var _wsResizeTimer = null; +function _wsBindWindowResize() { + if (_wsResizeBound) return; + _wsResizeBound = true; + window.addEventListener('resize', function () { + clearTimeout(_wsResizeTimer); + _wsResizeTimer = setTimeout(function () { + var at = _wsActiveTab(); + if (at) _wsRefitTab(at); // refits panes AND re-lays the drag handles + }, 120); + // A second pass catches the end of macOS's fullscreen animation, where the + // final size arrives after the last 'resize' event. + setTimeout(function () { var at = _wsActiveTab(); if (at) _wsLayoutResizers(at); }, 450); + }); } + +// Is focus in a real editable field (a rename box, the command modal…)? Then +// browser-style tab shortcuts must NOT hijack the keystroke. The xterm terminal +// uses a hidden helper textarea — that's the normal terminal case, so we DON'T +// treat it as a form field (Cmd+T/W should still manage tabs while in a term). +function _wsInFormField() { + var el = document.activeElement; + if (!el) return false; + if (el.classList && el.classList.contains('xterm-helper-textarea')) return false; + var tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; +} + +// Chrome-like tab keyboard shortcuts, bound once globally: +// Cmd/Ctrl+T → new empty tab +// Cmd/Ctrl+W → close the current tab (same as the × — confirms if live) +// Cmd/Ctrl+Shift+T → reopen the last closed tab, fully (layout + panes) +// The tab shortcuts only act inside the Workspace so they don't steal Cmd+W / +// Cmd+T on other views (there Cmd+W keeps its native "close window" meaning). +var _wsKeysBound = false; +function _wsBindReopenShortcut() { + if (_wsKeysBound) return; + _wsKeysBound = true; + document.addEventListener('keydown', function (e) { + var mod = e.metaKey || e.ctrlKey; + if (!mod) return; + var isT = (e.key === 'T' || e.key === 't' || e.code === 'KeyT'); + var isW = (e.key === 'W' || e.key === 'w' || e.code === 'KeyW'); + var inWs = (typeof currentView === 'undefined') || currentView === 'workspace'; + + if (e.shiftKey && isT) { // reopen closed tab + if (_wsInFormField()) return; // don't hijack while typing in a field + if (_wsClosedTabs.length) { e.preventDefault(); reopenLastClosedTab(); } + return; + } + if (e.shiftKey) return; + if (_wsInFormField()) return; + if (isT && inWs) { // new empty tab + e.preventDefault(); + addWorkspaceTab(); + var nt = _wsActiveTab(); + if (nt && nt.panes[0] && nt.panes[0].term) nt.panes[0].term.focus(); + return; + } + if (isW && inWs && _wsActiveTabId) { // close current tab (like the ×) + e.preventDefault(); + closeWorkspaceTab(_wsActiveTabId); + return; + } + }); +} + +// In the desktop app the native menu grabs Cmd+W before the page can, so main +// forwards it here (see desktop/main.js before-input-event). Bound once at load +// so it works on every view. In a plain browser this is inert (no codbashDesktop). +(function _wsBindDesktopShortcuts() { + if (typeof window === 'undefined' || !window.codbashDesktop || !window.codbashDesktop.onShortcut) return; + window.codbashDesktop.onShortcut(function (name) { + if (name !== 'close-tab') return; + var inWs = (typeof currentView === 'undefined') || currentView === 'workspace'; + if (inWs && typeof _wsActiveTabId !== 'undefined' && _wsActiveTabId) { + closeWorkspaceTab(_wsActiveTabId); + } else if (window.codbashDesktop.closeWindow) { + window.codbashDesktop.closeWindow(); // nothing to close in-page → close window + } + }); +})(); diff --git a/src/pty-registry.js b/src/pty-registry.js new file mode 100644 index 0000000..9f796c8 --- /dev/null +++ b/src/pty-registry.js @@ -0,0 +1,20 @@ +'use strict'; + +// Live registry of the pty (shell) PIDs that codbash itself spawned — one per +// Workspace pane. getActiveSessions() uses it to scope RUNNING AGENTS to ONLY +// the agents whose process tree descends from a codbash terminal, instead of +// every claude/codex process on the machine. When no pane is open the registry +// is empty and RUNNING AGENTS is correctly empty too. +// +// Deliberately dependency-free and decoupled from terminal.js so requiring it +// from data.js never risks pulling in the lazy node-pty native module. + +const _live = new Set(); + +function add(pid) { if (pid && Number.isFinite(pid)) _live.add(pid); } +function remove(pid) { _live.delete(pid); } +function has(pid) { return _live.has(pid); } +function all() { return Array.from(_live); } +function size() { return _live.size; } + +module.exports = { add, remove, has, all, size }; diff --git a/src/server.js b/src/server.js index 3c1f156..f8710db 100644 --- a/src/server.js +++ b/src/server.js @@ -3,6 +3,8 @@ const http = require('http'); const https = require('https'); const { URL } = require('url'); const { exec, execFile, execFileSync } = require('child_process'); +const { promisify } = require('util'); +const execFileAsync = promisify(execFile); const dataApi = require('./data'); const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost, getProjectGitInfo, getLeaderboardStats } = dataApi; const { detectTerminals, openInTerminal, focusTerminalByPid, isWSL } = require('./terminals'); @@ -27,6 +29,74 @@ const { handleRepoRefreshRoute } = require('./repo-refresh-routes'); const SAFE_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/; +// The agent command currently running inside a pane's shell, for session +// restore — so a hand-typed `HTTPS_PROXY=… claude …` comes back, not just the +// folder. We look for a recognized agent among the shell's child processes, +// take its command line, and rebuild any proxy env prefix from the child's +// environment (the env isn't part of the argv). Returns '' when the pane is +// just a plain shell. pid is a validated positive integer. +const _AGENT_CMD_RE = /(^|[\/\s])(claude|codex|opencode|cursor-agent|kiro|kilo|qwen|gemini|aider|pi|omp)(\s|$)/; +const _PROXY_ENV_KEYS = ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'https_proxy', 'http_proxy', 'all_proxy']; +// ASYNC on purpose: these run pgrep/ps/lsof, which can each take up to a couple +// of seconds. The old synchronous execFileSync versions ran on the single Node +// event loop — and because /api/terminal/cwd polls them every few seconds for +// every pane, the loop stalled in bursts, freezing terminal echo/input. Using +// async execFile keeps the subprocesses OFF the loop so the pty keeps flowing. +async function resolvePaneCommand(shellPid) { + let kids = []; + try { + const { stdout } = await execFileAsync('pgrep', ['-P', String(shellPid)], { encoding: 'utf8', timeout: 2000 }); + kids = stdout.trim().split('\n').map(s => s.trim()).filter(Boolean); + } catch (_e) { return ''; } + for (const kid of kids) { + if (!/^\d+$/.test(kid)) continue; + let cmd = ''; + try { cmd = (await execFileAsync('ps', ['-o', 'command=', '-p', kid], { encoding: 'utf8', timeout: 2000 })).stdout.trim(); } + catch (_e) { continue; } + if (!cmd || !_AGENT_CMD_RE.test(cmd)) continue; + // Rebuild a proxy env prefix (values contain no spaces, so \S+ is safe). + let prefix = ''; + try { + const eww = (await execFileAsync('ps', ['eww', '-o', 'command=', '-p', kid], { encoding: 'utf8', timeout: 2000 })).stdout; + const seen = new Set(); + for (const key of _PROXY_ENV_KEYS) { + const up = key.toUpperCase(); + if (seen.has(up)) continue; + const m = eww.match(new RegExp('(?:^|\\s)' + key + '=(\\S+)')); + if (m) { prefix += up + "='" + m[1] + "' "; seen.add(up); } + } + } catch (_e) { /* env unreadable — return bare command */ } + return prefix + cmd; + } + return ''; +} + +// Current working directory of a running process, for terminal session restore. +// Linux: read /proc//cwd (fast, no spawn). macOS/other: `lsof -a -p +// -d cwd -Fn` (async — lsof is slow, see resolvePaneCommand). Returns '' on any +// failure. pid is a validated positive integer. +async function resolveProcessCwd(pid) { + if (process.platform === 'linux') { + try { + const t = fs.readlinkSync(`/proc/${pid}/cwd`); + if (t && t.startsWith('/')) return t; + } catch (_e) { /* fall through to lsof */ } + } + try { + const { stdout: out } = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { + encoding: 'utf8', timeout: 2000, + }); + const m = out.match(/^n(\/[^\n]*)/m); + if (m) { + let p = m[1].trim(); + const errIdx = p.indexOf(' ('); + if (errIdx !== -1) p = p.slice(0, errIdx).trim(); + if (p && p.startsWith('/') && !p.startsWith('/proc/')) return p; + } + } catch (_e) { /* process gone or lsof unavailable */ } + return ''; +} + function getValidatedPiResumeTarget(sessionId, resumeTarget, project) { if (typeof sessionId !== 'string' || !SAFE_SESSION_ID.test(sessionId)) return ''; if (typeof resumeTarget !== 'string' || !resumeTarget.endsWith('.jsonl')) return ''; @@ -158,6 +228,8 @@ function startServer(host, port, openBrowser = true) { const VENDOR_FILES = { 'xterm.js': 'application/javascript; charset=utf-8', 'addon-fit.js': 'application/javascript; charset=utf-8', + 'addon-canvas.js': 'application/javascript; charset=utf-8', + 'addon-webgl.js': 'application/javascript; charset=utf-8', 'xterm.css': 'text/css; charset=utf-8', }; const name = pathname.slice('/vendor/'.length); @@ -184,6 +256,26 @@ function startServer(host, port, openBrowser = true) { jsonLog(res, { available: status.available, error: status.error, hint: status.hint, token: terminal.getToken() }); } + // ── Live cwd of terminal panes (so session restore follows `cd`) ── + // Given the pane shells' pids, return each one's CURRENT working directory + // by reading the process (macOS: lsof; Linux: /proc). Lets the workspace + // persist where a pane actually IS, not just where it opened. + else if (req.method === 'GET' && pathname === '/api/terminal/cwd') { + const pids = (parsed.searchParams.get('pids') || '') + .split(',').map(s => parseInt(s, 10)) + .filter(n => Number.isInteger(n) && n > 0).slice(0, 32); + // Resolve every pane concurrently and OFF the event loop (async execFile), + // so this poll never stalls terminal I/O even when lsof/ps are slow. + Promise.all(pids.map(async (pid) => { + const [cwd, cmd] = await Promise.all([resolveProcessCwd(pid), resolvePaneCommand(pid)]); + return { pid, cwd, cmd }; + })).then((results) => { + const out = {}; + for (const r of results) { if (r.cwd || r.cmd) out[r.pid] = { cwd: r.cwd, cmd: r.cmd }; } + json(res, out); + }).catch(() => json(res, {})); + } + // ── Saved Workspace commands (may contain proxy secrets; stored 0600) ── else if (pathname === '/api/terminal/commands' && req.method === 'GET') { jsonLog(res, { commands: workspaceCommands.loadCommands() }); @@ -463,20 +555,23 @@ function startServer(host, port, openBrowser = true) { // ── Active sessions ───────────────────── else if (req.method === 'GET' && pathname === '/api/active') { - const active = getActiveSessions(); - // Log only when active set changes - const activeKey = active.map(a => a.pid + ':' + a.status).sort().join(','); - if (activeKey !== startServer._lastActiveKey) { - startServer._lastActiveKey = activeKey; - if (active.length > 0) { - for (const a of active) { - log('ACTIVE', `pid=${a.pid} ${a.kind}/${a.status} cpu=${a.cpu}% cwd=${a.cwd || '?'} session=${a.sessionId ? a.sessionId.slice(0,8) + '...' : 'none'} source=${a._sessionSource || 'none'}`); + // getActiveSessions is async (runs ps/lsof off the event loop) so this + // 1-second poll never stalls terminal I/O. + getActiveSessions().then((active) => { + // Log only when active set changes + const activeKey = active.map(a => a.pid + ':' + a.status).sort().join(','); + if (activeKey !== startServer._lastActiveKey) { + startServer._lastActiveKey = activeKey; + if (active.length > 0) { + for (const a of active) { + log('ACTIVE', `pid=${a.pid} ${a.kind}/${a.status} cpu=${a.cpu}% cwd=${a.cwd || '?'} session=${a.sessionId ? a.sessionId.slice(0,8) + '...' : 'none'} source=${a._sessionSource || 'none'}`); + } + } else if (startServer._lastActiveKey !== '') { + log('ACTIVE', 'no running agents'); } - } else if (startServer._lastActiveKey !== '') { - log('ACTIVE', 'no running agents'); } - } - json(res, active); + json(res, active); + }).catch(() => json(res, [])); } // ── Open in IDE ──────────────────────── @@ -590,7 +685,10 @@ function startServer(host, port, openBrowser = true) { const to = parsed.searchParams.get('to'); if (from) sessions = sessions.filter(s => s.date >= from); if (to) sessions = sessions.filter(s => s.date <= to); - const data = getCostAnalytics(sessions); + // Unfiltered (Overview/startup) requests may serve a stale result while + // recomputing in the background — this is the hot cold-start path. Date- + // filtered requests must be exact. + const data = getCostAnalytics(sessions, { allowStale: !from && !to }); json(res, data); } @@ -876,11 +974,15 @@ function startServer(host, port, openBrowser = true) { else if (req.method === 'GET' && pathname === '/api/version') { const pkg = require('../package.json'); const current = pkg.version; + // `dev` marks a from-source run (NODE_ENV=development) so the UI can show a + // DEV badge — an at-a-glance signal that you're looking at the live-editing + // build (your uncommitted changes), NOT the installed DMG. + const dev = process.env.NODE_ENV === 'development'; // Fetch latest from npm registry fetchLatestVersion(pkg.name).then(latest => { - json(res, { current, latest, updateAvailable: latest && latest !== current && isNewer(latest, current) }); + json(res, { current, latest, updateAvailable: latest && latest !== current && isNewer(latest, current), dev }); }).catch(() => { - json(res, { current, latest: null, updateAvailable: false }); + json(res, { current, latest: null, updateAvailable: false, dev }); }); } @@ -963,6 +1065,13 @@ function startServer(host, port, openBrowser = true) { console.log(' \x1b[2mPress Ctrl+C to stop\x1b[0m'); console.log(''); + // Warm the optional native terminal module (@lydell/node-pty) in the + // background. The require() blocks the loop briefly (~1s) the first time, but + // running it here overlaps the Electron window/page-load dead time — before + // the frontend ever asks for /api/terminal/status — so the terminal-first + // landing opens its first pane instantly instead of paying the load then. + setImmediate(() => { try { terminal.isTerminalAvailable(); } catch (_e) {} }); + if (openBrowser) { if (process.platform === 'darwin') { execFile('open', [browserUrl]); diff --git a/src/shell-path.js b/src/shell-path.js index 1875704..482bcb9 100644 --- a/src/shell-path.js +++ b/src/shell-path.js @@ -17,12 +17,30 @@ // Opt out entirely with `CODBASH_NO_PATH_REPAIR=1`. Set `CODBASH_DEBUG=1` to // log the skip/augment decisions to stderr. -const { execFileSync } = require('child_process'); +const { execFileSync, execFile } = require('child_process'); const os = require('os'); const path = require('path'); +const fs = require('fs'); let _done = false; +// Disk cache for the login-shell PATH probe. The probe spawns an interactive +// login shell (~1–1.5s, running the user's whole rc chain) and, before this +// cache, ran on EVERY app launch — a large slice of cold-start latency. We now +// remember the captured PATH and reuse it instantly on subsequent launches, +// refreshing it in the background so a newly-installed tool is picked up next +// time. Only the very first launch (empty cache) pays the synchronous probe. +const PATH_CACHE_FILE = path.join(os.homedir(), '.codedash', 'path-cache.json'); +const PATH_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // refresh in background once/day + +// The interactive-login probe script, shared by the sync capture and the async +// background refresh so both read PATH identically. +const PROBE_SCRIPT = 'printf "\\1CBP\\1%s\\1CBE\\1" "$PATH"'; +function _extractProbePath(raw) { + const m = /\x01CBP\x01([\s\S]*?)\x01CBE\x01/.exec(raw || ''); + return m ? m[1] : ''; +} + function debugEnabled() { return process.env.CODBASH_DEBUG === '1' || process.env.CODBASH_DEBUG === 'true'; } @@ -58,7 +76,7 @@ function hasUserBinPaths(p, home) { }); } -function captureLoginShellPath() { +function _resolveShell() { let shell = process.env.SHELL || ''; // $SHELL is attacker-controllable only by someone who already has env-setting // (= code-execution) capability in this process, so this is a robustness @@ -66,25 +84,21 @@ function captureLoginShellPath() { // to the macOS default login shell (zsh since Catalina; this feature is // macOS-focused — cf. terminal.js which defaults to bash for its own path). if (!shell || !path.isAbsolute(shell)) shell = '/bin/zsh'; + return shell; +} - // Sentinels (SOH-delimited) let us pull PATH out of stdout even when rc files - // print banners / shell-integration escape sequences on startup. NOTE: this - // guards against *accidental* banner noise only — it is NOT a trust boundary - // against a malicious rc file, which already controls $PATH directly. - const script = 'printf "\\1CBP\\1%s\\1CBE\\1" "$PATH"'; - - // Separate flags (not a bundled `-ilc`) for portability across shells with - // stricter getopt parsing. `-i -l` sources both login (.zprofile/.zlogin) and - // interactive (.zshrc/.bashrc) config, so PATH matches a real terminal no - // matter which file the user's exports live in. Cost: this runs the user's rc - // chain once per app launch — an accepted tradeoff for correct detection. - // killSignal is SIGKILL because a shell/rc that traps or ignores SIGTERM - // could otherwise outlive the timeout; SIGKILL is still best-effort (a - // process blocked in an uninterruptible syscall cannot be reaped until it - // unblocks), so the 4s bound is a strong guideline, not a hard guarantee. +// Synchronous interactive-login probe. Separate flags (not a bundled `-ilc`) +// for portability across shells with stricter getopt parsing. `-i -l` sources +// both login (.zprofile/.zlogin) and interactive (.zshrc/.bashrc) config, so +// PATH matches a real terminal no matter which file the user's exports live in. +// killSignal is SIGKILL because a shell/rc that traps or ignores SIGTERM could +// otherwise outlive the timeout; SIGKILL is still best-effort (a process blocked +// in an uninterruptible syscall cannot be reaped until it unblocks), so the 4s +// bound is a strong guideline, not a hard guarantee. +function _probeSync(shell) { let raw; try { - raw = execFileSync(shell, ['-i', '-l', '-c', script], { + raw = execFileSync(shell, ['-i', '-l', '-c', PROBE_SCRIPT], { encoding: 'utf8', timeout: 4000, killSignal: 'SIGKILL', @@ -97,8 +111,67 @@ function captureLoginShellPath() { console.error('[codbash] PATH repair: login-shell probe failed: ' + (err && err.message ? err.message : String(err))); return ''; } - const m = /\x01CBP\x01([\s\S]*?)\x01CBE\x01/.exec(raw); - return m ? m[1] : ''; + return _extractProbePath(raw); +} + +function _readPathCache() { + try { + const c = JSON.parse(fs.readFileSync(PATH_CACHE_FILE, 'utf8')); + if (c && typeof c.path === 'string' && c.path) return c; + } catch (_) {} + return null; +} + +function _writePathCache(shell, capturedPath, stamp) { + try { + fs.mkdirSync(path.dirname(PATH_CACHE_FILE), { recursive: true }); + fs.writeFileSync(PATH_CACHE_FILE, JSON.stringify({ shell: shell, path: capturedPath, ts: stamp })); + } catch (_) {} +} + +// Re-probe the login shell off the critical path and refresh the cache for the +// NEXT launch. Never touches this run's process.env (already repaired) and never +// blocks; failures are swallowed (the stale cache stays valid). +let _bgRefreshRunning = false; +function _backgroundRefreshPath(shell) { + if (_bgRefreshRunning) return; + _bgRefreshRunning = true; + try { + execFile(shell, ['-i', '-l', '-c', PROBE_SCRIPT], { + encoding: 'utf8', timeout: 8000, killSignal: 'SIGKILL', + }, (err, stdout) => { + _bgRefreshRunning = false; + if (err) return; + const p = _extractProbePath(stdout); + if (p) { _writePathCache(shell, p, monoNow()); debug('PATH cache refreshed in background'); } + }); + } catch (_) { _bgRefreshRunning = false; } +} + +// Wall-clock stamp for cache freshness. Kept in one place so tests can reason +// about it; Date.now() is fine here (not a resume-sensitive workflow script). +function monoNow() { return Date.now(); } + +// Return the login-shell PATH, served from a disk cache when possible so only +// the first-ever launch pays the ~1s synchronous shell spawn. A cache hit +// schedules a background refresh when older than the TTL. +function captureLoginShellPath() { + const shell = _resolveShell(); + + const cached = _readPathCache(); + if (cached && cached.shell === shell) { + // Cheap: reuse instantly, refresh in the background if it's gone stale. + if (!cached.ts || (monoNow() - cached.ts) > PATH_CACHE_TTL_MS) { + _backgroundRefreshPath(shell); + } + debug('PATH repair: served from disk cache'); + return cached.path; + } + + // Cold cache (first launch, or $SHELL changed): probe synchronously, persist. + const captured = _probeSync(shell); + if (captured) _writePathCache(shell, captured, monoNow()); + return captured; } // Merge the login-shell PATH into process.env.PATH. Existing entries keep diff --git a/src/terminal.js b/src/terminal.js index 626ffc5..4b5d800 100644 --- a/src/terminal.js +++ b/src/terminal.js @@ -17,10 +17,43 @@ const crypto = require('crypto'); const os = require('os'); +const fs = require('fs'); +const ptyRegistry = require('./pty-registry'); const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const WS_PATH = '/ws/terminal'; +// Env vars that must NOT leak from codbash's own process into a pane's shell. +// Two families: +// +// 1. Agent-session markers. If codbash is launched from within a Claude/Codex +// session (the dev server, or a shell an agent spawned), these leak through +// `process.env`. A `claude` started in the pane would then see e.g. +// CLAUDE_CODE_SESSION_ID and behave as a NESTED/child session — its +// conversation gets filed under the parent's `subagents/` dir instead of +// becoming a normal top-level dialog. That's the "my dialog vanished / went +// to a hidden folder" bug. +// +// 2. npm's per-run vars. When the server is started via `npm start` (dev) or an +// `npx` wrapper, npm injects `npm_config_*`/`npm_package_*`/… into the +// environment. `npm_config_prefix` in particular makes nvm refuse to load in +// the pane's shell ("nvm is not compatible with the npm_config_prefix +// environment variable"). These are npm-invocation scratch vars, never meant +// to outlive the npm command, so a fresh shell must not inherit them. +// +// We KEEP user CONFIG (CLAUDE_CONFIG_DIR, ANTHROPIC_*) — that legitimately +// controls how the agent runs. +const AGENT_SESSION_ENV_RE = /^(CLAUDE_CODE_|CLAUDECODE$|CLAUDE_PID$|CLAUDE_EFFORT$|CLAUDE_REMOTE_URL$|CODEX_SESSION|CODEX_SANDBOX|OPENCODE_SESSION|CURSOR_SESSION|CURSOR_AGENT|GEMINI_CLI_SESSION|QWEN_SESSION|npm_config_|npm_package_|npm_lifecycle_|npm_command$|npm_execpath$|npm_node_execpath$)/; +function sanitizedPtyEnv() { + const src = process.env; + const out = {}; + for (const k of Object.keys(src)) { + if (AGENT_SESSION_ENV_RE.test(k)) continue; + out[k] = src[k]; + } + return out; +} + // ── Lazy node-pty loader ──────────────────────────────────────────────────── let _pty = null; let _ptyTried = false; @@ -168,20 +201,37 @@ function sendClose(socket) { try { socket.write(encodeFrame(Buffer.alloc(0), 0x8)); } catch (_e) {} } -// Validate/normalize the requested cwd. `isSafeCwd(dir)` is injected by the -// server so we reuse the dashboard's known-git-roots trust boundary. +// Resolve the requested cwd for the pty. Returns { cwd, requested, fellBack }. +// +// IMPORTANT: this cwd is handed to pty.spawn's `cwd` option, which passes it +// straight to the OS — it never goes through a shell. So shell-metacharacter +// restrictions (the `isSafeCwd`/isSafeLaunchPath char check, meant for building +// `cd "..." && …` command strings elsewhere) MUST NOT gate it here: doing so +// silently rewrote legitimate folders — anything containing ()[]{}&'"… — to +// $HOME, which then misfiled the agent's conversation under the wrong project +// (Claude stores history keyed by cwd). That was the "my dialog disappeared from +// this folder" bug. We honor any path that is a real, existing directory and is +// not a symlink (the one check worth keeping — a symlink could smuggle a path +// out of the user's tree). Only a genuinely unusable path falls back to $HOME, +// and we flag that so the client can warn instead of silently misfiling. function resolveCwd(url, isSafeCwd) { const requested = url.searchParams.get('cwd'); - // Expand a leading ~ before the safety check AND before handing the path to - // pty.spawn — a shell won't expand ~ in a cwd, so a raw "~/foo" spawns ENOENT. - const expanded = !requested ? requested - : requested === '~' ? os.homedir() + if (!requested) return { cwd: os.homedir(), requested: null, fellBack: false }; + // Expand a leading ~ — a shell won't expand it in a raw cwd (spawns ENOENT). + const expanded = requested === '~' ? os.homedir() : requested.startsWith('~/') ? os.homedir() + requested.slice(1) : requested; - if (expanded && typeof isSafeCwd === 'function' && isSafeCwd(expanded)) { - return expanded; + try { + const lst = fs.lstatSync(expanded); + if (lst.isDirectory() && !lst.isSymbolicLink()) { + return { cwd: expanded, requested: expanded, fellBack: false }; + } + } catch (_e) { /* doesn't exist / not accessible → fall through */ } + // Belt-and-braces: also accept anything the server's stricter check blesses. + if (typeof isSafeCwd === 'function' && isSafeCwd(expanded)) { + return { cwd: expanded, requested: expanded, fellBack: false }; } - return os.homedir(); + return { cwd: os.homedir(), requested: expanded, fellBack: true }; } // Attach the upgrade handler + spawn a pty per connection. @@ -229,7 +279,8 @@ function handleUpgrade(req, socket, head, opts) { '\r\n' ); - const cwd = resolveCwd(url, opts.isSafeCwd); + const resolved = resolveCwd(url, opts.isSafeCwd); + const cwd = resolved.cwd; const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash'); const cols = parseInt(url.searchParams.get('cols'), 10) || 80; const rows = parseInt(url.searchParams.get('rows'), 10) || 24; @@ -241,7 +292,9 @@ function handleUpgrade(req, socket, head, opts) { cols: cols, rows: rows, cwd: cwd, - env: process.env + // Clean, top-level env — strip inherited agent-session markers so a + // `claude`/`codex` launched here starts a normal dialog, not a nested one. + env: sanitizedPtyEnv() }); } catch (err) { sendText(socket, { t: 'error', message: 'Failed to start shell: ' + (err && err.message) }); @@ -250,8 +303,18 @@ function handleUpgrade(req, socket, head, opts) { return; } + // Register this pty so RUNNING AGENTS can scope to codbash-launched agents + // (agents whose process tree descends from a codbash terminal pane). + ptyRegistry.add(term.pid); + + if (resolved.fellBack) { + log('TERM', 'requested cwd unusable (' + resolved.requested + ') — fell back to ' + cwd); + } log('TERM', 'pty spawned pid=' + term.pid + ' cwd=' + cwd); - sendText(socket, { t: 'ready', pid: term.pid, cwd: cwd, shell: shell }); + sendText(socket, { + t: 'ready', pid: term.pid, cwd: cwd, shell: shell, + requestedCwd: resolved.requested, cwdFellBack: !!resolved.fellBack, + }); // pty -> client (raw bytes as binary frames) const onData = term.onData(function (data) { @@ -267,6 +330,7 @@ function handleUpgrade(req, socket, head, opts) { function cleanup() { if (closed) return; closed = true; + ptyRegistry.remove(term.pid); try { onData.dispose(); } catch (_e) {} try { onExit.dispose(); } catch (_e) {} try { term.kill(); } catch (_e) {} @@ -297,6 +361,12 @@ function handleUpgrade(req, socket, head, opts) { if (c > 0 && r > 0) { try { term.resize(c, r); } catch (_e) {} } } else if (msg && msg.t === 'input' && typeof msg.data === 'string') { try { term.write(msg.data); } catch (_e) {} + } else if (msg && msg.t === 'pause') { + // Flow control: the client is behind on parsing output — stop reading + // from the pty so a burst can't flood the browser and freeze typing. + try { term.pause(); } catch (_e) {} + } else if (msg && msg.t === 'resume') { + try { term.resume(); } catch (_e) {} } } }); @@ -317,5 +387,8 @@ module.exports = { // exported for unit tests encodeFrame: encodeFrame, createFrameDecoder: createFrameDecoder, - tokensMatch: tokensMatch + tokensMatch: tokensMatch, + resolveCwd: resolveCwd, + sanitizedPtyEnv: sanitizedPtyEnv, + AGENT_SESSION_ENV_RE: AGENT_SESSION_ENV_RE }; diff --git a/test/terminal.test.js b/test/terminal.test.js index 749a5b8..f145ca3 100644 --- a/test/terminal.test.js +++ b/test/terminal.test.js @@ -91,3 +91,62 @@ test('getToken returns a stable 64-char hex token', () => { assert.equal(t1, t2); assert.match(t1, /^[0-9a-f]{64}$/); }); + +// ── env sanitization: nested-agent markers must not leak into a pane ────────── +// If codbash is launched from inside an agent session, process.env carries +// markers like CLAUDE_CODE_SESSION_ID; a `claude` spawned in a pane would then +// nest under the parent and misfile its conversation. sanitizedPtyEnv strips them. +test('sanitizedPtyEnv strips inherited agent-session markers', () => { + const saved = {}; + const markers = { + CLAUDE_CODE_SESSION_ID: 'abc', CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_PID: '123', CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDECODE: '1', + CLAUDE_EFFORT: 'medium', CLAUDE_REMOTE_URL: 'http://x', + // npm-injected vars that break nvm in the pane's shell. + npm_config_prefix: '/opt/homebrew', npm_package_name: 'codbash-app', + npm_lifecycle_event: 'start', npm_execpath: '/usr/lib/npm.js', + }; + Object.keys(markers).forEach(k => { saved[k] = process.env[k]; process.env[k] = markers[k]; }); + // Keep legit config + a normal var. + const savedCfg = process.env.CLAUDE_CONFIG_DIR; process.env.CLAUDE_CONFIG_DIR = '/tmp/cfg'; + process.env.PATH = process.env.PATH || '/usr/bin'; + try { + const env = terminal.sanitizedPtyEnv(); + Object.keys(markers).forEach(k => assert.equal(env[k], undefined, k + ' should be stripped')); + assert.equal(env.CLAUDE_CONFIG_DIR, '/tmp/cfg', 'user config must be preserved'); + assert.ok(env.PATH, 'PATH must be preserved'); + } finally { + Object.keys(markers).forEach(k => { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }); + if (savedCfg === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = savedCfg; + } +}); + +// ── resolveCwd: honor real dirs, flag fallback (never silently misfile) ─────── +test('resolveCwd honors an existing real directory (even with () chars)', () => { + const os = require('os'); + const fs = require('fs'); + const path = require('path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cb (test)-')); // parens in name + try { + const url = new URL('http://x/ws?cwd=' + encodeURIComponent(dir)); + const r = terminal.resolveCwd(url, () => false); // stricter check rejects → must still honor + assert.equal(r.cwd, dir); + assert.equal(r.fellBack, false); + } finally { fs.rmdirSync(dir); } +}); + +test('resolveCwd falls back to home and flags it for a non-existent dir', () => { + const os = require('os'); + const url = new URL('http://x/ws?cwd=' + encodeURIComponent('/no/such/dir/xyz123')); + const r = terminal.resolveCwd(url, () => false); + assert.equal(r.cwd, os.homedir()); + assert.equal(r.fellBack, true); +}); + +test('resolveCwd with no cwd param returns home, no fallback flag', () => { + const os = require('os'); + const url = new URL('http://x/ws'); + const r = terminal.resolveCwd(url, () => false); + assert.equal(r.cwd, os.homedir()); + assert.equal(r.fellBack, false); +});