Merge pull request #11 from built-by-as/unread-indicator-fix
Improve unread indicator detection and add terminal logging
This commit is contained in:
commit
74bcedf375
10
main.ts
10
main.ts
|
|
@ -9,6 +9,7 @@ import {simpleGit} from "simple-git";
|
||||||
import {promisify} from "util";
|
import {promisify} from "util";
|
||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import {PersistedSession, SessionConfig} from "./types";
|
import {PersistedSession, SessionConfig} from "./types";
|
||||||
|
import {isTerminalReady} from "./terminal-utils";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -31,12 +32,6 @@ function getWorktreeBaseDir(): string {
|
||||||
return path.join(os.homedir(), "worktrees");
|
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[]) {
|
function savePersistedSessions(sessions: PersistedSession[]) {
|
||||||
(store as any).set("sessions", sessions);
|
(store as any).set("sessions", sessions);
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +134,7 @@ function spawnMcpPoller(sessionId: string, projectDir: string) {
|
||||||
const serverMap = new Map<string, any>();
|
const serverMap = new Map<string, any>();
|
||||||
|
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
|
|
||||||
// Accumulate output without displaying it
|
// Accumulate output without displaying it
|
||||||
outputBuffer += data;
|
outputBuffer += data;
|
||||||
|
|
||||||
|
|
@ -469,6 +465,7 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => {
|
||||||
// Handle session input
|
// Handle session input
|
||||||
ipcMain.on("session-input", (_event, sessionId: string, data: string) => {
|
ipcMain.on("session-input", (_event, sessionId: string, data: string) => {
|
||||||
const ptyProcess = activePtyProcesses.get(sessionId);
|
const ptyProcess = activePtyProcesses.get(sessionId);
|
||||||
|
|
||||||
if (ptyProcess) {
|
if (ptyProcess) {
|
||||||
ptyProcess.write(data);
|
ptyProcess.write(data);
|
||||||
}
|
}
|
||||||
|
|
@ -508,6 +505,7 @@ ipcMain.on("reopen-session", (event, sessionId: string) => {
|
||||||
// Close session (kill PTY but keep session)
|
// Close session (kill PTY but keep session)
|
||||||
ipcMain.on("close-session", (_event, sessionId: string) => {
|
ipcMain.on("close-session", (_event, sessionId: string) => {
|
||||||
const ptyProcess = activePtyProcesses.get(sessionId);
|
const ptyProcess = activePtyProcesses.get(sessionId);
|
||||||
|
|
||||||
if (ptyProcess) {
|
if (ptyProcess) {
|
||||||
ptyProcess.kill();
|
ptyProcess.kill();
|
||||||
activePtyProcesses.delete(sessionId);
|
activePtyProcesses.delete(sessionId);
|
||||||
|
|
|
||||||
55
renderer.ts
55
renderer.ts
|
|
@ -2,6 +2,7 @@ import {FitAddon} from "@xterm/addon-fit";
|
||||||
import {ipcRenderer} from "electron";
|
import {ipcRenderer} from "electron";
|
||||||
import {Terminal} from "xterm";
|
import {Terminal} from "xterm";
|
||||||
import {PersistedSession, SessionConfig} from "./types";
|
import {PersistedSession, SessionConfig} from "./types";
|
||||||
|
import {isClaudeSessionReady} from "./terminal-utils";
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -12,7 +13,6 @@ interface Session {
|
||||||
config: SessionConfig;
|
config: SessionConfig;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
hasActivePty: boolean;
|
hasActivePty: boolean;
|
||||||
hasUnreadActivity: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface McpServer {
|
interface McpServer {
|
||||||
|
|
@ -272,7 +272,6 @@ function addSession(persistedSession: PersistedSession, hasActivePty: boolean) {
|
||||||
config: persistedSession.config,
|
config: persistedSession.config,
|
||||||
worktreePath: persistedSession.worktreePath,
|
worktreePath: persistedSession.worktreePath,
|
||||||
hasActivePty,
|
hasActivePty,
|
||||||
hasUnreadActivity: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sessions.set(persistedSession.id, session);
|
sessions.set(persistedSession.id, session);
|
||||||
|
|
@ -481,8 +480,6 @@ function markSessionAsUnread(sessionId: string) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
session.hasUnreadActivity = true;
|
|
||||||
|
|
||||||
// Add unread indicator to tab
|
// Add unread indicator to tab
|
||||||
const tab = document.getElementById(`tab-${sessionId}`);
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
if (tab) {
|
if (tab) {
|
||||||
|
|
@ -494,8 +491,6 @@ function clearUnreadStatus(sessionId: string) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
session.hasUnreadActivity = false;
|
|
||||||
|
|
||||||
// Remove unread indicator from tab
|
// Remove unread indicator from tab
|
||||||
const tab = document.getElementById(`tab-${sessionId}`);
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
if (tab) {
|
if (tab) {
|
||||||
|
|
@ -537,13 +532,6 @@ function switchToSession(sessionId: string) {
|
||||||
// Clear unread status when switching to this session
|
// Clear unread status when switching to this session
|
||||||
clearUnreadStatus(sessionId);
|
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
|
// Focus and resize
|
||||||
session.terminal.focus();
|
session.terminal.focus();
|
||||||
// Dispatch resize event to trigger terminal resize
|
// Dispatch resize event to trigger terminal resize
|
||||||
|
|
@ -575,13 +563,6 @@ function closeSession(sessionId: string) {
|
||||||
// Update UI indicator
|
// Update UI indicator
|
||||||
updateSessionState(sessionId, false);
|
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
|
// Close PTY in main process
|
||||||
ipcRenderer.send("close-session", sessionId);
|
ipcRenderer.send("close-session", sessionId);
|
||||||
|
|
||||||
|
|
@ -623,13 +604,6 @@ function deleteSession(sessionId: string) {
|
||||||
// Remove from sessions map
|
// Remove from sessions map
|
||||||
sessions.delete(sessionId);
|
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)
|
// Delete in main process (handles worktree removal)
|
||||||
ipcRenderer.send("delete-session", sessionId);
|
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
|
// Handle session output
|
||||||
ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
|
|
@ -664,27 +634,10 @@ ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
||||||
session.terminal.write(filteredData);
|
session.terminal.write(filteredData);
|
||||||
|
|
||||||
// Only mark as unread if this is not the active session
|
// Only mark as unread if this is not the active session
|
||||||
if (activeSessionId !== sessionId && session.hasActivePty && !session.hasUnreadActivity) {
|
if (activeSessionId !== sessionId && session.hasActivePty) {
|
||||||
// Only track substantive output (ignore cursor movements, keepalives, etc)
|
// Check if Claude session is ready for input
|
||||||
// Look for actual text content or common escape sequences that indicate real output
|
if (isClaudeSessionReady(filteredData)) {
|
||||||
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);
|
markSessionAsUnread(sessionId);
|
||||||
sessionIdleTimers.delete(sessionId);
|
|
||||||
}, IDLE_DELAY_MS);
|
|
||||||
|
|
||||||
sessionIdleTimers.set(sessionId, timer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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