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 f3e8288..7177c9d 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -33,6 +33,9 @@ import { destroyTerminal, swapTerminals, type SessionStatus, + isSoftTodo, + isHardTodo, + hasTodo, } from '../lib/terminal-registry'; import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav'; import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot'; @@ -279,7 +282,7 @@ function AlarmContextMenu({ const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot); const sessionState = sessionStates.get(sessionId) ?? DEFAULT_SESSION_UI_STATE; const alarmEnabled = sessionState.status !== 'ALARM_DISABLED'; - const hasHardTodo = sessionState.todo === 'hard'; + const hasHardTodo = isHardTodo(sessionState.todo); const menuRef = useRef(null); const firstActionRef = useRef(null); @@ -340,7 +343,7 @@ function TodoPillPrompt({ const clearButtonRef = useRef(null); useEffect(() => { - if (sessionState.todo !== 'soft') { + if (!isSoftTodo(sessionState.todo)) { onClose(); } }, [onClose, sessionState.todo]); @@ -508,7 +511,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const [tier, setTier] = useState('full'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [todoPrompt, setTodoPrompt] = 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' @@ -628,13 +631,18 @@ 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(' ')} - aria-label={sessionState.todo === 'soft' ? 'Soft TODO options' : 'Clear TODO'} + 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={isSoftTodo(sessionState.todo) ? 'Soft TODO options' : 'Clear TODO'} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); - if (sessionState.todo === 'soft') { + if (isSoftTodo(sessionState.todo)) { const rect = e.currentTarget.getBoundingClientRect(); setTodoPrompt({ x: rect.left + rect.width / 2, y: rect.bottom + 6 }); return; diff --git a/lib/src/lib/alarm-manager.test.ts b/lib/src/lib/alarm-manager.test.ts index ecbee4d..0ae3e86 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,111 @@ 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('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 f862cec..f4a5651 100644 --- a/lib/src/lib/alarm-manager.ts +++ b/lib/src/lib/alarm-manager.ts @@ -3,7 +3,24 @@ 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; } + export type AlarmButtonActionResult = 'enabled' | 'disabled' | 'dismissed' | 'noop'; export interface AlarmState { @@ -15,7 +32,7 @@ export interface AlarmState { export const DEFAULT_ALARM_STATE: AlarmState = { status: 'ALARM_DISABLED', - todo: false, + todo: TODO_OFF, attentionDismissedRing: false, }; @@ -23,9 +40,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 +121,8 @@ export class AlarmManager { if (previousStatus === 'ALARM_RINGING') { entry.attentionDismissedRing = true; - if (entry.todo === false) { - entry.todo = 'soft'; + if (entry.todo === TODO_OFF) { + entry.todo = TODO_SOFT_FULL; } } entry.monitor?.attend(); @@ -161,8 +182,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 (entry.todo === TODO_OFF) { + entry.todo = TODO_SOFT_FULL; } entry.monitor.attend(); // onChange fires → notify @@ -204,14 +225,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 +247,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 @@ -236,16 +260,56 @@ export class AlarmManager { /** Promote soft TODO to hard */ promoteTodo(id: string): void { const entry = this.getOrCreateEntry(id); - if (entry.todo !== 'soft') return; - entry.todo = 'hard'; + 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); } @@ -273,6 +337,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) { @@ -302,6 +367,7 @@ export class AlarmManager { dispose(): void { for (const entry of this.entries.values()) { + this.clearBucketRefillTimer(entry); entry.monitor?.dispose(); } this.entries.clear(); @@ -314,12 +380,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 1f14b60..a924ee1 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -145,6 +145,7 @@ export class FakePtyAdapter implements PlatformAdapter { 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 4472ff6..0909471 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -54,6 +54,7 @@ export interface PlatformAdapter { 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 bb8eec5..5aa0279 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -202,6 +202,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 db1493d..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(), @@ -50,6 +51,7 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { alarmMarkTodo: () => {}, alarmPromoteTodo: () => {}, 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..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 43d7556..971f784 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 { @@ -349,8 +349,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); } } @@ -377,7 +377,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 a1794a3..be3cb4c 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -207,6 +207,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 f985ae6..dc03dc8 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -292,6 +292,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 fd66c99..eaaf2ee 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -23,7 +23,8 @@ export type WebviewMessage = | { type: 'alarm:toggleTodo'; id: string } | { type: 'alarm:markTodo'; id: string } | { type: 'alarm:promoteTodo'; id: string } - | { type: 'alarm:clearTodo'; id: string }; + | { type: 'alarm:clearTodo'; id: string } + | { type: 'alarm:drainTodoBucket'; id: string }; export interface PtyInfo { id: string;