Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f687c77
Collapse TODO state to boolean on/off.
nedtwigg Apr 24, 2026
4c20264
Improve the popup dialog.
nedtwigg Apr 24, 2026
543c12b
Fix hover.
nedtwigg Apr 24, 2026
f915a1d
Close the popup dialog when the mouse leaves its hot area.
nedtwigg Apr 24, 2026
c9535a2
Smooth the TODO dismiss animation, fix dialog key repeats, rename spl…
nedtwigg Apr 24, 2026
c46b002
Extract HeaderActionButton, TodoAlertDialog, and KillConfirm out of P…
nedtwigg Apr 24, 2026
2b18eee
Simplify TodoAlertDialog position API to just the trigger rect.
Apr 24, 2026
eff493b
docs: update TODO alert spec
Apr 24, 2026
755be2a
Restore tooltip-on-focus in HeaderActionButton.
Apr 24, 2026
69d2154
Clamp TodoAlertDialog inside the viewport.
Apr 24, 2026
58b1c4a
Arm TodoAlertDialog's mouse-leave-to-close only after cursor enters.
Apr 24, 2026
56f873d
Clear TODO on command-mode enter
Apr 24, 2026
b0ddc4d
Use alert button path for keyboard shortcut
Apr 24, 2026
ab985ae
Document v3 persisted session shape
Apr 24, 2026
716b658
Replace dialogKeyboardActive module global with a React context
Apr 24, 2026
3d79742
Comment why 'a' key goes through the DOM button rather than calling d…
Apr 24, 2026
06285be
Comment why todo is read before enterTerminalMode on Enter
Apr 24, 2026
41c7cca
Note in test file why soft-TODO bucket tests were removed
Apr 24, 2026
dd1eb70
Keep auto TODO after Enter dismisses alert
Apr 24, 2026
fa852a7
Clamp legacy TODO migration
Apr 24, 2026
6b975ed
Remove unnecessary keyboard focus.
nedtwigg Apr 24, 2026
63d2aec
Revert "Clear TODO on command-mode enter"
nedtwigg Apr 24, 2026
2115bb7
Fix ambiguity in spec.
nedtwigg Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 25 additions & 30 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:

Expand All @@ -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`.

Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions docs/specs/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ activationEvents: ["onWebviewPanel:mouseterm"]

```typescript
interface PersistedSession {
version: 2;
version: 3;
panes: PersistedPane[];
doors?: PersistedDoor[];
layout: unknown; // SerializedDockview
Expand All @@ -209,6 +209,11 @@ interface PersistedPane {
alert?: PersistedAlertState | null;
}

interface PersistedAlertState {
status: SessionStatus;
todo: boolean;
}

interface PersistedDoor {
id: string;
title: string;
Expand Down
6 changes: 0 additions & 6 deletions lib/src/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
10 changes: 4 additions & 6 deletions lib/src/components/Door.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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).
Expand Down Expand Up @@ -56,10 +56,8 @@ export function Door({
<span className="flex shrink-0 items-center gap-1.5">
{todoPill.visible && (
<span
className={[
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
isSoftTodo(todo) || todoPill.flourishing ? 'border border-dashed border-border' : 'border border-border',
].join(' ')}
className="todo-pill-shell rounded border border-border bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground"
data-flourishing={todoPill.flourishing ? 'true' : 'false'}
>
{todoPill.body}
</span>
Expand Down
107 changes: 107 additions & 0 deletions lib/src/components/HeaderActionButton.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => void;
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => 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<HTMLButtonElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties | null>(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 (
<>
<div className="relative flex shrink-0 items-center">
<button
ref={buttonRef}
type="button"
className={className}
data-alert-button-for={dataAlertButtonFor}
onMouseDownCapture={onMouseDownCapture}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onMouseDown?.(e);
}}
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
onContextMenu={onContextMenu ? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e);
} : undefined}
aria-label={ariaLabel}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
</button>
</div>
{isVisible && tooltipStyle && createPortal(
<PopupButtonRow
role="tooltip"
className="pointer-events-none z-[9999] whitespace-nowrap px-2 py-1.5"
style={tooltipStyle}
>
<div className="flex flex-col gap-0.5 leading-none">
<div>{renderShortcuts(tooltipPrimary)}</div>
{tooltipDetail && <div>{renderShortcuts(tooltipDetail)}</div>}
</div>
</PopupButtonRow>,
document.body,
)}
</>
);
}
Loading
Loading