diff --git a/main.ts b/main.ts index f718322..a1ab887 100644 --- a/main.ts +++ b/main.ts @@ -9,6 +9,7 @@ import {simpleGit} from "simple-git"; import {promisify} from "util"; import {v4 as uuidv4} from "uuid"; import {PersistedSession, SessionConfig} from "./types"; +import {isTerminalReady} from "./terminal-utils"; const execAsync = promisify(exec); @@ -31,12 +32,6 @@ function getWorktreeBaseDir(): string { return path.join(os.homedir(), "worktrees"); } -function isTerminalReady(buffer: string, startPos: number = 0): boolean { - const searchBuffer = buffer.slice(startPos); - - return searchBuffer.includes("\x1b[?2004h", startPos); -} - function savePersistedSessions(sessions: PersistedSession[]) { (store as any).set("sessions", sessions); } @@ -139,6 +134,7 @@ function spawnMcpPoller(sessionId: string, projectDir: string) { const serverMap = new Map(); ptyProcess.onData((data) => { + // Accumulate output without displaying it outputBuffer += data; @@ -469,6 +465,7 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => { // Handle session input ipcMain.on("session-input", (_event, sessionId: string, data: string) => { const ptyProcess = activePtyProcesses.get(sessionId); + if (ptyProcess) { ptyProcess.write(data); } @@ -508,6 +505,7 @@ ipcMain.on("reopen-session", (event, sessionId: string) => { // Close session (kill PTY but keep session) ipcMain.on("close-session", (_event, sessionId: string) => { const ptyProcess = activePtyProcesses.get(sessionId); + if (ptyProcess) { ptyProcess.kill(); activePtyProcesses.delete(sessionId); diff --git a/renderer.ts b/renderer.ts index 0c721f7..0ff7359 100644 --- a/renderer.ts +++ b/renderer.ts @@ -2,6 +2,7 @@ import {FitAddon} from "@xterm/addon-fit"; import {ipcRenderer} from "electron"; import {Terminal} from "xterm"; import {PersistedSession, SessionConfig} from "./types"; +import {isClaudeSessionReady} from "./terminal-utils"; interface Session { id: string; @@ -12,7 +13,6 @@ interface Session { config: SessionConfig; worktreePath: string; hasActivePty: boolean; - hasUnreadActivity: boolean; } interface McpServer { @@ -272,7 +272,6 @@ function addSession(persistedSession: PersistedSession, hasActivePty: boolean) { config: persistedSession.config, worktreePath: persistedSession.worktreePath, hasActivePty, - hasUnreadActivity: false, }; sessions.set(persistedSession.id, session); @@ -481,8 +480,6 @@ function markSessionAsUnread(sessionId: string) { const session = sessions.get(sessionId); if (!session) return; - session.hasUnreadActivity = true; - // Add unread indicator to tab const tab = document.getElementById(`tab-${sessionId}`); if (tab) { @@ -494,8 +491,6 @@ function clearUnreadStatus(sessionId: string) { const session = sessions.get(sessionId); if (!session) return; - session.hasUnreadActivity = false; - // Remove unread indicator from tab const tab = document.getElementById(`tab-${sessionId}`); if (tab) { @@ -537,13 +532,6 @@ function switchToSession(sessionId: string) { // Clear unread status when switching to this session clearUnreadStatus(sessionId); - // Clear any pending idle timer for this session (Bug 1 fix) - const existingTimer = sessionIdleTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - sessionIdleTimers.delete(sessionId); - } - // Focus and resize session.terminal.focus(); // Dispatch resize event to trigger terminal resize @@ -575,13 +563,6 @@ function closeSession(sessionId: string) { // Update UI indicator updateSessionState(sessionId, false); - // Clean up idle timer (Bug 2 fix) - const existingTimer = sessionIdleTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - sessionIdleTimers.delete(sessionId); - } - // Close PTY in main process ipcRenderer.send("close-session", sessionId); @@ -623,13 +604,6 @@ function deleteSession(sessionId: string) { // Remove from sessions map sessions.delete(sessionId); - // Clean up idle timer (Bug 2 fix) - const existingTimer = sessionIdleTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - sessionIdleTimers.delete(sessionId); - } - // Delete in main process (handles worktree removal) ipcRenderer.send("delete-session", sessionId); @@ -649,10 +623,6 @@ function deleteSession(sessionId: string) { } } -// Track idle timers per session to detect when output stops (Claude is done) -const sessionIdleTimers = new Map(); -const IDLE_DELAY_MS = 500; // 0.5 seconds of no output = Claude is done - // Handle session output ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => { const session = sessions.get(sessionId); @@ -664,27 +634,10 @@ ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => { session.terminal.write(filteredData); // Only mark as unread if this is not the active session - if (activeSessionId !== sessionId && session.hasActivePty && !session.hasUnreadActivity) { - // Only track substantive output (ignore cursor movements, keepalives, etc) - // Look for actual text content or common escape sequences that indicate real output - const hasSubstantiveOutput = /[a-zA-Z0-9]/.test(filteredData) || - filteredData.includes('\n') || - filteredData.includes('\r'); - - if (hasSubstantiveOutput) { - // Clear any existing idle timer - const existingTimer = sessionIdleTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - // Set a new timer - if no output for IDLE_DELAY_MS, mark as unread - const timer = setTimeout(() => { - markSessionAsUnread(sessionId); - sessionIdleTimers.delete(sessionId); - }, IDLE_DELAY_MS); - - sessionIdleTimers.set(sessionId, timer); + if (activeSessionId !== sessionId && session.hasActivePty) { + // Check if Claude session is ready for input + if (isClaudeSessionReady(filteredData)) { + markSessionAsUnread(sessionId); } } } diff --git a/terminal-utils.ts b/terminal-utils.ts new file mode 100644 index 0000000..324a55b --- /dev/null +++ b/terminal-utils.ts @@ -0,0 +1,20 @@ +// Terminal escape sequences used for detecting terminal state + +// Bracketed paste mode enable - indicates terminal is ready for input +export const BRACKETED_PASTE_MODE_ENABLE = "\x1b[?2004h"; + +// Pattern that indicates Claude interactive session is done and waiting for input +// Looks for: >\r\n (empty prompt with no space, no suggestion text) +const CLAUDE_READY_PROMPT_PATTERN = />\r\n/; + +// Check if a normal shell terminal is ready for input +// Used during terminal initialization in main.ts +export function isTerminalReady(buffer: string, startPos: number = 0): boolean { + return buffer.includes(BRACKETED_PASTE_MODE_ENABLE, startPos); +} + +// Check if Claude interactive session is done and ready for input +// Used for unread indicator detection in renderer.ts +export function isClaudeSessionReady(buffer: string): boolean { + return CLAUDE_READY_PROMPT_PATTERN.test(buffer); +}