diff --git a/docs/specs/alarm.md b/docs/specs/alarm.md index 223cbcd..8a6c7b8 100644 --- a/docs/specs/alarm.md +++ b/docs/specs/alarm.md @@ -46,11 +46,14 @@ Each Session owns: - Transitional states: `MIGHT_BE_BUSY`, `MIGHT_NEED_ATTENTION`. - When the user enables the alarm, status transitions from `ALARM_DISABLED` to `NOTHING_TO_SHOW` and activity tracking begins fresh from that moment. - When the user disables the alarm, activity tracking stops and status returns to `ALARM_DISABLED`. -- `todo: false | 'soft' | 'hard'` - - Reminder state for the Session. Default `false`. - - `'soft'`: auto-created when a ringing alarm is phantom-dismissed (any attention path). Dashed-outline pill. Auto-clears when the user types printable text into the terminal (synthetic terminal reports like focus events and cursor-position responses are excluded). - - `'hard'`: explicitly set by the user via `t` key or context menu. Solid-outline pill. Only clears via explicit toggle. - - Dismissing a ringing alarm when `todo` is already `'soft'` or `'hard'` does not downgrade it. +- `todo: TodoState` (numeric) + - Reminder state for the Session. Default `TODO_OFF` (`-1`). + - `TODO_OFF` (`-1`): no TODO. + - `[0, 1]` (soft TODO): auto-created when a ringing alarm is phantom-dismissed (any attention path). Dashed-outline pill rendered as the word `TODO`. The value is quantized to five strike levels (`1.0` = no strikes, `0.75 / 0.5 / 0.25` = 1 / 2 / 3 letters struck, `0` = about to clear). Each printable keypress strikes exactly one letter (4 keypresses clears the TODO). After `recoverySecondsPerLetter` seconds of idle, one struck letter un-strikes; this repeats until the pill is fully un-struck. Synthetic terminal reports (focus events, cursor-position responses) do not count as keypresses. + - `TODO_HARD` (`2`): explicitly set by the user via `t` key or context menu. Solid-outline pill. Only clears via explicit toggle. + - Dismissing a ringing alarm when `todo` is already soft or hard does not downgrade it. + - Helper functions: `isSoftTodo(todo)`, `isHardTodo(todo)`, `hasTodo(todo)`. + - Strike-timing tuning parameter is in `cfg.todoBucket.recoverySecondsPerLetter`. Each Session also owns: @@ -203,7 +206,7 @@ The Session leaves `ALARM_RINGING` and returns to `NOTHING_TO_SHOW` when any of - the user marks the Session as hard TODO (`t` key or context menu) - new output arrives while the Session has attention (starts a new `MIGHT_BE_BUSY` cycle; without attention the alarm stays ringing — see latch in transition rules) -All attention-based dismissals (the first three above) create a soft TODO if `todo` is currently `false`. This prevents phantom dismissals where the alarm vanishes without a trace. Typing printable text into the terminal auto-clears soft TODOs, so users who engage with the output don't accumulate breadcrumbs. Synthetic terminal reports (focus events, cursor-position responses) do not count as typing. +All attention-based dismissals (the first three above) create a soft TODO if `todo` is not already `TODO_HARD`. If a partially-struck soft TODO already exists, the pill resets to fully un-struck — a fresh alarm ring deserves a full strike cycle. This prevents phantom dismissals where the alarm vanishes without a trace. Printable keypresses strike one letter of the `TODO` pill at a time (4 strikes clears it), so users who engage with the output don't accumulate breadcrumbs. After `cfg.todoBucket.recoverySecondsPerLetter` (default 1 s) of idle, one struck letter un-strikes; this repeats until the pill is fully un-struck. Synthetic terminal reports (focus events, cursor-position responses) do not count as keypresses. The Session leaves `ALARM_RINGING` and returns to `ALARM_DISABLED` when: @@ -215,7 +218,7 @@ The Session's alarm state is cleared entirely when: If more output arrives later and the Session makes a fresh transition back into `ALARM_RINGING`, the alarm rings again. -Marking a Session as hard TODO resets the alarm to `NOTHING_TO_SHOW` and sets `todo = 'hard'`, but it does **not** disable future alarms. `todo` and the alarm toggle are separate concerns. +Marking a Session as hard TODO resets the alarm to `NOTHING_TO_SHOW` and sets `todo = TODO_HARD`, but it does **not** disable future alarms. `todo` and the alarm toggle are separate concerns. Disabling alarms disposes the activity monitor and returns `status` to `ALARM_DISABLED`. @@ -230,10 +233,11 @@ The Pane header exposes two independent concepts: TODO pill: -- toggled in command mode with `t` (cycles: `false` → `'hard'`, `'soft'` → `'hard'`, `'hard'` → `false`) -- shown when `todo` is `'soft'` or `'hard'` -- `'soft'`: dashed-outline pill — auto-created on alarm dismiss, auto-clears on user input -- `'hard'`: solid-outline pill — explicitly set, only clears manually +- toggled in command mode with `t` (cycles: `TODO_OFF` → `TODO_HARD`, soft → `TODO_HARD`, `TODO_HARD` → `TODO_OFF`) +- shown when `hasTodo(todo)` is true (i.e. `todo !== TODO_OFF`) +- soft (`isSoftTodo(todo)`): dashed-outline pill — auto-created on alarm dismiss; each printable keypress strikes one letter of the word `TODO` (4 keypresses clears it), and one letter un-strikes per `recoverySecondsPerLetter` of idle +- when the 4th strike lands and the soft TODO clears, the pill briefly morphs to a `✓` glyph in the success color (~500 ms) before unmounting — this marks the moment of completion so the pill never vanishes silently +- `TODO_HARD` (`isHardTodo(todo)`): solid-outline pill — explicitly set, only clears manually - clicking a soft pill shows a prompt: "Clear" / "Keep" (keep promotes to hard) - clicking a hard pill clears it - no empty placeholder when off @@ -276,7 +280,7 @@ A Door is display-only for alarm state in v1. It must not replace the existing D Door indicators: - show bell indicator only when `status !== 'ALARM_DISABLED'` -- show TODO pill when `todo !== false` (`'soft'` or `'hard'`) +- show TODO pill when `hasTodo(todo)` (soft or hard) - if `status === 'ALARM_RINGING'`, the Door itself gets the ringing treatment, not just a tiny icon - the Door bell icon shows the same dot badge as the Pane header for `MIGHT_BE_BUSY`, `BUSY`, and `MIGHT_NEED_ATTENTION` states, but smaller (4px vs 6px) to match the smaller bell icon @@ -366,7 +370,7 @@ Consequences: - A Session rings. - User clicks into the pane to read the output. - The alarm clears, a soft TODO appears (dashed pill). -- User types a command → soft TODO auto-clears (they engaged). +- User types a command → each printable keypress strikes one letter of the `TODO` pill; after 4 keypresses the pill morphs to a `✓` and clears (they engaged). - The Session later emits new output, progresses through `BUSY`, and eventually reaches `ALARM_RINGING` again. ### User dismisses but doesn't engage diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts index 672c030..9ab272f 100644 --- a/lib/src/cfg.ts +++ b/lib/src/cfg.ts @@ -27,4 +27,10 @@ export const cfg = { /** ms — attention idle expiry. How long before "looking at this pane" wears off. */ userAttention: 15_000, }, + todoBucket: { + /** Seconds of idle time needed to un-strike one letter of the soft-TODO pill. + * The word TODO has 4 letters; each printable keypress strikes one letter, + * and each `recoverySecondsPerLetter` of idle time un-strikes one. */ + recoverySecondsPerLetter: 1, + }, }; diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 655653d..18f5bfc 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -143,8 +143,8 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp key={item.id} title={item.title} status={sessionState.status} - todo={sessionState.todo} + /> ); })} @@ -179,7 +179,6 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp isActive={activeId === item.id} windowFocused={windowFocused} status={sessionState.status} - todo={sessionState.todo} onClick={() => onReattach(item)} /> diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index c45ac2b..34b111b 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -1,5 +1,6 @@ import { BellIcon } from '@phosphor-icons/react'; -import type { SessionStatus, TodoState } from '../lib/terminal-registry'; +import { TODO_OFF, isSoftTodo, type SessionStatus, type TodoState } from '../lib/terminal-registry'; +import { useTodoPillContent } from './TodoPillBody'; export interface DoorProps { doorId?: string; @@ -17,7 +18,7 @@ export function Door({ isActive = false, windowFocused = true, status = 'ALARM_DISABLED', - todo = false, + todo = TODO_OFF, onClick, }: DoorProps) { // Doors can only be active in command mode (navigated to via arrow keys). @@ -28,6 +29,7 @@ export function Door({ const alarmEnabled = status !== 'ALARM_DISABLED'; const alarmRinging = status === 'ALARM_RINGING'; + const todoPill = useTodoPillContent(todo); return ( - @@ -368,7 +372,7 @@ function TodoAlarmDialog({
When an alarming tab is selected,
the alarm is cleared and the tab gets a soft TODO.
- Typing characters into the tab will automatically clear a soft TODO. + Typing drains the soft TODO; stop typing and it refills.
, document.body, @@ -534,7 +538,8 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const suppressAlarmClickRef = useRef(false); const [tier, setTier] = useState('full'); const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null); - const showTodoPill = sessionState.todo !== false && tier !== 'minimal'; + const todoPill = useTodoPillContent(sessionState.todo); + const showTodoPill = todoPill.visible && tier !== 'minimal'; const alarmButtonAriaLabel = sessionState.status === 'ALARM_RINGING' ? 'Alarm ringing' : sessionState.status === 'ALARM_DISABLED' @@ -649,23 +654,32 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { {showTodoPill && ( - + todoPill.flourishing ? ( + + {todoPill.body} + + ) : ( + + ) )} {!isRenaming && ( @@ -1477,7 +1491,7 @@ export function Pond({ // don't overlap — the outgoing pane crushes/fades first, then the new pane // reveals from the top-left. If anything restores a pane in the meantime // (e.g. door reattach), the delayed spawn becomes a no-op. - e.api.onDidRemovePanel((removed) => { + e.api.onDidRemovePanel(() => { if (e.api.totalPanels !== 0) return; const reduceMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; diff --git a/lib/src/components/TodoPillBody.tsx b/lib/src/components/TodoPillBody.tsx new file mode 100644 index 0000000..05901d0 --- /dev/null +++ b/lib/src/components/TodoPillBody.tsx @@ -0,0 +1,92 @@ +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { + hasTodo, + isHardTodo, + isSoftTodo, + TODO_OFF, + type TodoState, +} from '../lib/terminal-registry'; + +interface StrikeLetterProps { + char: string; + strike: boolean; +} + +function StrikeLetter({ char, strike }: StrikeLetterProps) { + return ( + + {char} + + ); +} + +const TODO_LETTERS = ['T', 'O', 'D', 'O'] as const; +const FLOURISH_MS = 500; + +/** + * Shared render body + flourish state for the soft/hard TODO pill. + * + * Returns `visible: false` when the pill should not render at all. + * Returns `flourishing: true` briefly after a soft TODO clears, so the + * caller can render a non-interactive wrapper (no click target). + */ +export function useTodoPillContent(todo: TodoState): { + visible: boolean; + flourishing: boolean; + body: ReactNode; +} { + const [flourishing, setFlourishing] = useState(false); + const prevRef = useRef(todo); + const timerRef = useRef | null>(null); + + useEffect(() => { + const prev = prevRef.current; + prevRef.current = todo; + if (isSoftTodo(prev) && todo === TODO_OFF) { + if (timerRef.current !== null) clearTimeout(timerRef.current); + setFlourishing(true); + timerRef.current = setTimeout(() => { + setFlourishing(false); + timerRef.current = null; + }, FLOURISH_MS); + } + }, [todo]); + + useEffect( + () => () => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + }, + [], + ); + + const visible = hasTodo(todo) || flourishing; + + let body: ReactNode = null; + if (flourishing) { + body = ( + + + {TODO_LETTERS.map((ch, i) => ( + + ))} + + + ✓ + + + ); + } else if (isSoftTodo(todo)) { + const strikes = Math.round((1 - todo) * 4); + body = ( + + {TODO_LETTERS.map((ch, i) => ( + i} /> + ))} + + ); + } else if (isHardTodo(todo)) { + body = <>TODO; + } + + return { visible, flourishing, body }; +} diff --git a/lib/src/lib/alarm-manager.test.ts b/lib/src/lib/alarm-manager.test.ts index ecbee4d..56d4ea4 100644 --- a/lib/src/lib/alarm-manager.test.ts +++ b/lib/src/lib/alarm-manager.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { AlarmManager } from './alarm-manager'; +import { AlarmManager, TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo } from './alarm-manager'; +import { cfg } from '../cfg'; + +const STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000; describe('AlarmManager in isolation', () => { let manager: AlarmManager; @@ -153,4 +156,219 @@ describe('AlarmManager in isolation', () => { expect(states).toContain('MIGHT_NEED_ATTENTION'); expect(states).toContain('ALARM_RINGING'); }); + + // --- Soft-TODO bucket tests --- + + function createSoftTodo(id: string): void { + manager.toggleAlarm(id); + manager.clearAttention(id); + // Drive to BUSY → silence → ALARM_RINGING + manager.onData(id); + vi.advanceTimersByTime(1_600); + manager.onData(id); + manager.onData(id); + vi.advanceTimersByTime(2_000); + vi.advanceTimersByTime(3_000); + expect(manager.getState(id).status).toBe('ALARM_RINGING'); + // Attend creates soft TODO + manager.attend(id); + expect(isSoftTodo(manager.getState(id).todo)).toBe(true); + } + + it('soft-TODO bucket starts full', () => { + const id = 'bucket-full'; + createSoftTodo(id); + expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL); + }); + + it('4 keypresses strike all letters and clear soft-TODO', () => { + const id = 'bucket-drain'; + createSoftTodo(id); + + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.75); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.5); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.25); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBe(TODO_OFF); + }); + + it('3 keypresses strike 3 letters but do not clear soft-TODO', () => { + const id = 'bucket-partial'; + createSoftTodo(id); + + for (let i = 0; i < 3; i++) { + manager.drainTodoBucket(id); + } + + expect(isSoftTodo(manager.getState(id).todo)).toBe(true); + expect(manager.getState(id).todo).toBeCloseTo(0.25); + }); + + it('one letter recovers after recoverySecondsPerLetter of idle', () => { + const id = 'bucket-one-recovery'; + createSoftTodo(id); + + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.5); + + vi.advanceTimersByTime(STRIKE_RECOVERY_MS); + expect(manager.getState(id).todo).toBeCloseTo(0.75); + }); + + it('recovery ticks repeat until bucket reaches full, then stops', () => { + const id = 'bucket-full-recovery'; + createSoftTodo(id); + + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.25); + + // Three recovery ticks bring it back to full. + vi.advanceTimersByTime(STRIKE_RECOVERY_MS); + expect(manager.getState(id).todo).toBeCloseTo(0.5); + vi.advanceTimersByTime(STRIKE_RECOVERY_MS); + expect(manager.getState(id).todo).toBeCloseTo(0.75); + vi.advanceTimersByTime(STRIKE_RECOVERY_MS); + expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL); + + // Further time advances must not push past full or introduce drift. + vi.advanceTimersByTime(10 * STRIKE_RECOVERY_MS); + expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL); + }); + + it('a keypress between recovery ticks resets the recovery clock', () => { + const id = 'bucket-recovery-reset'; + createSoftTodo(id); + + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.5); + + // Almost-but-not-quite one recovery interval. + vi.advanceTimersByTime(STRIKE_RECOVERY_MS - 1); + expect(manager.getState(id).todo).toBeCloseTo(0.5); + + // A fresh strike lands; the recovery clock restarts. + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.25); + + // Another almost-interval — no tick yet. + vi.advanceTimersByTime(STRIKE_RECOVERY_MS - 1); + expect(manager.getState(id).todo).toBeCloseTo(0.25); + + // Crossing the threshold finally restores one letter. + vi.advanceTimersByTime(2); + expect(manager.getState(id).todo).toBeCloseTo(0.5); + }); + + it('marking a partially-struck soft-TODO as hard resets to hard', () => { + const id = 'bucket-promote'; + createSoftTodo(id); + + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.5); + + manager.markTodo(id); + expect(manager.getState(id).todo).toBe(TODO_HARD); + }); + + it('hard TODO uses TODO_HARD constant', () => { + const id = 'bucket-hard'; + manager.toggleTodo(id); // off → hard + expect(manager.getState(id).todo).toBe(TODO_HARD); + }); + + it('re-attending a ringing alarm resets a partially-struck soft-TODO to full and clears its recovery timer', () => { + const id = 'bucket-reset-on-reattend'; + createSoftTodo(id); + + // Strike 3 of 4 letters. + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.25); + + // Drive to ALARM_RINGING again + manager.clearAttention(id); + manager.onData(id); + vi.advanceTimersByTime(1_600); + manager.onData(id); + manager.onData(id); + vi.advanceTimersByTime(2_000); + vi.advanceTimersByTime(3_000); + expect(manager.getState(id).status).toBe('ALARM_RINGING'); + + // Re-attend should reset the bucket to full + manager.attend(id); + expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL); + + // The pending recovery timer from before the re-attend must not fire now + // that we're already at full (it would be a no-op but would still schedule + // further ticks in the old code path). + vi.advanceTimersByTime(10 * STRIKE_RECOVERY_MS); + expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL); + }); + + it('dismissing a ringing alarm resets a partially-struck soft-TODO to full and clears its recovery timer', () => { + const id = 'bucket-reset-on-dismiss'; + createSoftTodo(id); + + // Strike 2 of 4 letters. + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.5); + + // Drive to ALARM_RINGING again + manager.clearAttention(id); + manager.onData(id); + vi.advanceTimersByTime(1_600); + manager.onData(id); + manager.onData(id); + vi.advanceTimersByTime(2_000); + vi.advanceTimersByTime(3_000); + expect(manager.getState(id).status).toBe('ALARM_RINGING'); + + // Dismiss should reset the bucket to full + manager.dismissAlarm(id); + expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL); + + vi.advanceTimersByTime(10 * STRIKE_RECOVERY_MS); + expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL); + }); + + it('re-attending a ringing alarm does NOT override a hard TODO', () => { + const id = 'bucket-no-reset-hard'; + createSoftTodo(id); + + // Promote to hard + manager.markTodo(id); + expect(manager.getState(id).todo).toBe(TODO_HARD); + + // Drive to ALARM_RINGING again + manager.clearAttention(id); + manager.onData(id); + vi.advanceTimersByTime(1_600); + manager.onData(id); + manager.onData(id); + vi.advanceTimersByTime(2_000); + vi.advanceTimersByTime(3_000); + expect(manager.getState(id).status).toBe('ALARM_RINGING'); + + // Re-attend should NOT change hard TODO + manager.attend(id); + expect(manager.getState(id).todo).toBe(TODO_HARD); + }); + + it('drainTodoBucket is a no-op for hard TODOs', () => { + const id = 'bucket-hard-noop'; + manager.toggleTodo(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBe(TODO_HARD); + }); }); diff --git a/lib/src/lib/alarm-manager.ts b/lib/src/lib/alarm-manager.ts index a74fdec..c2aec79 100644 --- a/lib/src/lib/alarm-manager.ts +++ b/lib/src/lib/alarm-manager.ts @@ -3,7 +3,32 @@ import { cfg } from '../cfg'; export { type SessionStatus } from './activity-monitor'; -export type TodoState = false | 'soft' | 'hard'; +/** + * Unified todo state as a single number. + * + * TODO_OFF (-1) — no TODO + * [0, 1] — soft TODO; value is bucket fill level (1 = full, 0 = about to clear) + * TODO_HARD (2) — hard TODO (manually set, never auto-clears) + * + * Helpers: isSoftTodo(), isHardTodo(), hasTodo() + */ +export type TodoState = number; +export const TODO_OFF = -1; +export const TODO_SOFT_FULL = 1; +export const TODO_HARD = 2; + +export function isSoftTodo(todo: TodoState): boolean { return todo >= 0 && todo <= 1; } +export function isHardTodo(todo: TodoState): boolean { return todo === TODO_HARD; } +export function hasTodo(todo: TodoState): boolean { return todo !== TODO_OFF; } + +/** Migrate legacy persisted TodoState values (false/'soft'/'hard') to numeric. */ +export function migrateTodoState(todo: unknown): TodoState { + if (typeof todo === 'number') return todo; + if (todo === 'hard') return TODO_HARD; + if (todo === 'soft') return TODO_SOFT_FULL; + return TODO_OFF; // false, null, undefined, or any other unexpected value +} + export type AlarmButtonActionResult = 'enabled' | 'disabled' | 'dismissed' | 'noop'; export interface AlarmState { @@ -15,7 +40,7 @@ export interface AlarmState { export const DEFAULT_ALARM_STATE: AlarmState = { status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, attentionDismissedRing: false, }; @@ -23,9 +48,12 @@ interface AlarmEntry { monitor: ActivityMonitor | null; todo: TodoState; attentionDismissedRing: boolean; + recoveryTimer: ReturnType | null; } const T_USER_ATTENTION = cfg.alarm.userAttention; +const STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000; +const STRIKE_STEP = 0.25; /** * Manages ActivityMonitors, attention tracking, and todo state for PTY sessions. @@ -100,8 +128,9 @@ export class AlarmManager { if (previousStatus === 'ALARM_RINGING') { entry.attentionDismissedRing = true; - if (entry.todo === false) { - entry.todo = 'soft'; + if (!isHardTodo(entry.todo)) { + this.clearRecoveryTimer(entry); + entry.todo = TODO_SOFT_FULL; } } entry.monitor?.attend(); @@ -161,8 +190,9 @@ export class AlarmManager { const entry = this.entries.get(id); if (!entry?.monitor) return; if (entry.monitor.getStatus() !== 'ALARM_RINGING') return; - if (entry.todo === false) { - entry.todo = 'soft'; + if (!isHardTodo(entry.todo)) { + this.clearRecoveryTimer(entry); + entry.todo = TODO_SOFT_FULL; } entry.monitor.attend(); // onChange fires → notify @@ -204,14 +234,15 @@ export class AlarmManager { // --- Todo controls --- - /** Toggle: false → hard, soft → hard, hard → false */ + /** Toggle: off → hard, soft → hard, hard → off */ toggleTodo(id: string): void { const entry = this.getOrCreateEntry(id); - if (entry.todo === 'hard') { - entry.todo = false; + this.clearRecoveryTimer(entry); + if (entry.todo === TODO_HARD) { + entry.todo = TODO_OFF; this.notify(id); } else { - entry.todo = 'hard'; + entry.todo = TODO_HARD; if (entry.monitor?.getStatus() === 'ALARM_RINGING') { entry.monitor.attend(); return; // onChange fires → notify @@ -224,8 +255,9 @@ export class AlarmManager { markTodo(id: string): void { const entry = this.getOrCreateEntry(id); const isRinging = entry.monitor?.getStatus() === 'ALARM_RINGING'; - if (entry.todo === 'hard' && !isRinging) return; - entry.todo = 'hard'; + if (entry.todo === TODO_HARD && !isRinging) return; + this.clearRecoveryTimer(entry); + entry.todo = TODO_HARD; if (isRinging) { entry.monitor!.attend(); return; // onChange fires → notify @@ -236,11 +268,47 @@ export class AlarmManager { /** Clear any TODO state */ clearTodo(id: string): void { const entry = this.getOrCreateEntry(id); - if (entry.todo === false) return; - entry.todo = false; + if (entry.todo === TODO_OFF) return; + this.clearRecoveryTimer(entry); + entry.todo = TODO_OFF; + this.notify(id); + } + + /** + * Strike one letter of the soft-TODO pill. + * 4 strikes clear the TODO. One letter recovers after each `recoverySecondsPerLetter` + * of idle (no further strikes). + */ + drainTodoBucket(id: string): void { + const entry = this.entries.get(id); + if (!entry || !isSoftTodo(entry.todo)) return; + + entry.todo = entry.todo - STRIKE_STEP; + + if (entry.todo < 1e-9) { + entry.todo = TODO_OFF; + this.clearRecoveryTimer(entry); + this.notify(id); + return; + } + + this.scheduleRecoveryTick(id, entry); this.notify(id); } + private scheduleRecoveryTick(id: string, entry: AlarmEntry): void { + this.clearRecoveryTimer(entry); + entry.recoveryTimer = setTimeout(() => { + entry.recoveryTimer = null; + if (!isSoftTodo(entry.todo)) return; + entry.todo = Math.min(TODO_SOFT_FULL, entry.todo + STRIKE_STEP); + this.notify(id); + if (entry.todo < TODO_SOFT_FULL) { + this.scheduleRecoveryTick(id, entry); + } + }, STRIKE_RECOVERY_MS); + } + // --- Query --- getState(id: string): AlarmState { @@ -265,6 +333,7 @@ export class AlarmManager { remove(id: string): void { const entry = this.entries.get(id); if (!entry) return; + this.clearRecoveryTimer(entry); entry.monitor?.dispose(); this.entries.delete(id); if (this.attentionId === id) { @@ -282,7 +351,7 @@ export class AlarmManager { */ restore(id: string, state: { status: string; todo: TodoState }): void { const entry = this.getOrCreateEntry(id); - entry.todo = state.todo; + entry.todo = migrateTodoState(state.todo); // If the alarm was enabled (anything other than ALARM_DISABLED), create a monitor if (state.status !== 'ALARM_DISABLED') { if (!entry.monitor) { @@ -294,6 +363,7 @@ export class AlarmManager { dispose(): void { for (const entry of this.entries.values()) { + this.clearRecoveryTimer(entry); entry.monitor?.dispose(); } this.entries.clear(); @@ -306,12 +376,19 @@ export class AlarmManager { private getOrCreateEntry(id: string): AlarmEntry { let entry = this.entries.get(id); if (!entry) { - entry = { monitor: null, todo: false, attentionDismissedRing: false }; + entry = { monitor: null, todo: TODO_OFF, attentionDismissedRing: false, recoveryTimer: null }; this.entries.set(id, entry); } return entry; } + private clearRecoveryTimer(entry: AlarmEntry): void { + if (entry.recoveryTimer !== null) { + clearTimeout(entry.recoveryTimer); + entry.recoveryTimer = null; + } + } + private notify(id: string): void { const state = this.getState(id); for (const listener of this.listeners) { diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index cbfaeec..1c264fe 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -148,6 +148,7 @@ export class FakePtyAdapter implements PlatformAdapter { alarmToggleTodo(id: string): void { this.alarmManager.toggleTodo(id); } alarmMarkTodo(id: string): void { this.alarmManager.markTodo(id); } alarmClearTodo(id: string): void { this.alarmManager.clearTodo(id); } + alarmDrainTodoBucket(id: string): void { this.alarmManager.drainTodoBucket(id); } onAlarmState(handler: (detail: AlarmStateDetail) => void): void { this.alarmStateHandlers.add(handler); } offAlarmState(handler: (detail: AlarmStateDetail) => void): void { this.alarmStateHandlers.delete(handler); } diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 0491871..b9e5f9f 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -56,6 +56,7 @@ export interface PlatformAdapter { alarmToggleTodo(id: string): void; alarmMarkTodo(id: string): void; alarmClearTodo(id: string): void; + alarmDrainTodoBucket(id: string): void; onAlarmState(handler: (detail: AlarmStateDetail) => void): void; offAlarmState(handler: (detail: AlarmStateDetail) => void): void; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 395e65f..9d8698a 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -207,6 +207,10 @@ export class VSCodeAdapter implements PlatformAdapter { this.vscode.postMessage({ type: 'alarm:clearTodo', id }); } + alarmDrainTodoBucket(id: string): void { + this.vscode.postMessage({ type: 'alarm:drainTodoBucket', id }); + } + onAlarmState(handler: (detail: AlarmStateDetail) => void): void { this.alarmStateHandlers.add(handler); } diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 92b2f4b..2f32ccf 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { PlatformAdapter } from './platform/types'; import type { PersistedSession } from './session-types'; +import { TODO_HARD } from './alarm-manager'; const terminalRegistryMocks = vi.hoisted(() => ({ getLivePersistedAlarmState: vi.fn(), @@ -50,6 +51,7 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { alarmToggleTodo: () => {}, alarmMarkTodo: () => {}, alarmClearTodo: () => {}, + alarmDrainTodoBucket: () => {}, onAlarmState: () => {}, offAlarmState: () => {}, saveState: vi.fn((state: unknown) => { @@ -73,7 +75,7 @@ describe('saveSession', () => { panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null, alarm: null }], }); - terminalRegistryMocks.getLivePersistedAlarmState.mockReturnValue({ status: 'NOTHING_TO_SHOW', todo: 'hard' }); + terminalRegistryMocks.getLivePersistedAlarmState.mockReturnValue({ status: 'NOTHING_TO_SHOW', todo: TODO_HARD }); await saveSession(platform, { root: true }, [{ id: 'pane-a', title: 'Pane A' }]); @@ -84,7 +86,7 @@ describe('saveSession', () => { panes: [ expect.objectContaining({ id: 'pane-a', - alarm: { status: 'NOTHING_TO_SHOW', todo: 'hard' }, + alarm: { status: 'NOTHING_TO_SHOW', todo: TODO_HARD }, }), ], }); diff --git a/lib/src/lib/terminal-registry.alarm.test.ts b/lib/src/lib/terminal-registry.alarm.test.ts index e7d28a2..934a24b 100644 --- a/lib/src/lib/terminal-registry.alarm.test.ts +++ b/lib/src/lib/terminal-registry.alarm.test.ts @@ -74,6 +74,9 @@ vi.mock('./platform', async () => { import * as platformModule from './platform'; import { makeAlarmScenario, type FakePtyAdapter, type FakeScenario } from './platform'; +import { cfg } from '../cfg'; + +const STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000; import { DEFAULT_SESSION_UI_STATE, attachTerminal, @@ -94,6 +97,10 @@ import { swapTerminals, toggleSessionAlarm, toggleSessionTodo, + TODO_OFF, + TODO_SOFT_FULL, + TODO_HARD, + isSoftTodo, } from './terminal-registry'; interface MockTerminalInstance { @@ -238,7 +245,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -269,7 +276,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toMatchObject({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -303,7 +310,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -317,7 +324,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -331,7 +338,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toMatchObject({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); // New output starts a fresh cycle that can ring again @@ -342,7 +349,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toMatchObject({ status: 'ALARM_RINGING', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -356,7 +363,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'hard', + todo: TODO_HARD, }); }); @@ -370,7 +377,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); // No monitor means output doesn't drive state changes @@ -380,7 +387,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); @@ -424,7 +431,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -441,7 +448,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -457,7 +464,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -477,11 +484,11 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(alpha)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); expect(getSessionState(beta)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -494,7 +501,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'hard', + todo: TODO_HARD, }); destroyTerminal(id); @@ -510,7 +517,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -522,11 +529,10 @@ describe('terminal-registry alarm behavior', () => { driveToRingingNeedsAttention(id); entry.terminal.emitInput('x'); - expect(getSessionState(id)).toEqual({ - status: 'NOTHING_TO_SHOW', - - todo: false, - }); + // Typing while ringing: attend creates a fresh soft TODO, then the keypress strikes one letter + expect(getSessionState(id).status).toBe('NOTHING_TO_SHOW'); + expect(isSoftTodo(getSessionState(id).todo)).toBe(true); + expect(getSessionState(id).todo).toBeCloseTo(0.75); }); it('no monitor is created until alarm is enabled', () => { @@ -542,7 +548,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); @@ -567,7 +573,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id).status).toBe('BUSY'); }); - it('phantom dismiss creates soft TODO, typing clears it', () => { + it('phantom dismiss creates soft TODO, typing 4 chars clears it', () => { const id = 'soft-todo-clear'; const entry = createSession(id); toggleSessionAlarm(id); @@ -575,12 +581,46 @@ describe('terminal-registry alarm behavior', () => { driveToRingingNeedsAttention(id); attendSession(id); - expect(getSessionState(id).todo).toBe('soft'); + expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); - // Typing clears the soft TODO - entry.terminal.emitInput('ls'); + // 3 keypresses strike 3 letters but don't clear + for (let i = 0; i < 3; i++) { + entry.terminal.emitInput('a'); + } + expect(isSoftTodo(getSessionState(id).todo)).toBe(true); + + // 4th keypress clears it + entry.terminal.emitInput('a'); + expect(getSessionState(id).todo).toBe(TODO_OFF); + }); + + it('soft TODO recovers after idle and requires fresh keypresses', () => { + const id = 'soft-todo-refill'; + const entry = createSession(id); + toggleSessionAlarm(id); + + driveToRingingNeedsAttention(id); + attendSession(id); + + expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + + // 2 keypresses strike 2 letters + entry.terminal.emitInput('a'); + entry.terminal.emitInput('a'); + expect(getSessionState(id).todo).toBeCloseTo(0.5); + + // 2 recovery intervals restore both letters + vi.advanceTimersByTime(2 * STRIKE_RECOVERY_MS); + expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + + // Need 4 fresh keypresses to clear again + for (let i = 0; i < 3; i++) { + entry.terminal.emitInput('a'); + } + expect(isSoftTodo(getSessionState(id).todo)).toBe(true); - expect(getSessionState(id).todo).toBe(false); + entry.terminal.emitInput('a'); + expect(getSessionState(id).todo).toBe(TODO_OFF); }); it('focus-report control sequences do not clear a soft TODO', () => { @@ -591,11 +631,11 @@ describe('terminal-registry alarm behavior', () => { driveToRingingNeedsAttention(id); attendSession(id); - expect(getSessionState(id).todo).toBe('soft'); + expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); entry.terminal.emitInput('\x1b[I'); - expect(getSessionState(id).todo).toBe('soft'); + expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); }); it('typing does not clear a hard TODO', () => { @@ -606,11 +646,11 @@ describe('terminal-registry alarm behavior', () => { driveToRingingNeedsAttention(id); toggleSessionTodo(id); // ringing → hard TODO + attend - expect(getSessionState(id).todo).toBe('hard'); + expect(getSessionState(id).todo).toBe(TODO_HARD); entry.terminal.emitInput('ls'); - expect(getSessionState(id).todo).toBe('hard'); + expect(getSessionState(id).todo).toBe(TODO_HARD); }); it('toggleSessionTodo promotes soft to hard', () => { @@ -621,24 +661,24 @@ describe('terminal-registry alarm behavior', () => { driveToRingingNeedsAttention(id); attendSession(id); - expect(getSessionState(id).todo).toBe('soft'); + expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); toggleSessionTodo(id); - expect(getSessionState(id).todo).toBe('hard'); + expect(getSessionState(id).todo).toBe(TODO_HARD); }); it('toggleSessionTodo cycles: false → hard → false', () => { const id = 'toggle-cycle'; createSession(id); - expect(getSessionState(id).todo).toBe(false); + expect(getSessionState(id).todo).toBe(TODO_OFF); toggleSessionTodo(id); - expect(getSessionState(id).todo).toBe('hard'); + expect(getSessionState(id).todo).toBe(TODO_HARD); toggleSessionTodo(id); - expect(getSessionState(id).todo).toBe(false); + expect(getSessionState(id).todo).toBe(TODO_OFF); }); it('dismiss does not downgrade hard TODO to soft', () => { @@ -656,7 +696,7 @@ describe('terminal-registry alarm behavior', () => { dismissSessionAlarm(id); // Hard TODO should survive — soft TODO only set when todo === false - expect(getSessionState(id).todo).toBe('hard'); + expect(getSessionState(id).todo).toBe(TODO_HARD); }); it('new output while ringing without attention does not create a soft TODO', () => { @@ -670,7 +710,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -684,7 +724,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); @@ -696,7 +736,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -710,7 +750,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); @@ -724,7 +764,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -738,14 +778,14 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); dismissOrToggleAlarm(id, 'ALARM_RINGING'); expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -759,13 +799,13 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); expect(dismissOrToggleAlarm(id, 'NOTHING_TO_SHOW')).toBe('dismissed'); expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -779,7 +819,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -796,7 +836,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -817,11 +857,11 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(alpha)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); expect(getSessionState(beta)).toEqual({ status: 'BUSY', - todo: false, + todo: TODO_OFF, }); }); @@ -836,22 +876,22 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(alpha)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); expect(getSessionState(beta)).toEqual({ status: 'ALARM_DISABLED', - todo: 'hard', + todo: TODO_HARD, }); clearSessionTodo(beta); expect(getSessionState(alpha)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); expect(getSessionState(beta)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); }); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 74d1c4e..64e8c1d 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -2,12 +2,12 @@ import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { getPlatform } from './platform'; import type { SessionStatus } from './activity-monitor'; -import type { TodoState, AlarmButtonActionResult } from './alarm-manager'; +import { TODO_OFF, isSoftTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; import type { AlarmStateDetail } from './platform/types'; import type { PersistedAlarmState } from './session-types'; export type { SessionStatus } from './activity-monitor'; -export type { TodoState, AlarmButtonActionResult } from './alarm-manager'; +export { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo, isHardTodo, hasTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; export interface SessionUiState { status: SessionStatus; @@ -16,7 +16,7 @@ export interface SessionUiState { export const DEFAULT_SESSION_UI_STATE: SessionUiState = { status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }; interface TerminalEntry { @@ -346,8 +346,8 @@ function setupTerminalEntry(id: string): TerminalEntry { if (!isSyntheticTerminalReport) { getPlatform().alarmAttend(id); const entry = registry.get(id); - if (entry?.todo === 'soft' && inputContainsPrintableText(data)) { - getPlatform().alarmClearTodo(id); + if (entry && isSoftTodo(entry.todo) && inputContainsPrintableText(data)) { + getPlatform().alarmDrainTodoBucket(id); } } @@ -374,7 +374,7 @@ function setupTerminalEntry(id: string): TerminalEntry { element, cleanup, alarmStatus: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, attentionDismissedRing: false, }; diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx index ef1d8b9..78d1c38 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Baseboard } from '../components/Baseboard'; import type { DetachedItem } from '../components/Pond'; +import { TODO_OFF, TODO_HARD } from '../lib/terminal-registry'; const makeItem = (id: string, title: string): DetachedItem => ({ id, @@ -48,7 +49,7 @@ export const OneRingingDoor: Story = { p1: { status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }, }), }; @@ -67,22 +68,22 @@ export const MixedDoorStates: Story = { p1: { status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }, p2: { status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }, p3: { status: 'ALARM_DISABLED', - todo: 'hard', + todo: TODO_HARD, }, p4: { status: 'ALARM_RINGING', - todo: 'hard', + todo: TODO_HARD, }, }), }; @@ -104,17 +105,17 @@ export const OverflowWithRingingDoor: Story = { p2: { status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }, p5: { status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }, p7: { status: 'ALARM_DISABLED', - todo: 'hard', + todo: TODO_HARD, }, }), decorators: [ @@ -138,7 +139,7 @@ export const ExtremeTitleWithBothIndicators: Story = { p2: { status: 'ALARM_RINGING', - todo: 'hard', + todo: TODO_HARD, }, }), decorators: [ diff --git a/lib/src/stories/Door.stories.tsx b/lib/src/stories/Door.stories.tsx index 4d26ecb..fedbdf3 100644 --- a/lib/src/stories/Door.stories.tsx +++ b/lib/src/stories/Door.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Door } from '../components/Door'; +import { TODO_OFF, TODO_HARD } from '../lib/terminal-registry'; function DoorStory({ width = 260, @@ -28,8 +29,7 @@ const meta: Meta = { title: 'build-server', isActive: false, status: 'ALARM_DISABLED', - - todo: false, + todo: TODO_OFF, width: 260, reducedMotion: false, }, @@ -37,8 +37,7 @@ const meta: Meta = { title: { control: 'text' }, isActive: { control: 'boolean' }, status: { control: 'radio', options: ['ALARM_DISABLED', 'NOTHING_TO_SHOW', 'MIGHT_BE_BUSY', 'BUSY', 'MIGHT_NEED_ATTENTION', 'ALARM_RINGING'] }, - - todo: { control: 'boolean' }, + todo: { control: 'number' }, width: { control: 'number' }, reducedMotion: { control: 'boolean' }, }, @@ -48,71 +47,19 @@ export default meta; type Story = StoryObj; export const AlarmDisabled: Story = {}; - -export const AlarmEnabled: Story = { - args: { - status: 'NOTHING_TO_SHOW', - }, -}; - -export const AlarmMightBeBusy: Story = { - args: { - status: 'MIGHT_BE_BUSY', - }, -}; - -export const AlarmBusy: Story = { - args: { - status: 'BUSY', - }, -}; - -export const AlarmMightNeedAttention: Story = { - args: { - status: 'MIGHT_NEED_ATTENTION', - }, -}; - -export const AlarmRinging: Story = { - args: { - status: 'ALARM_RINGING', - - }, -}; - -export const TodoOnly: Story = { - args: { - todo: 'hard', - }, -}; - -export const TodoAndAlarmEnabled: Story = { - args: { - todo: 'hard', - status: 'NOTHING_TO_SHOW', - }, -}; - -export const TodoAndAlarmRinging: Story = { - args: { - todo: 'hard', - status: 'ALARM_RINGING', - - }, -}; - +export const AlarmEnabled: Story = { args: { status: 'NOTHING_TO_SHOW' } }; +export const AlarmMightBeBusy: Story = { args: { status: 'MIGHT_BE_BUSY' } }; +export const AlarmBusy: Story = { args: { status: 'BUSY' } }; +export const AlarmMightNeedAttention: Story = { args: { status: 'MIGHT_NEED_ATTENTION' } }; +export const AlarmRinging: Story = { args: { status: 'ALARM_RINGING' } }; +export const TodoOnly: Story = { args: { todo: TODO_HARD } }; +export const TodoAndAlarmEnabled: Story = { args: { todo: TODO_HARD, status: 'NOTHING_TO_SHOW' } }; +export const TodoAndAlarmRinging: Story = { args: { todo: TODO_HARD, status: 'ALARM_RINGING' } }; export const LongTitleWithIndicators: Story = { args: { title: 'my-extremely-long-running-background-process-with-a-very-descriptive-name', - todo: 'hard', + todo: TODO_HARD, status: 'NOTHING_TO_SHOW', }, }; - -export const ActiveDoorRinging: Story = { - args: { - isActive: true, - status: 'ALARM_RINGING', - - }, -}; +export const ActiveDoorRinging: Story = { args: { isActive: true, status: 'ALARM_RINGING' } }; diff --git a/lib/src/stories/Pond.stories.tsx b/lib/src/stories/Pond.stories.tsx index 663984b..b734ec3 100644 --- a/lib/src/stories/Pond.stories.tsx +++ b/lib/src/stories/Pond.stories.tsx @@ -7,7 +7,7 @@ import { SCENARIO_ANSI_COLORS, SCENARIO_LONG_RUNNING, } from '../lib/platform'; -import { getSessionStateSnapshot, primeSessionState, type SessionUiState } from '../lib/terminal-registry'; +import { getSessionStateSnapshot, primeSessionState, type SessionUiState, TODO_OFF, TODO_HARD } from '../lib/terminal-registry'; const meta: Meta = { title: 'App/Pond', @@ -109,7 +109,7 @@ export const AlarmEnabledIdlePane: Story = { { status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }, ], }, @@ -124,7 +124,7 @@ export const AlarmRingingPane: Story = { { status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }, ], }, @@ -139,7 +139,7 @@ export const AlarmRingingDoor: Story = { { status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }, ]); await wait(100); @@ -154,7 +154,7 @@ export const AlarmModalOpen: Story = { { status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }, ], }, @@ -170,7 +170,7 @@ export const TodoAfterDismiss: Story = { { status: 'ALARM_RINGING', - todo: 'hard', + todo: TODO_HARD, }, ], }, @@ -185,12 +185,12 @@ export const DetachedRingingSession: Story = { { status: 'ALARM_RINGING', - todo: 'hard', + todo: TODO_HARD, }, { status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }, ]); await wait(100); @@ -205,17 +205,17 @@ export const MultipleRingingSessions: Story = { { status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }, { status: 'ALARM_RINGING', - todo: 'hard', + todo: TODO_HARD, }, { status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }, ]); await wait(100); diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index e7be453..f502d46 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -8,6 +8,7 @@ import { type PondMode, type PondActions, } from '../components/Pond'; +import { TODO_OFF, TODO_SOFT_FULL, TODO_HARD } from '../lib/terminal-registry'; const SESSION_ID = 'tab-story'; @@ -127,7 +128,7 @@ export const AlarmDisabled: Story = { parameters: primedState({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }), }; @@ -135,7 +136,7 @@ export const AlarmEnabled: Story = { parameters: primedState({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }), }; @@ -143,7 +144,7 @@ export const AlarmMightBeBusy: Story = { parameters: primedState({ status: 'MIGHT_BE_BUSY', - todo: false, + todo: TODO_OFF, }), }; @@ -151,7 +152,7 @@ export const AlarmBusy: Story = { parameters: primedState({ status: 'BUSY', - todo: false, + todo: TODO_OFF, }), }; @@ -159,7 +160,7 @@ export const AlarmMightNeedAttention: Story = { parameters: primedState({ status: 'MIGHT_NEED_ATTENTION', - todo: false, + todo: TODO_OFF, }), }; @@ -167,21 +168,21 @@ export const AlarmRinging: Story = { parameters: primedState({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }), }; export const SoftTodo: Story = { parameters: primedState({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }), }; export const AlarmRightClickDialog: Story = { parameters: primedState({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }), play: openAlarmRightClickDialog, }; @@ -189,7 +190,7 @@ export const AlarmRightClickDialog: Story = { export const SoftTodoPrompt: Story = { parameters: primedState({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }), play: clickSoftTodo, }; @@ -197,7 +198,7 @@ export const SoftTodoPrompt: Story = { export const TodoOnly: Story = { parameters: primedState({ status: 'ALARM_DISABLED', - todo: 'hard', + todo: TODO_HARD, }), }; @@ -205,7 +206,7 @@ export const TodoAndAlarmEnabled: Story = { parameters: primedState({ status: 'NOTHING_TO_SHOW', - todo: 'hard', + todo: TODO_HARD, }), }; @@ -213,7 +214,7 @@ export const TodoAndAlarmRinging: Story = { parameters: primedState({ status: 'ALARM_RINGING', - todo: 'hard', + todo: TODO_HARD, }), }; @@ -224,7 +225,7 @@ export const CompactWidthWithAlarm: Story = { parameters: primedState({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }), }; @@ -235,7 +236,7 @@ export const MinimalWidthWithAlarm: Story = { parameters: primedState({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }), }; @@ -247,7 +248,7 @@ export const LongTitleWithAlarmAndTodo: Story = { parameters: primedState({ status: 'ALARM_RINGING', - todo: 'hard', + todo: TODO_HARD, }), }; @@ -258,6 +259,6 @@ export const ReducedMotionRinging: Story = { parameters: primedState({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }), }; diff --git a/lib/src/stories/TodoBucket.stories.tsx b/lib/src/stories/TodoBucket.stories.tsx new file mode 100644 index 0000000..994518c --- /dev/null +++ b/lib/src/stories/TodoBucket.stories.tsx @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Door } from '../components/Door'; +import { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo } from '../lib/terminal-registry'; +import { cfg } from '../cfg'; + +const STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000; +const STRIKE_STEP = 0.25; + +/** + * Interactive story to test the soft-TODO strike feel. + * Type in the input to strike one letter per printable keypress. + * Stop typing and one letter recovers every `recoverySecondsPerLetter` seconds. + */ +function TodoBucketDemo({ width = 300 }: { width?: number }) { + const [todo, setTodo] = useState(TODO_SOFT_FULL); + const recoveryTimerRef = useRef | null>(null); + + const clearRecoveryTimer = useCallback(() => { + if (recoveryTimerRef.current !== null) { + clearTimeout(recoveryTimerRef.current); + recoveryTimerRef.current = null; + } + }, []); + + const scheduleRecoveryTick = useCallback(() => { + clearRecoveryTimer(); + const tick = () => { + recoveryTimerRef.current = null; + setTodo((prev) => { + if (!isSoftTodo(prev)) return prev; + const next = Math.min(TODO_SOFT_FULL, prev + STRIKE_STEP); + if (next < TODO_SOFT_FULL) { + recoveryTimerRef.current = setTimeout(tick, STRIKE_RECOVERY_MS); + } + return next; + }); + }; + recoveryTimerRef.current = setTimeout(tick, STRIKE_RECOVERY_MS); + }, [clearRecoveryTimer]); + + const strike = useCallback(() => { + setTodo((prev) => { + if (!isSoftTodo(prev)) return prev; + const next = prev - STRIKE_STEP; + if (next < 1e-9) { + clearRecoveryTimer(); + return TODO_OFF; + } + scheduleRecoveryTick(); + return next; + }); + }, [clearRecoveryTimer, scheduleRecoveryTick]); + + useEffect(() => clearRecoveryTimer, [clearRecoveryTimer]); + + const reset = useCallback(() => { + clearRecoveryTimer(); + setTodo(TODO_SOFT_FULL); + }, [clearRecoveryTimer]); + + const strikes = isSoftTodo(todo) ? Math.round((1 - todo) * 4) : 0; + const label = todo === TODO_OFF + ? 'OFF' + : todo === TODO_HARD + ? 'HARD' + : `SOFT (${strikes}/4 strikes)`; + + return ( +
+
+ Type in the box below — each printable keypress strikes one letter of TODO. + Stop typing and one letter recovers every {cfg.todoBucket.recoverySecondsPerLetter}s. + 4 strikes clears the TODO (watch for the ✓ flourish). +
+ +
+
+ +
+
+ +
+
+
+
+ {label} +
+ +
+ { + if (e.key.length === 1) strike(); + }} + autoFocus + /> +
+ +
+ + + +
+
+ ); +} + +const meta: Meta = { + title: 'Interactions/TodoBucket', + component: TodoBucketDemo, + args: { + width: 300, + }, + argTypes: { + width: { control: 'number' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Interactive: Story = {}; diff --git a/lib/src/theme.css b/lib/src/theme.css index 56011a9..1030329 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -203,3 +203,61 @@ body.vscode-light { .pane-fading-and-shrinking-to-br, .ring-shrinking-to-br { animation: none; } } + +/* Soft-TODO pill: one letter per keypress gets a strike line. + * The line draws left-to-right on strike, retracts on un-strike. */ +.strike-letter { + position: relative; + display: inline-block; +} +.strike-letter::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + background-color: currentColor; + transform: scaleX(0); + transform-origin: left center; + transition: transform 150ms ease-out; +} +.strike-letter[data-strike='true']::after { + transform: scaleX(1); +} + +/* 4th-strike flourish: struck letters fade out while a ✓ pulses in, then fades. */ +@keyframes todo-flourish-letters { + 0% { opacity: 1; } + 30% { opacity: 0; } + 100% { opacity: 0; } +} +@keyframes todo-flourish-check { + 0% { opacity: 0; transform: scale(0.7); } + 30% { opacity: 1; transform: scale(1); } + 70% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(1); } +} +.todo-pill-flourish { + display: inline-grid; +} +.todo-pill-flourish > * { + grid-column: 1; + grid-row: 1; +} +.todo-pill-flourish__letters { + animation: todo-flourish-letters 500ms ease-out forwards; +} +.todo-pill-flourish__check { + justify-self: center; + align-self: center; + color: var(--color-success); + opacity: 0; + animation: todo-flourish-check 500ms ease-out forwards; +} + +@media (prefers-reduced-motion: reduce) { + .strike-letter::after { transition: none; } + .todo-pill-flourish__letters { animation: none; opacity: 0; } + .todo-pill-flourish__check { animation: none; opacity: 1; } +} diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 3aa3132..68c5331 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -209,6 +209,10 @@ export class TauriAdapter implements PlatformAdapter { this.alarmManager.clearTodo(id); } + alarmDrainTodoBucket(id: string): void { + this.alarmManager.drainTodoBucket(id); + } + onAlarmState(handler: (detail: AlarmStateDetail) => void): void { this.alarmStateHandlers.add(handler); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 8a0908b..1d8c0b9 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -301,6 +301,9 @@ export function attachRouter( case 'alarm:clearTodo': alarmManager.clearTodo(msg.id); break; + case 'alarm:drainTodoBucket': + alarmManager.drainTodoBucket(msg.id); + break; } }); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 6429833..624f1f2 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -23,7 +23,8 @@ export type WebviewMessage = | { type: 'alarm:clearAttention'; id?: string } | { type: 'alarm:toggleTodo'; id: string } | { type: 'alarm:markTodo'; id: string } - | { type: 'alarm:clearTodo'; id: string }; + | { type: 'alarm:clearTodo'; id: string } + | { type: 'alarm:drainTodoBucket'; id: string }; export interface PtyInfo { id: string;