From 15d36ab461a84f7aea8ac9ad6b0cedb6665789ac Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 19 Dec 2025 16:43:04 +0900 Subject: [PATCH] feat(todo-continuation-enforcer): implement countdown toast notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/hooks/todo-continuation-enforcer.ts | 94 +++++++++++++++++-------- 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts index 0af5b18..94d00ea 100644 --- a/src/hooks/todo-continuation-enforcer.ts +++ b/src/hooks/todo-continuation-enforcer.ts @@ -61,12 +61,20 @@ function detectInterrupt(error: unknown): boolean { 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 +} + export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer { const remindedSessions = new Set() const interruptedSessions = new Set() const errorSessions = new Set() const recoveringSessions = new Set() - const pendingTimers = new Map>() + const pendingCountdowns = new Map() const markRecovering = (sessionID: string): void => { recoveringSessions.add(sessionID) @@ -89,11 +97,10 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati } log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error }) - // Cancel pending continuation if error occurs - const timer = pendingTimers.get(sessionID) - if (timer) { - clearTimeout(timer) - pendingTimers.delete(sessionID) + const countdown = pendingCountdowns.get(sessionID) + if (countdown) { + clearInterval(countdown.intervalId) + pendingCountdowns.delete(sessionID) } } return @@ -105,17 +112,27 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati log(`[${HOOK_NAME}] session.idle received`, { sessionID }) - // Cancel any existing timer to debounce - const existingTimer = pendingTimers.get(sessionID) - if (existingTimer) { - clearTimeout(existingTimer) - log(`[${HOOK_NAME}] Cancelled existing timer`, { sessionID }) + const existingCountdown = pendingCountdowns.get(sessionID) + if (existingCountdown) { + clearInterval(existingCountdown.intervalId) + pendingCountdowns.delete(sessionID) + log(`[${HOOK_NAME}] Cancelled existing countdown`, { sessionID }) } - // Schedule continuation check - const timer = setTimeout(async () => { - pendingTimers.delete(sessionID) - log(`[${HOOK_NAME}] Timer fired, checking conditions`, { sessionID }) + const showCountdownToast = async (seconds: number): Promise => { + await ctx.client.tui.showToast({ + body: { + title: "Todo Continuation", + message: `Resuming in ${seconds}s...`, + variant: "warning" as const, + duration: TOAST_DURATION_MS, + }, + }).catch(() => {}) + } + + const executeAfterCountdown = async (): Promise => { + 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 if (recoveringSessions.has(sessionID)) { @@ -206,9 +223,32 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) }) 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") { @@ -217,12 +257,11 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati log(`[${HOOK_NAME}] message.updated received`, { sessionID, role: info?.role }) if (sessionID && info?.role === "user") { - // Cancel pending continuation on user interaction (real user input) - const timer = pendingTimers.get(sessionID) - if (timer) { - clearTimeout(timer) - pendingTimers.delete(sessionID) - log(`[${HOOK_NAME}] Cancelled pending timer on user message`, { sessionID }) + const countdown = pendingCountdowns.get(sessionID) + if (countdown) { + clearInterval(countdown.intervalId) + pendingCountdowns.delete(sessionID) + log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID }) } } @@ -241,11 +280,10 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati errorSessions.delete(sessionInfo.id) recoveringSessions.delete(sessionInfo.id) - // Cancel pending continuation - const timer = pendingTimers.get(sessionInfo.id) - if (timer) { - clearTimeout(timer) - pendingTimers.delete(sessionInfo.id) + const countdown = pendingCountdowns.get(sessionInfo.id) + if (countdown) { + clearInterval(countdown.intervalId) + pendingCountdowns.delete(sessionInfo.id) } } }