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:
@@ -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
|
||||
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<string, number>): boolean {
|
||||
|
||||
22
src/index.ts
22
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) {
|
||||
|
||||
Reference in New Issue
Block a user