feat: idle panel navigation, dock bounce, and fix session text parsing

- Tab/shift-tab to select idle sessions, enter to focus terminal
- Dock bounces on busy→idle transition for attention
- Fix session parser to handle array-format user messages and skip
  system tags (<local-command-caveat>, <command-name>, etc.)
- Eagerly load session data for active projects so idle panel
  always shows title, prompt, and response
- Render idle rows as individual Text nodes to avoid t`` nesting bug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-24 01:51:42 +00:00
parent 287d7d3228
commit 66ac50813a
3 changed files with 109 additions and 35 deletions

View File

@@ -216,6 +216,15 @@ export function playDoneSound(): void {
}) })
} }
export function bounceDock(): void {
Bun.spawn(["osascript", "-e", 'tell application "System Events" to tell application process "Terminal" to set frontmost to false'], {
stdout: "ignore",
stderr: "ignore",
})
// BEL character triggers Terminal dock bounce when not focused
process.stdout.write("\x07")
}
export function getSessionStatus(projectPath: string, sessionId: string): "busy" | "idle" | null { export function getSessionStatus(projectPath: string, sessionId: string): "busy" | "idle" | null {
const sessions = sessionsByPath.get(projectPath) const sessions = sessionsByPath.get(projectPath)
if (!sessions) return null if (!sessions) return null
@@ -254,6 +263,7 @@ export interface IdleSessionInfo {
idleSinceMs: number idleSinceMs: number
sessionTitle: string sessionTitle: string
lastPrompt: string lastPrompt: string
lastResponse: string
} }
export function getIdleSessions(projects: Project[]): IdleSessionInfo[] { export function getIdleSessions(projects: Project[]): IdleSessionInfo[] {
@@ -267,6 +277,7 @@ export function getIdleSessions(projects: Project[]): IdleSessionInfo[] {
// Find matching session info for title/prompt // Find matching session info for title/prompt
let title = "" let title = ""
let lastPrompt = "" let lastPrompt = ""
let lastResponse = ""
if (project.sessions) { if (project.sessions) {
const match = project.sessions.find( const match = project.sessions.find(
ps => s.sessionFile && s.sessionFile.endsWith(`${ps.id}.jsonl`) ps => s.sessionFile && s.sessionFile.endsWith(`${ps.id}.jsonl`)
@@ -274,6 +285,7 @@ export function getIdleSessions(projects: Project[]): IdleSessionInfo[] {
if (match) { if (match) {
title = match.title title = match.title
lastPrompt = match.lastUserPrompt lastPrompt = match.lastUserPrompt
lastResponse = match.lastAssistantMsg
} }
} }
idle.push({ idle.push({
@@ -283,6 +295,7 @@ export function getIdleSessions(projects: Project[]): IdleSessionInfo[] {
idleSinceMs: s.lastActivityMs, idleSinceMs: s.lastActivityMs,
sessionTitle: title || "(session)", sessionTitle: title || "(session)",
lastPrompt: lastPrompt || "", lastPrompt: lastPrompt || "",
lastResponse: lastResponse || "",
}) })
} }
} }

View File

@@ -54,9 +54,9 @@ async function extractSessionInfo(
firstTimestamp = new Date(d.timestamp).getTime() firstTimestamp = new Date(d.timestamp).getTime()
} }
if (!branch && d.gitBranch) branch = d.gitBranch if (!branch && d.gitBranch) branch = d.gitBranch
if (d.type === "user" && typeof d.message?.content === "string") { if (d.type === "user") {
title = d.message.content const text = extractUserText(d)
break if (text) { title = text; break }
} }
} catch {} } catch {}
} }
@@ -75,12 +75,9 @@ async function extractSessionInfo(
const ts = new Date(d.timestamp).getTime() const ts = new Date(d.timestamp).getTime()
if (ts > lastTimestamp) lastTimestamp = ts if (ts > lastTimestamp) lastTimestamp = ts
} }
if ( if (!lastUserPrompt && d.type === "user") {
!lastUserPrompt && const text = extractUserText(d)
d.type === "user" && if (text) lastUserPrompt = text
typeof d.message?.content === "string"
) {
lastUserPrompt = d.message.content
} }
if (!lastAssistantMsg && d.type === "assistant" && Array.isArray(d.message?.content)) { if (!lastAssistantMsg && d.type === "assistant" && Array.isArray(d.message?.content)) {
for (const block of d.message.content) { for (const block of d.message.content) {
@@ -109,6 +106,25 @@ async function extractSessionInfo(
} }
} }
const SYSTEM_TAG_RE = /^<(local-command|command-name|command-message|command-args|system-reminder)/
function extractUserText(d: any): string {
const content = d.message?.content
if (typeof content === "string") {
if (SYSTEM_TAG_RE.test(content.trim())) return ""
return content
}
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && typeof block.text === "string") {
const text = block.text.trim()
if (text && !SYSTEM_TAG_RE.test(text)) return text
}
}
}
return ""
}
function cleanText(text: string, maxLen: number): string { function cleanText(text: string, maxLen: number): string {
const cleaned = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim() const cleaned = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim()
if (cleaned.length > maxLen) return cleaned.slice(0, maxLen - 3) + "..." if (cleaned.length > maxLen) return cleaned.slice(0, maxLen - 3) + "..."

View File

@@ -21,7 +21,7 @@ import { discoverProjects } from "./data/history"
import { loadGitMetadata, loadBranches } from "./data/git" import { loadGitMetadata, loadBranches } from "./data/git"
import { loadSessions } from "./data/sessions" import { loadSessions } from "./data/sessions"
import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock" import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock"
import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor" import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor"
import { launchSelections } from "./actions/launcher" import { launchSelections } from "./actions/launcher"
import type { Project, DisplayRow } from "./lib/types" import type { Project, DisplayRow } from "./lib/types"
import { timeAgo, formatSize, elapsedCompact } from "./lib/time" import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
@@ -47,6 +47,8 @@ let monitorInterval: ReturnType<typeof setInterval> | null = null
let prevBusySnapshot: Map<string, number> = new Map() let prevBusySnapshot: Map<string, number> = new Map()
let bottomPanelMode: "preview" | "idle" = "preview" let bottomPanelMode: "preview" | "idle" = "preview"
let destroyed = false let destroyed = false
let idleCursor = 0
let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = []
// ─── UI Refs ──────────────────────────────────────────────────────── // ─── UI Refs ────────────────────────────────────────────────────────
let renderer: CliRenderer let renderer: CliRenderer
@@ -261,50 +263,67 @@ function updateColumnHeaders() {
colHeaderText.content = t` ${dim(cols)}` colHeaderText.content = t` ${dim(cols)}`
} }
function fmtIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string }) { function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string; lastPrompt: string; lastResponse: string }, isCursor: boolean) {
const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6) const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6)
const name = (s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName).padEnd(22) const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName
const title = s.sessionTitle.length > 40 ? s.sessionTitle.slice(0, 37) + "..." : s.sessionTitle const title = s.sessionTitle.length > 50 ? s.sessionTitle.slice(0, 47) + "..." : s.sessionTitle
return t` ${yellow("◉")} ${dim(elapsed)}${name}${dim('"' + title + '"')}` const prompt = s.lastPrompt
? s.lastPrompt.length > 60 ? s.lastPrompt.slice(0, 57) + "..." : s.lastPrompt
: "(no text)"
const response = s.lastResponse
? s.lastResponse.length > 60 ? s.lastResponse.slice(0, 57) + "..." : s.lastResponse
: "(no response)"
const pointer = isCursor ? "▸" : " "
previewBox.add(Text({ content: t` ${yellow("◉")} ${isCursor ? cyan(pointer) : dim(pointer)} ${dim(elapsed)}${bold(name)} ${fg(ACCENT)('"' + title + '"')}`, width: "100%", height: 1 }))
previewBox.add(Text({ content: t` ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + prompt + '"')}`, width: "100%", height: 1 }))
previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 }))
} }
function updateIdlePanel() { function updateIdlePanel() {
const idle = getIdleSessions(projects) cachedIdleSessions = getIdleSessions(projects)
const n = idle.length const n = cachedIdleSessions.length
previewBox.title = ` Idle Sessions (${n}) ` previewBox.title = ` Idle Sessions (${n}) — enter to focus `
// Clear all children and rebuild
for (const child of previewBox.getChildren()) previewBox.remove(child.id)
if (n === 0) { if (n === 0) {
previewText.content = t`${dim(" No idle sessions")}` idleCursor = 0
previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 }))
return return
} }
const show = idle.slice(0, 5) if (idleCursor >= n) idleCursor = n - 1
const r0 = show[0] ? fmtIdleRow(show[0]) : t`` const show = cachedIdleSessions.slice(0, 3)
const r1 = show[1] ? fmtIdleRow(show[1]) : null for (let i = 0; i < show.length; i++) {
const r2 = show[2] ? fmtIdleRow(show[2]) : null addIdleRow(show[i], idleCursor === i)
const r3 = show[3] ? fmtIdleRow(show[3]) : null }
const r4 = show[4] ? fmtIdleRow(show[4]) : null if (n > 3) {
const more = n > 5 ? t` previewBox.add(Text({ content: t` ${dim(`+${n - 3} more`)}`, width: "100%", height: 1 }))
${dim(`+${n - 5} more`)}` : t`` }
previewText.content = t` ${dim("TIME".padEnd(6))}${dim("PROJECT".padEnd(22))}${dim("SESSION")}
${r0}${r1 ? t`
${r1}` : t``}${r2 ? t`
${r2}` : t``}${r3 ? t`
${r3}` : t``}${r4 ? t`
${r4}` : t``}${more}`
} }
function updateBottomPanel() { function updateBottomPanel() {
if (bottomPanelMode === "idle") { if (bottomPanelMode === "idle") {
previewBox.height = 12
updateIdlePanel() updateIdlePanel()
} else { } else {
// Restore previewText as sole child
for (const child of previewBox.getChildren()) previewBox.remove(child.id)
previewBox.add(previewText)
previewBox.height = 7
previewBox.title = " Preview " previewBox.title = " Preview "
updatePreview() updatePreview()
} }
} }
function updateFooter() { function updateFooter() {
if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) {
footerText.content = t` ${dim(
"↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit"
)}`
} else {
footerText.content = t` ${dim( footerText.content = t` ${dim(
"↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter launch │ q quit" "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter launch │ q quit"
)}` )}`
}
} }
function updatePreview() { function updatePreview() {
@@ -432,6 +451,7 @@ function updateAll() {
updateHeader() updateHeader()
rebuildList() rebuildList()
updateBottomPanel() updateBottomPanel()
updateFooter()
} }
// ─── Keyboard ─────────────────────────────────────────────────────── // ─── Keyboard ───────────────────────────────────────────────────────
@@ -544,6 +564,17 @@ function handleKeypress(key: KeyEvent) {
case "i": case "i":
bottomPanelMode = bottomPanelMode === "preview" ? "idle" : "preview" bottomPanelMode = bottomPanelMode === "preview" ? "idle" : "preview"
idleCursor = 0
break
case "tab":
if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) {
if (key.shift) {
idleCursor = idleCursor > 0 ? idleCursor - 1 : Math.min(cachedIdleSessions.length, 3) - 1
} else {
idleCursor = (idleCursor + 1) % Math.min(cachedIdleSessions.length, 3)
}
}
break break
case "s": case "s":
@@ -553,6 +584,11 @@ function handleKeypress(key: KeyEvent) {
break break
case "return": { case "return": {
// Focus idle session from idle panel
if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0 && idleCursor < cachedIdleSessions.length) {
focusTerminalByPath(cachedIdleSessions[idleCursor].projectPath)
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]
if ( if (
@@ -769,16 +805,25 @@ async function main() {
prevBusySnapshot = snapshotBusy(projects) prevBusySnapshot = snapshotBusy(projects)
if (transitioned.length > 0) { if (transitioned.length > 0) {
playDoneSound() playDoneSound()
bounceDock()
bottomPanelMode = "idle" bottomPanelMode = "idle"
} }
updateAll() updateAll()
} else { } else {
const sessions = await detectActiveSessions() const sessions = await detectActiveSessions()
const changed = updateProjectSessions(projects, sessions) const changed = updateProjectSessions(projects, sessions)
// Eagerly load session data for active projects (needed for idle panel)
for (const p of projects) {
if (p.activeSessions > 0 && !p.sessions) {
p.sessions = await loadSessions(p.path)
p.sessionCount = p.sessions.length
}
}
const transitioned = checkTransitions(projects, prevBusySnapshot) const transitioned = checkTransitions(projects, prevBusySnapshot)
prevBusySnapshot = snapshotBusy(projects) prevBusySnapshot = snapshotBusy(projects)
if (transitioned.length > 0) { if (transitioned.length > 0) {
playDoneSound() playDoneSound()
bounceDock()
bottomPanelMode = "idle" bottomPanelMode = "idle"
} }
if (changed) updateAll() if (changed) updateAll()