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 ( +
+ +
+ ); + } + + // Fallback: centered in viewport + 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). +export 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); +} diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index e3ff624..9657a87 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -14,7 +14,10 @@ import { createPortal } from 'react-dom'; import { TerminalPane } from './TerminalPane'; import { Baseboard } from './Baseboard'; import { tv } from 'tailwind-variants'; -import { PopupButtonRow, popupButton, renderShortcuts } from './design'; +import { PopupButtonRow, popupButton } from './design'; +import { HeaderActionButton } from './HeaderActionButton'; +import { TodoAlertDialog } from './TodoAlertDialog'; +import { KillConfirmOverlay, orchestrateKill, randomKillChar, type ConfirmKill } from './KillConfirm'; import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon, CursorClickIcon, SelectionSlashIcon } from '@phosphor-icons/react'; import { DEFAULT_MOUSE_SELECTION_STATE, @@ -33,24 +36,17 @@ import { clearSessionAttention, clearSessionTodo, DEFAULT_ACTIVITY_STATE, - disableSessionAlert, dismissOrToggleAlert, focusSession, getActivity, getActivitySnapshot, markSessionAttention, - markSessionTodo, subscribeToActivity, - toggleSessionAlert, toggleSessionTodo, - disposeSession, swapTerminals, setPendingShellOpts, getDefaultShellOpts, type SessionStatus, - isSoftTodo, - isHardTodo, - TODO_OFF, } from '../lib/terminal-registry'; import { resolvePanelElement, findPanelInDirection, findReattachNeighbor } from '../lib/spatial-nav'; import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot'; @@ -71,20 +67,12 @@ const mousetermTheme: DockviewTheme = { dndPanelOverlay: 'group', }; -let dialogKeyboardActive = false; - // --- Types --- export type DooredItem = Omit & { layoutAtMinimize: SerializedDockview | null; }; -interface ConfirmKill { - id: string; - char: string; - shaking?: boolean; -} - export type PondMode = 'command' | 'passthrough'; export type PondSelectionKind = 'pane' | 'door'; @@ -108,110 +96,6 @@ const tabVariant = tv({ }, }); -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; -} - -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, - )} - - ); -} - // --- Alert context menu (right-click on bell) --- /** @@ -296,164 +180,9 @@ function clampOverlayPosition({ left, top, width, height }: { }; } -/** - * 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, - restoreFocusSelector?: string, -) { - 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); - if (restoreFocusSelector) { - document.querySelector(restoreFocusSelector)?.focus(); - } - }; - }, [ref, onClose, restoreFocusSelector]); -} - -function TodoAlertDialog({ - position, - sessionId, - onClose, -}: { - position: { x: number; y: number }; - sessionId: string; - onClose: () => void; -}) { - const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); - const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE; - const alertEnabled = activity.status !== 'ALERT_DISABLED'; - const dialogRef = useRef(null); - - usePopoverFocusTrap(dialogRef, onClose, `[data-alert-button-for="${sessionId}"]`); - - useEffect(() => { - dialogRef.current?.querySelector('button')?.focus(); - }, []); - - // Keyboard shortcuts within dialog - useEffect(() => { - const el = dialogRef.current; - if (!el) return; - dialogKeyboardActive = 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 () => { - dialogKeyboardActive = false; - window.removeEventListener('keydown', handler, true); - }; - }, [sessionId]); - - const toggleBtn = (active: boolean) => [ - 'rounded px-2 py-1 text-[11px] font-medium transition-colors', - active - ? 'bg-accent/20 text-accent border border-accent/40' - : 'text-muted border border-border hover:bg-foreground/10 hover:text-foreground', - ].join(' '); - - return createPortal( -
- {/* TODO row */} -
- [t] - TODO -
- - -
-
- - {/* Alert row */} -
- [a] - alert -
- - -
-
- - {/* Help text */} -
- When an alerting tab is selected,
- the alert is cleared and the tab gets a soft TODO.
- Typing drains the soft TODO; stop typing and it refills. -
-
, - document.body, - ); +function findAlertButtonForSession(id: string): HTMLButtonElement | null { + return Array.from(document.querySelectorAll('[data-alert-button-for]')) + .find((button) => button.dataset.alertButtonFor === id) ?? null; } // --- Contexts --- @@ -513,6 +242,10 @@ export const RenamingIdContext = createContext(null); export const ZoomedContext = createContext(false); export const WindowFocusedContext = createContext(true); +// Lets TodoAlertDialog notify Pond's command-mode keyboard handler to stand +// down while the dialog is open (both listen on window capture, Pond first). +export const DialogKeyboardContext = createContext<(active: boolean) => void>(() => {}); + // Transient map of pane ids that were just created → their spawn direction. // TerminalPanel consumes (and removes) its id on first mount to trigger a directional spawn animation. // 'left' — born from horizontal split (new pane appeared to the right of the source) @@ -535,12 +268,6 @@ function idsMatch(a: string[], b: string[]): boolean { return a.length === b.length && a.every((id, i) => id === b[i]); } -/** Random A-Z excluding X (prevents accidental double-tap on kill shortcut) */ -const KILL_CONFIRM_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWYZ'; // no X -function randomKillChar(): string { - return KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)]; -} - // --- Panel content component --- function TerminalPanel({ api }: IDockviewPanelProps) { @@ -605,6 +332,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const renamingId = useContext(RenamingIdContext); const zoomed = useContext(ZoomedContext); const windowFocused = useContext(WindowFocusedContext); + const setDialogKeyboardActive = useContext(DialogKeyboardContext); const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const mouseStates = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); const actions = useContext(PondActionsContext); @@ -623,7 +351,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const [mouseIconAnchor, setMouseIconAnchor] = useState(null); const suppressAlertClickRef = useRef(false); const [tier, setTier] = useState('full'); - const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null); + const [dialogTriggerRect, setDialogTriggerRect] = useState(null); const todoPill = useTodoPillContent(activity.todo); const showTodoPill = todoPill.visible && tier !== 'minimal'; const alertButtonAriaLabel = activity.status === 'ALERT_RINGING' @@ -640,20 +368,14 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { ? 'Click to dismiss and show options' : 'Right-click for options'; - const openDialogFromButton = useCallback((button: HTMLButtonElement) => { - const rect = button.getBoundingClientRect(); - setDialogPosition({ - x: rect.left + rect.width / 2 - 140, - y: rect.bottom + 6, - }); - }, []); + const closeDialog = useCallback(() => setDialogTriggerRect(null), []); const triggerAlertButtonAction = useCallback((displayedStatus: SessionStatus, button: HTMLButtonElement) => { const result = actions.onAlertButton(api.id, displayedStatus); if (result === 'dismissed') { - openDialogFromButton(button); + setDialogTriggerRect(button.getBoundingClientRect()); } - }, [actions, api.id, openDialogFromButton]); + }, [actions, api.id]); useEffect(() => { const el = tabRef.current; @@ -721,7 +443,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { } triggerAlertButtonAction(activity.status, e.currentTarget); }} - onContextMenu={(e) => { e.preventDefault(); setDialogPosition({ x: e.clientX, y: e.clientY }); }} + onContextMenu={(e) => { + e.preventDefault(); + setDialogTriggerRect(e.currentTarget.getBoundingClientRect()); + }} ariaLabel={alertButtonAriaLabel} tooltip={alertButtonTooltip} tooltipDetail={alertButtonTooltipDetail} @@ -737,32 +462,21 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
{showTodoPill && ( - todoPill.flourishing ? ( - - {todoPill.body} - - ) : ( - - ) + )} {!isRenaming && ( @@ -802,14 +516,14 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { { e.stopPropagation(); actions.onSplitH(api.id); }} - ariaLabel="Split horizontal" - tooltip='Split horizontal [|] or [%]' + ariaLabel="Split left/right" + tooltip='Split left/right [|] or [%]' > { e.stopPropagation(); actions.onSplitV(api.id); }} - ariaLabel="Split vertical" - tooltip='Split vertical [-] or ["]' + ariaLabel="Split top/bottom" + tooltip='Split top/bottom [-] or ["]' > )} - {dialogPosition && ( + {dialogTriggerRect && ( setDialogPosition(null)} + onClose={closeDialog} + onKeyboardActiveChange={setDialogKeyboardActive} /> )} @@ -1050,186 +765,6 @@ function SelectionOverlay({ apiRef, selectedId, selectedType, mode, overlayElRef ); } -// --- Kill confirmation overlay --- - -export function KillConfirmCard({ char, onCancel, shaking }: { char: string; onCancel?: () => void; shaking?: boolean }) { - return ( -
-

Kill Session?

-
- {char} -
-
-
[{char}] to confirm
- -
-
- ); -} - -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 ( -
- -
- ); - } - - // Fallback: centered in viewport - 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). -
- -
-
- -
-
- -
-
-
-
- {label} -
- -
- { - if (e.key.length === 1) strike(); - }} - autoFocus - /> -
- -
- - - -
-
- ); -} - -const meta: Meta = { - title: 'Interactions/TodoBucket', - component: TodoBucketDemo, - args: { - width: 300, - }, - argTypes: { - width: { control: 'number' }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Interactive: Story = {}; diff --git a/lib/src/theme.css b/lib/src/theme.css index d5779ab..c720145 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -207,60 +207,62 @@ body.vscode-light { .ring-shrinking-to-br { animation: none; } } -/* Soft-TODO pill: one letter per keypress gets a strike line. - * The line draws left-to-right on strike, retracts on un-strike. */ -.strike-letter { - position: relative; - display: inline-block; +/* TODO dismiss flourish. Sequence across 500ms: + * 0–30% letters fade out while the check springs in (with a small overshoot) + * 30–55% check settles, pill is visually still + * 55–100% whole pill shell (border, bg, check) dissolves together + * + * The pill body is a grid-stacked so width is stable + * throughout — no reflow when the animation starts. */ +.todo-pill-stack { + display: inline-grid; +} +.todo-pill-stack > * { + grid-column: 1; + grid-row: 1; +} +.todo-pill-stack__letters { + opacity: 1; +} +.todo-pill-stack__check { + justify-self: center; + align-self: center; + color: var(--color-success); + opacity: 0; +} + +.todo-pill-shell[data-flourishing='true'] { + animation: todo-pill-dissolve 500ms ease-out forwards; + pointer-events: none; } -.strike-letter::after { - content: ''; - position: absolute; - left: 0; - right: 0; - top: 50%; - height: 1px; - background-color: currentColor; - transform: scaleX(0); - transform-origin: left center; - transition: transform 150ms ease-out; +.todo-pill-shell[data-flourishing='true'] .todo-pill-stack__letters { + animation: todo-flourish-letters 500ms ease-out forwards; } -.strike-letter[data-strike='true']::after { - transform: scaleX(1); +.todo-pill-shell[data-flourishing='true'] .todo-pill-stack__check { + animation: todo-flourish-check 500ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } -/* 4th-strike flourish: struck letters fade out while a ✓ pulses in, then fades. */ @keyframes todo-flourish-letters { 0% { opacity: 1; } 30% { opacity: 0; } 100% { opacity: 0; } } @keyframes todo-flourish-check { - 0% { opacity: 0; transform: scale(0.7); } - 30% { opacity: 1; transform: scale(1); } - 70% { opacity: 1; transform: scale(1); } - 100% { opacity: 0; transform: scale(1); } -} -.todo-pill-flourish { - display: inline-grid; + 0% { opacity: 0; transform: scale(0.5); } + 35% { opacity: 1; transform: scale(1.15); } + 55% { transform: scale(1); } + 100% { opacity: 1; transform: scale(1); } } -.todo-pill-flourish > * { - grid-column: 1; - grid-row: 1; -} -.todo-pill-flourish__letters { - animation: todo-flourish-letters 500ms ease-out forwards; -} -.todo-pill-flourish__check { - justify-self: center; - align-self: center; - color: var(--color-success); - opacity: 0; - animation: todo-flourish-check 500ms ease-out forwards; +@keyframes todo-pill-dissolve { + 0%, 55% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(0.92); } } @media (prefers-reduced-motion: reduce) { - .strike-letter::after { transition: none; } - .todo-pill-flourish__letters { animation: none; opacity: 0; } - .todo-pill-flourish__check { animation: none; opacity: 1; } + .todo-pill-shell[data-flourishing='true'], + .todo-pill-shell[data-flourishing='true'] .todo-pill-stack__letters, + .todo-pill-shell[data-flourishing='true'] .todo-pill-stack__check { + animation: none; + opacity: 0; + } } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 0cbc96f..1416b77 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -235,10 +235,6 @@ export class TauriAdapter implements PlatformAdapter { this.alertManager.clearTodo(id); } - alertDrainTodoBucket(id: string): void { - this.alertManager.drainTodoBucket(id); - } - onAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.add(handler); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index b4a3795..d178958 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -338,9 +338,6 @@ export function attachRouter( case 'alert:clearTodo': alertManager.clearTodo(msg.id); break; - case 'alert:drainTodoBucket': - alertManager.drainTodoBucket(msg.id); - break; } }); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index fb8d8b3..52860eb 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -25,8 +25,7 @@ export type WebviewMessage = | { type: 'alert:clearAttention'; id?: string } | { type: 'alert:toggleTodo'; id: string } | { type: 'alert:markTodo'; id: string } - | { type: 'alert:clearTodo'; id: string } - | { type: 'alert:drainTodoBucket'; id: string }; + | { type: 'alert:clearTodo'; id: string }; export interface PtyInfo { id: string; diff --git a/website/src/lib/tutorial-shell.ts b/website/src/lib/tutorial-shell.ts index b18aa28..90f514e 100644 --- a/website/src/lib/tutorial-shell.ts +++ b/website/src/lib/tutorial-shell.ts @@ -59,7 +59,7 @@ const STEPS: TutorialStep[] = [ phase: 'Keyboard Power', title: 'Split using keyboard shortcuts', description: 'Split a pane without leaving the keyboard.', - hint: 'In command mode, press " to split horizontally or % to split vertically.', + hint: 'In command mode, press " to split top/bottom or % to split left/right.', }, ];