From fb24118c5162b3a5168fa11168d8e6a1447f145b Mon Sep 17 00:00:00 2001 From: HaydnG Date: Mon, 1 Jun 2026 09:36:47 +0100 Subject: [PATCH] Keep terminal mounted with an exit banner when a session dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a pre-launch command (or claude itself) fails fast, the renderer's onProcessExited handler was calling destroySession immediately, which disposes the xterm and removes the DOM element. Any stderr the failing command wrote (claude: command not found, devbox: unknown flag, shell errors, etc.) was destroyed with the terminal, leaving the user with no feedback — a blank terminal opens for a moment and vanishes. Write a styled "── session exited (code N) ──" banner into the terminal and defer the destroy. For Claude sessions the terminal stays mounted so the user can scroll back and read whatever was printed before the exit; re-clicking the session destroys + relaunches via the existing closed- entry branch in openSession. Plain terminal sessions remain ephemeral (destroyed and removed from the sidebar immediately). --- public/app.js | 55 +++++++++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/public/app.js b/public/app.js index 9ab01b8..ff0a3c6 100644 --- a/public/app.js +++ b/public/app.js @@ -270,22 +270,30 @@ window.api.onProcessExited((sessionId, exitCode) => { const session = sessionMap.get(sessionId); if (entry) { entry.closed = true; + // Write a visible exit banner so the user can see when the process ended + // and read any error output it printed (claude / devbox / shell stderr). + // Without this, a fast-failing pre-launch command would tear down the + // terminal before the user could read the error. + try { + const colour = exitCode === 0 ? '\x1b[2m' : '\x1b[33m'; + entry.terminal.write( + `\r\n${colour}── session exited (code ${exitCode}) — re-click this session in the sidebar to relaunch, or click another to dismiss ──\x1b[0m\r\n` + ); + } catch {} } - // Clean up terminal UI on exit (uses destroySession to handle grid cards too) - if (entry) { - destroySession(sessionId); - } - if (gridViewActive) { - gridViewerCount.textContent = gridCards.size + ' session' + (gridCards.size !== 1 ? 's' : ''); - } else if (activeSessionId === sessionId) { - setActiveSession(null); - terminalHeader.style.display = 'none'; - placeholder.style.display = ''; - } - - // Plain terminal sessions: remove from sidebar entirely (ephemeral) + // Plain terminal sessions are ephemeral — destroy immediately and remove from + // the sidebar. Claude sessions stay mounted (see below) so the user can read + // the exit reason. if (session?.type === 'terminal') { + if (entry) destroySession(sessionId); + if (gridViewActive) { + gridViewerCount.textContent = gridCards.size + ' session' + (gridCards.size !== 1 ? 's' : ''); + } else if (activeSessionId === sessionId) { + setActiveSession(null); + terminalHeader.style.display = 'none'; + placeholder.style.display = ''; + } pendingSessions.delete(sessionId); for (const projList of [cachedProjects, cachedAllProjects]) { for (const proj of projList) { @@ -298,17 +306,16 @@ window.api.onProcessExited((sessionId, exitCode) => { return; } - // Clean up no-op pending sessions (never created a .jsonl) - if (pendingSessions.has(sessionId)) { - pendingSessions.delete(sessionId); - // Remove from cached project data - for (const projList of [cachedProjects, cachedAllProjects]) { - for (const proj of projList) { - proj.sessions = proj.sessions.filter(s => s.sessionId !== sessionId); - } - } - sessionMap.delete(sessionId); - refreshSidebar(); + // Claude sessions: keep the terminal mounted with the exit banner visible so + // the user can read what happened. Cleanup is deferred — openSession destroys + // the closed entry when the user re-clicks the session (existing behavior). + // If the session was pending (no .jsonl was written), leave the sidebar + // entry in place too so the user has somewhere to relaunch from; it'll be + // tidied up by the regular pending-reconciliation pass once it's clear no + // real session file is coming. + + if (gridViewActive) { + gridViewerCount.textContent = gridCards.size + ' session' + (gridCards.size !== 1 ? 's' : ''); } pollActiveSessions();