From 66ac50813abb97dcc6f62572fb2e6986969f9c8a 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 01:51:42 +0000 Subject: [PATCH] feat: idle panel navigation, dock bounce, and fix session text parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 (, , 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 --- src/data/monitor.ts | 13 ++++++ src/data/sessions.ts | 34 ++++++++++++---- src/index.ts | 97 ++++++++++++++++++++++++++++++++------------ 3 files changed, 109 insertions(+), 35 deletions(-) diff --git a/src/data/monitor.ts b/src/data/monitor.ts index 0246f7b..79ae150 100644 --- a/src/data/monitor.ts +++ b/src/data/monitor.ts @@ -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 { const sessions = sessionsByPath.get(projectPath) if (!sessions) return null @@ -254,6 +263,7 @@ export interface IdleSessionInfo { idleSinceMs: number sessionTitle: string lastPrompt: string + lastResponse: string } export function getIdleSessions(projects: Project[]): IdleSessionInfo[] { @@ -267,6 +277,7 @@ export function getIdleSessions(projects: Project[]): IdleSessionInfo[] { // Find matching session info for title/prompt let title = "" let lastPrompt = "" + let lastResponse = "" if (project.sessions) { const match = project.sessions.find( ps => s.sessionFile && s.sessionFile.endsWith(`${ps.id}.jsonl`) @@ -274,6 +285,7 @@ export function getIdleSessions(projects: Project[]): IdleSessionInfo[] { if (match) { title = match.title lastPrompt = match.lastUserPrompt + lastResponse = match.lastAssistantMsg } } idle.push({ @@ -283,6 +295,7 @@ export function getIdleSessions(projects: Project[]): IdleSessionInfo[] { idleSinceMs: s.lastActivityMs, sessionTitle: title || "(session)", lastPrompt: lastPrompt || "", + lastResponse: lastResponse || "", }) } } diff --git a/src/data/sessions.ts b/src/data/sessions.ts index db4652e..cbbb6ff 100644 --- a/src/data/sessions.ts +++ b/src/data/sessions.ts @@ -54,9 +54,9 @@ async function extractSessionInfo( firstTimestamp = new Date(d.timestamp).getTime() } if (!branch && d.gitBranch) branch = d.gitBranch - if (d.type === "user" && typeof d.message?.content === "string") { - title = d.message.content - break + if (d.type === "user") { + const text = extractUserText(d) + if (text) { title = text; break } } } catch {} } @@ -75,12 +75,9 @@ async function extractSessionInfo( const ts = new Date(d.timestamp).getTime() if (ts > lastTimestamp) lastTimestamp = ts } - if ( - !lastUserPrompt && - d.type === "user" && - typeof d.message?.content === "string" - ) { - lastUserPrompt = d.message.content + if (!lastUserPrompt && d.type === "user") { + const text = extractUserText(d) + if (text) lastUserPrompt = text } if (!lastAssistantMsg && d.type === "assistant" && Array.isArray(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 { const cleaned = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim() if (cleaned.length > maxLen) return cleaned.slice(0, maxLen - 3) + "..." diff --git a/src/index.ts b/src/index.ts index 32388e5..2d5b0e9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ import { discoverProjects } from "./data/history" import { loadGitMetadata, loadBranches } from "./data/git" import { loadSessions } from "./data/sessions" 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 type { Project, DisplayRow } from "./lib/types" import { timeAgo, formatSize, elapsedCompact } from "./lib/time" @@ -47,6 +47,8 @@ let monitorInterval: ReturnType | null = null let prevBusySnapshot: Map = new Map() let bottomPanelMode: "preview" | "idle" = "preview" let destroyed = false +let idleCursor = 0 +let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = [] // ─── UI Refs ──────────────────────────────────────────────────────── let renderer: CliRenderer @@ -261,50 +263,67 @@ function updateColumnHeaders() { 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 name = (s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName).padEnd(22) - const title = s.sessionTitle.length > 40 ? s.sessionTitle.slice(0, 37) + "..." : s.sessionTitle - return t` ${yellow("◉")} ${dim(elapsed)}${name}${dim('"' + title + '"')}` + const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName + const title = s.sessionTitle.length > 50 ? s.sessionTitle.slice(0, 47) + "..." : s.sessionTitle + 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() { - const idle = getIdleSessions(projects) - const n = idle.length - previewBox.title = ` Idle Sessions (${n}) ` + cachedIdleSessions = getIdleSessions(projects) + const n = cachedIdleSessions.length + 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) { - previewText.content = t`${dim(" No idle sessions")}` + idleCursor = 0 + previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) return } - const show = idle.slice(0, 5) - const r0 = show[0] ? fmtIdleRow(show[0]) : t`` - const r1 = show[1] ? fmtIdleRow(show[1]) : null - const r2 = show[2] ? fmtIdleRow(show[2]) : null - const r3 = show[3] ? fmtIdleRow(show[3]) : null - const r4 = show[4] ? fmtIdleRow(show[4]) : null - const more = n > 5 ? t` - ${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}` + if (idleCursor >= n) idleCursor = n - 1 + const show = cachedIdleSessions.slice(0, 3) + for (let i = 0; i < show.length; i++) { + addIdleRow(show[i], idleCursor === i) + } + if (n > 3) { + previewBox.add(Text({ content: t` ${dim(`+${n - 3} more`)}`, width: "100%", height: 1 })) + } } function updateBottomPanel() { if (bottomPanelMode === "idle") { + previewBox.height = 12 updateIdlePanel() } 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 " updatePreview() } } function updateFooter() { - 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" - )}` + 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( + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter launch │ q quit" + )}` + } } function updatePreview() { @@ -432,6 +451,7 @@ function updateAll() { updateHeader() rebuildList() updateBottomPanel() + updateFooter() } // ─── Keyboard ─────────────────────────────────────────────────────── @@ -544,6 +564,17 @@ function handleKeypress(key: KeyEvent) { case "i": 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 case "s": @@ -553,6 +584,11 @@ function handleKeypress(key: KeyEvent) { break 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 const returnRow = displayRows[cursor] if ( @@ -769,16 +805,25 @@ async function main() { prevBusySnapshot = snapshotBusy(projects) if (transitioned.length > 0) { playDoneSound() + bounceDock() bottomPanelMode = "idle" } updateAll() } else { const sessions = await detectActiveSessions() 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) prevBusySnapshot = snapshotBusy(projects) if (transitioned.length > 0) { playDoneSound() + bounceDock() bottomPanelMode = "idle" } if (changed) updateAll()