Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions docs/specs/alarm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:

Expand All @@ -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`.

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/src/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
3 changes: 1 addition & 2 deletions lib/src/components/Baseboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
key={item.id}
title={item.title}
status={sessionState.status}

todo={sessionState.todo}

/>
);
})}
Expand Down Expand Up @@ -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)}
/>
Expand Down
23 changes: 15 additions & 8 deletions lib/src/components/Door.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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).
Expand Down Expand Up @@ -49,13 +49,20 @@ export function Door({
<span className={['min-w-0 flex-1 truncate', isActive ? 'text-foreground' : 'text-muted'].join(' ')}>
{title}
</span>
{(todo || alarmEnabled) && (
{(hasTodo(todo) || alarmEnabled) && (
<span className="flex shrink-0 items-center gap-1.5">
{todo && (
<span className={[
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
todo === 'soft' ? 'border border-dashed border-border' : 'border border-border',
].join(' ')}>
{hasTodo(todo) && (
<span
className={[
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
isSoftTodo(todo) ? 'border border-dashed border-border' : 'border border-border',
].join(' ')}
style={isSoftTodo(todo) ? {
opacity: 0.3 + 0.7 * todo,
transform: `scale(${0.7 + 0.3 * todo})`,
transition: 'opacity 0.15s ease, transform 0.15s ease',
} : undefined}
>
TODO
</span>
)}
Expand Down
23 changes: 16 additions & 7 deletions lib/src/components/Pond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -335,12 +339,12 @@ function TodoAlarmDialog({
<span className="text-[10px] font-mono text-muted">[t]</span>
<span className="text-[11px] text-foreground font-medium w-10">TODO</span>
<div className="flex gap-1 ml-auto">
<button type="button" className={toggleBtn(sessionState.todo === 'hard')}
onClick={() => { if (sessionState.todo !== 'hard') markSessionTodo(sessionId); }}>
<button type="button" className={toggleBtn(isHardTodo(sessionState.todo))}
onClick={() => { if (!isHardTodo(sessionState.todo)) markSessionTodo(sessionId); }}>
hard
</button>
<button type="button" className={toggleBtn(sessionState.todo === false)}
onClick={() => { if (sessionState.todo !== false) clearSessionTodo(sessionId); }}>
<button type="button" className={toggleBtn(sessionState.todo === TODO_OFF)}
onClick={() => { if (sessionState.todo !== TODO_OFF) clearSessionTodo(sessionId); }}>
off
</button>
</div>
Expand All @@ -366,7 +370,7 @@ function TodoAlarmDialog({
<div className="border-t border-border pt-2 text-[9px] leading-relaxed text-muted">
When an alarming tab is selected,<br />
the alarm is cleared and the tab gets a soft TODO.<br />
Typing characters into the tab will automatically clear a soft TODO.
Typing drains the soft TODO; stop typing and it refills.
</div>
</div>,
document.body,
Expand Down Expand Up @@ -495,7 +499,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
const suppressAlarmClickRef = useRef(false);
const [tier, setTier] = useState<HeaderTier>('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'
Expand Down Expand Up @@ -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) => {
Expand Down
181 changes: 180 additions & 1 deletion lib/src/lib/alarm-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
});
Loading
Loading