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:
@@ -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 || "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) + "..."
|
||||||
|
|||||||
91
src/index.ts
91
src/index.ts
@@ -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,51 +263,68 @@ 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() {
|
||||||
if (cursor >= displayRows.length) {
|
if (cursor >= displayRows.length) {
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user