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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-24 17:33:44 +00:00
parent f8c6a5f584
commit e56ed9bc95
2 changed files with 141 additions and 67 deletions

View File

@@ -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<Map<string, string>> {
const result = new Map<string, string>()
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<string> {
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<Map<string, number>> {
const result = new Map<string, number>()
sessionsByPath.clear()
@@ -59,6 +141,9 @@ export async function detectActiveSessions(): Promise<Map<string, number>> {
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<ActiveSession | null> => {
try {
const proc = Bun.spawn(["lsof", "-p", pid, "-a", "-d", "cwd,0", "-F", "nf"], {
@@ -82,6 +167,11 @@ export async function detectActiveSessions(): Promise<Map<string, number>> {
}
}
// 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<Map<string, number>> {
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<boolean> {
const ttys = getSessionTtys(projectPath)
if (ttys.length === 0) return false
export async function focusTerminalByPath(projectPath: string, sessionId?: string): Promise<boolean> {
// Collect all candidate ttys: from sessionsByPath first, then ps fallback
const triedTtys = new Set<string>()
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
// 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
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)
}
}
// 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 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",
})
}
export function updateProjectSessions(projects: Project[], sessions: Map<string, number>): boolean {

View File

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