diff --git a/docs/specs/alarm.md b/docs/specs/alarm.md index 223cbcd..46ed730 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). Value is the leaky-bucket fill level (`1` = full, `0` = about to clear). Dashed-outline pill. Uses a leaky-bucket mechanism: each printable keypress drains the bucket by `1/keypressesToEmpty` (default 5 keypresses to fully drain). When typing stops, the bucket refills to full over `timeToFullSeconds` (default 3 seconds). If the bucket empties completely, the soft TODO clears. Synthetic terminal reports (focus events, cursor-position responses) do not drain the bucket. + - `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)`. + - Leaky-bucket tuning parameters are in `cfg.todoBucket`. 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-drained soft TODO already exists, the bucket resets to full — a fresh alarm ring deserves a full drain cycle. This prevents phantom dismissals where the alarm vanishes without a trace. Printable keypresses drain the soft TODO's leaky bucket, and if the bucket empties completely the soft TODO clears — so users who engage with the output don't accumulate breadcrumbs. If the user stops typing, the bucket refills over `cfg.todoBucket.timeToFullSeconds` (default 3 s). Synthetic terminal reports (focus events, cursor-position responses) do not drain the bucket. 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,10 @@ 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, drains via leaky bucket on typing +- `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 +279,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 +369,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 → printable keypresses drain the soft TODO's leaky bucket; if enough keypresses occur without long pauses, the soft TODO 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..2add178 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 for a fully-drained soft-TODO bucket to refill to full when idle. */ + timeToFullSeconds: 3, + /** Number of printable keypresses to drain a full bucket to zero. */ + keypressesToEmpty: 5, + }, }; diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index e6c2410..95ea270 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -141,8 +141,8 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) { key={item.id} title={item.title} status={sessionState.status} - todo={sessionState.todo} + /> ); })} @@ -176,7 +176,6 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) { title={item.title} isActive={activeId === item.id} status={sessionState.status} - todo={sessionState.todo} onClick={() => onReattach(item)} /> diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index 95b1b68..3755188 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -1,5 +1,5 @@ import { BellIcon } from '@phosphor-icons/react'; -import type { SessionStatus, TodoState } from '../lib/terminal-registry'; +import { TODO_OFF, isSoftTodo, hasTodo, type SessionStatus, type TodoState } from '../lib/terminal-registry'; export interface DoorProps { doorId?: string; @@ -15,7 +15,7 @@ export function Door({ title, isActive = false, status = 'ALARM_DISABLED', - todo = false, + todo = TODO_OFF, onClick, }: DoorProps) { // Doors can only be active in command mode (navigated to via arrow keys). @@ -49,13 +49,20 @@ export function Door({ {title} - {(todo || alarmEnabled) && ( + {(hasTodo(todo) || alarmEnabled) && ( - {todo && ( - + {hasTodo(todo) && ( + TODO )} diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 7f4fc47..f6ea762 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -33,6 +33,10 @@ import { destroyTerminal, swapTerminals, type SessionStatus, + isSoftTodo, + isHardTodo, + hasTodo, + TODO_OFF, } from '../lib/terminal-registry'; import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav'; import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot'; @@ -335,12 +339,12 @@ function TodoAlarmDialog({ [t] TODO
- -
@@ -366,7 +370,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, @@ -495,7 +499,7 @@ 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 showTodoPill = hasTodo(sessionState.todo) && tier !== 'minimal'; const alarmButtonAriaLabel = sessionState.status === 'ALARM_RINGING' ? 'Alarm ringing' : sessionState.status === 'ALARM_DISABLED' @@ -615,8 +619,13 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { data-session-todo-for={api.id} className={[ 'shrink-0 rounded px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted transition-colors hover:bg-foreground/10', - sessionState.todo === 'soft' ? 'border border-dashed border-muted' : 'border border-muted', + isSoftTodo(sessionState.todo) ? 'border border-dashed border-muted' : 'border border-muted', ].join(' ')} + style={isSoftTodo(sessionState.todo) ? { + opacity: 0.3 + 0.7 * sessionState.todo, + transform: `scale(${0.7 + 0.3 * sessionState.todo})`, + transition: 'opacity 0.15s ease, transform 0.15s ease', + } : undefined} aria-label="TODO settings" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { diff --git a/lib/src/lib/alarm-manager.test.ts b/lib/src/lib/alarm-manager.test.ts index ecbee4d..ed18850 100644 --- a/lib/src/lib/alarm-manager.test.ts +++ b/lib/src/lib/alarm-manager.test.ts @@ -1,5 +1,5 @@ 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'; describe('AlarmManager in isolation', () => { let manager: AlarmManager; @@ -153,4 +153,183 @@ 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('5 rapid keypresses drain bucket to 0 and clear soft-TODO', () => { + const id = 'bucket-drain'; + createSoftTodo(id); + + for (let i = 0; i < 5; i++) { + manager.drainTodoBucket(id); + } + + expect(manager.getState(id).todo).toBe(TODO_OFF); + }); + + it('4 keypresses drain but do not clear soft-TODO', () => { + const id = 'bucket-partial'; + createSoftTodo(id); + + for (let i = 0; i < 4; i++) { + manager.drainTodoBucket(id); + } + + expect(isSoftTodo(manager.getState(id).todo)).toBe(true); + expect(manager.getState(id).todo).toBeCloseTo(0.2); + }); + + it('bucket refills to full after timeToFull seconds of idle', () => { + const id = 'bucket-refill'; + createSoftTodo(id); + + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.4); + + // Wait for full refill (3 seconds for full, but only need 0.6 * 3 = 1.8s) + vi.advanceTimersByTime(1_800); + + expect(isSoftTodo(manager.getState(id).todo)).toBe(true); + expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL); + }); + + it('partial refill + more keypresses — correct math', () => { + const id = 'bucket-partial-refill'; + createSoftTodo(id); + + // Drain 3 times → level = 0.4 + for (let i = 0; i < 3; i++) { + manager.drainTodoBucket(id); + } + expect(manager.getState(id).todo).toBeCloseTo(0.4); + + // Wait 1.5s → refill = 1.5/3 = 0.5, so level = min(1, 0.4 + 0.5) = 0.9 + vi.advanceTimersByTime(1_500); + + // Drain once more → refill applied first, then drain: 0.9 - 0.2 = 0.7 + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.7); + expect(isSoftTodo(manager.getState(id).todo)).toBe(true); + }); + + it('promoting a partially-drained soft-TODO resets to hard', () => { + const id = 'bucket-promote'; + createSoftTodo(id); + + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.6); + + manager.promoteTodo(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-drained soft-TODO bucket to full', () => { + const id = 'bucket-reset-on-reattend'; + createSoftTodo(id); + + // Drain the bucket partially (3 out of 5 keypresses) + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.4); + + // 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); + }); + + it('dismissing a ringing alarm resets a partially-drained soft-TODO bucket to full', () => { + const id = 'bucket-reset-on-dismiss'; + createSoftTodo(id); + + // Drain the bucket partially + manager.drainTodoBucket(id); + manager.drainTodoBucket(id); + expect(manager.getState(id).todo).toBeCloseTo(0.6); + + // 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); + }); + + it('re-attending a ringing alarm does NOT override a hard TODO', () => { + const id = 'bucket-no-reset-hard'; + createSoftTodo(id); + + // Promote to hard + manager.promoteTodo(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..6492781 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,13 @@ interface AlarmEntry { monitor: ActivityMonitor | null; todo: TodoState; attentionDismissedRing: boolean; + bucketLastDrainAt: number; + bucketRefillTimer: ReturnType | null; } const T_USER_ATTENTION = cfg.alarm.userAttention; +const BUCKET_TIME_TO_FULL_MS = cfg.todoBucket.timeToFullSeconds * 1_000; +const BUCKET_KEYPRESSES_TO_EMPTY = cfg.todoBucket.keypressesToEmpty; /** * Manages ActivityMonitors, attention tracking, and todo state for PTY sessions. @@ -100,8 +129,8 @@ export class AlarmManager { if (previousStatus === 'ALARM_RINGING') { entry.attentionDismissedRing = true; - if (entry.todo === false) { - entry.todo = 'soft'; + if (!isHardTodo(entry.todo)) { + entry.todo = TODO_SOFT_FULL; } } entry.monitor?.attend(); @@ -161,8 +190,8 @@ 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)) { + entry.todo = TODO_SOFT_FULL; } entry.monitor.attend(); // onChange fires → notify @@ -204,14 +233,16 @@ 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; + if (entry.todo === TODO_HARD) { + this.clearBucketRefillTimer(entry); + entry.todo = TODO_OFF; this.notify(id); } else { - entry.todo = 'hard'; + this.clearBucketRefillTimer(entry); + 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.clearBucketRefillTimer(entry); + entry.todo = TODO_HARD; if (isRinging) { entry.monitor!.attend(); return; // onChange fires → notify @@ -233,11 +265,59 @@ export class AlarmManager { this.notify(id); } + /** Promote soft TODO to hard */ + promoteTodo(id: string): void { + const entry = this.getOrCreateEntry(id); + if (!isSoftTodo(entry.todo)) return; + this.clearBucketRefillTimer(entry); + entry.todo = TODO_HARD; + this.notify(id); + } + /** 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.clearBucketRefillTimer(entry); + entry.todo = TODO_OFF; + this.notify(id); + } + + /** Drain the soft-TODO bucket by one keypress. Clears the TODO if bucket empties. */ + drainTodoBucket(id: string): void { + const entry = this.entries.get(id); + if (!entry || !isSoftTodo(entry.todo)) return; + + const now = Date.now(); + + // Apply refill based on time since last drain + if (entry.bucketLastDrainAt > 0) { + const elapsed = now - entry.bucketLastDrainAt; + entry.todo = Math.min(TODO_SOFT_FULL, entry.todo + elapsed / BUCKET_TIME_TO_FULL_MS); + } + + // Drain by one keypress + entry.todo = entry.todo - 1 / BUCKET_KEYPRESSES_TO_EMPTY; + entry.bucketLastDrainAt = now; + + if (entry.todo < 1e-9) { + entry.todo = TODO_OFF; + this.clearBucketRefillTimer(entry); + this.notify(id); + return; + } + + // Schedule refill timer + this.clearBucketRefillTimer(entry); + entry.bucketRefillTimer = setTimeout(() => { + entry.bucketRefillTimer = null; + if (isSoftTodo(entry.todo)) { + entry.todo = TODO_SOFT_FULL; + entry.bucketLastDrainAt = 0; + this.notify(id); + } + }, (TODO_SOFT_FULL - entry.todo) * BUCKET_TIME_TO_FULL_MS); + this.notify(id); } @@ -265,6 +345,7 @@ export class AlarmManager { remove(id: string): void { const entry = this.entries.get(id); if (!entry) return; + this.clearBucketRefillTimer(entry); entry.monitor?.dispose(); this.entries.delete(id); if (this.attentionId === id) { @@ -282,7 +363,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 +375,7 @@ export class AlarmManager { dispose(): void { for (const entry of this.entries.values()) { + this.clearBucketRefillTimer(entry); entry.monitor?.dispose(); } this.entries.clear(); @@ -306,12 +388,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, bucketLastDrainAt: 0, bucketRefillTimer: null }; this.entries.set(id, entry); } return entry; } + private clearBucketRefillTimer(entry: AlarmEntry): void { + if (entry.bucketRefillTimer !== null) { + clearTimeout(entry.bucketRefillTimer); + entry.bucketRefillTimer = 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 7073ad4..a924ee1 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -143,7 +143,9 @@ export class FakePtyAdapter implements PlatformAdapter { alarmClearAttention(id?: string): void { this.alarmManager.clearAttention(id); } alarmToggleTodo(id: string): void { this.alarmManager.toggleTodo(id); } alarmMarkTodo(id: string): void { this.alarmManager.markTodo(id); } + alarmPromoteTodo(id: string): void { this.alarmManager.promoteTodo(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 bb49ec5..0909471 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -52,7 +52,9 @@ export interface PlatformAdapter { alarmClearAttention(id?: string): void; alarmToggleTodo(id: string): void; alarmMarkTodo(id: string): void; + alarmPromoteTodo(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 e0c8e38..5aa0279 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -194,10 +194,18 @@ export class VSCodeAdapter implements PlatformAdapter { this.vscode.postMessage({ type: 'alarm:markTodo', id }); } + alarmPromoteTodo(id: string): void { + this.vscode.postMessage({ type: 'alarm:promoteTodo', id }); + } + alarmClearTodo(id: string): void { 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 c562709..4af4dbb 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(), @@ -48,7 +49,9 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { alarmClearAttention: () => {}, alarmToggleTodo: () => {}, alarmMarkTodo: () => {}, + alarmPromoteTodo: () => {}, alarmClearTodo: () => {}, + alarmDrainTodoBucket: () => {}, onAlarmState: () => {}, offAlarmState: () => {}, saveState: vi.fn((state: unknown) => { @@ -72,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' }]); @@ -83,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..bdf4c0d 100644 --- a/lib/src/lib/terminal-registry.alarm.test.ts +++ b/lib/src/lib/terminal-registry.alarm.test.ts @@ -94,6 +94,10 @@ import { swapTerminals, toggleSessionAlarm, toggleSessionTodo, + TODO_OFF, + TODO_SOFT_FULL, + TODO_HARD, + isSoftTodo, } from './terminal-registry'; interface MockTerminalInstance { @@ -238,7 +242,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -269,7 +273,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toMatchObject({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -303,7 +307,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -317,7 +321,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -331,7 +335,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 +346,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toMatchObject({ status: 'ALARM_RINGING', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -356,7 +360,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'hard', + todo: TODO_HARD, }); }); @@ -370,7 +374,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 +384,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); @@ -424,7 +428,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -441,7 +445,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -457,7 +461,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -477,11 +481,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 +498,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'hard', + todo: TODO_HARD, }); destroyTerminal(id); @@ -510,7 +514,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -522,11 +526,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 soft TODO, then the keypress drains the bucket by 1/5 + expect(getSessionState(id).status).toBe('NOTHING_TO_SHOW'); + expect(isSoftTodo(getSessionState(id).todo)).toBe(true); + expect(getSessionState(id).todo).toBeCloseTo(0.8); }); it('no monitor is created until alarm is enabled', () => { @@ -542,7 +545,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); @@ -567,7 +570,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 5 chars clears it', () => { const id = 'soft-todo-clear'; const entry = createSession(id); toggleSessionAlarm(id); @@ -575,12 +578,47 @@ 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'); + // 4 keypresses drain but don't clear + for (let i = 0; i < 4; i++) { + entry.terminal.emitInput('a'); + } + expect(isSoftTodo(getSessionState(id).todo)).toBe(true); + + // 5th keypress clears it + entry.terminal.emitInput('a'); + expect(getSessionState(id).todo).toBe(TODO_OFF); + }); + + it('soft TODO bucket refills 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); + + // 3 keypresses + for (let i = 0; i < 3; i++) { + entry.terminal.emitInput('a'); + } + expect(isSoftTodo(getSessionState(id).todo)).toBe(true); + + // Wait for full refill (3 seconds) + vi.advanceTimersByTime(3_000); + expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + + // Need 5 fresh keypresses to clear + for (let i = 0; i < 4; 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 +629,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 +644,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 +659,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 +694,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 +708,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -684,7 +722,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); @@ -696,7 +734,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -710,7 +748,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, }); }); @@ -724,7 +762,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: 'soft', + todo: TODO_SOFT_FULL, }); }); @@ -738,14 +776,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 +797,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 +817,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'ALARM_RINGING', - todo: false, + todo: TODO_OFF, }); }); @@ -796,7 +834,7 @@ describe('terminal-registry alarm behavior', () => { expect(getSessionState(id)).toEqual({ status: 'NOTHING_TO_SHOW', - todo: false, + todo: TODO_OFF, }); }); @@ -817,11 +855,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 +874,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 cb7de8f..4560657 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 { @@ -345,8 +345,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); } } @@ -373,7 +373,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 9525d77..63dd2d6 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..24e61db --- /dev/null +++ b/lib/src/stories/TodoBucket.stories.tsx @@ -0,0 +1,144 @@ +import { useState, useCallback } 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 BUCKET_TIME_TO_FULL_MS = cfg.todoBucket.timeToFullSeconds * 1_000; +const BUCKET_KEYPRESSES_TO_EMPTY = cfg.todoBucket.keypressesToEmpty; + +/** + * Interactive story to test the soft-TODO bucket feel. + * Type in the input to drain the bucket. Stop typing to let it refill. + */ +function TodoBucketDemo({ width = 300 }: { width?: number }) { + const [todo, setTodo] = useState(TODO_SOFT_FULL); + const [lastDrainAt, setLastDrainAt] = useState(0); + const [refillTimer, setRefillTimer] = useState | null>(null); + + const drain = useCallback(() => { + setTodo((prev) => { + if (!isSoftTodo(prev)) return prev; + + const now = Date.now(); + let level = prev; + + // Apply refill based on time since last drain + if (lastDrainAt > 0) { + const elapsed = now - lastDrainAt; + level = Math.min(TODO_SOFT_FULL, level + elapsed / BUCKET_TIME_TO_FULL_MS); + } + + // Drain by one keypress + level = level - 1 / BUCKET_KEYPRESSES_TO_EMPTY; + setLastDrainAt(now); + + if (level < 1e-9) { + if (refillTimer) clearTimeout(refillTimer); + setRefillTimer(null); + return TODO_OFF; + } + + // Schedule refill + if (refillTimer) clearTimeout(refillTimer); + const timer = setTimeout(() => { + setTodo(TODO_SOFT_FULL); + setLastDrainAt(0); + setRefillTimer(null); + }, (TODO_SOFT_FULL - level) * BUCKET_TIME_TO_FULL_MS); + setRefillTimer(timer); + + return level; + }); + }, [lastDrainAt, refillTimer]); + + const reset = useCallback(() => { + if (refillTimer) clearTimeout(refillTimer); + setRefillTimer(null); + setTodo(1); + setLastDrainAt(0); + }, [refillTimer]); + + const bucketPercent = isSoftTodo(todo) ? Math.round(todo * 100) : todo === TODO_HARD ? 100 : 0; + const label = todo === TODO_OFF ? 'OFF' : todo === TODO_HARD ? 'HARD' : `SOFT (${bucketPercent}%)`; + + return ( +
+
+ Type in the box below to drain the soft-TODO bucket. + Stop typing and it will refill over {cfg.todoBucket.timeToFullSeconds}s. + Takes {cfg.todoBucket.keypressesToEmpty} rapid keypresses to empty. +
+ +
+
+ +
+
+ +
+
+
+
+ {label} +
+ +
+ { + if (e.key.length === 1) drain(); + }} + 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/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 1a7df53..be3cb4c 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -199,10 +199,18 @@ export class TauriAdapter implements PlatformAdapter { this.alarmManager.markTodo(id); } + alarmPromoteTodo(id: string): void { + this.alarmManager.promoteTodo(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); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 3d1bafb..f676aa1 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -291,9 +291,15 @@ export function attachRouter( case 'alarm:markTodo': alarmManager.markTodo(msg.id); break; + case 'alarm:promoteTodo': + alarmManager.promoteTodo(msg.id); + break; 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 9a24f53..eaaf2ee 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -22,7 +22,9 @@ 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:promoteTodo'; id: string } + | { type: 'alarm:clearTodo'; id: string } + | { type: 'alarm:drainTodoBucket'; id: string }; export interface PtyInfo { id: string;