Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
}
Expand Down Expand Up @@ -139,6 +134,7 @@ function spawnMcpPoller(sessionId: string, projectDir: string) {
const serverMap = new Map<string, any>();

ptyProcess.onData((data) => {

// Accumulate output without displaying it
outputBuffer += data;

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
57 changes: 5 additions & 52 deletions renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,7 +13,6 @@ interface Session {
config: SessionConfig;
worktreePath: string;
hasActivePty: boolean;
hasUnreadActivity: boolean;
}

interface McpServer {
Expand Down Expand Up @@ -272,7 +272,6 @@ function addSession(persistedSession: PersistedSession, hasActivePty: boolean) {
config: persistedSession.config,
worktreePath: persistedSession.worktreePath,
hasActivePty,
hasUnreadActivity: false,
};

sessions.set(persistedSession.id, session);
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -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<string, NodeJS.Timeout>();
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);
Expand All @@ -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);
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions terminal-utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}