From e56ed9bc95eb242b726b8bfa93a833e9c0e169d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:33:44 +0000 Subject: [PATCH] fix: robust terminal focus with session targeting and code review fixes - Extract helpers: focusTerminalTab, getTtyViaPsForPids, escapeAppleScript - Batch ps tty lookup (single call for all PIDs instead of sequential) - Target specific session tty when g pressed on session row - Sort ttys by most recent activity for project-level focus - Deduplicate tried ttys between primary and fallback paths - Escape AppleScript interpolations to prevent injection - Wrap flash animation in try/end try for mid-close safety - Wrap async handleKeypress in try/catch for unhandled rejections - Fix activeTag padding for consistent column alignment Co-Authored-By: Claude Opus 4.6 --- src/data/monitor.ts | 186 ++++++++++++++++++++++++++++++-------------- src/index.ts | 22 ++++-- 2 files changed, 141 insertions(+), 67 deletions(-) diff --git a/src/data/monitor.ts b/src/data/monitor.ts index 01b908a..0767ce5 100644 --- a/src/data/monitor.ts +++ b/src/data/monitor.ts @@ -40,6 +40,88 @@ function findActiveJsonl(projectKey: string): { path: string; mtime: number } | } } +function escapeAppleScript(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') +} + +async function getTtyViaPsForPids(pids: string[]): Promise> { + const result = new Map() + if (pids.length === 0) return result + try { + const proc = Bun.spawn(["ps", "-o", "pid=,tty=", "-p", pids.join(",")], { + stdout: "pipe", + stderr: "ignore", + }) + const text = (await new Response(proc.stdout).text()).trim() + await proc.exited + for (const line of text.split("\n")) { + const parts = line.trim().split(/\s+/) + if (parts.length >= 2) { + const pid = parts[0] + const tty = parts[1] + if (tty && tty !== "??" && tty !== "-") { + result.set(pid, `/dev/tty${tty}`) + } + } + } + } catch {} + return result +} + +async function focusTerminalTab(tty: string): Promise { + const escaped = escapeAppleScript(tty) + try { + const proc = Bun.spawn(["osascript", "-e", ` +tell application "Terminal" + activate + repeat with w in windows + repeat with t in tabs of w + if tty of t is "${escaped}" then + set selected of t to true + set index of w to 1 + return tty of t + end if + end repeat + end repeat +end tell +return ""`], { + stdout: "pipe", + stderr: "ignore", + }) + const out = (await new Response(proc.stdout).text()).trim() + await proc.exited + return out + } catch { + return "" + } +} + +function flashTerminalByTty(tty: string): void { + const escaped = escapeAppleScript(tty) + Bun.spawn(["osascript", "-e", ` +tell application "Terminal" + repeat with w in windows + repeat with t in tabs of w + if tty of t is "${escaped}" then + try + set origBg to background color of t + repeat 3 times + set background color of t to {12000, 12000, 28000} + delay 0.12 + set background color of t to origBg + delay 0.12 + end repeat + end try + return + end if + end repeat + end repeat +end tell`], { + stdout: "ignore", + stderr: "ignore", + }) +} + export async function detectActiveSessions(): Promise> { const result = new Map() sessionsByPath.clear() @@ -59,6 +141,9 @@ export async function detectActiveSessions(): Promise> { if (pids.length === 0) return result + // Batch-fetch ttys via ps for all PIDs at once + const psTtyMap = await getTtyViaPsForPids(pids) + const infoPromises = pids.map(async (pid): Promise => { try { const proc = Bun.spawn(["lsof", "-p", pid, "-a", "-d", "cwd,0", "-F", "nf"], { @@ -82,6 +167,11 @@ export async function detectActiveSessions(): Promise> { } } + // Fallback: use pre-fetched ps tty + if (!tty) { + tty = psTtyMap.get(pid) ?? "" + } + if (cwd) { const key = cwdToProjectKey(cwd) const jsonl = findActiveJsonl(key) @@ -105,10 +195,17 @@ export async function detectActiveSessions(): Promise> { return result } -export function getSessionTtys(projectPath: string): string[] { +export function getSessionTtys(projectPath: string, sessionId?: string): string[] { const sessions = sessionsByPath.get(projectPath) if (!sessions) return [] - return sessions.map(s => s.tty).filter(Boolean) + // If targeting a specific session, return only its tty + if (sessionId) { + const match = sessions.find(s => s.sessionFile?.endsWith(`${sessionId}.jsonl`)) + return match?.tty ? [match.tty] : [] + } + // Sort by most recently active first so the best candidate is tried first + const sorted = [...sessions].sort((a, b) => b.lastActivityMs - a.lastActivityMs) + return sorted.map(s => s.tty).filter(Boolean) } export function getBusyCount(projectPath: string): number { @@ -127,67 +224,38 @@ export function getLastActivityMs(projectPath: string): number { return best } -export async function focusTerminalByPath(projectPath: string): Promise { - const ttys = getSessionTtys(projectPath) - if (ttys.length === 0) return false +export async function focusTerminalByPath(projectPath: string, sessionId?: string): Promise { + // Collect all candidate ttys: from sessionsByPath first, then ps fallback + const triedTtys = new Set() - const tty = ttys[0] - const script = ` -tell application "Terminal" - activate - repeat with w in windows - repeat with t in tabs of w - if tty of t is "${tty}" then - set selected of t to true - set index of w to 1 - return true - end if - end repeat - end repeat -end tell -return false` - - try { - const proc = Bun.spawn(["osascript", "-e", script], { - stdout: "pipe", - stderr: "ignore", - }) - const out = await new Response(proc.stdout).text() - await proc.exited - const focused = out.trim() === "true" - - if (focused) { - flashTerminalByTty(tty) + // Try cached ttys from sessionsByPath (sorted by most recent, or filtered to specific session) + const cachedTtys = getSessionTtys(projectPath, sessionId) + for (const tty of cachedTtys) { + triedTtys.add(tty) + const matched = await focusTerminalTab(tty) + if (matched) { + flashTerminalByTty(matched) + return true } - - return focused - } catch { - return false } -} -function flashTerminalByTty(tty: string): void { - const script = ` -tell application "Terminal" - repeat with w in windows - repeat with t in tabs of w - if tty of t is "${tty}" then - set origBg to background color of t - repeat 3 times - set background color of t to {12000, 12000, 28000} - delay 0.12 - set background color of t to origBg - delay 0.12 - end repeat - return - end if - end repeat - end repeat -end tell` - Bun.spawn(["osascript", "-e", script], { - stdout: "ignore", - stderr: "ignore", - }) + // Fallback: fresh ps lookup for PIDs not already covered + const sessions = sessionsByPath.get(projectPath) + const pids = sessions?.map(s => s.pid) ?? [] + if (pids.length === 0) return false + + const psTtyMap = await getTtyViaPsForPids(pids) + for (const tty of psTtyMap.values()) { + if (triedTtys.has(tty)) continue + triedTtys.add(tty) + const matched = await focusTerminalTab(tty) + if (matched) { + flashTerminalByTty(matched) + return true + } + } + + return false } export function updateProjectSessions(projects: Project[], sessions: Map): boolean { diff --git a/src/index.ts b/src/index.ts index b26fdf7..ffc66bd 100755 --- a/src/index.ts +++ b/src/index.ts @@ -125,11 +125,12 @@ function fmtProjectRow(project: Project, isSelected: boolean) { if (project.activeSessions > 0) { if (project.busySessions > 0) { activeDot = green("●") - activeTag = project.activeSessions > 1 ? yellow(String(project.activeSessions).padEnd(2)) : " " + const count = String(project.activeSessions) + activeTag = project.activeSessions > 1 ? yellow((count + " ").slice(0, 2)) : " " } else { activeDot = yellow("◉") const elapsed = elapsedCompact(project.lastActivityMs) - activeTag = elapsed ? dim(elapsed.padEnd(2).slice(0, 2)) : " " + activeTag = elapsed ? dim((elapsed + " ").slice(0, 2)) : " " } } else { activeDot = dim("○") @@ -455,7 +456,8 @@ function updateAll() { } // ─── Keyboard ─────────────────────────────────────────────────────── -function handleKeypress(key: KeyEvent) { +async function handleKeypress(key: KeyEvent) { + try { const total = displayRows.length if (total === 0) return @@ -547,7 +549,10 @@ function handleKeypress(key: KeyEvent) { const row = displayRows[cursor] const project = projects[row.projectIndex] if (project.activeSessions > 0) { - focusTerminalByPath(project.path) + const sid = row.type === "session" && project.sessions + ? project.sessions[row.sessionIndex!]?.id + : undefined + await focusTerminalByPath(project.path, sid) } return } @@ -586,8 +591,8 @@ function handleKeypress(key: KeyEvent) { case "return": { // Focus idle session from idle panel if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0 && idleCursor < cachedIdleSessions.length) { - focusTerminalByPath(cachedIdleSessions[idleCursor].projectPath) - return + const focused = await focusTerminalByPath(cachedIdleSessions[idleCursor].projectPath) + if (focused) return } // If cursor is on a project row with active session and nothing selected, focus it const returnRow = displayRows[cursor] @@ -597,8 +602,8 @@ function handleKeypress(key: KeyEvent) { selectedProjects.size === 0 && selectedSessions.size === 0 ) { - focusTerminalByPath(projects[returnRow.projectIndex].path) - return + const focused = await focusTerminalByPath(projects[returnRow.projectIndex].path) + if (focused) return } doLaunch() break @@ -616,6 +621,7 @@ function handleKeypress(key: KeyEvent) { } updateAll() + } catch {} } async function expandProject(projectIndex: number) {