Skip to content

Commit 27261e2

Browse files
nedtwiggclaude
andcommitted
Letter-strike soft-TODO with ✓ flourish on clear
Replace the continuous leaky-bucket drain with a discrete model tied to the 4 letters of the word TODO: each printable keypress strikes one letter, 4 strikes clear the pill, and one letter un-strikes per `recoverySecondsPerLetter` (default 1s) of idle. When the 4th strike lands, the pill briefly morphs to a ✓ in the success color before unmounting so completion is never silent. Also fix a latent timer leak: attend() and dismissAlarm() now clear the pending recovery timer before resetting todo to full. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent caaa8c5 commit 27261e2

10 files changed

Lines changed: 389 additions & 193 deletions

File tree

docs/specs/alarm.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ Each Session owns:
4949
- `todo: TodoState` (numeric)
5050
- Reminder state for the Session. Default `TODO_OFF` (`-1`).
5151
- `TODO_OFF` (`-1`): no TODO.
52-
- `[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.
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.
5353
- `TODO_HARD` (`2`): explicitly set by the user via `t` key or context menu. Solid-outline pill. Only clears via explicit toggle.
5454
- Dismissing a ringing alarm when `todo` is already soft or hard does not downgrade it.
5555
- Helper functions: `isSoftTodo(todo)`, `isHardTodo(todo)`, `hasTodo(todo)`.
56-
- Leaky-bucket tuning parameters are in `cfg.todoBucket`.
56+
- Strike-timing tuning parameter is in `cfg.todoBucket.recoverySecondsPerLetter`.
5757

5858
Each Session also owns:
5959

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

209-
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.
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.
210210

211211
The Session leaves `ALARM_RINGING` and returns to `ALARM_DISABLED` when:
212212

@@ -235,7 +235,8 @@ TODO pill:
235235

236236
- toggled in command mode with `t` (cycles: `TODO_OFF``TODO_HARD`, soft → `TODO_HARD`, `TODO_HARD``TODO_OFF`)
237237
- shown when `hasTodo(todo)` is true (i.e. `todo !== TODO_OFF`)
238-
- soft (`isSoftTodo(todo)`): dashed-outline pill — auto-created on alarm dismiss, drains via leaky bucket on typing
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
239240
- `TODO_HARD` (`isHardTodo(todo)`): solid-outline pill — explicitly set, only clears manually
240241
- clicking a soft pill shows a prompt: "Clear" / "Keep" (keep promotes to hard)
241242
- clicking a hard pill clears it
@@ -369,7 +370,7 @@ Consequences:
369370
- A Session rings.
370371
- User clicks into the pane to read the output.
371372
- The alarm clears, a soft TODO appears (dashed pill).
372-
- 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).
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).
373374
- The Session later emits new output, progresses through `BUSY`, and eventually reaches `ALARM_RINGING` again.
374375

375376
### User dismisses but doesn't engage

lib/src/cfg.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ export const cfg = {
2828
userAttention: 15_000,
2929
},
3030
todoBucket: {
31-
/** Seconds for a fully-drained soft-TODO bucket to refill to full when idle. */
32-
timeToFullSeconds: 3,
33-
/** Number of printable keypresses to drain a full bucket to zero. */
34-
keypressesToEmpty: 5,
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,
3535
},
3636
};

lib/src/components/Door.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BellIcon } from '@phosphor-icons/react';
2-
import { TODO_OFF, isSoftTodo, hasTodo, type SessionStatus, type 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;
@@ -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,21 +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-
{(hasTodo(todo) || alarmEnabled) && (
56+
{(todoPill.visible || alarmEnabled) && (
5557
<span className="flex shrink-0 items-center gap-1.5">
56-
{hasTodo(todo) && (
58+
{todoPill.visible && (
5759
<span
5860
className={[
5961
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
60-
isSoftTodo(todo) ? 'border border-dashed border-border' : 'border border-border',
62+
isSoftTodo(todo) || todoPill.flourishing ? 'border border-dashed border-border' : 'border border-border',
6163
].join(' ')}
62-
style={isSoftTodo(todo) ? {
63-
opacity: 0.3 + 0.7 * todo,
64-
transform: `scale(${0.7 + 0.3 * todo})`,
65-
transition: 'opacity 0.15s ease, transform 0.15s ease',
66-
} : undefined}
6764
>
68-
TODO
65+
{todoPill.body}
6966
</span>
7067
)}
7168
{alarmEnabled && (

lib/src/components/Pond.tsx

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import {
3636
type SessionStatus,
3737
isSoftTodo,
3838
isHardTodo,
39-
hasTodo,
4039
TODO_OFF,
4140
} from '../lib/terminal-registry';
4241
import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav';
@@ -45,6 +44,7 @@ import { getPlatform } from '../lib/platform';
4544
import { saveSession } from '../lib/session-save';
4645
import type { PersistedDetachedItem } from '../lib/session-types';
4746
import { cfg } from '../cfg';
47+
import { useTodoPillContent } from './TodoPillBody';
4848

4949
// --- Theme ---
5050

@@ -538,7 +538,8 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
538538
const suppressAlarmClickRef = useRef(false);
539539
const [tier, setTier] = useState<HeaderTier>('full');
540540
const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null);
541-
const showTodoPill = hasTodo(sessionState.todo) && tier !== 'minimal';
541+
const todoPill = useTodoPillContent(sessionState.todo);
542+
const showTodoPill = todoPill.visible && tier !== 'minimal';
542543
const alarmButtonAriaLabel = sessionState.status === 'ALARM_RINGING'
543544
? 'Alarm ringing'
544545
: sessionState.status === 'ALARM_DISABLED'
@@ -653,28 +654,32 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
653654
</span>
654655
</HeaderActionButton>
655656
{showTodoPill && (
656-
<button
657-
type="button"
658-
data-session-todo-for={api.id}
659-
className={[
660-
'shrink-0 rounded px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted transition-colors hover:bg-foreground/10',
661-
isSoftTodo(sessionState.todo) ? 'border border-dashed border-muted' : 'border border-muted',
662-
].join(' ')}
663-
style={isSoftTodo(sessionState.todo) ? {
664-
opacity: 0.3 + 0.7 * sessionState.todo,
665-
transform: `scale(${0.7 + 0.3 * sessionState.todo})`,
666-
transition: 'opacity 0.15s ease, transform 0.15s ease',
667-
} : undefined}
668-
aria-label="TODO settings"
669-
onMouseDown={(e) => e.stopPropagation()}
670-
onClick={(e) => {
671-
e.stopPropagation();
672-
const rect = e.currentTarget.getBoundingClientRect();
673-
setDialogPosition({ x: rect.left + rect.width / 2 - 140, y: rect.bottom + 6 });
674-
}}
675-
>
676-
TODO
677-
</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+
)
678683
)}
679684
</div>
680685
{!isRenaming && (
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)