Improve unread indicator detection
- Fix unread indicator to only trigger when Claude finishes responding (using `>\r\n` pattern instead of timer-based detection) - Separate terminal ready detection logic for shell initialization vs Claude interactive session completion - Add terminal-utils.ts with `isTerminalReady()` (shell) and `isClaudeSessionReady()` (Claude interactive) - Update renderer.ts to use `isClaudeSessionReady()` and remove timer-based detection logic
This commit is contained in:
parent
d6f853a497
commit
d19fc88992
10
main.ts
10
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<string, any>();
|
||||
|
||||
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);
|
||||
|
|
|
|||
57
renderer.ts
57
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<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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
terminal-utils.ts
Normal file
20
terminal-utils.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue