Skip to content
Merged
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
30 changes: 17 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). 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.
- `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)`.
- Strike-timing tuning parameter is in `cfg.todoBucket.recoverySecondsPerLetter`.

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

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,11 @@ 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; each printable keypress strikes one letter of the word `TODO` (4 keypresses clears it), and one letter un-strikes per `recoverySecondsPerLetter` of idle
- 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
- `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 +280,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 +370,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 → each printable keypress strikes one letter of the `TODO` pill; after 4 keypresses the pill morphs to a `✓` and 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 of idle time needed to un-strike one letter of the soft-TODO pill.
* The word TODO has 4 letters; each printable keypress strikes one letter,
* and each `recoverySecondsPerLetter` of idle time un-strikes one. */
recoverySecondsPerLetter: 1,
},
};
3 changes: 1 addition & 2 deletions lib/src/components/Baseboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp
key={item.id}
title={item.title}
status={sessionState.status}

todo={sessionState.todo}

/>
);
})}
Expand Down Expand Up @@ -179,7 +179,6 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp
isActive={activeId === item.id}
windowFocused={windowFocused}
status={sessionState.status}

todo={sessionState.todo}
onClick={() => onReattach(item)}
/>
Expand Down
22 changes: 13 additions & 9 deletions lib/src/components/Door.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BellIcon } from '@phosphor-icons/react';
import type { SessionStatus, TodoState } from '../lib/terminal-registry';
import { TODO_OFF, isSoftTodo, type SessionStatus, type TodoState } from '../lib/terminal-registry';
import { useTodoPillContent } from './TodoPillBody';

export interface DoorProps {
doorId?: string;
Expand All @@ -17,7 +18,7 @@ export function Door({
isActive = false,
windowFocused = true,
status = 'ALARM_DISABLED',
todo = false,
todo = TODO_OFF,
onClick,
}: DoorProps) {
// Doors can only be active in command mode (navigated to via arrow keys).
Expand All @@ -28,6 +29,7 @@ export function Door({

const alarmEnabled = status !== 'ALARM_DISABLED';
const alarmRinging = status === 'ALARM_RINGING';
const todoPill = useTodoPillContent(todo);

return (
<button
Expand All @@ -51,14 +53,16 @@ export function Door({
<span className={['min-w-0 flex-1 truncate', (isActive && windowFocused) ? 'text-foreground' : 'text-muted'].join(' ')}>
{title}
</span>
{(todo || alarmEnabled) && (
{(todoPill.visible || 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(' ')}>
TODO
{todoPill.visible && (
<span
className={[
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
isSoftTodo(todo) || todoPill.flourishing ? 'border border-dashed border-border' : 'border border-border',
].join(' ')}
>
{todoPill.body}
</span>
)}
{alarmEnabled && (
Expand Down
62 changes: 38 additions & 24 deletions lib/src/components/Pond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ import {
swapTerminals,
setPendingShellOpts,
type SessionStatus,
isSoftTodo,
isHardTodo,
TODO_OFF,
} from '../lib/terminal-registry';
import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav';
import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot';
import { getPlatform } from '../lib/platform';
import { saveSession } from '../lib/session-save';
import type { PersistedDetachedItem } from '../lib/session-types';
import { cfg } from '../cfg';
import { useTodoPillContent } from './TodoPillBody';

// --- Theme ---

Expand Down Expand Up @@ -337,12 +341,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 @@ -368,7 +372,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 @@ -534,7 +538,8 @@ 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 todoPill = useTodoPillContent(sessionState.todo);
const showTodoPill = todoPill.visible && tier !== 'minimal';
const alarmButtonAriaLabel = sessionState.status === 'ALARM_RINGING'
? 'Alarm ringing'
: sessionState.status === 'ALARM_DISABLED'
Expand Down Expand Up @@ -649,23 +654,32 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
</span>
</HeaderActionButton>
{showTodoPill && (
<button
type="button"
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',
].join(' ')}
aria-label="TODO settings"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setDialogPosition({ x: rect.left + rect.width / 2 - 140, y: rect.bottom + 6 });
}}
>
TODO
</button>
todoPill.flourishing ? (
<span
className="shrink-0 rounded border border-dashed border-muted px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted"
aria-hidden
>
{todoPill.body}
</span>
) : (
<button
type="button"
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',
isSoftTodo(sessionState.todo) ? 'border border-dashed border-muted' : 'border border-muted',
].join(' ')}
aria-label="TODO settings"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setDialogPosition({ x: rect.left + rect.width / 2 - 140, y: rect.bottom + 6 });
}}
>
{todoPill.body}
</button>
)
)}
</div>
{!isRenaming && (
Expand Down Expand Up @@ -1477,7 +1491,7 @@ export function Pond({
// don't overlap — the outgoing pane crushes/fades first, then the new pane
// reveals from the top-left. If anything restores a pane in the meantime
// (e.g. door reattach), the delayed spawn becomes a no-op.
e.api.onDidRemovePanel((removed) => {
e.api.onDidRemovePanel(() => {
if (e.api.totalPanels !== 0) return;
const reduceMotion = typeof window !== 'undefined'
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
Expand Down
92 changes: 92 additions & 0 deletions lib/src/components/TodoPillBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import {
hasTodo,
isHardTodo,
isSoftTodo,
TODO_OFF,
type TodoState,
} from '../lib/terminal-registry';

interface StrikeLetterProps {
char: string;
strike: boolean;
}

function StrikeLetter({ char, strike }: StrikeLetterProps) {
return (
<span className="strike-letter" data-strike={strike ? 'true' : 'false'}>
{char}
</span>
);
}

const TODO_LETTERS = ['T', 'O', 'D', 'O'] as const;
const FLOURISH_MS = 500;

/**
* Shared render body + flourish state for the soft/hard TODO pill.
*
* Returns `visible: false` when the pill should not render at all.
* Returns `flourishing: true` briefly after a soft TODO clears, so the
* caller can render a non-interactive wrapper (no click target).
*/
export function useTodoPillContent(todo: TodoState): {
visible: boolean;
flourishing: boolean;
body: ReactNode;
} {
const [flourishing, setFlourishing] = useState(false);
const prevRef = useRef<TodoState>(todo);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
const prev = prevRef.current;
prevRef.current = todo;
if (isSoftTodo(prev) && todo === TODO_OFF) {
if (timerRef.current !== null) clearTimeout(timerRef.current);
setFlourishing(true);
timerRef.current = setTimeout(() => {
setFlourishing(false);
timerRef.current = null;
}, FLOURISH_MS);
}
}, [todo]);

useEffect(
() => () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
},
[],
);

const visible = hasTodo(todo) || flourishing;

let body: ReactNode = null;
if (flourishing) {
body = (
<span className="todo-pill-flourish">
<span className="todo-pill-flourish__letters">
{TODO_LETTERS.map((ch, i) => (
<StrikeLetter key={i} char={ch} strike />
))}
</span>
<span className="todo-pill-flourish__check" aria-hidden>
</span>
</span>
);
} else if (isSoftTodo(todo)) {
const strikes = Math.round((1 - todo) * 4);
body = (
<span className="inline-flex">
{TODO_LETTERS.map((ch, i) => (
<StrikeLetter key={i} char={ch} strike={strikes > i} />
))}
</span>
);
} else if (isHardTodo(todo)) {
body = <>TODO</>;
}

return { visible, flourishing, body };
}
Loading
Loading