diff --git a/docs/specs/layout.md b/docs/specs/layout.md index d0345d0f..f8410ea1 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -167,7 +167,7 @@ All handled in a single capture-phase `keydown` listener on `window`. Every hand ### Kill confirmation -Pressing `x` shows a pane-centered semi-transparent overlay (`KillConfirmOverlay` → `KillConfirmCard`) with a random uppercase letter (A-Z, excluding X). Typing that letter confirms the kill (destroys session, removes pane). Escape cancels. +Pressing `x` (or clicking the kill button) enters command mode and shows a pane-centered semi-transparent overlay (`KillConfirmOverlay` → `KillConfirmCard`) with a random uppercase letter (A-Z, excluding X). Typing that letter confirms the kill (destroys session, removes pane). Cancel with Escape key, clicking the `[ESC] to cancel` button, or clicking another panel. Any other key triggers a shake animation (400ms `shake-x` keyframe) then auto-dismisses the confirmation. ## Selection overlay diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 5622f44c..4e0d478c 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -76,6 +76,7 @@ function toDetachedItem(item: PersistedDetachedItem): DetachedItem { interface ConfirmKill { id: string; char: string; + shaking?: boolean; } export type PondMode = 'command' | 'passthrough'; @@ -887,24 +888,25 @@ function SelectionOverlay({ apiRef, selectedId, selectedType, mode }: { // --- Kill confirmation overlay --- -function KillConfirmCard({ char }: { char: string }) { +export function KillConfirmCard({ char, onCancel, shaking }: { char: string; onCancel?: () => void; shaking?: boolean }) { return ( -
-

Kill Session?

+
+

Kill Session?

- {char} + {char}
-
+
[{char}] to confirm
-
[ESC] to cancel
+
); } -function KillConfirmOverlay({ confirmKill, panelElements }: { +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); @@ -930,7 +932,7 @@ function KillConfirmOverlay({ confirmKill, panelElements }: { style={{ position: 'fixed', top: rect.top, left: rect.left, width: rect.width, height: rect.height, zIndex: 100 }} className="flex items-center justify-center bg-surface/50 rounded" > - +
); } @@ -938,7 +940,7 @@ function KillConfirmOverlay({ confirmKill, panelElements }: { // Fallback: centered in viewport return (
- +
); } @@ -998,6 +1000,7 @@ export function Pond({ // UI state const [confirmKill, setConfirmKill] = useState(null); + useEffect(() => { if (!confirmKill) { clearTimeout(shakeTimerRef.current!); } }, [confirmKill]); const [renamingPaneId, setRenamingPaneId] = useState(null); const [detached, setDetached] = useState(() => (initialDetached ?? []).map(toDetachedItem)); const [zoomed, setZoomed] = useState(false); @@ -1022,6 +1025,7 @@ export function Pond({ confirmKillRef.current = confirmKill; const renamingRef = useRef(renamingPaneId); renamingRef.current = renamingPaneId; + const shakeTimerRef = useRef | null>(null); const sessionSaveTimerRef = useRef | null>(null); const sessionSavePromiseRef = useRef | null>(null); @@ -1410,8 +1414,14 @@ export function Pond({ } else { setSelectedId(null); } + setConfirmKill(null); + return; + } + // Wrong key — shake then dismiss + if (!ck.shaking) { + setConfirmKill({ ...ck, shaking: true }); + shakeTimerRef.current = setTimeout(() => setConfirmKill(null), 400); } - setConfirmKill(null); return; } @@ -1745,6 +1755,7 @@ export function Pond({ const pondActions: PondActions = useMemo(() => ({ onKill: (id: string) => { + exitTerminalMode(); const char = randomKillChar(); setConfirmKill({ id, char }); }, @@ -1775,6 +1786,7 @@ export function Pond({ } }, onClickPanel: (id: string) => { + setConfirmKill(null); enterTerminalMode(id); }, onStartRename: (id: string) => { @@ -1790,7 +1802,7 @@ export function Pond({ onCancelRename: () => { setRenamingPaneId(null); }, - }), [addSplitPanel, detachPanel, enterTerminalMode]); + }), [addSplitPanel, detachPanel, enterTerminalMode, exitTerminalMode]); const pondActionsRef = useRef(pondActions); pondActionsRef.current = pondActions; @@ -1827,6 +1839,7 @@ export function Pond({ setConfirmKill(null)} /> )} diff --git a/lib/src/stories/KillModal.stories.tsx b/lib/src/stories/KillModal.stories.tsx index 91434cd8..a26ff7fa 100644 --- a/lib/src/stories/KillModal.stories.tsx +++ b/lib/src/stories/KillModal.stories.tsx @@ -1,25 +1,17 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { KillConfirmCard } from '../components/Pond'; -function KillModal({ char = 'G' }: { char?: string }) { +function KillModal({ char = 'G', onCancel, shaking }: { char?: string; onCancel?: () => void; shaking?: boolean }) { return (
{/* Simulated terminal content behind the overlay */} -
+
user@mouseterm:~$ npm run build
Building project...
{/* Kill confirmation overlay — positioned over the pane */}
-
-

Kill Session?

-
- {char} -
-
-
[{char}] to confirm
-
[ESC] to cancel
-
-
+
); @@ -43,3 +35,7 @@ export const Default: Story = { export const RandomChar: Story = { args: { char: 'W' }, }; + +export const Shaking: Story = { + args: { char: 'G', shaking: true }, +}; diff --git a/lib/src/theme.css b/lib/src/theme.css index 306931a4..530f555c 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -68,6 +68,7 @@ /* Animation */ --animate-alarm-dot: alarm-dot 2s ease-in-out infinite; + --animate-shake-x: shake-x 400ms ease-out; } /* --- Light mode fallback defaults --- @@ -136,3 +137,11 @@ body.vscode-light { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + +@keyframes shake-x { + 0%, 100% { translate: 0; } + 20% { translate: -6px; } + 40% { translate: 5px; } + 60% { translate: -3px; } + 80% { translate: 2px; } +}