Skip to content

Commit 3b7adcd

Browse files
authored
Leaky-bucket mechanism for soft-TODOs (#2)
2 parents 3cb1abb + 9cdea93 commit 3b7adcd

23 files changed

Lines changed: 855 additions & 230 deletions

docs/specs/alarm.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,14 @@ Each Session owns:
4646
- Transitional states: `MIGHT_BE_BUSY`, `MIGHT_NEED_ATTENTION`.
4747
- When the user enables the alarm, status transitions from `ALARM_DISABLED` to `NOTHING_TO_SHOW` and activity tracking begins fresh from that moment.
4848
- When the user disables the alarm, activity tracking stops and status returns to `ALARM_DISABLED`.
49-
- `todo: false | 'soft' | 'hard'`
50-
- Reminder state for the Session. Default `false`.
51-
- `'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).
52-
- `'hard'`: explicitly set by the user via `t` key or context menu. Solid-outline pill. Only clears via explicit toggle.
53-
- Dismissing a ringing alarm when `todo` is already `'soft'` or `'hard'` does not downgrade it.
49+
- `todo: TodoState` (numeric)
50+
- Reminder state for the Session. Default `TODO_OFF` (`-1`).
51+
- `TODO_OFF` (`-1`): no TODO.
52+
- `[0, 1]` (soft TODO): auto-created when a ringing alarm is phantom-dismissed (any attention path). Dashed-outline pill rendered as the word `TODO`. The value is quantized to five strike levels (`1.0` = no strikes, `0.75 / 0.5 / 0.25` = 1 / 2 / 3 letters struck, `0` = about to clear). Each printable keypress strikes exactly one letter (4 keypresses clears the TODO). After `recoverySecondsPerLetter` seconds of idle, one struck letter un-strikes; this repeats until the pill is fully un-struck. Synthetic terminal reports (focus events, cursor-position responses) do not count as keypresses.
53+
- `TODO_HARD` (`2`): explicitly set by the user via `t` key or context menu. Solid-outline pill. Only clears via explicit toggle.
54+
- Dismissing a ringing alarm when `todo` is already soft or hard does not downgrade it.
55+
- Helper functions: `isSoftTodo(todo)`, `isHardTodo(todo)`, `hasTodo(todo)`.
56+
- Strike-timing tuning parameter is in `cfg.todoBucket.recoverySecondsPerLetter`.
5457

5558
Each Session also owns:
5659

@@ -203,7 +206,7 @@ The Session leaves `ALARM_RINGING` and returns to `NOTHING_TO_SHOW` when any of
203206
- the user marks the Session as hard TODO (`t` key or context menu)
204207
- 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)
205208

206-
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.
209+
All attention-based dismissals (the first three above) create a soft TODO if `todo` is not already `TODO_HARD`. If a partially-struck soft TODO already exists, the pill resets to fully un-struck — a fresh alarm ring deserves a full strike cycle. This prevents phantom dismissals where the alarm vanishes without a trace. Printable keypresses strike one letter of the `TODO` pill at a time (4 strikes clears it), so users who engage with the output don't accumulate breadcrumbs. After `cfg.todoBucket.recoverySecondsPerLetter` (default 1 s) of idle, one struck letter un-strikes; this repeats until the pill is fully un-struck. Synthetic terminal reports (focus events, cursor-position responses) do not count as keypresses.
207210

208211
The Session leaves `ALARM_RINGING` and returns to `ALARM_DISABLED` when:
209212

@@ -215,7 +218,7 @@ The Session's alarm state is cleared entirely when:
215218

216219
If more output arrives later and the Session makes a fresh transition back into `ALARM_RINGING`, the alarm rings again.
217220

218-
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.
221+
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.
219222

220223
Disabling alarms disposes the activity monitor and returns `status` to `ALARM_DISABLED`.
221224

@@ -230,10 +233,11 @@ The Pane header exposes two independent concepts:
230233

231234
TODO pill:
232235

233-
- toggled in command mode with `t` (cycles: `false``'hard'`, `'soft'``'hard'`, `'hard'``false`)
234-
- shown when `todo` is `'soft'` or `'hard'`
235-
- `'soft'`: dashed-outline pill — auto-created on alarm dismiss, auto-clears on user input
236-
- `'hard'`: solid-outline pill — explicitly set, only clears manually
236+
- toggled in command mode with `t` (cycles: `TODO_OFF``TODO_HARD`, soft → `TODO_HARD`, `TODO_HARD``TODO_OFF`)
237+
- shown when `hasTodo(todo)` is true (i.e. `todo !== TODO_OFF`)
238+
- soft (`isSoftTodo(todo)`): dashed-outline pill — auto-created on alarm dismiss; each printable keypress strikes one letter of the word `TODO` (4 keypresses clears it), and one letter un-strikes per `recoverySecondsPerLetter` of idle
239+
- when the 4th strike lands and the soft TODO clears, the pill briefly morphs to a `` glyph in the success color (~500 ms) before unmounting — this marks the moment of completion so the pill never vanishes silently
240+
- `TODO_HARD` (`isHardTodo(todo)`): solid-outline pill — explicitly set, only clears manually
237241
- clicking a soft pill shows a prompt: "Clear" / "Keep" (keep promotes to hard)
238242
- clicking a hard pill clears it
239243
- no empty placeholder when off
@@ -276,7 +280,7 @@ A Door is display-only for alarm state in v1. It must not replace the existing D
276280
Door indicators:
277281

278282
- show bell indicator only when `status !== 'ALARM_DISABLED'`
279-
- show TODO pill when `todo !== false` (`'soft'` or `'hard'`)
283+
- show TODO pill when `hasTodo(todo)` (soft or hard)
280284
- if `status === 'ALARM_RINGING'`, the Door itself gets the ringing treatment, not just a tiny icon
281285
- 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
282286

@@ -366,7 +370,7 @@ Consequences:
366370
- A Session rings.
367371
- User clicks into the pane to read the output.
368372
- The alarm clears, a soft TODO appears (dashed pill).
369-
- User types a command → soft TODO auto-clears (they engaged).
373+
- User types a command → each printable keypress strikes one letter of the `TODO` pill; after 4 keypresses the pill morphs to a `` and clears (they engaged).
370374
- The Session later emits new output, progresses through `BUSY`, and eventually reaches `ALARM_RINGING` again.
371375

372376
### User dismisses but doesn't engage

lib/src/cfg.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ export const cfg = {
2727
/** ms — attention idle expiry. How long before "looking at this pane" wears off. */
2828
userAttention: 15_000,
2929
},
30+
todoBucket: {
31+
/** Seconds of idle time needed to un-strike one letter of the soft-TODO pill.
32+
* The word TODO has 4 letters; each printable keypress strikes one letter,
33+
* and each `recoverySecondsPerLetter` of idle time un-strikes one. */
34+
recoverySecondsPerLetter: 1,
35+
},
3036
};

lib/src/components/Baseboard.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp
143143
key={item.id}
144144
title={item.title}
145145
status={sessionState.status}
146-
147146
todo={sessionState.todo}
147+
148148
/>
149149
);
150150
})}
@@ -179,7 +179,6 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp
179179
isActive={activeId === item.id}
180180
windowFocused={windowFocused}
181181
status={sessionState.status}
182-
183182
todo={sessionState.todo}
184183
onClick={() => onReattach(item)}
185184
/>

lib/src/components/Door.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BellIcon } from '@phosphor-icons/react';
2-
import type { SessionStatus, TodoState } from '../lib/terminal-registry';
2+
import { TODO_OFF, isSoftTodo, type SessionStatus, type TodoState } from '../lib/terminal-registry';
3+
import { useTodoPillContent } from './TodoPillBody';
34

45
export interface DoorProps {
56
doorId?: string;
@@ -17,7 +18,7 @@ export function Door({
1718
isActive = false,
1819
windowFocused = true,
1920
status = 'ALARM_DISABLED',
20-
todo = false,
21+
todo = TODO_OFF,
2122
onClick,
2223
}: DoorProps) {
2324
// Doors can only be active in command mode (navigated to via arrow keys).
@@ -28,6 +29,7 @@ export function Door({
2829

2930
const alarmEnabled = status !== 'ALARM_DISABLED';
3031
const alarmRinging = status === 'ALARM_RINGING';
32+
const todoPill = useTodoPillContent(todo);
3133

3234
return (
3335
<button
@@ -51,14 +53,16 @@ export function Door({
5153
<span className={['min-w-0 flex-1 truncate', (isActive && windowFocused) ? 'text-foreground' : 'text-muted'].join(' ')}>
5254
{title}
5355
</span>
54-
{(todo || alarmEnabled) && (
56+
{(todoPill.visible || alarmEnabled) && (
5557
<span className="flex shrink-0 items-center gap-1.5">
56-
{todo && (
57-
<span className={[
58-
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
59-
todo === 'soft' ? 'border border-dashed border-border' : 'border border-border',
60-
].join(' ')}>
61-
TODO
58+
{todoPill.visible && (
59+
<span
60+
className={[
61+
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
62+
isSoftTodo(todo) || todoPill.flourishing ? 'border border-dashed border-border' : 'border border-border',
63+
].join(' ')}
64+
>
65+
{todoPill.body}
6266
</span>
6367
)}
6468
{alarmEnabled && (

lib/src/components/Pond.tsx

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@ import {
3434
swapTerminals,
3535
setPendingShellOpts,
3636
type SessionStatus,
37+
isSoftTodo,
38+
isHardTodo,
39+
TODO_OFF,
3740
} from '../lib/terminal-registry';
3841
import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav';
3942
import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot';
4043
import { getPlatform } from '../lib/platform';
4144
import { saveSession } from '../lib/session-save';
4245
import type { PersistedDetachedItem } from '../lib/session-types';
4346
import { cfg } from '../cfg';
47+
import { useTodoPillContent } from './TodoPillBody';
4448

4549
// --- Theme ---
4650

@@ -337,12 +341,12 @@ function TodoAlarmDialog({
337341
<span className="text-[10px] font-mono text-muted">[t]</span>
338342
<span className="text-[11px] text-foreground font-medium w-10">TODO</span>
339343
<div className="flex gap-1 ml-auto">
340-
<button type="button" className={toggleBtn(sessionState.todo === 'hard')}
341-
onClick={() => { if (sessionState.todo !== 'hard') markSessionTodo(sessionId); }}>
344+
<button type="button" className={toggleBtn(isHardTodo(sessionState.todo))}
345+
onClick={() => { if (!isHardTodo(sessionState.todo)) markSessionTodo(sessionId); }}>
342346
hard
343347
</button>
344-
<button type="button" className={toggleBtn(sessionState.todo === false)}
345-
onClick={() => { if (sessionState.todo !== false) clearSessionTodo(sessionId); }}>
348+
<button type="button" className={toggleBtn(sessionState.todo === TODO_OFF)}
349+
onClick={() => { if (sessionState.todo !== TODO_OFF) clearSessionTodo(sessionId); }}>
346350
off
347351
</button>
348352
</div>
@@ -368,7 +372,7 @@ function TodoAlarmDialog({
368372
<div className="border-t border-border pt-2 text-[9px] leading-relaxed text-muted">
369373
When an alarming tab is selected,<br />
370374
the alarm is cleared and the tab gets a soft TODO.<br />
371-
Typing characters into the tab will automatically clear a soft TODO.
375+
Typing drains the soft TODO; stop typing and it refills.
372376
</div>
373377
</div>,
374378
document.body,
@@ -534,7 +538,8 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
534538
const suppressAlarmClickRef = useRef(false);
535539
const [tier, setTier] = useState<HeaderTier>('full');
536540
const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null);
537-
const showTodoPill = sessionState.todo !== false && tier !== 'minimal';
541+
const todoPill = useTodoPillContent(sessionState.todo);
542+
const showTodoPill = todoPill.visible && tier !== 'minimal';
538543
const alarmButtonAriaLabel = sessionState.status === 'ALARM_RINGING'
539544
? 'Alarm ringing'
540545
: sessionState.status === 'ALARM_DISABLED'
@@ -649,23 +654,32 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
649654
</span>
650655
</HeaderActionButton>
651656
{showTodoPill && (
652-
<button
653-
type="button"
654-
data-session-todo-for={api.id}
655-
className={[
656-
'shrink-0 rounded px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted transition-colors hover:bg-foreground/10',
657-
sessionState.todo === 'soft' ? 'border border-dashed border-muted' : 'border border-muted',
658-
].join(' ')}
659-
aria-label="TODO settings"
660-
onMouseDown={(e) => e.stopPropagation()}
661-
onClick={(e) => {
662-
e.stopPropagation();
663-
const rect = e.currentTarget.getBoundingClientRect();
664-
setDialogPosition({ x: rect.left + rect.width / 2 - 140, y: rect.bottom + 6 });
665-
}}
666-
>
667-
TODO
668-
</button>
657+
todoPill.flourishing ? (
658+
<span
659+
className="shrink-0 rounded border border-dashed border-muted px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted"
660+
aria-hidden
661+
>
662+
{todoPill.body}
663+
</span>
664+
) : (
665+
<button
666+
type="button"
667+
data-session-todo-for={api.id}
668+
className={[
669+
'shrink-0 rounded px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted transition-colors hover:bg-foreground/10',
670+
isSoftTodo(sessionState.todo) ? 'border border-dashed border-muted' : 'border border-muted',
671+
].join(' ')}
672+
aria-label="TODO settings"
673+
onMouseDown={(e) => e.stopPropagation()}
674+
onClick={(e) => {
675+
e.stopPropagation();
676+
const rect = e.currentTarget.getBoundingClientRect();
677+
setDialogPosition({ x: rect.left + rect.width / 2 - 140, y: rect.bottom + 6 });
678+
}}
679+
>
680+
{todoPill.body}
681+
</button>
682+
)
669683
)}
670684
</div>
671685
{!isRenaming && (
@@ -1477,7 +1491,7 @@ export function Pond({
14771491
// don't overlap — the outgoing pane crushes/fades first, then the new pane
14781492
// reveals from the top-left. If anything restores a pane in the meantime
14791493
// (e.g. door reattach), the delayed spawn becomes a no-op.
1480-
e.api.onDidRemovePanel((removed) => {
1494+
e.api.onDidRemovePanel(() => {
14811495
if (e.api.totalPanels !== 0) return;
14821496
const reduceMotion = typeof window !== 'undefined'
14831497
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { type ReactNode, useEffect, useRef, useState } from 'react';
2+
import {
3+
hasTodo,
4+
isHardTodo,
5+
isSoftTodo,
6+
TODO_OFF,
7+
type TodoState,
8+
} from '../lib/terminal-registry';
9+
10+
interface StrikeLetterProps {
11+
char: string;
12+
strike: boolean;
13+
}
14+
15+
function StrikeLetter({ char, strike }: StrikeLetterProps) {
16+
return (
17+
<span className="strike-letter" data-strike={strike ? 'true' : 'false'}>
18+
{char}
19+
</span>
20+
);
21+
}
22+
23+
const TODO_LETTERS = ['T', 'O', 'D', 'O'] as const;
24+
const FLOURISH_MS = 500;
25+
26+
/**
27+
* Shared render body + flourish state for the soft/hard TODO pill.
28+
*
29+
* Returns `visible: false` when the pill should not render at all.
30+
* Returns `flourishing: true` briefly after a soft TODO clears, so the
31+
* caller can render a non-interactive wrapper (no click target).
32+
*/
33+
export function useTodoPillContent(todo: TodoState): {
34+
visible: boolean;
35+
flourishing: boolean;
36+
body: ReactNode;
37+
} {
38+
const [flourishing, setFlourishing] = useState(false);
39+
const prevRef = useRef<TodoState>(todo);
40+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
41+
42+
useEffect(() => {
43+
const prev = prevRef.current;
44+
prevRef.current = todo;
45+
if (isSoftTodo(prev) && todo === TODO_OFF) {
46+
if (timerRef.current !== null) clearTimeout(timerRef.current);
47+
setFlourishing(true);
48+
timerRef.current = setTimeout(() => {
49+
setFlourishing(false);
50+
timerRef.current = null;
51+
}, FLOURISH_MS);
52+
}
53+
}, [todo]);
54+
55+
useEffect(
56+
() => () => {
57+
if (timerRef.current !== null) clearTimeout(timerRef.current);
58+
},
59+
[],
60+
);
61+
62+
const visible = hasTodo(todo) || flourishing;
63+
64+
let body: ReactNode = null;
65+
if (flourishing) {
66+
body = (
67+
<span className="todo-pill-flourish">
68+
<span className="todo-pill-flourish__letters">
69+
{TODO_LETTERS.map((ch, i) => (
70+
<StrikeLetter key={i} char={ch} strike />
71+
))}
72+
</span>
73+
<span className="todo-pill-flourish__check" aria-hidden>
74+
75+
</span>
76+
</span>
77+
);
78+
} else if (isSoftTodo(todo)) {
79+
const strikes = Math.round((1 - todo) * 4);
80+
body = (
81+
<span className="inline-flex">
82+
{TODO_LETTERS.map((ch, i) => (
83+
<StrikeLetter key={i} char={ch} strike={strikes > i} />
84+
))}
85+
</span>
86+
);
87+
} else if (isHardTodo(todo)) {
88+
body = <>TODO</>;
89+
}
90+
91+
return { visible, flourishing, body };
92+
}

0 commit comments

Comments
 (0)