diff --git a/docs/specs/alert.md b/docs/specs/alert.md
index 7e4f34a..23e0c54 100644
--- a/docs/specs/alert.md
+++ b/docs/specs/alert.md
@@ -46,14 +46,12 @@ Each Session owns:
- Transitional states: `MIGHT_BE_BUSY`, `MIGHT_NEED_ATTENTION`.
- When the user enables the alert, status transitions from `ALERT_DISABLED` to `NOTHING_TO_SHOW` and activity tracking begins fresh from that moment.
- When the user disables the alert, activity tracking stops and status returns to `ALERT_DISABLED`.
-- `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 alert 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 alert 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`.
+- `todo: boolean`
+ - Reminder state for the Session. Default `false`.
+ - `false`: no TODO.
+ - `true`: TODO is shown. It may be set explicitly by the user, or auto-created when a ringing alert is dismissed by attention or by the bell.
+ - Dismissing a ringing alert when `todo` is already `true` leaves it `true`.
+ - Legacy persisted TODO encodings migrate into this boolean shape: `-1` / `false` / unknown values become `false`; numeric soft buckets, `2`, `'soft'`, and `'hard'` become `true`.
Each Session also owns:
@@ -203,10 +201,10 @@ The Session leaves `ALERT_RINGING` and returns to `NOTHING_TO_SHOW` when any of
- the user attends to the Session (clicking into the Pane, typing in passthrough, restoring a Door via click/`Enter`)
- the user dismisses the alert (clicking the ringing bell, pressing `a`)
-- the user marks the Session as hard TODO (`t` key or context menu)
+- the user marks the Session as TODO (`t` key or context menu)
- new output arrives while the Session has attention (starts a new `MIGHT_BE_BUSY` cycle; without attention the alert stays ringing — see latch in transition rules)
-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 alert ring deserves a full strike cycle. This prevents phantom dismissals where the alert 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.
+All attention-based dismissals (the first three above) set `todo = true` if it is not already set. This prevents phantom dismissals where the alert vanishes without a trace. Once the TODO is visible, the user can clear it explicitly from the pill/dialog or by typing `Enter` as passthrough input into that Session's shell (i.e., the keystroke is forwarded to the PTY). The command-mode `Enter` that *switches into* passthrough does not clear the TODO. Synthetic terminal reports (focus events, cursor-position responses) also do not count as user input for clearing.
The Session leaves `ALERT_RINGING` and returns to `ALERT_DISABLED` when:
@@ -218,7 +216,7 @@ The Session's alert state is cleared entirely when:
If more output arrives later and the Session makes a fresh transition back into `ALERT_RINGING`, the alert rings again.
-Marking a Session as hard TODO resets the alert to `NOTHING_TO_SHOW` and sets `todo = TODO_HARD`, but it does **not** disable future alerts. `todo` and the alert toggle are separate concerns.
+Marking a Session as TODO resets the alert to `NOTHING_TO_SHOW` and sets `todo = true`, but it does **not** disable future alerts. `todo` and the alert toggle are separate concerns.
Disabling alerts disposes the activity monitor and returns `status` to `ALERT_DISABLED`.
@@ -233,13 +231,12 @@ The Pane header exposes two independent concepts:
TODO pill:
-- 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 alert 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
+- toggled in command mode with `t` (`false` -> `true` -> `false`)
+- shown when `todo === true`
+- auto-created on alert dismiss or attention-based alert clearing
+- typing `Enter` as passthrough input (forwarded to the Session's shell) clears the TODO; the command-mode `Enter` that switches *into* passthrough does not
+- clicking the TODO pill clears it
+- when 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
- no empty placeholder when off
Alert button:
@@ -259,15 +256,14 @@ Alert button:
Interaction (`dismissOrToggleAlert` state machine):
- left-click the bell while `ALERT_DISABLED`: enables the alert (creates activity monitor)
-- left-click the bell while `ALERT_RINGING`: dismisses the alert, creates a soft TODO if none exists, then opens the context menu anchored below the button
+- left-click the bell while `ALERT_RINGING`: dismisses the alert, creates a TODO if none exists, then opens the context menu anchored below the button
- left-click the bell after an attention-based dismissal (`attentionDismissedRing` is set): clears the flag and opens the context menu. This lets the user access TODO/disable options after attending to a ringing Session without requiring a right-click.
- left-click the bell in any other enabled state: disables the alert (destroys activity monitor)
- pressing `a` on a selected Pane in command mode: same as left-click
- right-click the bell (any state): opens a context menu with:
- - a TODO row with `hard` and `off` options only; soft TODOs are never manually selectable here
- - "Mark as TODO" / "Clear TODO" (toggles hard TODO), with `[t]` shortcut hint
- - "Disable alerts" (only when alert is enabled)
- - brief description of soft/hard TODO behavior
+ - a TODO on/off switch with `[t]` shortcut hint
+ - an alert on/off switch with `[a]` shortcut hint
+ - brief description of TODO clearing behavior
- tooltip includes "Right-click for options" hint
The alert control has higher layout priority than split or zoom controls. Long titles must truncate before the bell disappears.
@@ -279,7 +275,7 @@ A Door is display-only for alert state in v1. It must not replace the existing D
Door indicators:
- show bell indicator only when `status !== 'ALERT_DISABLED'`
-- show TODO pill when `hasTodo(todo)` (soft or hard)
+- show TODO pill when `todo === true`
- if `status === 'ALERT_RINGING'`, the Door bell icon uses warning color and the same rocking animation as the Pane header
- the Door bell icon shows the same tilt angles as the Pane header for escalation states
@@ -368,18 +364,17 @@ Consequences:
- A Session rings.
- User clicks into the pane to read the output.
-- The alert clears, a soft TODO appears (dashed pill).
-- 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 alert clears, and a TODO appears.
+- User presses `Enter` into the Session → the `TODO` pill morphs to a `✓` and clears (they engaged).
- The Session later emits new output, progresses through `BUSY`, and eventually reaches `ALERT_RINGING` again.
### User dismisses but doesn't engage
- A Session rings.
- User clicks into the pane briefly, then switches to another session.
-- The alert clears, a soft TODO appears.
-- User never types into the terminal → soft TODO persists.
-- User later notices the dashed TODO pill and clicks it → "Clear" / "Keep".
-- Choosing "Keep" promotes to a hard (solid) TODO.
+- The alert clears, and a TODO appears.
+- User never presses `Enter` into the terminal → TODO persists.
+- User later notices the TODO pill and clicks it to clear it.
## Verification checklist
diff --git a/docs/specs/layout.md b/docs/specs/layout.md
index 2449149..e7dc556 100644
--- a/docs/specs/layout.md
+++ b/docs/specs/layout.md
@@ -78,8 +78,8 @@ Elements from left to right:
- Alert bell button (reflects session activity status)
- TODO pill (if todo state is set; hidden in minimal tier)
- Flexible gap
-- SplitHorizontalIcon `split horizontal ["]` (full tier only)
-- SplitVerticalIcon `split vertical [%]` (full tier only)
+- SplitHorizontalIcon `split left/right [|]` (full tier only)
+- SplitVerticalIcon `split top/bottom [-]` (full tier only)
- ArrowsOutIcon / ArrowsInIcon `zoom / unzoom [z]` (full tier only)
- ArrowLineDownIcon `minimize [m]`
- XIcon `kill [x]` (hover turns error-red)
diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md
index 771597c..9801a8c 100644
--- a/docs/specs/shortcuts.md
+++ b/docs/specs/shortcuts.md
@@ -18,14 +18,14 @@ mouseterm has two modes:
| Key | Action | Description |
|-----|--------|-------------|
-| `\|` or `%` | Split horizontal | Split the selected pane into two side-by-side panes. |
-| `-` or `"` | Split vertical | Split the selected pane into two stacked panes. |
+| `\|` or `%` | Split left/right | Split the selected pane into two side-by-side panes. |
+| `-` or `"` | Split top/bottom | Split the selected pane into two stacked panes. |
| `z` | Toggle zoom | Fullscreen the selected pane, or return to the normal layout. |
| `m` or `d` | Minimize / reattach | Minimize the selected pane to the baseboard, or reattach a minimized door. |
| `k` or `x` | Kill | Kill the selected pane or door. Prompts for a random character to confirm. |
| `,` | Rename | Enter rename mode for the selected pane's title. |
| `a` | Toggle alert | Dismiss or toggle the bell alert for the selected pane. |
-| `t` | Toggle todo | Toggle the TODO marker (soft / hard) on the selected pane. |
+| `t` | Toggle todo | Toggle the TODO marker on or off for the selected pane. |
## Navigation (workspace mode)
diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md
index 4bfedc0..f899556 100644
--- a/docs/specs/tutorial.md
+++ b/docs/specs/tutorial.md
@@ -104,7 +104,7 @@ Detection: Watches `PondEvent.modeChange` for transition to `'command'`, then tr
**Step 6 — Split using keyboard shortcuts**
> Split a pane without leaving the keyboard.
>
-> *In command mode, press " to split horizontally or % to split vertically.*
+> *In command mode, press " to split top/bottom or % to split left/right.*
Detection: Watches `PondEvent.split` with `source: 'keyboard'` while in command mode.
diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md
index 6210818..342f526 100644
--- a/docs/specs/vscode.md
+++ b/docs/specs/vscode.md
@@ -194,7 +194,7 @@ activationEvents: ["onWebviewPanel:mouseterm"]
```typescript
interface PersistedSession {
- version: 2;
+ version: 3;
panes: PersistedPane[];
doors?: PersistedDoor[];
layout: unknown; // SerializedDockview
@@ -209,6 +209,11 @@ interface PersistedPane {
alert?: PersistedAlertState | null;
}
+interface PersistedAlertState {
+ status: SessionStatus;
+ todo: boolean;
+}
+
interface PersistedDoor {
id: string;
title: string;
diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts
index e2b7317..06d7938 100644
--- a/lib/src/cfg.ts
+++ b/lib/src/cfg.ts
@@ -29,10 +29,4 @@ export const cfg = {
/** When true, the ALERT_RINGING bell-ring animation is frozen at T=0 (for deterministic Chromatic snapshots). */
ringingPaused: false,
},
- 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,
- },
};
diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx
index bde0860..b7b5f6f 100644
--- a/lib/src/components/Door.tsx
+++ b/lib/src/components/Door.tsx
@@ -1,5 +1,5 @@
import { BellIcon } from '@phosphor-icons/react';
-import { TODO_OFF, isSoftTodo, type SessionStatus, type TodoState } from '../lib/terminal-registry';
+import type { SessionStatus, TodoState } from '../lib/terminal-registry';
import { useTodoPillContent } from './TodoPillBody';
import { bellIconClass } from './bell-icon-class';
@@ -19,7 +19,7 @@ export function Door({
isActive = false,
windowFocused = true,
status = 'ALERT_DISABLED',
- todo = TODO_OFF,
+ todo = false,
onClick,
}: DoorProps) {
// Doors can only be active in command mode (navigated to via arrow keys).
@@ -56,10 +56,8 @@ export function Door({
{todoPill.visible && (
{todoPill.body}
diff --git a/lib/src/components/HeaderActionButton.tsx b/lib/src/components/HeaderActionButton.tsx
new file mode 100644
index 0000000..62cd259
--- /dev/null
+++ b/lib/src/components/HeaderActionButton.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { PopupButtonRow, renderShortcuts } from './design';
+
+export interface HeaderActionButtonProps {
+ className: string;
+ ariaLabel: string;
+ tooltip?: string;
+ tooltipDetail?: string;
+ tooltipAlign?: 'left' | 'right';
+ onMouseDownCapture?: (e: React.MouseEvent) => void;
+ onMouseDown?: (e: React.MouseEvent) => void;
+ onClick: (e: React.MouseEvent) => void;
+ onContextMenu?: (e: React.MouseEvent) => void;
+ children: React.ReactNode;
+ dataAlertButtonFor?: string;
+}
+
+export function HeaderActionButton({
+ className,
+ ariaLabel,
+ tooltip,
+ tooltipDetail,
+ tooltipAlign = 'right',
+ onMouseDownCapture,
+ onMouseDown,
+ onClick,
+ onContextMenu,
+ children,
+ dataAlertButtonFor,
+}: HeaderActionButtonProps) {
+ const buttonRef = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
+ const [tooltipStyle, setTooltipStyle] = useState(null);
+ const tooltipPrimary = tooltip ?? ariaLabel;
+
+ useEffect(() => {
+ if (!isVisible || !buttonRef.current) return;
+
+ const updatePosition = () => {
+ const rect = buttonRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ setTooltipStyle({
+ position: 'fixed',
+ left: tooltipAlign === 'left' ? rect.left : rect.right,
+ top: rect.bottom + 8,
+ transform: tooltipAlign === 'left' ? 'translate(0, 0)' : 'translate(-100%, 0)',
+ });
+ };
+
+ updatePosition();
+ window.addEventListener('scroll', updatePosition, true);
+ window.addEventListener('resize', updatePosition);
+ return () => {
+ window.removeEventListener('scroll', updatePosition, true);
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, [isVisible, tooltipAlign]);
+
+ return (
+ <>
+
+
+
+ {isVisible && tooltipStyle && createPortal(
+
+
+
{renderShortcuts(tooltipPrimary)}
+ {tooltipDetail &&
{renderShortcuts(tooltipDetail)}
}
+
+ ,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/lib/src/components/KillConfirm.tsx b/lib/src/components/KillConfirm.tsx
new file mode 100644
index 0000000..e9edbcb
--- /dev/null
+++ b/lib/src/components/KillConfirm.tsx
@@ -0,0 +1,195 @@
+import { useLayoutEffect, useState } from 'react';
+import type { DockviewApi } from 'dockview-react';
+import { resolvePanelElement } from '../lib/spatial-nav';
+import { disposeSession } from '../lib/terminal-registry';
+
+export interface ConfirmKill {
+ id: string;
+ char: string;
+ shaking?: boolean;
+}
+
+/** Random A-Z excluding X (prevents accidental double-tap on kill shortcut) */
+const KILL_CONFIRM_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWYZ'; // no X
+export function randomKillChar(): string {
+ return KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)];
+}
+
+export function KillConfirmCard({ char, onCancel, shaking }: { char: string; onCancel?: () => void; shaking?: boolean }) {
+ return (
+
+
Kill Session?
+
+ {char}
+
+
+
[{char}] to confirm
+
+
+
+ );
+}
+
+export function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: {
+ confirmKill: ConfirmKill;
+ panelElements: Map;
+ onCancel: () => void;
+}) {
+ const [rect, setRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null);
+
+ // useLayoutEffect (not useEffect) so the initial measurement + re-render happens
+ // before the browser paints. Otherwise the centered-in-viewport fallback below
+ // flashes for one frame before the overlay snaps to the panel.
+ useLayoutEffect(() => {
+ const panelEl = resolvePanelElement(panelElements.get(confirmKill.id));
+ if (!panelEl) { setRect(null); return; }
+
+ const update = () => {
+ const r = panelEl.getBoundingClientRect();
+ setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
+ };
+
+ update();
+ const ro = new ResizeObserver(update);
+ ro.observe(panelEl);
+ window.addEventListener('resize', update);
+ return () => { ro.disconnect(); window.removeEventListener('resize', update); };
+ }, [confirmKill.id, panelElements]);
+
+ if (rect) {
+ return (
+
- );
-}
-
-
-// --- Kill animation ---
-//
-// Orchestrates the visual reclaim when a pane is killed:
-// 1. Fade the real killed pane's group element in place (its actual content
-// dissolves — a solid-color ghost over a same-colored background would be
-// invisible).
-// 2. After the fade completes, capture pre-rects of surviving panes, remove
-// the panel (dockview snaps the layout), and FLIP each grower via
-// clip-path so its newly claimed territory is hidden at start and swept
-// in by the transition. clip-path (not transform) keeps
-// getBoundingClientRect accurate so the SelectionOverlay doesn't lag.
-//
-// killInProgressRef is set across api.removePanel so the onDidRemovePanel
-// auto-spawn handler knows we already waited for our own fade and can skip
-// its own 440ms delay (avoids stacking 440ms + 440ms on last-pane kill).
-function orchestrateKill(
- api: DockviewApi,
- killedId: string,
- selectPanel: (id: string) => void,
- setSelectedId: (id: string | null) => void,
- killInProgressRef: { current: boolean },
- overlayElRef: { current: HTMLElement | null },
-): void {
- const panel = api.getPanel(killedId);
- if (!panel) return;
-
- const bareRemove = () => {
- killInProgressRef.current = true;
- disposeSession(killedId);
- api.removePanel(panel);
- killInProgressRef.current = false;
- if (api.panels.length > 0) selectPanel(api.panels[0].id);
- else setSelectedId(null);
- };
-
- const reduceMotion = typeof window !== 'undefined'
- && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
- const killedGroupEl = panel.api.group?.element;
- if (reduceMotion || !killedGroupEl) {
- bareRemove();
- return;
- }
-
- // Fade the killed pane in place. Block input on it during the fade.
- // For a last-pane kill (auto-spawn will create a replacement), also shrink
- // the pane toward the bottom-right so the disappearance is visible — a plain
- // fade offers no visual cue since the pane's space is reclaimed by a new one
- // appearing in exactly the same rect from the opposite corner. The focus
- // ring (SelectionOverlay element) gets a matching shrink animation so it
- // scales with the pane rather than sitting over empty space.
- const isLastPane = api.panels.length === 1;
- const fadeClass = isLastPane ? 'pane-fading-and-shrinking-to-br' : 'pane-fading-out';
- const fadeAnimationName = isLastPane ? 'pane-fade-and-shrink-to-br' : 'pane-fade-out';
- killedGroupEl.style.pointerEvents = 'none';
- killedGroupEl.classList.add(fadeClass);
- const overlayEl = isLastPane ? overlayElRef.current : null;
- if (overlayEl) overlayEl.classList.add('ring-shrinking-to-br');
-
- let finalized = false;
- const finalize = () => {
- if (finalized) return;
- finalized = true;
-
- // Snapshot pre-rects just before removal.
- interface Pre { el: HTMLElement; rect: DOMRect; }
- const preRects = new Map();
- for (const p of api.panels) {
- if (p.id === killedId) continue;
- const el = p.api.group?.element;
- if (el) preRects.set(p.id, { el, rect: el.getBoundingClientRect() });
- }
-
- bareRemove();
-
- // FLIP each grower.
- for (const p of api.panels) {
- const pre = preRects.get(p.id);
- if (!pre) continue;
- const postRect = pre.el.getBoundingClientRect();
- const dw = postRect.width - pre.rect.width;
- const dh = postRect.height - pre.rect.height;
- if (Math.abs(dw) < 0.5 && Math.abs(dh) < 0.5) continue;
-
- // Clear any in-progress spawn animation before applying FLIP.
- pre.el.classList.remove('pane-spawning-from-left', 'pane-spawning-from-top', 'pane-spawning-from-top-left');
-
- const clipTop = Math.max(0, (pre.rect.top - postRect.top) / postRect.height * 100);
- const clipBottom = Math.max(0, (postRect.bottom - pre.rect.bottom) / postRect.height * 100);
- const clipLeft = Math.max(0, (pre.rect.left - postRect.left) / postRect.width * 100);
- const clipRight = Math.max(0, (postRect.right - pre.rect.right) / postRect.width * 100);
-
- pre.el.style.transition = 'none';
- pre.el.style.clipPath = `inset(${clipTop}% ${clipRight}% ${clipBottom}% ${clipLeft}%)`;
- void pre.el.offsetHeight;
- pre.el.style.transition = 'clip-path 440ms cubic-bezier(0.22, 1, 0.36, 1)';
- pre.el.style.clipPath = 'inset(0)';
- const cleanup = () => {
- pre.el.style.transition = '';
- pre.el.style.clipPath = '';
- };
- pre.el.addEventListener('transitionend', cleanup, { once: true });
- setTimeout(cleanup, 1000);
- }
-
- // Peel the ring-shrink class so the next selection's overlay renders at
- // full scale. The element may have been reused by React for the next
- // selected pane's overlay by the time the animation finishes.
- if (overlayEl) overlayEl.classList.remove('ring-shrinking-to-br');
- };
-
- killedGroupEl.addEventListener('animationend', (ev) => {
- if ((ev as AnimationEvent).animationName !== fadeAnimationName) return;
- finalize();
- });
- // Safety: if animationend never fires, still finalize.
- setTimeout(finalize, 1000);
-}
// --- Main component ---
@@ -1273,6 +808,11 @@ export function Pond({
// animate the focus ring in sync with the killed pane's shrink (last-pane case).
const overlayElRef = useRef(null);
+ const dialogKeyboardActiveRef = useRef(false);
+ const setDialogKeyboardActive = useCallback((active: boolean) => {
+ dialogKeyboardActiveRef.current = active;
+ }, []);
+
// Consumed once in handleReady to restore existing sessions
const initialPaneIdsRef = useRef(initialPaneIds);
const restoredLayoutRef = useRef(restoredLayout);
@@ -1947,7 +1487,7 @@ export function Pond({
}
if (e.key === 't' && sid && selectedTypeRef.current === 'pane') {
- if (dialogKeyboardActive) return;
+ if (dialogKeyboardActiveRef.current) return;
e.preventDefault();
e.stopPropagation();
toggleSessionTodo(sid);
@@ -1955,10 +1495,17 @@ export function Pond({
}
if (e.key === 'a' && sid && selectedTypeRef.current === 'pane') {
- if (dialogKeyboardActive) return;
+ if (dialogKeyboardActiveRef.current) return;
e.preventDefault();
e.stopPropagation();
- dismissOrToggleAlert(sid, getActivity(sid).status);
+ // Go through the real button so that a dismiss opens the dialog. The
+ // fallback handles the edge case where the header isn't mounted yet.
+ const alertButton = findAlertButtonForSession(sid);
+ if (alertButton) {
+ alertButton.click();
+ } else {
+ dismissOrToggleAlert(sid, getActivity(sid).status);
+ }
return;
}
@@ -2246,6 +1793,7 @@ export function Pond({
+
{/* Dockview */}
@@ -2274,6 +1822,7 @@ export function Pond({
)}
+
diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx
new file mode 100644
index 0000000..2e649af
--- /dev/null
+++ b/lib/src/components/TodoAlertDialog.tsx
@@ -0,0 +1,264 @@
+import { useLayoutEffect, useEffect, useRef, useState, useSyncExternalStore } from 'react';
+import { createPortal } from 'react-dom';
+import { XIcon } from '@phosphor-icons/react';
+import { Shortcut } from './design';
+import {
+ clearSessionTodo,
+ DEFAULT_ACTIVITY_STATE,
+ disableSessionAlert,
+ dismissOrToggleAlert,
+ getActivity,
+ getActivitySnapshot,
+ markSessionTodo,
+ subscribeToActivity,
+ toggleSessionAlert,
+ toggleSessionTodo,
+} from '../lib/terminal-registry';
+
+
+function pointInConvexPolygon(x: number, y: number, vertices: Array<{ x: number; y: number }>): boolean {
+ let sign = 0;
+ for (let i = 0; i < vertices.length; i++) {
+ const a = vertices[i];
+ const b = vertices[(i + 1) % vertices.length];
+ const cross = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x);
+ if (cross === 0) continue;
+ if (sign === 0) sign = cross > 0 ? 1 : -1;
+ else if ((cross > 0 ? 1 : -1) !== sign) return false;
+ }
+ return true;
+}
+
+/**
+ * Manages focus trapping, Escape-to-close, and click-outside-to-close for
+ * portal-based popovers. Scopes keyboard handling to the popover's DOM subtree
+ * so Tab/Escape don't leak to the rest of the app.
+ */
+function usePopoverFocusTrap(
+ ref: React.RefObject,
+ onClose: () => void,
+) {
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+
+ const handleMouseDown = (e: MouseEvent) => {
+ if (!el.contains(e.target as Node)) onClose();
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Only handle keys when focus is inside the popover
+ if (!el.contains(document.activeElement)) return;
+
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ onClose();
+ return;
+ }
+ if (e.key !== 'Tab') return;
+
+ const focusables = Array.from(
+ el.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])'),
+ );
+ if (focusables.length === 0) return;
+
+ const currentIndex = focusables.findIndex((f) => f === document.activeElement);
+ const nextIndex = currentIndex === -1
+ ? 0
+ : (currentIndex + (e.shiftKey ? -1 : 1) + focusables.length) % focusables.length;
+
+ e.preventDefault();
+ focusables[nextIndex]?.focus();
+ };
+
+ window.addEventListener('mousedown', handleMouseDown);
+ window.addEventListener('keydown', handleKeyDown, true);
+ return () => {
+ window.removeEventListener('mousedown', handleMouseDown);
+ window.removeEventListener('keydown', handleKeyDown, true);
+ };
+ }, [ref, onClose]);
+}
+
+export function TodoAlertDialog({
+ triggerRect,
+ sessionId,
+ onClose,
+ onKeyboardActiveChange,
+}: {
+ triggerRect: DOMRect;
+ sessionId: string;
+ onClose: () => void;
+ onKeyboardActiveChange: (active: boolean) => void;
+}) {
+ const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot);
+ const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE;
+ const alertEnabled = activity.status !== 'ALERT_DISABLED';
+ const dialogRef = useRef(null);
+ const [position, setPosition] = useState<{ left: number; top: number }>({
+ left: triggerRect.left,
+ top: triggerRect.bottom + 8,
+ });
+
+ // Clamp the dialog inside the viewport after mount. w-fit makes the width
+ // content-driven, so we have to measure before we can clamp.
+ useLayoutEffect(() => {
+ const el = dialogRef.current;
+ if (!el) return;
+ const margin = 12;
+ const rect = el.getBoundingClientRect();
+ const desiredLeft = triggerRect.left;
+ const desiredTop = triggerRect.bottom + 8;
+ const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
+ const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
+ setPosition({
+ left: Math.min(Math.max(desiredLeft, margin), maxLeft),
+ top: Math.min(Math.max(desiredTop, margin), maxTop),
+ });
+ }, [triggerRect]);
+
+ usePopoverFocusTrap(dialogRef, onClose);
+
+ // Focus the dialog container itself (not a button inside) so our keyboard
+ // handlers fire via `el.contains(document.activeElement)`, without painting
+ // a native focus ring on any interactive element.
+ useEffect(() => {
+ dialogRef.current?.focus();
+ }, []);
+
+ // Keyboard shortcuts within dialog
+ useEffect(() => {
+ const el = dialogRef.current;
+ if (!el) return;
+ onKeyboardActiveChange(true);
+ const handler = (e: KeyboardEvent) => {
+ if (!el.contains(document.activeElement)) return;
+ if (e.key === 'a') {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ dismissOrToggleAlert(sessionId, getActivity(sessionId).status);
+ }
+ if (e.key === 't') {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ toggleSessionTodo(sessionId);
+ }
+ };
+ window.addEventListener('keydown', handler, true);
+ return () => {
+ onKeyboardActiveChange(false);
+ window.removeEventListener('keydown', handler, true);
+ };
+ }, [sessionId, onKeyboardActiveChange]);
+
+ // Hot area: close when mouse leaves (dialog ∪ funnel from trigger button to dialog top).
+ // Only arms after the cursor has entered the hot area, so a keyboard-triggered
+ // open (cursor far away) doesn't auto-close on the first unrelated mousemove.
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ let armed = false;
+ const handler = (e: MouseEvent) => {
+ const dialogRect = dialog.getBoundingClientRect();
+ const { clientX: x, clientY: y } = e;
+ const inDialog = x >= dialogRect.left && x <= dialogRect.right && y >= dialogRect.top && y <= dialogRect.bottom;
+ const funnel = [
+ { x: triggerRect.left, y: triggerRect.top },
+ { x: triggerRect.right, y: triggerRect.top },
+ { x: dialogRect.right, y: dialogRect.top },
+ { x: dialogRect.left, y: dialogRect.top },
+ ];
+ const inFunnel = pointInConvexPolygon(x, y, funnel);
+ const inside = inDialog || inFunnel;
+ if (!armed) {
+ if (inside) armed = true;
+ return;
+ }
+ if (!inside) onClose();
+ };
+ window.addEventListener('mousemove', handler);
+ return () => window.removeEventListener('mousemove', handler);
+ }, [triggerRect, onClose]);
+
+ return createPortal(
+
+
+
+
+ {/* TODO row */}
+ t
+ TODO
+ markSessionTodo(sessionId)}
+ onDisable={() => clearSessionTodo(sessionId)}
+ label="TODO"
+ />
+
+ {/* Alert row */}
+ a
+ alert
+ toggleSessionAlert(sessionId)}
+ onDisable={() => disableSessionAlert(sessionId)}
+ label="alert"
+ />
+
+
+
+ When a tab with a ringing alert is selected,
+ the alert is cleared and the tab gets a TODO.
+ Pressing [Enter] into the tab will clear the TODO.
+
+
,
+ document.body,
+ );
+}
+
+function OnOffSwitch({
+ on,
+ onEnable,
+ onDisable,
+ label,
+}: {
+ on: boolean;
+ onEnable: () => void;
+ onDisable: () => void;
+ label: string;
+}) {
+ return (
+
+ );
+}
diff --git a/lib/src/components/TodoPillBody.tsx b/lib/src/components/TodoPillBody.tsx
index 05901d0..a0525db 100644
--- a/lib/src/components/TodoPillBody.tsx
+++ b/lib/src/components/TodoPillBody.tsx
@@ -1,34 +1,17 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
-import {
- hasTodo,
- isHardTodo,
- isSoftTodo,
- TODO_OFF,
- type TodoState,
-} from '../lib/terminal-registry';
+import type { TodoState } from '../lib/terminal-registry';
-interface StrikeLetterProps {
- char: string;
- strike: boolean;
-}
-
-function StrikeLetter({ char, strike }: StrikeLetterProps) {
- return (
-
- {char}
-
- );
-}
-
-const TODO_LETTERS = ['T', 'O', 'D', 'O'] as const;
const FLOURISH_MS = 500;
/**
- * Shared render body + flourish state for the soft/hard TODO pill.
+ * Shared render body + flourish state for the 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).
+ * Returns `flourishing: true` briefly after a TODO clears so the
+ * caller can set `data-flourishing="true"` on its pill shell.
+ *
+ * The body is a grid-stacked so the pill width stays
+ * stable across steady/flourishing states — the CSS drives the animation.
*/
export function useTodoPillContent(todo: TodoState): {
visible: boolean;
@@ -42,7 +25,7 @@ export function useTodoPillContent(todo: TodoState): {
useEffect(() => {
const prev = prevRef.current;
prevRef.current = todo;
- if (isSoftTodo(prev) && todo === TODO_OFF) {
+ if (prev && !todo) {
if (timerRef.current !== null) clearTimeout(timerRef.current);
setFlourishing(true);
timerRef.current = setTimeout(() => {
@@ -59,34 +42,14 @@ export function useTodoPillContent(todo: TodoState): {
[],
);
- const visible = hasTodo(todo) || flourishing;
+ const visible = todo || flourishing;
- let body: ReactNode = null;
- if (flourishing) {
- body = (
-
-
- {TODO_LETTERS.map((ch, i) => (
-
- ))}
-
-
- ✓
-
-
- );
- } else if (isSoftTodo(todo)) {
- const strikes = Math.round((1 - todo) * 4);
- body = (
-
- {TODO_LETTERS.map((ch, i) => (
- i} />
- ))}
-
- );
- } else if (isHardTodo(todo)) {
- body = <>TODO>;
- }
+ const body: ReactNode = visible ? (
+
+ TODO
+ ✓
+
+ ) : null;
return { visible, flourishing, body };
}
diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx
index 6b2c56e..21db73f 100644
--- a/lib/src/components/design.tsx
+++ b/lib/src/components/design.tsx
@@ -51,7 +51,7 @@ export function Shortcut({
/**
* Render a string with any `[...]` segments replaced by . Use when
- * the shortcut is embedded inline in a label (e.g., "Split horizontal [" or |]").
+ * the shortcut is embedded inline in a label (e.g., "Split left/right [" or |]").
*/
export function renderShortcuts(text: string): ReactNode[] {
const parts: ReactNode[] = [];
diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts
index 43d95e9..36ac5ea 100644
--- a/lib/src/lib/alert-manager.test.ts
+++ b/lib/src/lib/alert-manager.test.ts
@@ -1,8 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { AlertManager, TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo } from './alert-manager';
-import { cfg } from '../cfg';
-
-const STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000;
+import { AlertManager } from './alert-manager';
describe('AlertManager in isolation', () => {
let manager: AlertManager;
@@ -51,25 +48,17 @@ describe('AlertManager in isolation', () => {
it('reproduces the exact user scenario: alert set, 5s task, collapse after 2s, wait 60s', () => {
const id = 'user-scenario';
- // Step 1: Set alert
manager.toggleAlert(id);
manager.clearAttention(id);
- // Step 2: Start task — output every 200ms for 5 seconds
for (let t = 0; t < 5_000; t += 200) {
manager.onData(id);
vi.advanceTimersByTime(200);
}
- // Task is running, monitor should be BUSY
expect(manager.getState(id).status).toBe('BUSY');
- // Step 3: Minimize after 2s (we're already 5s in, task just finished)
- // From here, no more data. No more attention. Just silence.
-
- // Step 4: Wait 60 seconds
vi.advanceTimersByTime(60_000);
- // Step 5: Restore — alert should already be ringing
expect(manager.getState(id).status).toBe('ALERT_RINGING');
});
@@ -78,31 +67,25 @@ describe('AlertManager in isolation', () => {
manager.toggleAlert(id);
manager.clearAttention(id);
- // Drive to BUSY
manager.onData(id);
vi.advanceTimersByTime(1_600);
manager.onData(id);
manager.onData(id);
expect(manager.getState(id).status).toBe('BUSY');
- // Silence → ALERT_RINGING
vi.advanceTimersByTime(2_000);
vi.advanceTimersByTime(3_000);
expect(manager.getState(id).status).toBe('ALERT_RINGING');
- // New data arrives (e.g. shell prompt) — alert should NOT reset
- // because the user has no attention (hasn't seen the alert)
manager.onData(id);
expect(manager.getState(id).status).toBe('ALERT_RINGING');
- // Even sustained output shouldn't reset it
for (let i = 0; i < 10; i++) {
manager.onData(id);
vi.advanceTimersByTime(200);
}
expect(manager.getState(id).status).toBe('ALERT_RINGING');
- // But once the user attends (focuses the pane), new data DOES reset
manager.attend(id);
manager.onData(id);
expect(manager.getState(id).status).not.toBe('ALERT_RINGING');
@@ -112,22 +95,18 @@ describe('AlertManager in isolation', () => {
const id = 'reset-test';
manager.toggleAlert(id);
- // Drive to ALERT_RINGING while user has attention
manager.attend(id);
manager.onData(id);
vi.advanceTimersByTime(1_600);
manager.onData(id);
manager.onData(id);
- // Let attention expire, then drive to ringing
manager.clearAttention(id);
vi.advanceTimersByTime(2_000);
vi.advanceTimersByTime(3_000);
expect(manager.getState(id).status).toBe('ALERT_RINGING');
- // User comes back and attends
manager.attend(id);
- // New data should reset the alert (user has seen it)
manager.onData(id);
expect(manager.getState(id).status).not.toBe('ALERT_RINGING');
});
@@ -142,13 +121,11 @@ describe('AlertManager in isolation', () => {
manager.toggleAlert(id);
manager.clearAttention(id);
- // Drive to BUSY
manager.onData(id);
vi.advanceTimersByTime(1_600);
manager.onData(id);
manager.onData(id);
- // Wait for silence
vi.advanceTimersByTime(2_000);
vi.advanceTimersByTime(3_000);
@@ -157,12 +134,13 @@ describe('AlertManager in isolation', () => {
expect(states).toContain('ALERT_RINGING');
});
- // --- Soft-TODO bucket tests ---
+ // --- Boolean TODO tests ---
+ // (The previous soft-TODO bucket tests — 4-keypress letter-striking, per-letter
+ // recovery timers — were removed when TODO was simplified to a plain boolean.)
- function createSoftTodo(id: string): void {
+ function driveToRinging(id: string): void {
manager.toggleAlert(id);
manager.clearAttention(id);
- // Drive to BUSY → silence → ALERT_RINGING
manager.onData(id);
vi.advanceTimersByTime(1_600);
manager.onData(id);
@@ -170,205 +148,36 @@ describe('AlertManager in isolation', () => {
vi.advanceTimersByTime(2_000);
vi.advanceTimersByTime(3_000);
expect(manager.getState(id).status).toBe('ALERT_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('4 keypresses strike all letters and clear soft-TODO', () => {
- const id = 'bucket-drain';
- createSoftTodo(id);
-
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.75);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.5);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.25);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBe(TODO_OFF);
- });
-
- it('3 keypresses strike 3 letters but do not clear soft-TODO', () => {
- const id = 'bucket-partial';
- createSoftTodo(id);
-
- for (let i = 0; i < 3; i++) {
- manager.drainTodoBucket(id);
- }
-
- expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
- expect(manager.getState(id).todo).toBeCloseTo(0.25);
- });
-
- it('one letter recovers after recoverySecondsPerLetter of idle', () => {
- const id = 'bucket-one-recovery';
- createSoftTodo(id);
-
- manager.drainTodoBucket(id);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.5);
-
- vi.advanceTimersByTime(STRIKE_RECOVERY_MS);
- expect(manager.getState(id).todo).toBeCloseTo(0.75);
- });
-
- it('recovery ticks repeat until bucket reaches full, then stops', () => {
- const id = 'bucket-full-recovery';
- createSoftTodo(id);
-
- manager.drainTodoBucket(id);
- manager.drainTodoBucket(id);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.25);
-
- // Three recovery ticks bring it back to full.
- vi.advanceTimersByTime(STRIKE_RECOVERY_MS);
- expect(manager.getState(id).todo).toBeCloseTo(0.5);
- vi.advanceTimersByTime(STRIKE_RECOVERY_MS);
- expect(manager.getState(id).todo).toBeCloseTo(0.75);
- vi.advanceTimersByTime(STRIKE_RECOVERY_MS);
- expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
-
- // Further time advances must not push past full or introduce drift.
- vi.advanceTimersByTime(10 * STRIKE_RECOVERY_MS);
- expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
- });
-
- it('a keypress between recovery ticks resets the recovery clock', () => {
- const id = 'bucket-recovery-reset';
- createSoftTodo(id);
-
- manager.drainTodoBucket(id);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.5);
-
- // Almost-but-not-quite one recovery interval.
- vi.advanceTimersByTime(STRIKE_RECOVERY_MS - 1);
- expect(manager.getState(id).todo).toBeCloseTo(0.5);
-
- // A fresh strike lands; the recovery clock restarts.
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.25);
-
- // Another almost-interval — no tick yet.
- vi.advanceTimersByTime(STRIKE_RECOVERY_MS - 1);
- expect(manager.getState(id).todo).toBeCloseTo(0.25);
-
- // Crossing the threshold finally restores one letter.
- vi.advanceTimersByTime(2);
- expect(manager.getState(id).todo).toBeCloseTo(0.5);
- });
-
- it('marking a partially-struck soft-TODO as hard resets to hard', () => {
- const id = 'bucket-promote';
- createSoftTodo(id);
-
- manager.drainTodoBucket(id);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.5);
-
- manager.markTodo(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 alert resets a partially-struck soft-TODO to full and clears its recovery timer', () => {
- const id = 'bucket-reset-on-reattend';
- createSoftTodo(id);
-
- // Strike 3 of 4 letters.
- manager.drainTodoBucket(id);
- manager.drainTodoBucket(id);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.25);
-
- // Drive to ALERT_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('ALERT_RINGING');
-
- // Re-attend should reset the bucket to full
+ it('attending a ringing alert turns TODO on', () => {
+ const id = 'attend-turns-todo-on';
+ driveToRinging(id);
manager.attend(id);
- expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
-
- // The pending recovery timer from before the re-attend must not fire now
- // that we're already at full (it would be a no-op but would still schedule
- // further ticks in the old code path).
- vi.advanceTimersByTime(10 * STRIKE_RECOVERY_MS);
- expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
+ expect(manager.getState(id).todo).toBe(true);
});
- it('dismissing a ringing alert resets a partially-struck soft-TODO to full and clears its recovery timer', () => {
- const id = 'bucket-reset-on-dismiss';
- createSoftTodo(id);
-
- // Strike 2 of 4 letters.
- manager.drainTodoBucket(id);
- manager.drainTodoBucket(id);
- expect(manager.getState(id).todo).toBeCloseTo(0.5);
-
- // Drive to ALERT_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('ALERT_RINGING');
-
- // Dismiss should reset the bucket to full
+ it('dismissing a ringing alert turns TODO on', () => {
+ const id = 'dismiss-turns-todo-on';
+ driveToRinging(id);
manager.dismissAlert(id);
- expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
-
- vi.advanceTimersByTime(10 * STRIKE_RECOVERY_MS);
- expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
+ expect(manager.getState(id).todo).toBe(true);
});
- it('re-attending a ringing alert does NOT override a hard TODO', () => {
- const id = 'bucket-no-reset-hard';
- createSoftTodo(id);
-
- // Promote to hard
- manager.markTodo(id);
- expect(manager.getState(id).todo).toBe(TODO_HARD);
-
- // Drive to ALERT_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('ALERT_RINGING');
-
- // Re-attend should NOT change hard TODO
- manager.attend(id);
- expect(manager.getState(id).todo).toBe(TODO_HARD);
+ it('toggleTodo flips on and off', () => {
+ const id = 'toggle-todo';
+ expect(manager.getState(id).todo).toBe(false);
+ manager.toggleTodo(id);
+ expect(manager.getState(id).todo).toBe(true);
+ manager.toggleTodo(id);
+ expect(manager.getState(id).todo).toBe(false);
});
- 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);
+ it('markTodo sets true; clearTodo sets false', () => {
+ const id = 'mark-clear-todo';
+ manager.markTodo(id);
+ expect(manager.getState(id).todo).toBe(true);
+ manager.clearTodo(id);
+ expect(manager.getState(id).todo).toBe(false);
});
});
diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts
index e1292b9..77bb5da 100644
--- a/lib/src/lib/alert-manager.ts
+++ b/lib/src/lib/alert-manager.ts
@@ -3,30 +3,17 @@ import { cfg } from '../cfg';
export { type SessionStatus } from './activity-monitor';
-/**
- * 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;
+/** Boolean TODO state: on (true) or off (false). */
+export type TodoState = boolean;
-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; }
-
-/** Migrate legacy persisted TodoState values (false/'soft'/'hard') to numeric. */
+/** Migrate legacy persisted TodoState values (numeric, string, boolean) to a boolean. */
export function migrateTodoState(todo: unknown): TodoState {
- if (typeof todo === 'number') return todo;
- if (todo === 'hard') return TODO_HARD;
- if (todo === 'soft') return TODO_SOFT_FULL;
- return TODO_OFF; // false, null, undefined, or any other unexpected value
+ if (typeof todo === 'boolean') return todo;
+ // v2 numeric encoding: -1 = off, [0,1] = soft, 2 = hard
+ if (typeof todo === 'number') return Number.isFinite(todo) && (todo === 2 || (todo >= 0 && todo <= 1));
+ // v1 string encoding: 'soft' | 'hard' | false
+ if (todo === 'hard' || todo === 'soft') return true;
+ return false;
}
export type AlertButtonActionResult = 'enabled' | 'disabled' | 'dismissed' | 'noop';
@@ -40,7 +27,7 @@ export interface AlertState {
export const DEFAULT_ALERT_STATE: AlertState = {
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
attentionDismissedRing: false,
};
@@ -48,12 +35,9 @@ interface AlertEntry {
monitor: ActivityMonitor | null;
todo: TodoState;
attentionDismissedRing: boolean;
- recoveryTimer: ReturnType | null;
}
const T_USER_ATTENTION = cfg.alert.userAttention;
-const STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000;
-const STRIKE_STEP = 0.25;
/**
* Manages ActivityMonitors, attention tracking, and todo state for PTY sessions.
@@ -128,10 +112,7 @@ export class AlertManager {
if (previousStatus === 'ALERT_RINGING') {
entry.attentionDismissedRing = true;
- if (!isHardTodo(entry.todo)) {
- this.clearRecoveryTimer(entry);
- entry.todo = TODO_SOFT_FULL;
- }
+ entry.todo = true;
}
entry.monitor?.attend();
this.notify(id);
@@ -190,10 +171,7 @@ export class AlertManager {
const entry = this.entries.get(id);
if (!entry?.monitor) return;
if (entry.monitor.getStatus() !== 'ALERT_RINGING') return;
- if (!isHardTodo(entry.todo)) {
- this.clearRecoveryTimer(entry);
- entry.todo = TODO_SOFT_FULL;
- }
+ entry.todo = true;
entry.monitor.attend();
// onChange fires → notify
}
@@ -234,30 +212,21 @@ export class AlertManager {
// --- Todo controls ---
- /** Toggle: off → hard, soft → hard, hard → off */
toggleTodo(id: string): void {
const entry = this.getOrCreateEntry(id);
- this.clearRecoveryTimer(entry);
- if (entry.todo === TODO_HARD) {
- entry.todo = TODO_OFF;
- this.notify(id);
- } else {
- entry.todo = TODO_HARD;
- if (entry.monitor?.getStatus() === 'ALERT_RINGING') {
- entry.monitor.attend();
- return; // onChange fires → notify
- }
- this.notify(id);
+ entry.todo = !entry.todo;
+ if (entry.todo && entry.monitor?.getStatus() === 'ALERT_RINGING') {
+ entry.monitor.attend();
+ return; // onChange fires → notify
}
+ this.notify(id);
}
- /** Explicitly mark as hard TODO */
markTodo(id: string): void {
const entry = this.getOrCreateEntry(id);
const isRinging = entry.monitor?.getStatus() === 'ALERT_RINGING';
- if (entry.todo === TODO_HARD && !isRinging) return;
- this.clearRecoveryTimer(entry);
- entry.todo = TODO_HARD;
+ if (entry.todo && !isRinging) return;
+ entry.todo = true;
if (isRinging) {
entry.monitor!.attend();
return; // onChange fires → notify
@@ -265,50 +234,13 @@ export class AlertManager {
this.notify(id);
}
- /** Clear any TODO state */
clearTodo(id: string): void {
const entry = this.getOrCreateEntry(id);
- if (entry.todo === TODO_OFF) return;
- this.clearRecoveryTimer(entry);
- entry.todo = TODO_OFF;
+ if (!entry.todo) return;
+ entry.todo = false;
this.notify(id);
}
- /**
- * Strike one letter of the soft-TODO pill.
- * 4 strikes clear the TODO. One letter recovers after each `recoverySecondsPerLetter`
- * of idle (no further strikes).
- */
- drainTodoBucket(id: string): void {
- const entry = this.entries.get(id);
- if (!entry || !isSoftTodo(entry.todo)) return;
-
- entry.todo = entry.todo - STRIKE_STEP;
-
- if (entry.todo < 1e-9) {
- entry.todo = TODO_OFF;
- this.clearRecoveryTimer(entry);
- this.notify(id);
- return;
- }
-
- this.scheduleRecoveryTick(id, entry);
- this.notify(id);
- }
-
- private scheduleRecoveryTick(id: string, entry: AlertEntry): void {
- this.clearRecoveryTimer(entry);
- entry.recoveryTimer = setTimeout(() => {
- entry.recoveryTimer = null;
- if (!isSoftTodo(entry.todo)) return;
- entry.todo = Math.min(TODO_SOFT_FULL, entry.todo + STRIKE_STEP);
- this.notify(id);
- if (entry.todo < TODO_SOFT_FULL) {
- this.scheduleRecoveryTick(id, entry);
- }
- }, STRIKE_RECOVERY_MS);
- }
-
// --- Query ---
getState(id: string): AlertState {
@@ -333,7 +265,6 @@ export class AlertManager {
remove(id: string): void {
const entry = this.entries.get(id);
if (!entry) return;
- this.clearRecoveryTimer(entry);
entry.monitor?.dispose();
this.entries.delete(id);
if (this.attentionId === id) {
@@ -349,7 +280,7 @@ export class AlertManager {
* creates a fresh ActivityMonitor (it will start in NOTHING_TO_SHOW until
* PTY data arrives).
*/
- seed(id: string, state: { status: string; todo: TodoState }): void {
+ seed(id: string, state: { status: string; todo: unknown }): void {
const entry = this.getOrCreateEntry(id);
entry.todo = migrateTodoState(state.todo);
// If the alert was enabled (anything other than ALERT_DISABLED), create a monitor
@@ -363,7 +294,6 @@ export class AlertManager {
dispose(): void {
for (const entry of this.entries.values()) {
- this.clearRecoveryTimer(entry);
entry.monitor?.dispose();
}
this.entries.clear();
@@ -376,19 +306,12 @@ export class AlertManager {
private getOrCreateEntry(id: string): AlertEntry {
let entry = this.entries.get(id);
if (!entry) {
- entry = { monitor: null, todo: TODO_OFF, attentionDismissedRing: false, recoveryTimer: null };
+ entry = { monitor: null, todo: false, attentionDismissedRing: false };
this.entries.set(id, entry);
}
return entry;
}
- private clearRecoveryTimer(entry: AlertEntry): void {
- if (entry.recoveryTimer !== null) {
- clearTimeout(entry.recoveryTimer);
- entry.recoveryTimer = 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 7103eac..00170da 100644
--- a/lib/src/lib/platform/fake-adapter.ts
+++ b/lib/src/lib/platform/fake-adapter.ts
@@ -151,7 +151,6 @@ export class FakePtyAdapter implements PlatformAdapter {
alertToggleTodo(id: string): void { this.alertManager.toggleTodo(id); }
alertMarkTodo(id: string): void { this.alertManager.markTodo(id); }
alertClearTodo(id: string): void { this.alertManager.clearTodo(id); }
- alertDrainTodoBucket(id: string): void { this.alertManager.drainTodoBucket(id); }
onAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.add(handler); }
offAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.delete(handler); }
diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts
index 95fa0e5..6c6f991 100644
--- a/lib/src/lib/platform/types.ts
+++ b/lib/src/lib/platform/types.ts
@@ -62,7 +62,6 @@ export interface PlatformAdapter {
alertToggleTodo(id: string): void;
alertMarkTodo(id: string): void;
alertClearTodo(id: string): void;
- alertDrainTodoBucket(id: string): void;
onAlertState(handler: (detail: AlertStateDetail) => void): void;
offAlertState(handler: (detail: AlertStateDetail) => void): void;
diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts
index 87c60b9..3fc28d8 100644
--- a/lib/src/lib/platform/vscode-adapter.ts
+++ b/lib/src/lib/platform/vscode-adapter.ts
@@ -240,10 +240,6 @@ export class VSCodeAdapter implements PlatformAdapter {
this.vscode.postMessage({ type: 'alert:clearTodo', id });
}
- alertDrainTodoBucket(id: string): void {
- this.vscode.postMessage({ type: 'alert:drainTodoBucket', id });
- }
-
onAlertState(handler: (detail: AlertStateDetail) => void): void {
this.alertStateHandlers.add(handler);
}
diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts
index f3de693..22de9fa 100644
--- a/lib/src/lib/reconnect.test.ts
+++ b/lib/src/lib/reconnect.test.ts
@@ -58,7 +58,6 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P
alertToggleTodo: vi.fn(),
alertMarkTodo: vi.fn(),
alertClearTodo: vi.fn(),
- alertDrainTodoBucket: vi.fn(),
onAlertState: vi.fn(),
offAlertState: vi.fn(),
saveState: vi.fn(),
@@ -88,7 +87,7 @@ describe('resumeOrRestore', () => {
layoutAtMinimizeSignature: 'sig',
}];
const saved: PersistedSession = {
- version: 2,
+ version: 3,
layout,
doors,
panes: [
@@ -117,7 +116,7 @@ describe('resumeOrRestore', () => {
it('does not reuse a saved layout when live PTYs do not match saved panes', async () => {
const saved: PersistedSession = {
- version: 2,
+ version: 3,
layout: { panels: { 'pane-a': {}, 'pane-b': {} } },
panes: [
{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null },
@@ -156,7 +155,7 @@ describe('resumeOrRestore', () => {
layoutAtMinimizeSignature: 'sig-b',
}];
const saved: PersistedSession = {
- version: 2,
+ version: 3,
layout: { panels: {} },
doors,
panes: [
@@ -182,7 +181,7 @@ describe('resumeOrRestore', () => {
it('ignores stale saved panes when the saved layout still matches live visible panes', async () => {
const layout = { panels: { 'pane-a': {}, 'pane-b': {} } };
const saved: PersistedSession = {
- version: 2,
+ version: 3,
layout,
panes: [
{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null },
diff --git a/lib/src/lib/session-migration.test.ts b/lib/src/lib/session-migration.test.ts
index a22ebfb..dbf5e5e 100644
--- a/lib/src/lib/session-migration.test.ts
+++ b/lib/src/lib/session-migration.test.ts
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
-import { migrateSessionV1toV2, readPersistedSession, type PersistedSessionV1 } from './session-types';
+import {
+ migrateSessionV1toV2,
+ migrateSessionV2toV3,
+ readPersistedSession,
+ type PersistedSessionV1,
+ type PersistedSessionV2,
+} from './session-types';
describe('session migration v1 → v2', () => {
it('migrates a v1 blob with doors to v2, renaming fields', () => {
@@ -57,18 +63,166 @@ describe('session migration v1 → v2', () => {
});
});
+describe('session migration v2 → v3', () => {
+ it('converts numeric TODO_HARD (2) to boolean true', () => {
+ const v2: PersistedSessionV2 = {
+ version: 2,
+ layout: null,
+ panes: [
+ {
+ id: 'pane-hard',
+ title: 'Pane Hard',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW', todo: 2 },
+ },
+ ],
+ };
+ const v3 = migrateSessionV2toV3(v2);
+ expect(v3.panes[0].alert?.todo).toBe(true);
+ expect(v3.version).toBe(3);
+ });
+
+ it('converts numeric soft-bucket values ([0,1]) to boolean true', () => {
+ const v2: PersistedSessionV2 = {
+ version: 2,
+ layout: null,
+ panes: [
+ {
+ id: 'pane-soft-full',
+ title: 'full',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW', todo: 1 },
+ },
+ {
+ id: 'pane-soft-half',
+ title: 'half',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW', todo: 0.5 },
+ },
+ {
+ id: 'pane-soft-zero',
+ title: 'zero',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW', todo: 0 },
+ },
+ ],
+ };
+ const v3 = migrateSessionV2toV3(v2);
+ expect(v3.panes[0].alert?.todo).toBe(true);
+ expect(v3.panes[1].alert?.todo).toBe(true);
+ expect(v3.panes[2].alert?.todo).toBe(true);
+ });
+
+ it('converts TODO_OFF (-1) to boolean false', () => {
+ const v2: PersistedSessionV2 = {
+ version: 2,
+ layout: null,
+ panes: [
+ {
+ id: 'pane-off',
+ title: 'off',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW', todo: -1 },
+ },
+ ],
+ };
+ const v3 = migrateSessionV2toV3(v2);
+ expect(v3.panes[0].alert?.todo).toBe(false);
+ });
+
+ it('converts unknown numeric TODO values to boolean false', () => {
+ const v2: PersistedSessionV2 = {
+ version: 2,
+ layout: null,
+ panes: [
+ {
+ id: 'pane-unknown-high',
+ title: 'unknown high',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW', todo: 3 },
+ },
+ {
+ id: 'pane-unknown-low',
+ title: 'unknown low',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW', todo: -2 },
+ },
+ {
+ id: 'pane-unknown-nan',
+ title: 'unknown nan',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW', todo: Number.NaN },
+ },
+ ],
+ };
+ const v3 = migrateSessionV2toV3(v2);
+ expect(v3.panes[0].alert?.todo).toBe(false);
+ expect(v3.panes[1].alert?.todo).toBe(false);
+ expect(v3.panes[2].alert?.todo).toBe(false);
+ });
+
+ it('preserves panes with null alert', () => {
+ const v2: PersistedSessionV2 = {
+ version: 2,
+ layout: null,
+ panes: [
+ { id: 'pane-null', title: 'null', cwd: null, scrollback: null, resumeCommand: null, alert: null },
+ ],
+ };
+ const v3 = migrateSessionV2toV3(v2);
+ expect(v3.panes[0].alert).toBeNull();
+ });
+});
+
describe('readPersistedSession', () => {
- it('returns a v2 blob unchanged', () => {
+ it('returns a v3 blob unchanged', () => {
+ const v3 = {
+ version: 3 as const,
+ layout: null,
+ panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }],
+ doors: [],
+ };
+ expect(readPersistedSession(v3)).toBe(v3);
+ });
+
+ it('migrates a v2 blob on read (numeric TODO → boolean)', () => {
const v2 = {
version: 2 as const,
layout: null,
- panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }],
+ panes: [
+ {
+ id: 'pane-a',
+ title: 'Pane A',
+ cwd: null,
+ scrollback: null,
+ resumeCommand: null,
+ alert: { status: 'NOTHING_TO_SHOW' as const, todo: 2 },
+ },
+ ],
doors: [],
};
- expect(readPersistedSession(v2)).toBe(v2);
+ const result = readPersistedSession(v2);
+ expect(result?.version).toBe(3);
+ expect(result?.panes[0].alert?.todo).toBe(true);
});
- it('migrates a v1 blob on read', () => {
+ it('migrates a v1 blob on read through v2 to v3', () => {
const v1 = {
version: 1 as const,
layout: null,
@@ -86,7 +240,7 @@ describe('readPersistedSession', () => {
],
};
const result = readPersistedSession(v1);
- expect(result?.version).toBe(2);
+ expect(result?.version).toBe(3);
expect(result?.doors?.[0]).toMatchObject({
id: 'pane-b',
remainingPaneIds: [],
diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts
index d8808b8..2855dfd 100644
--- a/lib/src/lib/session-restore.test.ts
+++ b/lib/src/lib/session-restore.test.ts
@@ -50,7 +50,6 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter {
alertToggleTodo: vi.fn(),
alertMarkTodo: vi.fn(),
alertClearTodo: vi.fn(),
- alertDrainTodoBucket: vi.fn(),
onAlertState: vi.fn(),
offAlertState: vi.fn(),
saveState: vi.fn(),
@@ -69,7 +68,7 @@ describe('restoreSession', () => {
args: ['-NoLogo'],
});
const saved: PersistedSession = {
- version: 2,
+ version: 3,
layout: { panels: { 'pane-a': {} } },
panes: [
{ id: 'pane-a', title: 'Pane A', cwd: 'C:\\repo', scrollback: 'hello', resumeCommand: null },
diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts
index 4006332..4c531c3 100644
--- a/lib/src/lib/session-save.test.ts
+++ b/lib/src/lib/session-save.test.ts
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlatformAdapter } from './platform/types';
import type { PersistedSession } from './session-types';
-import { TODO_HARD } from './alert-manager';
const terminalRegistryMocks = vi.hoisted(() => ({
getLivePersistedAlertState: vi.fn(),
@@ -53,7 +52,6 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter {
alertToggleTodo: () => {},
alertMarkTodo: () => {},
alertClearTodo: () => {},
- alertDrainTodoBucket: () => {},
onAlertState: () => {},
offAlertState: () => {},
saveState: vi.fn((state: unknown) => {
@@ -72,23 +70,23 @@ describe('saveSession', () => {
it('persists the live alert state even when the previous snapshot was empty', async () => {
const platform = createPlatform({
- version: 2,
+ version: 3,
layout: null,
panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null, alert: null }],
});
- terminalRegistryMocks.getLivePersistedAlertState.mockReturnValue({ status: 'NOTHING_TO_SHOW', todo: TODO_HARD });
+ terminalRegistryMocks.getLivePersistedAlertState.mockReturnValue({ status: 'NOTHING_TO_SHOW', todo: true });
await saveSession(platform, { root: true }, [{ id: 'pane-a', title: 'Pane A' }]);
expect(platform.saveState).toHaveBeenCalledWith({
- version: 2,
+ version: 3,
layout: { root: true },
doors: [],
panes: [
expect.objectContaining({
id: 'pane-a',
- alert: { status: 'NOTHING_TO_SHOW', todo: TODO_HARD },
+ alert: { status: 'NOTHING_TO_SHOW', todo: true },
}),
],
});
@@ -103,7 +101,7 @@ describe('saveSession', () => {
expect(platform.getScrollback).toHaveBeenCalledWith('pane-b');
expect(platform.getCwd).toHaveBeenCalledWith('pane-b');
expect(platform.saveState).toHaveBeenCalledWith({
- version: 2,
+ version: 3,
layout: { root: true },
doors: [],
panes: [
diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts
index d7d8f51..c1ed2db 100644
--- a/lib/src/lib/session-save.ts
+++ b/lib/src/lib/session-save.ts
@@ -46,6 +46,6 @@ export async function saveSession(
};
}),
);
- const session: PersistedSession = { version: 2, panes: persisted, doors, layout };
+ const session: PersistedSession = { version: 3, panes: persisted, doors, layout };
platform.saveState(session);
}
diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts
index 1def595..cbaecce 100644
--- a/lib/src/lib/session-types.ts
+++ b/lib/src/lib/session-types.ts
@@ -1,6 +1,6 @@
import type { DoorDirection } from './spatial-nav';
import type { SessionStatus } from './activity-monitor';
-import type { TodoState } from './alert-manager';
+import { migrateTodoState, type TodoState } from './alert-manager';
export interface PersistedAlertState {
status: SessionStatus;
@@ -27,12 +27,35 @@ export interface PersistedDoor {
}
export interface PersistedSession {
- version: 2;
+ version: 3;
panes: PersistedPane[];
doors?: PersistedDoor[];
layout: unknown; // SerializedDockview — kept as `unknown` to avoid dockview dep in types
}
+// --- Legacy v2 shapes (read-only, for migration) ---
+
+export interface PersistedAlertStateV2 {
+ status: SessionStatus;
+ todo: unknown; // numeric encoding: -1=off, [0,1]=soft, 2=hard
+}
+
+export interface PersistedPaneV2 {
+ id: string;
+ cwd: string | null;
+ title: string;
+ scrollback: string | null;
+ resumeCommand: string | null;
+ alert?: PersistedAlertStateV2 | null;
+}
+
+export interface PersistedSessionV2 {
+ version: 2;
+ panes: PersistedPaneV2[];
+ doors?: PersistedDoor[];
+ layout: unknown;
+}
+
// --- Legacy v1 shapes (read-only, for migration) ---
export interface PersistedDoorV1 {
@@ -47,22 +70,26 @@ export interface PersistedDoorV1 {
export interface PersistedSessionV1 {
version: 1;
- panes: PersistedPane[];
+ panes: PersistedPaneV2[];
detached?: PersistedDoorV1[];
layout: unknown;
}
+// --- Validation guards (reject untrusted blobs) ---
+
function isRecord(value: unknown): value is Record {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
-function isPersistedAlertState(value: unknown): value is PersistedAlertState {
+function isPersistedAlertShape(value: unknown): boolean {
if (value === null) return true;
if (!isRecord(value)) return false;
- return typeof value.status === 'string' && (typeof value.todo === 'number' || typeof value.todo === 'boolean');
+ if (typeof value.status !== 'string') return false;
+ const t = value.todo;
+ return typeof t === 'boolean' || typeof t === 'number' || typeof t === 'string';
}
-function isPersistedPane(value: unknown): value is PersistedPane {
+function isPersistedPaneShape(value: unknown): boolean {
if (!isRecord(value)) return false;
return (
typeof value.id === 'string' &&
@@ -70,7 +97,7 @@ function isPersistedPane(value: unknown): value is PersistedPane {
(typeof value.cwd === 'string' || value.cwd === null) &&
(typeof value.scrollback === 'string' || value.scrollback === null) &&
(typeof value.resumeCommand === 'string' || value.resumeCommand === null) &&
- (value.alert === undefined || isPersistedAlertState(value.alert))
+ (value.alert === undefined || isPersistedAlertShape(value.alert))
);
}
@@ -104,23 +131,35 @@ function isPersistedSessionV1(value: unknown): value is PersistedSessionV1 {
if (!isRecord(value) || value.version !== 1) return false;
return (
Array.isArray(value.panes) &&
- value.panes.every(isPersistedPane) &&
+ value.panes.every(isPersistedPaneShape) &&
(value.detached === undefined || (Array.isArray(value.detached) && value.detached.every(isPersistedDoorV1))) &&
'layout' in value
);
}
-function isPersistedSessionV2(value: unknown): value is PersistedSession {
+function isPersistedSessionV2(value: unknown): value is PersistedSessionV2 {
if (!isRecord(value) || value.version !== 2) return false;
return (
Array.isArray(value.panes) &&
- value.panes.every(isPersistedPane) &&
+ value.panes.every(isPersistedPaneShape) &&
+ (value.doors === undefined || (Array.isArray(value.doors) && value.doors.every(isPersistedDoor))) &&
+ 'layout' in value
+ );
+}
+
+function isPersistedSessionV3(value: unknown): value is PersistedSession {
+ if (!isRecord(value) || value.version !== 3) return false;
+ return (
+ Array.isArray(value.panes) &&
+ value.panes.every(isPersistedPaneShape) &&
(value.doors === undefined || (Array.isArray(value.doors) && value.doors.every(isPersistedDoor))) &&
'layout' in value
);
}
-export function migrateSessionV1toV2(v1: PersistedSessionV1): PersistedSession {
+// --- Migrations ---
+
+export function migrateSessionV1toV2(v1: PersistedSessionV1): PersistedSessionV2 {
return {
version: 2,
panes: v1.panes,
@@ -137,9 +176,24 @@ export function migrateSessionV1toV2(v1: PersistedSessionV1): PersistedSession {
};
}
+export function migrateSessionV2toV3(v2: PersistedSessionV2): PersistedSession {
+ return {
+ version: 3,
+ layout: v2.layout,
+ doors: v2.doors,
+ panes: v2.panes.map((pane) => ({
+ ...pane,
+ alert: pane.alert
+ ? { status: pane.alert.status, todo: migrateTodoState(pane.alert.todo) }
+ : pane.alert,
+ })),
+ };
+}
+
export function readPersistedSession(raw: unknown): PersistedSession | null {
if (!isRecord(raw)) return null;
- if (isPersistedSessionV2(raw)) return raw;
- if (isPersistedSessionV1(raw)) return migrateSessionV1toV2(raw);
+ if (isPersistedSessionV3(raw)) return raw;
+ if (isPersistedSessionV2(raw)) return migrateSessionV2toV3(raw);
+ if (isPersistedSessionV1(raw)) return migrateSessionV2toV3(migrateSessionV1toV2(raw));
return null;
}
diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts
index a591a78..8dd5125 100644
--- a/lib/src/lib/terminal-registry.alert.test.ts
+++ b/lib/src/lib/terminal-registry.alert.test.ts
@@ -86,9 +86,6 @@ vi.mock('./platform', async () => {
import * as platformModule from './platform';
import { makeAlertScenario, type FakePtyAdapter, type FakeScenario } from './platform';
-import { cfg } from '../cfg';
-
-const STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000;
import {
DEFAULT_ACTIVITY_STATE,
mountElement,
@@ -109,10 +106,6 @@ import {
swapTerminals,
toggleSessionAlert,
toggleSessionTodo,
- TODO_OFF,
- TODO_SOFT_FULL,
- TODO_HARD,
- isSoftTodo,
} from './terminal-registry';
interface MockTerminalInstance {
@@ -266,8 +259,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
-
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -285,10 +277,7 @@ describe('terminal-registry alert behavior', () => {
attendSession(id);
advance(1_800);
- expect(getActivity(id)).toMatchObject({
- status: 'BUSY',
-
- });
+ expect(getActivity(id)).toMatchObject({ status: 'BUSY' });
expireAttention(id);
advance(2_000);
@@ -297,8 +286,7 @@ describe('terminal-registry alert behavior', () => {
advance(3_000);
expect(getActivity(id)).toMatchObject({
status: 'ALERT_RINGING',
-
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -313,10 +301,7 @@ describe('terminal-registry alert behavior', () => {
emitOutput(id, 'still running');
- expect(getActivity(id)).toMatchObject({
- status: 'BUSY',
-
- });
+ expect(getActivity(id)).toMatchObject({ status: 'BUSY' });
});
it('Story 4: completion while still attended does not ring', () => {
@@ -331,12 +316,11 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
-
- todo: TODO_OFF,
+ todo: false,
});
});
- it('Story 5: user attends to a ringing pane — creates soft TODO', () => {
+ it('Story 5: user attends to a ringing pane — turns TODO on', () => {
const id = 'story-5';
createSession(id);
toggleSessionAlert(id);
@@ -346,11 +330,11 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
});
- it('Story 6: dismiss resets to NOTHING_TO_SHOW, can ring again later', () => {
+ it('Story 6: dismiss resets to NOTHING_TO_SHOW and turns TODO on; can ring again later', () => {
const id = 'story-6';
createSession(id);
toggleSessionAlert(id);
@@ -360,10 +344,9 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toMatchObject({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
- // New output starts a fresh cycle that can ring again
driveToBusy(id);
expireAttention(id);
advance(2_000);
@@ -371,11 +354,11 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toMatchObject({
status: 'ALERT_RINGING',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
});
- it('Story 7: TODO clears ring and resets status, leaves alerts enabled', () => {
+ it('Story 7: marking TODO clears ring and resets status, leaves alerts enabled', () => {
const id = 'story-7';
createSession(id);
toggleSessionAlert(id);
@@ -385,7 +368,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_HARD,
+ todo: true,
});
});
@@ -399,17 +382,16 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
});
- // No monitor means output doesn't drive state changes
emitOutput(id, 'new cycle');
emitOutput(id, 'more work');
advance(12_000);
expect(getActivity(id)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -419,15 +401,12 @@ describe('terminal-registry alert behavior', () => {
toggleSessionAlert(id);
driveToRingingNeedsAttention(id);
- // Shell prompt output should NOT silently dismiss the alert
emitOutput(id, 'shell prompt');
expect(getActivity(id).status).toBe('ALERT_RINGING');
- // User attends (focuses the pane) — this resets the monitor via attend()
attendSession(id);
expect(getActivity(id).status).toBe('NOTHING_TO_SHOW');
- // Now new output starts a fresh cycle
emitOutput(id, 'next task');
advance(1_600);
emitOutput(id, 'still going');
@@ -444,16 +423,13 @@ describe('terminal-registry alert behavior', () => {
minimizeSession(id);
driveToRingingNeedsAttention(id);
- expect(getActivity(id)).toMatchObject({
- status: 'ALERT_RINGING',
-
- });
+ expect(getActivity(id)).toMatchObject({ status: 'ALERT_RINGING' });
reattachDoorViaEnter(id);
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
});
@@ -469,8 +445,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'ALERT_RINGING',
-
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -485,8 +460,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
-
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -506,11 +480,11 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(alpha)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
expect(getActivity(beta)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
});
@@ -523,7 +497,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_HARD,
+ todo: true,
});
disposeSession(id);
@@ -538,8 +512,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'ALERT_RINGING',
-
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -551,10 +524,24 @@ describe('terminal-registry alert behavior', () => {
driveToRingingNeedsAttention(id);
entry.terminal.emitInput('x');
- // Typing while ringing: attend creates a fresh soft TODO, then the keypress strikes one letter
+ // Typing while ringing: attend clears ring, turns TODO on.
+ // Plain 'x' is not Enter, so TODO stays on.
expect(getActivity(id).status).toBe('NOTHING_TO_SHOW');
- expect(isSoftTodo(getActivity(id).todo)).toBe(true);
- expect(getActivity(id).todo).toBeCloseTo(0.75);
+ expect(getActivity(id).todo).toBe(true);
+ });
+
+ it('Enter that dismisses a ringing alert leaves the auto-created TODO visible', () => {
+ const id = 'enter-dismisses-ringing';
+ const entry = createSession(id);
+ toggleSessionAlert(id);
+
+ driveToRingingNeedsAttention(id);
+ entry.terminal.emitInput('\r');
+
+ expect(getActivity(id)).toEqual({
+ status: 'NOTHING_TO_SHOW',
+ todo: true,
+ });
});
it('no monitor is created until alert is enabled', () => {
@@ -569,8 +556,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'ALERT_DISABLED',
-
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -578,16 +564,13 @@ describe('terminal-registry alert behavior', () => {
const id = 'fresh-start';
createSession(id);
- // Output before alert is enabled — ignored
emitOutput(id, 'old output');
advance(5_000);
toggleSessionAlert(id);
- // Status starts at NOTHING_TO_SHOW, not retroactively computed
expect(getActivity(id).status).toBe('NOTHING_TO_SHOW');
- // New output after enabling drives state normally
emitOutput(id, 'prompt> ');
advance(1_600);
emitOutput(id, 'working...');
@@ -595,149 +578,75 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id).status).toBe('BUSY');
});
- it('phantom dismiss creates soft TODO, typing 4 chars clears it', () => {
- const id = 'soft-todo-clear';
+ it('Enter (\\r) in passthrough clears an on-TODO', () => {
+ const id = 'enter-clears-todo';
const entry = createSession(id);
toggleSessionAlert(id);
driveToRingingNeedsAttention(id);
attendSession(id);
+ expect(getActivity(id).todo).toBe(true);
- expect(getActivity(id).todo).toBe(TODO_SOFT_FULL);
-
- // 3 keypresses strike 3 letters but don't clear
- for (let i = 0; i < 3; i++) {
- entry.terminal.emitInput('a');
- }
- expect(isSoftTodo(getActivity(id).todo)).toBe(true);
-
- // 4th keypress clears it
- entry.terminal.emitInput('a');
- expect(getActivity(id).todo).toBe(TODO_OFF);
+ entry.terminal.emitInput('\r');
+ expect(getActivity(id).todo).toBe(false);
});
- it('soft TODO recovers after idle and requires fresh keypresses', () => {
- const id = 'soft-todo-refill';
+ it('printable input without Enter does not clear a TODO', () => {
+ const id = 'printable-keeps-todo';
const entry = createSession(id);
toggleSessionAlert(id);
driveToRingingNeedsAttention(id);
attendSession(id);
+ expect(getActivity(id).todo).toBe(true);
- expect(getActivity(id).todo).toBe(TODO_SOFT_FULL);
-
- // 2 keypresses strike 2 letters
- entry.terminal.emitInput('a');
- entry.terminal.emitInput('a');
- expect(getActivity(id).todo).toBeCloseTo(0.5);
-
- // 2 recovery intervals restore both letters
- vi.advanceTimersByTime(2 * STRIKE_RECOVERY_MS);
- expect(getActivity(id).todo).toBe(TODO_SOFT_FULL);
-
- // Need 4 fresh keypresses to clear again
- for (let i = 0; i < 3; i++) {
- entry.terminal.emitInput('a');
- }
- expect(isSoftTodo(getActivity(id).todo)).toBe(true);
-
- entry.terminal.emitInput('a');
- expect(getActivity(id).todo).toBe(TODO_OFF);
+ entry.terminal.emitInput('hello');
+ expect(getActivity(id).todo).toBe(true);
});
- it('focus-report control sequences do not clear a soft TODO', () => {
- const id = 'soft-todo-focus-report';
+ it('focus-report control sequences do not clear a TODO', () => {
+ const id = 'todo-focus-report';
const entry = createSession(id);
toggleSessionAlert(id);
driveToRingingNeedsAttention(id);
attendSession(id);
-
- expect(getActivity(id).todo).toBe(TODO_SOFT_FULL);
+ expect(getActivity(id).todo).toBe(true);
entry.terminal.emitInput('\x1b[I');
- expect(getActivity(id).todo).toBe(TODO_SOFT_FULL);
- });
-
- it('typing does not clear a hard TODO', () => {
- const id = 'hard-todo-persist';
- const entry = createSession(id);
- toggleSessionAlert(id);
-
- driveToRingingNeedsAttention(id);
- toggleSessionTodo(id); // ringing → hard TODO + attend
-
- expect(getActivity(id).todo).toBe(TODO_HARD);
-
- entry.terminal.emitInput('ls');
-
- expect(getActivity(id).todo).toBe(TODO_HARD);
- });
-
- it('toggleSessionTodo promotes soft to hard', () => {
- const id = 'promote-soft';
- createSession(id);
- toggleSessionAlert(id);
-
- driveToRingingNeedsAttention(id);
- attendSession(id);
-
- expect(getActivity(id).todo).toBe(TODO_SOFT_FULL);
-
- toggleSessionTodo(id);
-
- expect(getActivity(id).todo).toBe(TODO_HARD);
+ expect(getActivity(id).todo).toBe(true);
});
- it('toggleSessionTodo cycles: false → hard → false', () => {
+ it('toggleSessionTodo cycles: false → true → false', () => {
const id = 'toggle-cycle';
createSession(id);
- expect(getActivity(id).todo).toBe(TODO_OFF);
+ expect(getActivity(id).todo).toBe(false);
toggleSessionTodo(id);
- expect(getActivity(id).todo).toBe(TODO_HARD);
+ expect(getActivity(id).todo).toBe(true);
toggleSessionTodo(id);
- expect(getActivity(id).todo).toBe(TODO_OFF);
- });
-
- it('dismiss does not downgrade hard TODO to soft', () => {
- const id = 'hard-survives-dismiss';
- createSession(id);
- toggleSessionAlert(id);
- toggleSessionTodo(id); // set hard TODO before ringing
-
- driveToBusy(id);
- expireAttention(id);
- advance(2_000);
- advance(3_000);
- expect(getActivity(id).status).toBe('ALERT_RINGING');
-
- dismissSessionAlert(id);
-
- // Hard TODO should survive — soft TODO only set when todo === false
- expect(getActivity(id).todo).toBe(TODO_HARD);
+ expect(getActivity(id).todo).toBe(false);
});
- it('new output while ringing without attention does not create a soft TODO', () => {
- const id = 'ringing-output-no-soft-todo';
+ it('new output while ringing without attention does not turn TODO on', () => {
+ const id = 'ringing-output-no-todo';
createSession(id);
toggleSessionAlert(id);
driveToRingingNeedsAttention(id);
- // New output without attention — alert latches, no soft TODO created
emitOutput(id, 'next task');
expect(getActivity(id)).toEqual({
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
});
});
- it('disabling alerts while ringing does not create a soft TODO', () => {
- const id = 'disable-no-soft-todo';
+ it('disabling alerts while ringing does not turn TODO on', () => {
+ const id = 'disable-no-todo';
createSession(id);
toggleSessionAlert(id);
@@ -746,7 +655,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -758,7 +667,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -772,11 +681,11 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
});
});
- it('alert button dismisses ringing alerts to soft TODO', () => {
+ it('alert button dismisses ringing alerts and turns TODO on', () => {
const id = 'alert-button-dismiss';
createSession(id);
toggleSessionAlert(id);
@@ -786,7 +695,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
});
@@ -800,14 +709,14 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
dismissOrToggleAlert(id, 'ALERT_RINGING');
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
});
@@ -821,13 +730,13 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
expect(dismissOrToggleAlert(id, 'NOTHING_TO_SHOW')).toBe('dismissed');
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
});
});
@@ -841,7 +750,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -858,7 +767,7 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(id)).toEqual({
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -879,11 +788,11 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(alpha)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
});
expect(getActivity(beta)).toEqual({
status: 'BUSY',
- todo: TODO_OFF,
+ todo: false,
});
});
@@ -898,22 +807,22 @@ describe('terminal-registry alert behavior', () => {
expect(getActivity(alpha)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
});
expect(getActivity(beta)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_HARD,
+ todo: true,
});
clearSessionTodo(beta);
expect(getActivity(alpha)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
});
expect(getActivity(beta)).toEqual({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
});
});
});
diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts
index dc30fad..e67b94c 100644
--- a/lib/src/lib/terminal-registry.ts
+++ b/lib/src/lib/terminal-registry.ts
@@ -2,7 +2,7 @@ import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { getPlatform } from './platform';
import type { SessionStatus } from './activity-monitor';
-import { TODO_OFF, isSoftTodo, type TodoState, type AlertButtonActionResult } from './alert-manager';
+import type { TodoState, AlertButtonActionResult } from './alert-manager';
import type { AlertStateDetail } from './platform/types';
import type { PersistedAlertState } from './session-types';
import { attachMouseModeObserver } from './mouse-mode-observer';
@@ -23,7 +23,7 @@ import { detectTokenAt } from './smart-token';
import { extractSelectionText } from './selection-text';
export type { SessionStatus } from './activity-monitor';
-export { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo, isHardTodo, hasTodo, type TodoState, type AlertButtonActionResult } from './alert-manager';
+export { type TodoState, type AlertButtonActionResult } from './alert-manager';
export interface ActivityState {
status: SessionStatus;
@@ -32,7 +32,7 @@ export interface ActivityState {
export const DEFAULT_ACTIVITY_STATE: ActivityState = {
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
};
interface TerminalEntry {
@@ -295,16 +295,9 @@ function getTerminalTheme(): Record {
// --- Input analysis ---
-function inputContainsPrintableText(data: string): boolean {
- const withoutAnsiSequences = data
- // CSI sequences, including focus/mouse reporting like ESC [ I and ESC [ < ... M
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
- // SS3 sequences, used by some function/navigation keys
- .replace(/\x1bO[@-~]/g, '')
- // Strip remaining control chars, including bare ESC
- .replace(/[\x00-\x1f\x7f]/g, '');
-
- return withoutAnsiSequences.length > 0;
+function inputContainsEnter(data: string): boolean {
+ // xterm.js sends CR (\r) for the Enter key.
+ return data.includes('\r');
}
function inputIsSyntheticTerminalReport(data: string): boolean {
@@ -365,10 +358,11 @@ function setupTerminalEntry(id: string): TerminalEntry {
const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(data);
if (!isSyntheticTerminalReport) {
- getPlatform().alertAttend(id);
const entry = registry.get(id);
- if (entry && isSoftTodo(entry.todo) && inputContainsPrintableText(data)) {
- getPlatform().alertDrainTodoBucket(id);
+ const hadTodo = entry?.todo === true;
+ getPlatform().alertAttend(id);
+ if (hadTodo && inputContainsEnter(data)) {
+ getPlatform().alertClearTodo(id);
}
}
@@ -560,7 +554,7 @@ function setupTerminalEntry(id: string): TerminalEntry {
element,
cleanup,
alertStatus: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
attentionDismissedRing: false,
};
diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx
index cade52a..7cb9fdc 100644
--- a/lib/src/stories/Baseboard.stories.tsx
+++ b/lib/src/stories/Baseboard.stories.tsx
@@ -1,8 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Baseboard } from '../components/Baseboard';
import type { DooredItem } from '../components/Pond';
-import { TODO_OFF, TODO_HARD } from '../lib/terminal-registry';
-
const makeItem = (id: string, title: string): DooredItem => ({
id,
title,
@@ -49,7 +47,7 @@ export const OneRingingDoor: Story = {
p1: {
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
},
}),
};
@@ -68,22 +66,22 @@ export const MixedDoorStates: Story = {
p1: {
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
},
p2: {
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
},
p3: {
status: 'ALERT_DISABLED',
- todo: TODO_HARD,
+ todo: true,
},
p4: {
status: 'ALERT_RINGING',
- todo: TODO_HARD,
+ todo: true,
},
}),
};
@@ -105,17 +103,17 @@ export const OverflowWithRingingDoor: Story = {
p2: {
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
},
p5: {
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
},
p7: {
status: 'ALERT_DISABLED',
- todo: TODO_HARD,
+ todo: true,
},
}),
decorators: [
@@ -139,7 +137,7 @@ export const ExtremeTitleWithBothIndicators: Story = {
p2: {
status: 'ALERT_RINGING',
- todo: TODO_HARD,
+ todo: true,
},
}),
decorators: [
diff --git a/lib/src/stories/Door.stories.tsx b/lib/src/stories/Door.stories.tsx
index f131aba..5e8203f 100644
--- a/lib/src/stories/Door.stories.tsx
+++ b/lib/src/stories/Door.stories.tsx
@@ -1,6 +1,5 @@
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,
@@ -29,7 +28,7 @@ const meta: Meta = {
title: 'build-server',
isActive: false,
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
width: 260,
reducedMotion: false,
},
@@ -37,7 +36,7 @@ const meta: Meta = {
title: { control: 'text' },
isActive: { control: 'boolean' },
status: { control: 'radio', options: ['ALERT_DISABLED', 'NOTHING_TO_SHOW', 'MIGHT_BE_BUSY', 'BUSY', 'MIGHT_NEED_ATTENTION', 'ALERT_RINGING'] },
- todo: { control: 'number' },
+ todo: { control: 'boolean' },
width: { control: 'number' },
reducedMotion: { control: 'boolean' },
},
@@ -52,13 +51,13 @@ export const AlertMightBeBusy: Story = { args: { status: 'MIGHT_BE_BUSY' } };
export const AlertBusy: Story = { args: { status: 'BUSY' } };
export const AlertMightNeedAttention: Story = { args: { status: 'MIGHT_NEED_ATTENTION' } };
export const AlertRinging: Story = { args: { status: 'ALERT_RINGING' } };
-export const TodoOnly: Story = { args: { todo: TODO_HARD } };
-export const TodoAndAlertEnabled: Story = { args: { todo: TODO_HARD, status: 'NOTHING_TO_SHOW' } };
-export const TodoAndAlertRinging: Story = { args: { todo: TODO_HARD, status: 'ALERT_RINGING' } };
+export const TodoOnly: Story = { args: { todo: true } };
+export const TodoAndAlertEnabled: Story = { args: { todo: true, status: 'NOTHING_TO_SHOW' } };
+export const TodoAndAlertRinging: Story = { args: { todo: true, status: 'ALERT_RINGING' } };
export const LongTitleWithIndicators: Story = {
args: {
title: 'my-extremely-long-running-background-process-with-a-very-descriptive-name',
- todo: TODO_HARD,
+ todo: true,
status: 'NOTHING_TO_SHOW',
},
};
diff --git a/lib/src/stories/KillModal.stories.tsx b/lib/src/stories/KillModal.stories.tsx
index a26ff7f..5486619 100644
--- a/lib/src/stories/KillModal.stories.tsx
+++ b/lib/src/stories/KillModal.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
-import { KillConfirmCard } from '../components/Pond';
+import { KillConfirmCard } from '../components/KillConfirm';
function KillModal({ char = 'G', onCancel, shaking }: { char?: string; onCancel?: () => void; shaking?: boolean }) {
return (
diff --git a/lib/src/stories/Pond.stories.tsx b/lib/src/stories/Pond.stories.tsx
index edae0ea..04ea943 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 { getActivitySnapshot, primeActivity, type ActivityState, TODO_OFF, TODO_HARD } from '../lib/terminal-registry';
+import { getActivitySnapshot, primeActivity, type ActivityState } from '../lib/terminal-registry';
const meta: Meta = {
title: 'App/Pond',
@@ -109,7 +109,7 @@ export const AlertEnabledIdlePane: Story = {
{
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
},
],
},
@@ -124,7 +124,7 @@ export const AlertRingingPane: Story = {
{
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
},
],
},
@@ -139,7 +139,7 @@ export const AlertRingingDoor: Story = {
{
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
},
]);
await wait(100);
@@ -154,7 +154,7 @@ export const AlertModalOpen: Story = {
{
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
},
],
},
@@ -170,7 +170,7 @@ export const TodoAfterDismiss: Story = {
{
status: 'ALERT_RINGING',
- todo: TODO_HARD,
+ todo: true,
},
],
},
@@ -185,12 +185,12 @@ export const MinimizedRingingSession: Story = {
{
status: 'ALERT_RINGING',
- todo: TODO_HARD,
+ todo: true,
},
{
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
},
]);
await wait(100);
@@ -205,17 +205,17 @@ export const MultipleRingingSessions: Story = {
{
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
},
{
status: 'ALERT_RINGING',
- todo: TODO_HARD,
+ todo: true,
},
{
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
},
]);
await wait(100);
diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx
index 6141641..c80bc40 100644
--- a/lib/src/stories/TerminalPaneHeader.stories.tsx
+++ b/lib/src/stories/TerminalPaneHeader.stories.tsx
@@ -8,7 +8,6 @@ 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';
@@ -93,7 +92,7 @@ async function openAlertRightClickDialog() {
await wait(100);
}
-async function clickSoftTodo() {
+async function clickTodoPill() {
await wait(100);
const todoButton = document.querySelector(`[data-session-todo-for="${SESSION_ID}"]`);
todoButton?.click();
@@ -128,7 +127,7 @@ export const AlertDisabled: Story = {
parameters: primedState({
status: 'ALERT_DISABLED',
- todo: TODO_OFF,
+ todo: false,
}),
};
@@ -136,7 +135,7 @@ export const AlertEnabled: Story = {
parameters: primedState({
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
}),
};
@@ -144,7 +143,7 @@ export const AlertMightBeBusy: Story = {
parameters: primedState({
status: 'MIGHT_BE_BUSY',
- todo: TODO_OFF,
+ todo: false,
}),
};
@@ -152,7 +151,7 @@ export const AlertBusy: Story = {
parameters: primedState({
status: 'BUSY',
- todo: TODO_OFF,
+ todo: false,
}),
};
@@ -160,7 +159,7 @@ export const AlertMightNeedAttention: Story = {
parameters: primedState({
status: 'MIGHT_NEED_ATTENTION',
- todo: TODO_OFF,
+ todo: false,
}),
};
@@ -168,37 +167,30 @@ export const AlertRinging: Story = {
parameters: primedState({
status: 'ALERT_RINGING',
- todo: TODO_OFF,
- }),
-};
-
-export const SoftTodo: Story = {
- parameters: primedState({
- status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: false,
}),
};
export const AlertRightClickDialog: Story = {
parameters: primedState({
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
}),
play: openAlertRightClickDialog,
};
-export const SoftTodoPrompt: Story = {
+export const TodoClickToDismiss: Story = {
parameters: primedState({
status: 'NOTHING_TO_SHOW',
- todo: TODO_SOFT_FULL,
+ todo: true,
}),
- play: clickSoftTodo,
+ play: clickTodoPill,
};
export const TodoOnly: Story = {
parameters: primedState({
status: 'ALERT_DISABLED',
- todo: TODO_HARD,
+ todo: true,
}),
};
@@ -206,7 +198,7 @@ export const TodoAndAlertEnabled: Story = {
parameters: primedState({
status: 'NOTHING_TO_SHOW',
- todo: TODO_HARD,
+ todo: true,
}),
};
@@ -214,7 +206,7 @@ export const TodoAndAlertRinging: Story = {
parameters: primedState({
status: 'ALERT_RINGING',
- todo: TODO_HARD,
+ todo: true,
}),
};
@@ -225,7 +217,7 @@ export const CompactWidthWithAlert: Story = {
parameters: primedState({
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
}),
};
@@ -236,7 +228,7 @@ export const MinimalWidthWithAlert: Story = {
parameters: primedState({
status: 'NOTHING_TO_SHOW',
- todo: TODO_OFF,
+ todo: false,
}),
};
@@ -248,7 +240,7 @@ export const LongTitleWithAlertAndTodo: Story = {
parameters: primedState({
status: 'ALERT_RINGING',
- todo: TODO_HARD,
+ todo: true,
}),
};
@@ -259,6 +251,6 @@ export const ReducedMotionRinging: Story = {
parameters: primedState({
status: 'ALERT_RINGING',
- todo: TODO_OFF,
+ todo: false,
}),
};
diff --git a/lib/src/stories/TodoBucket.stories.tsx b/lib/src/stories/TodoBucket.stories.tsx
deleted file mode 100644
index 994518c..0000000
--- a/lib/src/stories/TodoBucket.stories.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useCallback, useEffect, useRef, useState } 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 STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000;
-const STRIKE_STEP = 0.25;
-
-/**
- * Interactive story to test the soft-TODO strike feel.
- * Type in the input to strike one letter per printable keypress.
- * Stop typing and one letter recovers every `recoverySecondsPerLetter` seconds.
- */
-function TodoBucketDemo({ width = 300 }: { width?: number }) {
- const [todo, setTodo] = useState(TODO_SOFT_FULL);
- const recoveryTimerRef = useRef | null>(null);
-
- const clearRecoveryTimer = useCallback(() => {
- if (recoveryTimerRef.current !== null) {
- clearTimeout(recoveryTimerRef.current);
- recoveryTimerRef.current = null;
- }
- }, []);
-
- const scheduleRecoveryTick = useCallback(() => {
- clearRecoveryTimer();
- const tick = () => {
- recoveryTimerRef.current = null;
- setTodo((prev) => {
- if (!isSoftTodo(prev)) return prev;
- const next = Math.min(TODO_SOFT_FULL, prev + STRIKE_STEP);
- if (next < TODO_SOFT_FULL) {
- recoveryTimerRef.current = setTimeout(tick, STRIKE_RECOVERY_MS);
- }
- return next;
- });
- };
- recoveryTimerRef.current = setTimeout(tick, STRIKE_RECOVERY_MS);
- }, [clearRecoveryTimer]);
-
- const strike = useCallback(() => {
- setTodo((prev) => {
- if (!isSoftTodo(prev)) return prev;
- const next = prev - STRIKE_STEP;
- if (next < 1e-9) {
- clearRecoveryTimer();
- return TODO_OFF;
- }
- scheduleRecoveryTick();
- return next;
- });
- }, [clearRecoveryTimer, scheduleRecoveryTick]);
-
- useEffect(() => clearRecoveryTimer, [clearRecoveryTimer]);
-
- const reset = useCallback(() => {
- clearRecoveryTimer();
- setTodo(TODO_SOFT_FULL);
- }, [clearRecoveryTimer]);
-
- const strikes = isSoftTodo(todo) ? Math.round((1 - todo) * 4) : 0;
- const label = todo === TODO_OFF
- ? 'OFF'
- : todo === TODO_HARD
- ? 'HARD'
- : `SOFT (${strikes}/4 strikes)`;
-
- return (
-
-
- Type in the box below — each printable keypress strikes one letter of TODO.
- Stop typing and one letter recovers every {cfg.todoBucket.recoverySecondsPerLetter}s.
- 4 strikes clears the TODO (watch for the ✓ flourish).
-