feat(todo-continuation-enforcer): implement countdown toast notification

Implement countdown toast feature showing visual feedback before todo continuation:
- Changed from 5-second timeout to interval-based countdown
- Shows toast every second: "Resuming in 5s...", "Resuming in 4s...", etc.
- Toast duration set to 900ms to prevent overlap
- Countdown cancels on user message, session error, or session deletion

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-19 16:43:04 +09:00
parent eccbfa5550
commit 15d36ab461

View File

@@ -61,12 +61,20 @@ function detectInterrupt(error: unknown): boolean {
return false return false
} }
const COUNTDOWN_SECONDS = 5
const TOAST_DURATION_MS = 900 // Slightly less than 1s so toasts don't overlap
interface CountdownState {
secondsRemaining: number
intervalId: ReturnType<typeof setInterval>
}
export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer { export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer {
const remindedSessions = new Set<string>() const remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>() const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>() const errorSessions = new Set<string>()
const recoveringSessions = new Set<string>() const recoveringSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>() const pendingCountdowns = new Map<string, CountdownState>()
const markRecovering = (sessionID: string): void => { const markRecovering = (sessionID: string): void => {
recoveringSessions.add(sessionID) recoveringSessions.add(sessionID)
@@ -89,11 +97,10 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
} }
log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error }) log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error })
// Cancel pending continuation if error occurs const countdown = pendingCountdowns.get(sessionID)
const timer = pendingTimers.get(sessionID) if (countdown) {
if (timer) { clearInterval(countdown.intervalId)
clearTimeout(timer) pendingCountdowns.delete(sessionID)
pendingTimers.delete(sessionID)
} }
} }
return return
@@ -105,17 +112,27 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
log(`[${HOOK_NAME}] session.idle received`, { sessionID }) log(`[${HOOK_NAME}] session.idle received`, { sessionID })
// Cancel any existing timer to debounce const existingCountdown = pendingCountdowns.get(sessionID)
const existingTimer = pendingTimers.get(sessionID) if (existingCountdown) {
if (existingTimer) { clearInterval(existingCountdown.intervalId)
clearTimeout(existingTimer) pendingCountdowns.delete(sessionID)
log(`[${HOOK_NAME}] Cancelled existing timer`, { sessionID }) log(`[${HOOK_NAME}] Cancelled existing countdown`, { sessionID })
} }
// Schedule continuation check const showCountdownToast = async (seconds: number): Promise<void> => {
const timer = setTimeout(async () => { await ctx.client.tui.showToast({
pendingTimers.delete(sessionID) body: {
log(`[${HOOK_NAME}] Timer fired, checking conditions`, { sessionID }) title: "Todo Continuation",
message: `Resuming in ${seconds}s...`,
variant: "warning" as const,
duration: TOAST_DURATION_MS,
},
}).catch(() => {})
}
const executeAfterCountdown = async (): Promise<void> => {
pendingCountdowns.delete(sessionID)
log(`[${HOOK_NAME}] Countdown finished, checking conditions`, { sessionID })
// Check if session is in recovery mode - if so, skip entirely without clearing state // Check if session is in recovery mode - if so, skip entirely without clearing state
if (recoveringSessions.has(sessionID)) { if (recoveringSessions.has(sessionID)) {
@@ -206,9 +223,32 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) }) log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) })
remindedSessions.delete(sessionID) remindedSessions.delete(sessionID)
} }
}, 5000) }
pendingTimers.set(sessionID, timer) let secondsRemaining = COUNTDOWN_SECONDS
showCountdownToast(secondsRemaining).catch(() => {})
const intervalId = setInterval(() => {
secondsRemaining--
if (secondsRemaining <= 0) {
clearInterval(intervalId)
pendingCountdowns.delete(sessionID)
executeAfterCountdown()
return
}
const countdown = pendingCountdowns.get(sessionID)
if (!countdown) {
clearInterval(intervalId)
return
}
countdown.secondsRemaining = secondsRemaining
showCountdownToast(secondsRemaining).catch(() => {})
}, 1000)
pendingCountdowns.set(sessionID, { secondsRemaining, intervalId })
} }
if (event.type === "message.updated") { if (event.type === "message.updated") {
@@ -217,12 +257,11 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
log(`[${HOOK_NAME}] message.updated received`, { sessionID, role: info?.role }) log(`[${HOOK_NAME}] message.updated received`, { sessionID, role: info?.role })
if (sessionID && info?.role === "user") { if (sessionID && info?.role === "user") {
// Cancel pending continuation on user interaction (real user input) const countdown = pendingCountdowns.get(sessionID)
const timer = pendingTimers.get(sessionID) if (countdown) {
if (timer) { clearInterval(countdown.intervalId)
clearTimeout(timer) pendingCountdowns.delete(sessionID)
pendingTimers.delete(sessionID) log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID })
log(`[${HOOK_NAME}] Cancelled pending timer on user message`, { sessionID })
} }
} }
@@ -241,11 +280,10 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
errorSessions.delete(sessionInfo.id) errorSessions.delete(sessionInfo.id)
recoveringSessions.delete(sessionInfo.id) recoveringSessions.delete(sessionInfo.id)
// Cancel pending continuation const countdown = pendingCountdowns.get(sessionInfo.id)
const timer = pendingTimers.get(sessionInfo.id) if (countdown) {
if (timer) { clearInterval(countdown.intervalId)
clearTimeout(timer) pendingCountdowns.delete(sessionInfo.id)
pendingTimers.delete(sessionInfo.id)
} }
} }
} }