This document explains how the focus system works in Wave Terminal, particularly for terminal blocks.
Wave Terminal uses a multi-layered focus system that coordinates between:
- Layout Focus State: Jotai atoms tracking which block is focused (
nodeModel.isFocused) - Visual Focus Ring: CSS styling showing the focused block
- DOM Focus: Actual browser focus on interactive elements
- View-Specific Focus: Custom focus handling by view models (e.g., XTerm terminal focus)
When you click on a terminal block, this sequence occurs:
frontend/app/block/block.tsx:219-223
const blockModel: BlockComponentModel2 = {
onClick: setBlockClickedTrue,
onFocusCapture: handleChildFocus,
blockRef: blockRef,
};frontend/app/block/block.tsx:165-167
When clicked, setBlockClickedTrue sets the blockClicked state to true.
frontend/app/block/block.tsx:151-163
useLayoutEffect(() => {
if (!blockClicked) {
return;
}
setBlockClicked(false);
const focusWithin = focusedBlockId() == nodeModel.blockId;
if (!focusWithin) {
setFocusTarget();
}
if (!isFocused) {
nodeModel.focusNode();
}
}, [blockClicked, isFocused]);frontend/app/block/block.tsx:211-217
const setFocusTarget = useCallback(() => {
const ok = viewModel?.giveFocus?.();
if (ok) {
return;
}
focusElemRef.current?.focus({ preventScroll: true });
}, []);The setFocusTarget function:
- First attempts to call the view model's
giveFocus()method - If that succeeds (returns true), we're done
- Otherwise, falls back to focusing a dummy input element
frontend/app/view/term/term.tsx:414-427
giveFocus(): boolean {
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {
return true;
}
let termMode = globalStore.get(this.termMode);
if (termMode == "term") {
if (this.termRef?.current?.terminal) {
this.termRef.current.terminal.focus();
return true;
}
}
return false;
}The terminal's giveFocus() calls XTerm's terminal.focus() to grant actual DOM focus.
A critical feature is that text selections are preserved when clicking within the same block.
frontend/app/block/block.tsx:156-158
const focusWithin = focusedBlockId() == nodeModel.blockId;
if (!focusWithin) {
setFocusTarget();
}The key is focusedBlockId() which checks:
- Active Element: Is there a focused DOM element within this block?
- Selection: Is there a text selection within this block?
export function focusedBlockId(): string {
const focused = document.activeElement;
if (focused instanceof HTMLElement) {
const blockId = findBlockId(focused);
if (blockId) {
return blockId;
}
}
const sel = document.getSelection();
if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) {
let anchor = sel.anchorNode;
if (anchor instanceof Text) {
anchor = anchor.parentElement;
}
if (anchor instanceof HTMLElement) {
const blockId = findBlockId(anchor);
if (blockId) {
return blockId;
}
}
}
return null;
}When making a text selection within a block:
focusWithinreturns true (selection exists in the block)setFocusTarget()is skipped- Selection is preserved
- Only
nodeModel.focusNode()is called to update layout state
There's an important separation between visual focus (the focus ring) and actual DOM focus.
frontend/app/block/block.tsx:200-209
const handleChildFocus = useCallback(
(event: React.FocusEvent<HTMLDivElement, Element>) => {
if (!isFocused) {
nodeModel.focusNode(); // Updates layout state immediately
}
},
[isFocused]
);This onFocusCapture handler fires on mousedown (capture phase), immediately updating the visual focus ring.
The actual DOM focus via giveFocus() only happens after click completion, through the onClick → useLayoutEffect path.
When making a selection in terminal 2 while terminal 1 is focused:
- Mousedown →
onFocusCapturefires →nodeModel.focusNode()updates focus ring- Terminal 2 now shows the focus ring
- Layout state updated
- Drag → Selection is made in terminal 2
- Mouseup → Selection completes
- Click handler →
onClickfires →setBlockClickedTrue→ triggers useLayoutEffect - useLayoutEffect → Checks
focusWithin(now true because selection exists) - Protected → Skips
setFocusTarget(), preserving the selection
Result: Focus ring updates immediately, but DOM focus is only granted after the selection is made, and is protected by the focusWithin check.
The terminal view has three useEffects that call giveFocus():
frontend/app/view/term/term.tsx:970-974
When the search panel closes, focus returns to the terminal.
frontend/app/view/term/term.tsx:1035-1038
When a terminal is recreated while focused (e.g., settings change), focus is restored.
frontend/app/view/term/term.tsx:1046-1052
When switching from vdom mode back to term mode, the terminal receives focus.
- Manages the BlockFull component
- Handles click and focus capture events
- Coordinates between layout focus and DOM focus
frontend/app/block/blocktypes.ts:7-12
export interface BlockNodeModel {
blockId: string;
isFocused: Atom<boolean>;
onClose: () => void;
focusNode: () => void;
}View models can implement giveFocus(): boolean to handle focus in a view-specific way.
focusedBlockId(): Determines which block has focus or selectionhasSelection(): Checks if there's an active text selectionfindBlockId(): Traverses DOM to find containing block
The focus system elegantly separates concerns:
- Visual feedback updates immediately on mousedown
- DOM focus is deferred until after user interaction completes
- Selections are protected by checking focus state before granting focus
- View-specific focus is delegated to view models via
giveFocus()
This design allows for responsive UI (immediate focus ring updates) while preventing disruption of user interactions like text selection.