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>> {
|
export async function detectActiveSessions(): Promise<Map<string, number>> {
|
||||||
const result = new Map<string, number>()
|
const result = new Map<string, number>()
|
||||||
sessionsByPath.clear()
|
sessionsByPath.clear()
|
||||||
@@ -59,6 +141,9 @@ export async function detectActiveSessions(): Promise<Map<string, number>> {
|
|||||||
|
|
||||||
if (pids.length === 0) return result
|
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> => {
|
const infoPromises = pids.map(async (pid): Promise<ActiveSession | null> => {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["lsof", "-p", pid, "-a", "-d", "cwd,0", "-F", "nf"], {
|
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) {
|
if (cwd) {
|
||||||
const key = cwdToProjectKey(cwd)
|
const key = cwdToProjectKey(cwd)
|
||||||
const jsonl = findActiveJsonl(key)
|
const jsonl = findActiveJsonl(key)
|
||||||
@@ -105,10 +195,17 @@ export async function detectActiveSessions(): Promise<Map<string, number>> {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionTtys(projectPath: string): string[] {
|
export function getSessionTtys(projectPath: string, sessionId?: string): string[] {
|
||||||
const sessions = sessionsByPath.get(projectPath)
|
const sessions = sessionsByPath.get(projectPath)
|
||||||
if (!sessions) return []
|
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 {
|
export function getBusyCount(projectPath: string): number {
|
||||||
@@ -127,67 +224,38 @@ export function getLastActivityMs(projectPath: string): number {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function focusTerminalByPath(projectPath: string): Promise<boolean> {
|
export async function focusTerminalByPath(projectPath: string, sessionId?: string): Promise<boolean> {
|
||||||
const ttys = getSessionTtys(projectPath)
|
// Collect all candidate ttys: from sessionsByPath first, then ps fallback
|
||||||
if (ttys.length === 0) return false
|
const triedTtys = new Set<string>()
|
||||||
|
|
||||||
const tty = ttys[0]
|
// Try cached ttys from sessionsByPath (sorted by most recent, or filtered to specific session)
|
||||||
const script = `
|
const cachedTtys = getSessionTtys(projectPath, sessionId)
|
||||||
tell application "Terminal"
|
for (const tty of cachedTtys) {
|
||||||
activate
|
triedTtys.add(tty)
|
||||||
repeat with w in windows
|
const matched = await focusTerminalTab(tty)
|
||||||
repeat with t in tabs of w
|
if (matched) {
|
||||||
if tty of t is "${tty}" then
|
flashTerminalByTty(matched)
|
||||||
set selected of t to true
|
|
||||||
set index of w to 1
|
|
||||||
return true
|
return true
|
||||||
end if
|
}
|
||||||
end repeat
|
}
|
||||||
end repeat
|
|
||||||
end tell
|
// Fallback: fresh ps lookup for PIDs not already covered
|
||||||
return false`
|
const sessions = sessionsByPath.get(projectPath)
|
||||||
|
const pids = sessions?.map(s => s.pid) ?? []
|
||||||
try {
|
if (pids.length === 0) return false
|
||||||
const proc = Bun.spawn(["osascript", "-e", script], {
|
|
||||||
stdout: "pipe",
|
const psTtyMap = await getTtyViaPsForPids(pids)
|
||||||
stderr: "ignore",
|
for (const tty of psTtyMap.values()) {
|
||||||
})
|
if (triedTtys.has(tty)) continue
|
||||||
const out = await new Response(proc.stdout).text()
|
triedTtys.add(tty)
|
||||||
await proc.exited
|
const matched = await focusTerminalTab(tty)
|
||||||
const focused = out.trim() === "true"
|
if (matched) {
|
||||||
|
flashTerminalByTty(matched)
|
||||||
if (focused) {
|
return true
|
||||||
flashTerminalByTty(tty)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return focused
|
|
||||||
} catch {
|
|
||||||
return false
|
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 {
|
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.activeSessions > 0) {
|
||||||
if (project.busySessions > 0) {
|
if (project.busySessions > 0) {
|
||||||
activeDot = green("●")
|
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 {
|
} else {
|
||||||
activeDot = yellow("◉")
|
activeDot = yellow("◉")
|
||||||
const elapsed = elapsedCompact(project.lastActivityMs)
|
const elapsed = elapsedCompact(project.lastActivityMs)
|
||||||
activeTag = elapsed ? dim(elapsed.padEnd(2).slice(0, 2)) : " "
|
activeTag = elapsed ? dim((elapsed + " ").slice(0, 2)) : " "
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
activeDot = dim("○")
|
activeDot = dim("○")
|
||||||
@@ -455,7 +456,8 @@ function updateAll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Keyboard ───────────────────────────────────────────────────────
|
// ─── Keyboard ───────────────────────────────────────────────────────
|
||||||
function handleKeypress(key: KeyEvent) {
|
async function handleKeypress(key: KeyEvent) {
|
||||||
|
try {
|
||||||
const total = displayRows.length
|
const total = displayRows.length
|
||||||
if (total === 0) return
|
if (total === 0) return
|
||||||
|
|
||||||
@@ -547,7 +549,10 @@ function handleKeypress(key: KeyEvent) {
|
|||||||
const row = displayRows[cursor]
|
const row = displayRows[cursor]
|
||||||
const project = projects[row.projectIndex]
|
const project = projects[row.projectIndex]
|
||||||
if (project.activeSessions > 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -586,8 +591,8 @@ function handleKeypress(key: KeyEvent) {
|
|||||||
case "return": {
|
case "return": {
|
||||||
// Focus idle session from idle panel
|
// Focus idle session from idle panel
|
||||||
if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0 && idleCursor < cachedIdleSessions.length) {
|
if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0 && idleCursor < cachedIdleSessions.length) {
|
||||||
focusTerminalByPath(cachedIdleSessions[idleCursor].projectPath)
|
const focused = await focusTerminalByPath(cachedIdleSessions[idleCursor].projectPath)
|
||||||
return
|
if (focused) return
|
||||||
}
|
}
|
||||||
// If cursor is on a project row with active session and nothing selected, focus it
|
// If cursor is on a project row with active session and nothing selected, focus it
|
||||||
const returnRow = displayRows[cursor]
|
const returnRow = displayRows[cursor]
|
||||||
@@ -597,8 +602,8 @@ function handleKeypress(key: KeyEvent) {
|
|||||||
selectedProjects.size === 0 &&
|
selectedProjects.size === 0 &&
|
||||||
selectedSessions.size === 0
|
selectedSessions.size === 0
|
||||||
) {
|
) {
|
||||||
focusTerminalByPath(projects[returnRow.projectIndex].path)
|
const focused = await focusTerminalByPath(projects[returnRow.projectIndex].path)
|
||||||
return
|
if (focused) return
|
||||||
}
|
}
|
||||||
doLaunch()
|
doLaunch()
|
||||||
break
|
break
|
||||||
@@ -616,6 +621,7 @@ function handleKeypress(key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateAll()
|
updateAll()
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expandProject(projectIndex: number) {
|
async function expandProject(projectIndex: number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user