Merge pull request #11 from built-by-as/unread-indicator-fix

Improve unread indicator detection and add terminal logging
This commit is contained in:
built-by-as 2025-10-10 19:45:09 -04:00 committed by GitHub
commit 74bcedf375
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 29 additions and 58 deletions

10
main.ts
View file

@ -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);

View file

@ -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(() => {
if (activeSessionId !== sessionId && session.hasActivePty) {
// Check if Claude session is ready for input
if (isClaudeSessionReady(filteredData)) {
markSessionAsUnread(sessionId);
sessionIdleTimers.delete(sessionId);
}, IDLE_DELAY_MS);
sessionIdleTimers.set(sessionId, timer);
}
}
}

20
terminal-utils.ts Normal file
View 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);
}