From 72b6995cfc302b119efbf7fb750dde9cc8296acd Mon Sep 17 00:00:00 2001 From: llc Date: Tue, 31 Mar 2026 15:47:28 +0200 Subject: [PATCH] feat: persist terminal sessions without tmux Replaces the tmux-based session persistence with a plain PTY approach: - On creation, each terminal's CWD, projectPath and customName are saved to userData/terminal-sessions.json via terminalPersistence.js - On app restart, terminals are restored as new PTY instances in their last-known working directory (no tmux required) - On explicit close, the entry is removed from the store This fixes scroll interception, Ctrl+B keybinding conflicts, and the implicit tmux dependency that degraded UX for users who had it installed. --- STRUCTURE.json | 115 +++++++++++++++++++++++++++----- src/main/ptyManager.js | 77 +++++++++++++++++++++ src/main/terminalPersistence.js | 73 ++++++++++++++++++++ src/renderer/multiTerminalUI.js | 8 ++- src/renderer/terminalManager.js | 35 +++++++++- src/shared/ipcChannels.js | 2 + 6 files changed, 291 insertions(+), 19 deletions(-) create mode 100644 src/main/terminalPersistence.js diff --git a/STRUCTURE.json b/STRUCTURE.json index 782ca1d..f173e20 100644 --- a/STRUCTURE.json +++ b/STRUCTURE.json @@ -1,7 +1,7 @@ { "version": "1.0", "description": "Claude Code IDE - Module structure and IPC communication map", - "lastUpdated": "2026-03-26", + "lastUpdated": "2026-03-31", "architecture": { "type": "electron", "mainProcess": "src/main/index.js", @@ -1618,12 +1618,14 @@ "ipc": { "listens": [ "TERMINAL_CREATED", + "TERMINALS_RESTORED", "AVAILABLE_SHELLS_DATA", "TERMINAL_OUTPUT_ID", "TERMINAL_DESTROYED" ], "emits": [ "TERMINAL_CREATE", + "TERMINALS_RESTORE", "GET_AVAILABLE_SHELLS", "TERMINAL_INPUT_ID", "TERMINAL_DESTROY", @@ -1804,6 +1806,7 @@ "exports": [ "init", "createTerminal", + "restoreTerminals", "writeToTerminal", "resizeTerminal", "destroyTerminal", @@ -1820,49 +1823,54 @@ "node-pty", "shared/ipcChannels", "promptLogger", + "terminalPersistence", "child_process", "fs" ], "functions": { "init": { - "line": 19, + "line": 20, "params": [ "window" ], "purpose": "Initialize PTY manager with window reference" }, "getDefaultShell": { - "line": 26, + "line": 27, "purpose": "Get default shell based on platform" }, "getAvailableShells": { - "line": 43, + "line": 44, "purpose": "Get available shells on the system" }, "createTerminal": { - "line": 129, + "line": 130, "params": [ "workingDir = null", "projectPath = null", "shellPath = null" ] }, + "restoreTerminals": { + "line": 192, + "purpose": "Restore terminals from persisted sessions (plain PTY, no tmux)" + }, "getTerminalsByProject": { - "line": 191, + "line": 255, "params": [ "projectPath" ], "purpose": "Get terminals for a specific project" }, "getTerminalInfo": { - "line": 206, + "line": 270, "params": [ "terminalId" ], "purpose": "Get terminal info" }, "writeToTerminal": { - "line": 217, + "line": 281, "params": [ "terminalId", "data" @@ -1870,7 +1878,7 @@ "purpose": "Write data to specific terminal" }, "resizeTerminal": { - "line": 227, + "line": 291, "params": [ "terminalId", "cols", @@ -1879,33 +1887,33 @@ "purpose": "Resize specific terminal" }, "destroyTerminal": { - "line": 237, + "line": 301, "params": [ "terminalId" ], "purpose": "Destroy specific terminal" }, "destroyAll": { - "line": 249, + "line": 314, "purpose": "Destroy all terminals" }, "getTerminalCount": { - "line": 260, + "line": 325, "purpose": "Get terminal count" }, "getTerminalIds": { - "line": 267, + "line": 332, "purpose": "Get all terminal IDs" }, "hasTerminal": { - "line": 274, + "line": 339, "params": [ "terminalId" ], "purpose": "Check if terminal exists" }, "setupIPC": { - "line": 281, + "line": 346, "params": [ "ipcMain" ], @@ -1918,9 +1926,12 @@ "TERMINAL_CREATE", "TERMINAL_DESTROY", "TERMINAL_INPUT_ID", - "TERMINAL_RESIZE_ID" + "TERMINAL_RESIZE_ID", + "TERMINALS_RESTORE" ], "emits": [ + "TERMINAL_OUTPUT_ID", + "TERMINAL_DESTROYED", "TERMINAL_OUTPUT_ID", "TERMINAL_DESTROYED" ] @@ -3499,6 +3510,61 @@ "LOAD_PROMPT_HISTORY" ] } + }, + "main/terminalPersistence": { + "file": "src/main/terminalPersistence.js", + "description": "Terminal Persistence Module", + "exports": [ + "load", + "save", + "add", + "remove", + "update" + ], + "depends": [ + "path", + "fs", + "electron" + ], + "functions": { + "getStoragePath": { + "line": 10 + }, + "load": { + "line": 18, + "purpose": "Load all persisted terminal sessions" + }, + "save": { + "line": 34, + "params": [ + "sessions" + ], + "purpose": "Persist all terminal sessions to disk" + }, + "add": { + "line": 47, + "params": [ + "terminalId", + "data" + ], + "purpose": "Add or update a terminal session entry" + }, + "remove": { + "line": 56, + "params": [ + "terminalId" + ], + "purpose": "Remove a terminal session entry" + }, + "update": { + "line": 67, + "params": [ + "terminalId", + "patch" + ], + "purpose": "Update a specific field of a terminal session" + } + } } }, "ipcChannels": { @@ -3975,6 +4041,16 @@ "name": "clone-github-repo-result", "direction": "", "description": "" + }, + "TERMINALS_RESTORE": { + "name": "terminals-restore", + "direction": "", + "description": "" + }, + "TERMINALS_RESTORED": { + "name": "terminals-restored", + "direction": "", + "description": "" } } }, @@ -4362,6 +4438,13 @@ "description": "Terminal Tab Bar Module" } ], + "terminal-persistence": [ + { + "module": "main/terminalPersistence", + "file": "src/main/terminalPersistence.js", + "description": "Terminal Persistence Module" + } + ], "workspace": [ { "module": "main/workspace", diff --git a/src/main/ptyManager.js b/src/main/ptyManager.js index 596445d..1007441 100644 --- a/src/main/ptyManager.js +++ b/src/main/ptyManager.js @@ -6,6 +6,7 @@ const pty = require('node-pty'); const { IPC } = require('../shared/ipcChannels'); const promptLogger = require('./promptLogger'); +const terminalPersistence = require('./terminalPersistence'); // Store multiple PTY instances const ptyInstances = new Map(); // Map @@ -178,11 +179,74 @@ function createTerminal(workingDir = null, projectPath = null, shellPath = null) }); ptyInstances.set(terminalId, { pty: ptyProcess, cwd, projectPath }); + terminalPersistence.add(terminalId, { cwd, projectPath: projectPath || null, customName: null }); console.log(`Created terminal ${terminalId} in ${cwd} (project: ${projectPath || 'global'})`); return terminalId; } +/** + * Restore terminals from persisted sessions (plain PTY, no tmux) + * @returns {Array<{terminalId, cwd, projectPath, customName}>} Restored terminal metadata + */ +function restoreTerminals() { + const sessions = terminalPersistence.load(); + const restored = []; + + for (const [terminalId, data] of Object.entries(sessions)) { + const { cwd, projectPath, customName } = data; + + // Update counter so new terminals don't collide with restored IDs + const match = terminalId.match(/term-(\d+)/); + if (match) { + const num = parseInt(match[1], 10); + if (num > terminalCounter) terminalCounter = num; + } + + const shell = getDefaultShell(); + let shellArgs = []; + if (process.platform !== 'win32') { + const shellName = shell.split('/').pop(); + if (shellName === 'fish') { + shellArgs = ['-i']; + } else if (shellName === 'nu') { + shellArgs = ['-l']; + } else { + shellArgs = ['-i', '-l']; + } + } + + const ptyProcess = pty.spawn(shell, shellArgs, { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd, + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' } + }); + + ptyProcess.onData((d) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC.TERMINAL_OUTPUT_ID, { terminalId, data: d }); + } + }); + + ptyProcess.onExit(({ exitCode }) => { + console.log(`Terminal ${terminalId} (restored) exited:`, exitCode); + ptyInstances.delete(terminalId); + terminalPersistence.remove(terminalId); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC.TERMINAL_DESTROYED, { terminalId, exitCode }); + } + }); + + ptyInstances.set(terminalId, { pty: ptyProcess, cwd, projectPath: projectPath || null }); + restored.push({ terminalId, cwd, projectPath: projectPath || null, customName: customName || null }); + console.log(`Restored terminal ${terminalId} in ${cwd}`); + } + + return restored; +} + /** * Get terminals for a specific project * @param {string|null} projectPath - Project path or null for global @@ -239,6 +303,7 @@ function destroyTerminal(terminalId) { if (instance) { instance.pty.kill(); ptyInstances.delete(terminalId); + terminalPersistence.remove(terminalId); console.log(`Destroyed terminal ${terminalId}`); } } @@ -329,11 +394,23 @@ function setupIPC(ipcMain) { ipcMain.on(IPC.TERMINAL_RESIZE_ID, (event, { terminalId, cols, rows }) => { resizeTerminal(terminalId, cols, rows); }); + + // Restore persisted terminals + ipcMain.on(IPC.TERMINALS_RESTORE, (event) => { + try { + const restored = restoreTerminals(); + event.reply(IPC.TERMINALS_RESTORED, { success: true, terminals: restored }); + } catch (error) { + console.error('[ptyManager] Failed to restore terminals:', error); + event.reply(IPC.TERMINALS_RESTORED, { success: false, terminals: [], error: error.message }); + } + }); } module.exports = { init, createTerminal, + restoreTerminals, writeToTerminal, resizeTerminal, destroyTerminal, diff --git a/src/main/terminalPersistence.js b/src/main/terminalPersistence.js new file mode 100644 index 0000000..3f9f516 --- /dev/null +++ b/src/main/terminalPersistence.js @@ -0,0 +1,73 @@ +/** + * Terminal Persistence Module + * Persists terminal session metadata to disk so they can be restored on app restart + */ + +const path = require('path'); +const fs = require('fs'); +const { app } = require('electron'); + +function getStoragePath() { + return path.join(app.getPath('userData'), 'terminal-sessions.json'); +} + +/** + * Load all persisted terminal sessions + * @returns {Object} Map of terminalId -> { cwd, projectPath, customName } + */ +function load() { + try { + const filePath = getStoragePath(); + if (!fs.existsSync(filePath)) return {}; + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); + } catch (err) { + console.error('[terminalPersistence] Failed to load sessions:', err); + return {}; + } +} + +/** + * Persist all terminal sessions to disk + * @param {Object} sessions - Map of terminalId -> session data + */ +function save(sessions) { + try { + fs.writeFileSync(getStoragePath(), JSON.stringify(sessions, null, 2), 'utf8'); + } catch (err) { + console.error('[terminalPersistence] Failed to save sessions:', err); + } +} + +/** + * Add or update a terminal session entry + * @param {string} terminalId + * @param {{ cwd: string, projectPath: string|null, customName: string|null }} data + */ +function add(terminalId, data) { + const sessions = load(); + save({ ...sessions, [terminalId]: data }); +} + +/** + * Remove a terminal session entry + * @param {string} terminalId + */ +function remove(terminalId) { + const sessions = load(); + const { [terminalId]: _removed, ...rest } = sessions; + save(rest); +} + +/** + * Update a specific field of a terminal session + * @param {string} terminalId + * @param {Object} patch + */ +function update(terminalId, patch) { + const sessions = load(); + if (!sessions[terminalId]) return; + save({ ...sessions, [terminalId]: { ...sessions[terminalId], ...patch } }); +} + +module.exports = { load, save, add, remove, update }; diff --git a/src/renderer/multiTerminalUI.js b/src/renderer/multiTerminalUI.js index 01c02ed..a7763cf 100644 --- a/src/renderer/multiTerminalUI.js +++ b/src/renderer/multiTerminalUI.js @@ -57,9 +57,13 @@ class MultiTerminalUI { // Setup keyboard shortcuts this._setupKeyboardShortcuts(); - // Create first terminal (global terminal for initial state) + // Try to restore persisted terminals first; create a new one only if none restored if (this.autoCreateInitialTerminal) { - this.manager.createTerminal({ projectPath: null }).then(() => { + this.manager.restorePersistedTerminals().then((restoredIds) => { + if (restoredIds.length === 0) { + return this.manager.createTerminal({ projectPath: null }); + } + }).then(() => { this.initialized = true; }); } else { diff --git a/src/renderer/terminalManager.js b/src/renderer/terminalManager.js index a42cedd..b8874dc 100644 --- a/src/renderer/terminalManager.js +++ b/src/renderer/terminalManager.js @@ -246,6 +246,39 @@ class TerminalManager { }); } + /** + * Restore terminals from persisted sessions + * @returns {Promise} Array of restored terminal IDs + */ + async restorePersistedTerminals() { + return new Promise((resolve) => { + const handler = (event, response) => { + ipcRenderer.removeListener(IPC.TERMINALS_RESTORED, handler); + + if (!response.success || !response.terminals || response.terminals.length === 0) { + resolve([]); + return; + } + + const restoredIds = []; + for (const data of response.terminals) { + const { terminalId, cwd, projectPath, customName } = data; + this._initializeTerminal(terminalId, { + cwd, + projectPath, + name: customName || null + }); + restoredIds.push(terminalId); + } + + resolve(restoredIds); + }; + + ipcRenderer.on(IPC.TERMINALS_RESTORED, handler); + ipcRenderer.send(IPC.TERMINALS_RESTORE); + }); + } + /** * Get available shells from main process * @returns {Promise>} @@ -298,7 +331,7 @@ class TerminalManager { const state = { id: terminalId, name: options.name || `Terminal ${++this.terminalCounter}`, - customName: null, + customName: options.name || null, isActive: false, createdAt: Date.now(), projectPath: options.projectPath !== undefined ? options.projectPath : this.currentProjectPath diff --git a/src/shared/ipcChannels.js b/src/shared/ipcChannels.js index 19cab52..4688503 100644 --- a/src/shared/ipcChannels.js +++ b/src/shared/ipcChannels.js @@ -62,6 +62,8 @@ const IPC = { TERMINAL_FOCUS: 'terminal-focus', GET_AVAILABLE_SHELLS: 'get-available-shells', AVAILABLE_SHELLS_DATA: 'available-shells-data', + TERMINALS_RESTORE: 'terminals-restore', + TERMINALS_RESTORED: 'terminals-restored', // Tasks Panel LOAD_TASKS: 'load-tasks',