There are about 80 commands within the app that are available to the user for editing, navigating, and other activities. Each can be activated with a toolbar button, keyboard shortcut, and/or gesture. The Command Universe brings up a list of all commands on desktop (Command/Ctrl + P).
Source of truth: ../src/commands/.
Every command is a single object that implements the Command interface, exported from a file under src/commands/. The barrel file src/commands/index.ts re-exports all of them, and src/commands.ts imports the barrel as * as commandsObject. There is no registration step — adding a *.ts file to src/commands/ and exporting it from the barrel is enough for the new command to participate in keyboard, gesture, toolbar, and Command Universe activation.
The minimal shape (Command.ts):
interface Command {
// required
id: CommandId
label: string
exec: (dispatch, getState, e, { type }) => void | Promise<void>
multicursor: boolean | { /* see below */ }
// optional
description?: string | (state) => string
keyboard?: Key | Key[] | string
gesture?: Gesture | Gesture[]
canExecute?: (state) => boolean
isActive?: (state) => boolean
svg?: (icon) => React.ReactNode
// ...and more
}A real example, abridged from pin.ts:
const pinCommand = {
id: 'pin',
label: 'Pin' as const,
labelInverse: 'Unpin',
description: 'Pins open a thought so its subthoughts are always visible.',
keyboard: { key: 'p', meta: true, alt: true },
svg: PinIcon,
canExecute: state => !!state.cursor || hasMulticursor(state),
multicursor: {
onComplete(filteredCursors, dispatch) {
dispatch(alert(`Pinned ${pluralize('thought', filteredCursors.length, true)}.`))
},
},
exec: (dispatch, getState, e, { type }) => {
/* dispatch pin() */
},
isActive: state => !!isPinned(state, head(/* ... */)),
} satisfies CommandThe satisfies Command and the as const on the label are load-bearing rather than stylistic. Annotating the constant : Command instead would type it as the interface, discarding what each command actually says about itself; satisfies checks the object against the interface while keeping the inferred type. The label needs as const on top of that, because the interface types label as string and that contextual type widens the literal even under satisfies. Together they let CommandLabel be derived as the union of every command's label, the same way CommandId is derived from the barrel's keys, so neither type has to repeat what the commands already declare. A command that skips either one widens its label to string and collapses that union. Rather than let that pass silently, CommandLabel resolves to a message naming the fix, so every call site that passes a label fails to compile and reports it.
exec receives the Redux dispatch, a getState thunk, the event that triggered the command, and a { type } field that is 'keyboard', 'gesture', 'toolbar', 'desktopCommandUniverse', or 'commandCenter' so the command can adapt its behavior (e.g. pin shows an alert only when triggered via keyboard, since the toolbar already gives visual feedback).
A command bound to an array of keyboard shortcuts also receives keyboardIndex, the index within that array of the shortcut that was pressed (undefined when the command was not activated by one of its own keyboard shortcuts). This lets one command cover a family of related shortcuts: applyColor maps Command/Ctrl + Option/Alt + n and Option/Alt + n to the nth text and background swatch of the ColorPicker. Since only the first shortcut of an array is displayed, such a command can set keyboardDisplay to a single Key representing the whole range (applyColor displays Cmd + Option + 0-8).
At startup, commands.ts flattens the barrel into registeredCommands: Command[]. Everything that lists commands or matches input against them reads globalCommands instead, which is the registered commands unless a test has stubbed them (see Stubbing the global commands). index() builds three indices:
commandKeyIndex—hash(keyboard) → Command. Used bykeyDownfor O(1) shortcut lookup.commandIdIndex—id → Command. Used bycommandById(id). Use this only when the id is dynamic; otherwise prefer a static import.commandGestureIndex—gestureSequence → Command. Used byhandleGestureEndfor O(1) gesture lookup.
The key and gesture indices are built from globalCommands. The id index is built from the registered commands, since commandById is only called with the ids of registered commands.
If two commands share the same keyboard hash, index() logs a console.error at startup naming both — so collisions surface early.
A snapshot of a command list, such as the Gesture Menu, would otherwise change whenever a command is added, removed, or renamed. A Puppeteer test can replace the global commands with a fixed list by preloading testFlags.stubCommands with plain data (label, gesture, keyboard, description), from which commands.ts builds inert commands. An icon is a component and cannot be preloaded, so a stub with icon: true is drawn with the Favorites icon, a real icon so that the snapshot covers the standard icon layout, and the same one for every stub so that redrawing any other icon does not change the snapshot. An entry can instead be the id of a registered command to keep, such as cancel and openMobileCommandUniverse, which the Gesture Menu always lists after the commands that match the gesture. globalCommands is computed once when commands.ts loads, so the flag must be set before the app starts; useTestFlags preloads it for every test in a describe block, before shared setup opens the page. The stubs replace the registered commands everywhere globalCommands is read: the Gesture Menu, Command Universe search results, keyboard shortcuts, and gestures. The Command Universe's default views, grouped by category or sorted by label, are built from COMMAND_DIFFICULTIES at startup and still show the registered commands.
Two readers keep using the registered commands: commandById, as above, and the guard in useCommandList that every visible registered command belongs to a group in COMMAND_DIFFICULTIES, since it checks the code rather than what a test displays.
The global keyDown handler (registered by initEvents.ts) hashes the event with hashKeyDown(e):
(meta|ctrl ? 'META_' : '') + (alt ? 'ALT_' : '') + (mac && ctrl ? 'CONTROL_' : '') + (shift ? 'SHIFT_' : '') + key.toUpperCase()
Hashes are uppercased, modifier-prefixed strings — so Cmd+Shift+P becomes META_SHIFT_P. commandKeyIndex[hash] resolves the command in O(1).
The key itself is resolved from e.keyCode first, falling back to e.code and then e.key. The indirection exists because macOS composes Option + N, E, I, U, and ` as dead keys, which corrupts e.key ('Dead' in Chrome, '~' in Safari) and e.keyCode (192, the tilde key) alike — so the Option chords, and only those, consult e.code for the physical key. e.keyCode stays ahead of it so that a layout reporting a letter's keyCode faithfully keeps resolving through it, as AZERTY does in reporting keyCode 65 for the physical KeyQ.
Key.control is the only modifier whose physical key differs by platform beyond the usual Command/Ctrl and Option/Alt substitution: on non-Mac platforms Ctrl already serves as meta, so control falls back to Shift and hashCommand emits SHIFT_ for it. heading0–heading5 are therefore Command + Option + Control + n on Mac and Ctrl + Alt + Shift + n elsewhere, which keeps them distinct from the text color shortcuts (Command/Ctrl + Option/Alt + n). parseCommandShortcut, which lets the Command Universe be searched by shortcut, applies the same platform mapping, so typing a command's shortcut exactly as it is displayed always resolves to that command.
The handler also:
- Skips entirely if
state.showDesktopCommandUniverseis open. - Skips when a modal is showing, unless the command has
allowExecuteFromModal: true(e.g. navigation commands that should still work). - Calls
e.preventDefault()before dispatching, unlesscommand.permitDefaultis set. (command.preventDefaultforces a preventDefault even whencanExecutereturns false.) - Routes through
executeCommandWithMulticursor, which short-circuits toexecuteCommandif no multicursor is active.
keyDown is a window listener, so a React handler that calls stopPropagation shadows every command bound to that key. Note does exactly that for the keys that navigate its own contenteditable — Escape and ArrowUp exit the note, Backspace deletes an empty one, ArrowDown moves the cursor down — so it only claims a keypress that carries no command modifier. A chord that includes Command/Ctrl or Option belongs to a command rather than to the note, and is left to propagate (issue #4954).
There's a special case in beforeInput for newThought and indent to handle iOS auto-capitalization: the Enter / space character is prevented in the beforeinput event rather than keydown (issue #3707). Another branch in beforeInput handles Android: soft keyboards report the space keydown as keyCode 229 ('Unidentified'), so it never matches the indent command in keyDown and keyCommandId is never set — the branch catches the beforeinput insertText of a single space over an empty thought and dispatches indent directly (issue #4178). Space-to-indent reads the thought's emptiness from editingValueStore, which holds the cursor thought's value even while the caret is in its note, so a space typed in a note must be told apart by state.noteFocus: keyDown returns before executing indent under noteFocus, and both beforeInput branches require !state.noteFocus, so the space is always inserted into the note (issue #5969). The check cannot live in indent's exec, because executeCommandWithMulticursor sets the cursor on each selected thought before calling it, and setCursor clears noteFocus.
Native undo/redo is intercepted before beforeInput sees it: initEvents hands every beforeinput to nativeHistory.beforeInput in device/nativeHistory.ts first. That module owns all of em's handling of WebKit's history, and delegates to handleNativeHistory in commands.ts to dispatch em's own undo/redo. iOS shake-to-undo and the three-finger swipe fire a cancelable beforeinput with inputType historyUndo / historyRedo rather than a keydown, so the Cmd+Z path never sees them. Left to run natively, the browser mutates the contenteditable directly and bypasses em's undo, leaving the DOM out of sync with Redux (issue #3954). The branch prevents the native operation and dispatches em's undo / redo instead. WebKit dispatches that beforeinput only while its own undo stack has a step, and it registers a step only for edits it performed itself; since em applies most edits by re-rendering the editable from Redux rather than through the browser's editing commands, that stack holds far fewer steps than em's history. Preventing the event does not stop WebKit from advancing its position through the stack either, so the gestures would run out while em still had plenty to undo, and iOS would handle the gesture itself and report "Nothing to Undo" (issue #4984). Advancing that position is reversible, so the branch replays the gesture and immediately inverts it — both replays prevented, and neither routed to em a second time — which leaves a step on either side of the position for as long as WebKit holds any step at all, and undo and redo gestures keep arriving. Unlike anchoring a step with an insertHTML as formatSelection does (issue #4637), the replay mutates no DOM and discards no redo steps. It has nothing to recycle when the step the gesture consumed belonged to an editable that em's undo has since unmounted — undoing the last thought does exactly that, and WebKit drops such a step rather than making it redoable — so the replay dispatching no beforeinput of its own is the signal to anchor a fresh step in the editable that is focused now, by typing a space and deleting it again under suppressChange in the editableSyncStore store so that the thought is left as it was. A second replay then makes the anchored step redoable as well as undoable. It is skipped outside iOS Safari, the only place a native history gesture arrives as a beforeinput at all. Like keyDown, it first triggers commandEmitter to flush the pending throttled edit — editing dispatches editThought on a throttle, so without the flush a native undo mid-edit would undo the previous step and let the in-progress edit commit afterwards, duplicating text (issue #4477). It also dispatches them with cursorAtEnd, which makes undoReducer / redoReducer set cursorOffset to the end of the restored thought instead of restoring the offset captured before the undone action — that offset is wherever the cursor was placed when the thought was entered (the tap position, or 0), which would leave the caret away from the restored word, typically at the beginning of the thought. Only the native path opts in; Cmd+Z and the toolbar keep the word-processor behavior of restoring the cursor to where it was. Placing the caret in the DOM is left to useEditMode, which re-runs on the editableNonce bump that the undo triggers — the re-render replaces the editable's innerHTML and destroys the caret, and the hook sets it again from cursorOffset. This is why the hook's editMode condition must reflect the real keyboard state; see Edit mode across a momentary blur.
The replay keeps undo gestures arriving, but it cannot be relied on for redo: any step it leaves on the redo side belongs to the editable that em's undo has just re-rendered, and the native redo gesture is not delivered without a live step there — iOS confirms the gesture with its own "Redo" overlay while nothing is restored (issue #5575). nativeHistory puts a fresh step there: it inserts an empty marker into the editable and immediately undoes it natively, moving that step onto the redo stack. The DOM effect is immaterial — the insert is undone before the function returns, and suppressChange in the editableSyncStore store hides both mutations from the Editable change handler, so no edit is recorded and the editable is not re-rendered. It does so after every native gesture em handles in Mobile Safari, whichever route the gesture arrived by, because a step is not only missing after the first prevented undo but goes stale after every one: em's undo re-renders the editable the step points at, and WebKit silently discards a step whose DOM the re-render replaced, dispatching nothing when the next gesture arrives. For the same reason the call is deferred past the re-render, by SWIPE_REGISTER_DELAY on the touch route — which runs at touchend, long before it — and by a bare task on the beforeinput route, which already arrives late enough. Registration needs a focused editing host, and em's editing hosts are the thoughts themselves, so undoing the creation of the only thought would leave the route with nowhere to register and nowhere to be delivered. nativeHistory supplies one that always exists: a 1px transparent contenteditable appended to the body, focused only when the insert finds no editable selection. inputmode=none keeps the software keyboard shut when it takes focus and pointer-events: none keeps it out of reach of taps; it carries no data-editable, so selection.isThought is false for it and the clearSelection middleware leaves its focus alone. The replay is scheduled before the registration, so that it never moves WebKit's position across the freshly registered redo step, and it is skipped for a gesture the touch route has already applied.
That step is what a shake depends on, since a shake reaches em through beforeinput alone. The three-finger swipe does not depend on it, because iOS delivers the swipe to the page as ordinary touch events before it reports any beforeinput — so device/nativeHistory.ts recognizes the gesture itself in Mobile Safari, from three touches travelling further horizontally than vertically, and calls handleNativeHistory directly. That reads no browser state, so it holds whether or not a step was successfully registered. A swipe handled that way leaves no beforeinput to arrive at all — with no editing host focused, WebKit dispatches nothing — which is why the step is refreshed after a gesture from either route rather than only from the beforeinput route: otherwise a shake taken after a swipe would find only a stale step and do nothing. When both signals do arrive for one swipe, the touch route records the time and nativeHistory.beforeInput skips any historyUndo / historyRedo within SWIPE_BEFOREINPUT_TIMEOUT of it — still preventing the default, so WebKit cannot mutate the contenteditable, but not applying the gesture a second time.
In the iOS Capacitor app the same gesture arrives by a second route, nativeHistory, and never reaches beforeInput at all, so it does not depend on WebKit's undo stack holding a step. The gestures are driven by the responder chain's undoManager, so NativeHistoryWebView (a WKWebView subclass supplied by DevServerViewController) returns an undo manager that, instead of performing undo and redo, emits the plugin's nativeHistory event. src/device/nativeHistory.ts subscribes to it from initEvents and calls the same handleNativeHistory, so both routes behave identically. Because the native manager consumes the gesture, only one route fires per gesture. That manager has no history of its own to answer from, so nativeHistory also reports em's isUndoEnabled / isRedoEnabled back to it through the plugin's setHistoryAvailability whenever they change. iOS reads them to decide whether to deliver the gesture at all, and confirms the gestures it does deliver with an "Undo"/"Redo" overlay — so a manager that claims an availability em cannot honor has iOS confirming an undo or redo that does nothing.
A gesture is a string of swipe directions, where each character is one of 'l', 'r', 'u', 'd' (left/right/up/down). For example, 'rdru' is right → down → right → up. Multiple sequences can map to the same command — the first one is the canonical gesture shown in the UI.
A gesture can only start inside the gesture zone (isInGestureZone, enforced by MultiGesture): the screen minus the scroll zone (a strip on the right, or on the left for left-handed users), the toolbar at the top, and — on devices with a home indicator (nonzero safe-area-inset-bottom) — a strip at the bottom where the OS recognizes system gestures. Without the bottom exclusion, the upward app switcher swipe is committed as the Open Command Center gesture right before the app suspends. Single-finger touches that start outside the zone scroll the page as usual; multi-finger touches are inert everywhere (see Multi-touch rejection).
shouldCancelGesture in AppComponent abandons a gesture that starts on the toolbar, on a selected text range, on the caret (see Cursor and Caret), or while a press, modal, sidebar, or the Mobile Command Universe is active. The one exception is a dry run: over the Mobile Command Universe, a gesture that starts on the open pinned command tooltip is allowed (see learning.md). It is evaluated with the touch point at touchstart and again without one during the gesture, so only the point-dependent checks are limited to the first call. The abandoned state is per touch: MultiGesture clears it when the first finger of the next touch lands, rather than relying only on its window touchend listener, because a control that stops the propagation of its touchend — such as a toolbar dropdown option — keeps that listener from running, and the next gesture would otherwise be abandoned too (issue #5850).
A gesture is also always a single finger. MultiGesture follows only the finger that began the gesture, by its touch identifier, and ends the gesture when that finger lifts, even if other fingers are still down. A finger that touches down before the gesture's first swipe is recognized latches multi-touch, and MultiGesture then ignores movement until every finger is up again, because iOS reports its own multi-finger system gestures to the page as ordinary touch events: tracking the first finger of a three-finger undo swipe would commit it as cursorForward, and the redo swipe as cursorBack. Both are undoable, so each would clear the redo stack and leave the redo half of the gesture with nothing to restore (issue #5575).
MultiGesture's reset() runs when a gesture is released, which can come before the window touchend listener runs. State that touchend reads, such as the Debug Log's moves count, is therefore reset on the first finger's touchstart rather than in reset().
handleGestureSegment is called incrementally as the user swipes; it triggers a haptic for each new segment and, after COMMAND_PALETTE_TIMEOUT, opens the gesture menu so the user can see all commands reachable from the current sequence. The menu is not opened while the pinned command tooltip is open (see learning.md), since the user is then practicing the gesture the tooltip shows. handleGestureEnd executes nothing over the Mobile Command Universe; during a dry run it only reports whether the gesture matched the pinned command.
handleGestureEnd runs when the gesture finishes. It looks up the final sequence in commandGestureIndex, with three special cases:
-
Download Debug Log. While Debug Logging is on, the secret gesture →↓←↓↑ saves the debug log instead of running a command. It is not a command, so it appears nowhere a command does — Command Universe, gesture menu, help, or tutorial.
-
Mobile Command Universe. If the sequence ends with the
openMobileCommandUniversegesture, that command runs. -
Chained commands. If the sequence starts with a gesture for a command that has
isChainableand continues with another command's gesture, and that command'sisChainableaccepts the second command, the two are chained and executed together. The canonical example:selectAllchains into any multicursor command, so<selectAll-gesture><archive-gesture>archives all selected thoughts in one motion.newThoughtchains only intooutdent, so New Thought followed by any other command's gesture runs nothing. The gesture menu applies the same check, so it offers exactly the chains that run on release.chainCommand(c1, c2)synthesizes aCommandwhose gesture and label combine both. Both commands execute inside one command transaction whose metadata identifies the combined command, so they produce one undo patch.
In training mode, an alert briefly confirms the command's label, unless the command has hideAlert: true. The label is shown just before the command executes, so an alert the command raises itself — such as an error explaining why it cannot be executed — replaces the label rather than being hidden by it.
Gestures are single-finger input. Two-finger tracing and pinch-to-zoom are inert: they draw no trace, open no gesture menu, execute no command, begin no drag, do not move the cursor, and neither zoom nor pan the page (issue #4233). A finger that touches down once a single-finger gesture has begun is ignored for the rest of the touch instead, whether it is the second finger or a later one: it is not traced, does not change the sequence or the gesture menu, and does not cancel the gesture, which executes when the first finger lifts (issue #5966).
Multi-touch is tracked by multitouchStore, a latch set as soon as a second finger touches down and reset only when a fresh interaction begins — the touchstart of the next single-finger touch, or a pointerdown from a mouse or pen, neither of which can be part of a multi-touch gesture. Without the pointer case the latch would survive indefinitely on a device that has both a touchscreen and a pointer, such as a touchscreen laptop or an iPad with a trackpad, leaving every subsequent click rejected and the cursor unmovable. It is wired to the window touch events in initEvents, in the capture phase so that it is set before any subsystem reads it. A latch rather than a live touch count is essential: during a two-finger trace one finger routinely lifts before the other, so a count would momentarily drop to 1 and let the remaining finger begin a drag; and the terminating tap of a multi-touch gesture must still read as multi-touch so that it does not move the cursor.
Each subsystem rejects multi-touch at its own entry point, since they do not share one:
MultiGestureabandons the sequence when another finger touches down before the first swipe of a gesture has been recognized. Once the first swipe is recognized, every other finger is ignored, however many there are: the sequence follows only the first finger's touch identifier, since React Native Web'smoveXandmoveYare the centroid of every finger that moved. Waiting for the first swipe rather than the firsttouchmovematters because the first finger of a two-finger trace, or of the iOS three-finger undo swipe, can move slightly before the other fingers land.TraceGesturelikewise draws only the first finger, replacing signature_pad's touch move and end handlers, which draw whichever finger changed first and stop drawing when two fingers are on the same element.- Drag-and-drop rejects it in
canDragand again inuseLongPress; see Drag and Drop. - Tap handling and caret placement ignore it; see Cursor and Caret.
Native browser behavior is suppressed in initEvents. While the latch is set and at most two fingers are down, touchmove is preventDefaulted so the native caret and text selection do not follow the fingers and the page does not scroll; and the Safari-only gesturestart/gesturechange/gestureend events are preventDefaulted, because iOS Safari ignores the viewport user-scalable=no / maximum-scale=1 settings and would otherwise pinch-zoom or pan the page. Gestures of three or more fingers belong to the OS rather than to em — notably the iOS three-finger swipe that drives undo and redo — so their default is left alone rather than fighting the system gesture recognizer for touches em has no use for. The two-finger bound still covers the tail of a two-finger gesture, when one finger has lifted and the caret would otherwise follow the remaining one. Both listeners are registered on touch devices only. macOS Safari fires the same gesture events for a trackpad pinch, where zooming the page is legitimate browser behavior; and a non-passive touchmove listener on window marks the whole viewport as a blocking touch-handler region, which changes how Chrome composites the page — invisible to the user, but enough to shift the subpixel anti-aliasing that the Puppeteer image snapshots compare.
The Toolbar renders a configurable subset of commands as buttons. The user's customization lives at [EM, Settings, Toolbar]. Toolbar-relevant fields on a Command:
svg— icon component.isActive(state)— returns true if the command should be highlighted (e.g.pinis active when the cursor thought is pinned).isDropdownOpen(state)— renders a small dropdown indicator beneath the icon. It also marks the command as the owner of a toolbar dropdown: tapping a button whose command has noisDropdownOpencloses any open toolbar dropdown before the command executes, while a dropdown command toggles its own dropdown and closes the others itself throughtoggleDropdown. The Command Center and the multiselect survive the tap, since the command may act on the selected thoughts. Undo and Redo also leave the Undo Slider open, since they move through the history it shows.overlay.gesture/overlay.keyboard— alternative shortcuts shown in the toolbar long-press overlay.longPress(dispatch)— runs when a toolbar button is long-pressed.
The Command Universe is the searchable command palette. Two flavors:
DesktopCommandUniverse(Cmd/Ctrl + P) — desktop palette opened byopenCommandCenter/openDesktopCommandUniverse.MobileCommandUniverse— dialog opened byopenMobileCommandUniverse, also reachable by gesture. Clicking a grid cell opens its command detail page, including when the command cannot currently execute. Cells are native buttons, so Enter and Space also open details. Every detail page has a Pin Command row that pins the command to the corner widget without executing it; see learning.md. Back/Forward inDialogHeadernavigate the dialog history. Search stays above the grid scroller, and changing search or sort resets the results to the top.
Each grid cell reserves a square for its gesture diagram but mounts the original SVG only near the grid scroller's viewport. An IntersectionObserver updates those cells as the user scrolls and holds the last visible diagrams through a page zoom, so Back reveals them immediately. This keeps offscreen SVGs out of the retained grid's DOM during the zoom without changing the diagrams themselves.
The grid uses Motion’s useInView with once: false to render diagrams near its scroll viewport. Already rendered diagrams are retained while the page is hidden or transitioning.
The pinned-command gesture tooltip opens the pinned command's detail page directly through commandUniverseNavigate, after opening a fresh dialog session. This visit settles the detail page immediately without the Command Universe entry zoom, while retaining the grid in Back history and the usual animated Back and Forward navigation. A previous search cannot hide the target, and this navigation never executes the command. On pointer activation, the ring captures the editor selection before it can take focus; the dialog's open action keeps that snapshot instead of replacing it with the tooltip selection.
The Command Universe has Redux-owned session navigation with separate routing and presentation layers:
state.commandUniverseNavigationowns the history entries, each recording how it was arrived at, and the active index.commandUniverseNavigate,commandUniverseBack, andcommandUniverseForwardmutate it through the app's filename-routed Redux reducer. Opening the mobile Command Universe composescommandUniverseResetto start a fresh session. These actions are non-undoable and can be dispatched by the Command system.commandUniversePagesmaps page ids to components.CommandUniversePagederives both the ids and the corresponding props from that registry. A history entry wraps a page with its ownentryId, so two visits to the same page remain distinct. The navigation state stores opaque page props and has no command-specific fields.CommandUniversePageRouterselects the active entry from Redux, resolves its registered component, and forwards its props. It does not own history, read command data, or size the dialog. The modal composition sizes the outer non-scrolling viewport. Each page usesDialogContentfor its independent scroller, padding, and custom scrollbar.CommandUniversePageTransitionsgives each retained surface a declarative target and lets Motion move it there, withuseReducedMotionskipping the zoom when requested. Because the target is declarative, navigating while a zoom is running simply re-targets: Motion continues from wherever the surface got to rather than restarting. The running transition is local presentation state, derived from the change of active entry: Back reverses the arrival of the entry being left, and Forward and new visits replay the destination's own arrival. A visit requestingtransition: 'none'settles before paint. Completion otherwise comes from Motion rather than a parallel timer, and clears the transition only if it is still the one that animation started, so a late completion from an abandoned animation is ignored. Inactive and transitioning pages are inert, but the header stays outside the page surfaces and so remains usable throughout. On arrival the view restores the destination's last focused element or focuses its designated heading without changing scroll position, committing the navigation first so the destination is no longer inert.
Each history entry records the zoom direction, optional animation type, and source point relative to the page of the navigation that created it. The point is measured before the destination mounts, avoiding another layout-measurement render. Back reverses that entry's zoom and Forward replays it, so a new entry can zoom either inward or outward. Navigation is never blocked by a running animation: Back and Forward stay enabled throughout, so a mistaken tap can be undone without waiting the zoom out. Reachable history entries remain mounted with their independent scroll positions. Starting a new branch discards its abandoned redo pages. Closing unmounts the page surfaces after the dialog fade, and reopening starts a fresh session even during an unfinished close fade. This history is independent of browser history and the editor's undo/redo.
Motion defaults live in commandUniverseMotion, using the shared duration utility. The transition view reads them directly, and an enclosing Motion MotionConfig overrides them where one is present, which is how tests run the animation at zero duration. Page zooms use complete transform: scale(...) targets so Motion can run them through native Web Animations alongside opacity and blur; Motion's independent scale value depends on the JavaScript frame loop and can stall when navigation finishes.
For a future docked presentation, keep the router's rendered page tree mounted in the same React position while changing the shell's layout. Redux preserves navigation history across presentations, but replacing the router subtree would still remount page-local state and scroll containers. The current app renders the modal presentation; docking controls are separate work.
Command.longDescription is a ReactNode, rendered directly by CommandUniverseDetailPage. Plain strings render as text. Use JSX paragraphs, fragments, normal anchors, or components for rich content. Command modules containing JSX use .tsx; a substantial description may be a separate component. There is no Markdown or HTML-string parser in the detail page.
Description components can dispatch the Command Universe navigation actions when they need internal navigation. Ordinary external links can use <a href='…'> directly. Static descriptions, such as newThought, define their JSX paragraphs inline in the command object.
- Create a page component under
src/components/CommandUniverse/. Its props are its navigation parameters. Dispatch the Command Universe navigation actions when needed, and useDialogContentif the page scrolls. - Import it into
commandUniversePages.tsand add its page id as a registry key.
The router and route types update from the registry. No separate id union, props union, or routing switch needs editing. commandUniverseNavigateActionCreator calls are checked against the registered component's props. Add tests for the new page's behavior.
Both filter globalCommands by name and respect hideFromDesktopCommandUniverse / hideFromGestureMenu / hideFromHelp.
Help and Customize Toolbar present commands grouped by COMMAND_DIFFICULTIES (in constants.ts), a two-level hierarchy of difficulty levels containing category groups, which defines the order: Beginner (Creating Thoughts → Navigation → Contexts) → Intermediate (Categorizing → Nudging → Deleting) → Advanced (Creating Thoughts II → Edit History → Notes → Views).
COMMAND_DIFFICULTIES is the single source of truth for the hierarchy. as const preserves its literal IDs, and satisfies validates the structure and command references. CommandDifficulty and CommandGroup (in src/@types) are inferred from that readonly configuration, so their ID unions update automatically when entries are added or removed. Difficulty IDs and globally unique group IDs stay unchanged when titles, ordering, or group placement change; features referencing the hierarchy use CommandDifficulty['id'] and CommandGroup['id']. The constant stores command IDs and presentation order; user learning progress belongs separately.
useCommandList resolves command IDs into Command[] and returns sections: display-only CommandSection objects with { id, title, difficulty?, commands }. Search results and the alphabetical list have their own IDs and no difficulty. CommandTable and MobileCommandUniverse render those sections using their stable IDs as React keys. CommandTable detects difficulty boundaries by ID and renders each difficulty title above its first visible section; CommandTableSection renders the titled command table within each section. Configuration groups remain categories, while display sections can also represent search results or the alphabetical list.
Both take the browser selection away from the thought as they open — the desktop palette by focusing its search input, the mobile drawer by clearing the selection outright — so both snapshot it into state.selectionOffsets on the way in, for the commands whose input is the selected text. See Caret / Browser Selection.
Typing into a thought does not reach Redux on the keystroke. Editable dispatches editThought through a lodash throttle of EDIT_THROTTLE (500 ms) with leading: false, so the value the user just typed sits on the trailing edge until the window closes. A command that mutates thought text and runs inside that window would read the pre-edit value, and the trailing edit would then commit over the command's own result — the typed character wins and the formatting is lost (issue #4774).
The exception is an edit typed while there is nothing to undo, such as the first keystroke after a reload, which Editable commits immediately. Undo's canExecute reads the undo history, so the throttle would otherwise leave the Undo button disabled for the first 500 ms of typing, and a tap on it in that window would be dropped. The edits that follow are throttled as usual, and the undo history merges them with the immediate one into a single step.
Editable subscribes to commandEmitter and flushes the throttle on command, so any activation surface can force the pending edit to commit first by triggering it. Every surface does, at the point where it knows a command is about to run:
- Keyboard and gesture —
keyDownandhandleGestureEndtrigger it before they dispatch. - Toolbar, Command Center, Command Universe —
executeCommandWithMulticursortriggers it for every type other than keyboard and gesture, which have already flushed. - Pickers — a swatch dispatches its action creator directly rather than executing a command, so
formatSelectionColorandformatLetterCasetrigger it themselves.
Notes are not part of this. Note commits through a plain react-contenteditable onChange that dispatches editThought / setDescendant immediately, with no throttle and no commandEmitter subscription, so a note edit is already in Redux by the time any command runs and there is nothing to flush.
When state.multicursors is non-empty, the user has one or more thoughts selected. A selection of exactly one thought is common — on mobile, opening the Command Center selects the cursor thought. Every command must declare how it behaves in this case via the required multicursor field — there is no implicit default.
A selection is started by long pressing a thought (useDragHold), by Cmd/Ctrl + Click or Shift + Click (Thought), or by the Select All command. Once one is active, a plain click or tap on a thought or on its bullet toggles that thought's selection rather than moving the cursor (Editable, BulletPositioner) — the same behavior on mobile and desktop. The thought text shows a pointer cursor for as long as the multiselect is active, so that a mouse click over it reads as selecting the thought rather than placing the caret; it reverts to the text cursor while the multiselection is being edited (see Multi edit mode), where a click does place the caret. The bullet's usual expand/collapse and =pin handling is suppressed while a multiselect is active, since expansion is determined by the selected thoughts (see expandThoughts). Deselecting the last selected thought ends the multiselect, which on touch also closes the Command Center (multicursorAlertMiddleware).
On touch, the cursor is parked outside the selection for as long as more than one thought is selected (multiselectCursorMiddleware). Visibility is computed relative to the cursor (calculateAutofocus), so a cursor left on one of the selected thoughts dims all the others and expands its own children, rendering equally selected thoughts differently from one another. The middleware therefore moves the cursor to the selection's nearest common ancestor, which shows every selected thought at full opacity and leaves them all collapsed, and lands it on the first selected thought in document order when the selection ends — typically when the Command Center is closed. It reads the settled multicursors after each action rather than hooking the multicursor reducers, so it re-parks when the selection is extended into another subtree or replaced wholesale by Back or Forward, and it ignores the multicursors entirely while isMulticursorExecuting is set. A selection whose common ancestor is the root is left alone, since the root is not a cursor position and a command that acts on the selection still reads the cursor — Categorize refuses to run without one even though it takes its thoughts from the multiselection. Parking is confined to touch because Shift + ArrowUp/ArrowDown extends the selection from the cursor, which requires the cursor to remain in it. That move is conditional on the cursor still being where the middleware parked it: Clear Thought sets the cursor to the first selected thought in order to edit the selection, and the blur that ends that editing session must not yank the cursor back out of the thought the user was just typing in.
multicursor: false— execute onstate.cursoras if no multicursor existed; selection stays. For commands that don't interact with the thoughtspace (e.g. opening modals). The cursor-navigation commands also declaremulticursor: falseyet still respond to a selection, since navigating a multiselect means moving the selection itself rather than executing once per selected thought:cursorUpandcursorDownreadstate.multicursorsin their ownexecto extend or collapse the selection, and thecursorForwardandcursorBackreducers replace the selection with the thoughts one level forward or back.multicursor: true— execute once per selected thought.multicursor: { ... }— fine-grained control with these options:
| Option | Meaning |
|---|---|
execMulticursor(cursors, dispatch, getState) |
Custom replacement for the per-cursor loop. The supplied dispatch retains command attribution through nested thunks and asynchronous work. |
onComplete(filteredCursors, dispatch, getState) |
Callback after the loop finishes. |
preventSetCursor |
Don't restore the cursor at the end. |
reverse |
Iterate cursors in reverse document order (matters for ops like move). |
clearMulticursor |
Clear the multicursor selection after execution. |
selectNewCursors |
Select the thoughts the executions moved the cursor to instead of restoring the original selection. |
toggle |
Toggle every selected thought in the same direction, based on isActive evaluated for each thought on its own (as the cursor, with nothing else selected — the state exec runs in). If the command is active on all of them, it executes on all of them; otherwise only on the ones it is not active on. The formatting commands (Bold, Italic, Underline, Strikethrough, Code) set it, with an isActive that matches the toolbar highlight — every selected thought formatted in its entirety — so a highlighted button removes the formatting from the whole selection and an unhighlighted one applies it to the whole selection. |
filter |
One of 'all' (default), 'first-sibling', 'last-sibling', 'prefer-ancestor', applied by filterCursors. |
executeCommandWithMulticursor walks the filtered cursors in document order (documentSort), setCursors each path in turn, calls the regular exec, and finally restores the original cursor (unless preventSetCursor is set) and the multicursors themselves (unless clearMulticursor is set). The command executor passes one transaction-scoped dispatch through the synchronous loop, so the entire multi-step operation produces one undo patch with the command's metadata.
After the invocation, the runner triggers commandEmitter's commandSucceeded event once for the command the user invoked, even when that command resolves to another command through Repeat. This is separate from the pre-execution command event that flushes pending edits. It triggers as soon as exec or execMulticursor returns, and does not report success when the command fails canExecute, throws, or raises an app error during execution. Asynchronous work (AI requests, the AI disclosure prompt, Copy Cursor loading thoughts) is not awaited: the user has completed the command once it starts, whatever the server or clipboard later returns. A guard that should stop an invocation from counting as a success therefore belongs in canExecute, not in an early return from exec. The learning middleware listens for success and dispatches a Redux action only when a pinned command earns a practice rep; see learning.md.
Restoring the multicursors is what keeps the Command Center open on mobile. setCursor clears state.multicursors unless preserveMulticursor is passed, and multicursorAlertMiddleware closes the Command Center as soon as the selection is empty — so a command that moves the selected thought (swapParent, whose reducer ends in setCursor) empties the selection mid-run and would dismiss the panel under the user, were the loop not to add the thoughts back at its new path. A command that bypasses the loop, as the disallow branch does for a single selected thought, must not move the thought it acts on for this reason.
The restore is not free for a command that changes nothing. setCursor recomputes state.expanded, which expandThoughts derives from the multicursors as well as from the cursor, so restoring a cursor that never moved still leaves a diff behind — and since the bracket's opening setIsMulticursorExecuting has already started an undo patch, that diff survives as an undo entry labeled with the command. copyCursor, which only reads the thoughtspace, therefore sets preventSetCursor: with no setCursor the multicursors are never cleared either, the bracket nets to no change, and the empty-patch guard in undoRedoEnhancer drops the patch, so Copy Cursor adds nothing to the undo history.
A command tapped in the Command Center is executed with type: 'commandCenter', and the loop then ends by selecting the thought the cursor landed on whenever nothing is selected any more — which is exactly what a clearMulticursor command such as delete leaves behind. Otherwise the Command Center would dismiss itself the moment such a command was tapped, since multicursorAlertMiddleware closes it as soon as the selection empties; instead it stays open, selecting the cursor thought the same way opening it does, and can be used again straight away. Deleting the last thought leaves no cursor to select, so there the Command Center closes as usual. note is the exception that closes it on purpose: it ends with the caret in a note and the keyboard open, which the sheet would cover, so on touch its onComplete closes the Command Center, and with it the selection, after the restore. Clearing the selection matters as much as hiding the sheet, since a selection left behind would re-open the Command Center as soon as the keyboard closed.
selectNewCursors is how the thought-creating commands leave the thoughts they created selected, so that a multiselect New Thought is followed by a multiselect of the new thoughts rather than by nothing. It needs no knowledge of where each command inserts, because each of them sets the cursor to the thought it creates: the loop takes the cursor after every exec and keeps it when it differs from the path it set, which also means a selected thought the command could not act on — newUncle at the root — contributes nothing. The loop then selects the collected thoughts and sets the cursor to the last of them with preserveMulticursor, which recomputes state.expanded so that thoughts created away from the original cursor are visible. That same setCursor passes isKeyboardOpen: false, since the new thoughts are selected rather than edited — there is no typing into several of them at once. Each exec opened the keyboard for the thought it created, and a multiselection with the keyboard open is how multicursorAlertMiddleware recognizes a multiselection being edited (Clear Thought), which it deliberately leaves the Command Center closed over; closing the keyboard is therefore what lets the middleware open the Command Center over the new selection on mobile. Fewer than two of them is not a selection, so it clears instead, ending as the command does without a multiselect: with the caret in the new thought and the keyboard open. newSubthought and newSubthoughtTop reach the same postcondition, including the closed keyboard, through onComplete and execMulticursor respectively.
setIsMulticursorExecuting is the general mechanism for that collapsing, not a private detail of the command loop: undoRedoEnhancer merges every action dispatched while state.isMulticursorExecuting is true into the preceding undo patch, and records undoLabel as action-level metadata for undo/redo alerts. Any code path that edits every selected thought without going through a multicursor: true command must bracket its dispatch with the same pair, or the user has to undo once per thought. The LetterCasePicker reaches the thoughtspace through formatLetterCase rather than through its multicursor: false toolbar command, so that action-creator does the bracketing itself; drag-and-drop of a multiselect does the same. The ColorPicker reaches it through formatSelection, which needs no bracket: its reducer colors every selected thought within a single action, which is a single patch. Both pickers edit the selected thoughts whether or not the selection has a cursor — a thought stays selectable once the Home button has dismissed the cursor — which is why textColor and letterCase admit hasMulticursor in canExecute and isActive.
The flag is also what marks the traversal as bookkeeping rather than user intent, so that observers do not react to the transient state it passes through. Setting the cursor to each selected thought empties state.multicursors (the loop does not pass preserveMulticursor) and the restore then re-adds them one at a time, so the count falls to zero and climbs back mid-command; it would likewise reset cursorCleared on each hop. setCursor preserves cursorCleared while the flag is set, and multicursorAlertMiddleware suspends the Command Center's show/hide reaction. Both settle on the closing setIsMulticursorExecuting({ value: false }), which the middleware evaluates against the final multicursors.
The setIsMulticursorExecuting bracket in executeCommandWithMulticursor is synchronous, so an asynchronous command gets no selection grouping from it: the bracket is opened and closed around the call, and anything dispatched after the first await lands outside it. The runner returns a promise for the invocation's completion without extending that bracket. Command attribution is separate and survives through the transaction-scoped dispatch after an await. generateThought, generateEmoji, defineTerm, and organizeThought still need their own asynchronous multicursor bracket because their returned edits must form one selection-wide undo step. Each defines an execMulticursor that yields once (so that the loop's synchronous bracket has closed), opens a second bracket, generates or reorganizes, and closes it only after the request has settled. A multicursor: true declaration would instead leave one undo step per generated thought, and per-cursor caret updates could land on whichever request happened to finish last.
Because that early restore runs before the request returns, an async command that moves selected thoughts must recompute the selection itself once the new paths exist. Organize Thoughts does this after applying the outline, so the originally selected thoughts stay selected at their new parents rather than leaving stale paths that no longer match any bullet.
Three fields shape what happens when the command might not be runnable:
canExecute(state)— boolean predicate. If false,execis not called. It also drives the enabled appearance of the Toolbar and Command Center buttons, so a predicate that reports a command as executable when it would be a no-op leaves an enabled button that does nothing when tapped. A disabled toolbar button is inert rather than merely non-executing:ToolbarButtonpreventDefaults thetouchendof any tap that lands on it, executable or not, so the tap neither runs the command nor blurs the editable and closes the virtual keyboard. A command that acts on the selection must therefore testselectedPaths— the multicursors if there are any, otherwise the cursor — rather thanstate.cursor, which is not the thought the command runs on when a thought elsewhere in the tree is selected. Pass the command's ownmulticursor.filtertoselectedPathsso that the predicate judges exactly the paths the loop will execute on; otherwise a path that the filter drops (such as a descendant of another selected thought) can disable a command that would have worked. Quantify witheveryrather thansome, since the multicursor loop aborts the whole selection as soon as one path failscanExecute:indentandoutdentrequire every selected path to be movable,swapParentrequires every selected path to be a subthought, andnewGrandChildrequires every selected thought to have a visible child. Thus a selection containing an ineligible thought disables the command outright rather than silently applying it to the rest. BecauseselectedPathsprefers the multicursors, the predicate returns the same value for every path in the loop, so the one predicate both dims the button and blocks the gesture; nodisallowbranch is needed, and none should be added, since that branch bypasses the multicursor restore described above.preventDefault— calle.preventDefault()even whencanExecutereturns false. Useful for keyboard shortcuts that should always swallow the keypress.permitDefault— do not calle.preventDefault()even when the command runs. Useful for shortcuts that piggyback on existing browser behavior (e.g. system copy/paste).allowExecuteFromModal— allow the command to run while a modal is open. Defaults to false; navigation commands set this to true.
repeat (Command/Ctrl + .) has no behavior of its own — its exec is a noop. executeCommand records the last command it executed in lastCommandStore, a private ministore in commands.ts, and both executeCommand and executeCommandWithMulticursor resolve repeat to it before executing, so the repeated command runs through the normal path with its own canExecute and multicursor handling. Resolving before execution (rather than executing from within repeat.exec) also keeps repeat.ts free of an import of commands.ts, which would be circular.
keyboardIndex is recorded alongside the command and restored when it is repeated, since it cannot be derived from the Command/Ctrl + . keypress — that keypress matches none of the repeated command's own shortcuts. Without it, repeating applyColor would have no swatch to apply and would silently do nothing. executeCommandWithMulticursor resolves repeat itself and then delegates an already-resolved command, so it forwards the recorded index through executeCommand's keyboardIndex option.
Only commands that make an undoable, non-navigational change are recorded, so that Repeat repeats the last edit no matter how many navigation or non-undoable commands intervened. The newest undo patch records its command id and whether it is navigational, so no action inference or patch identity comparison is needed. A command with a custom execMulticursor is recorded after that function settles. Consequently:
- Navigation commands (Cursor Down, Jump Back) are skipped — their actions are registered
isNavigation. - Commands that dispatch no undoable action (Export, Settings, Command Universe) are skipped, since they add no patch.
- Commands that set
repeatable: falseare never recorded.undoandredomove through the undo history rather than making a new undoable change, and recordingrepeatwould recurse. - An asynchronous command is recorded after its Promise settles and its patch exists.
Each undo entry on state.undoPatches is a Patch (undoRedoEnhancer) with two parts: ops, the fast-json-patch diff that reverts the change, and patch-level metadata. Every patch records the underlying actionTypes in first-occurrence order and whether all its actions are navigational. Command patches additionally record an invocation id, command id, authored label, optional input method (keyboard, gesture, toolbar, or a command universe), and optional keyboard-shortcut index. Programmatic execution leaves the input method undefined. Direct edits retain action metadata without command attribution. Action-based behavior reads actionTypes; command labels and reproduction steps read the command fields. An action may name its step with an undoLabel in its payload, which becomes the patch's label and takes precedence over the authored label of the command that dispatched it, because it says what the command actually did: the Note command's patch is labeled "Add Note" by the setDescendant that toggleNote dispatches to create the note, and every edit that Note dispatches is labeled "Edit Note". Undo and Redo alerts show that label. Undo and Redo preserve the metadata when they recompute a reverse patch, and omit a reverse patch when replay produces no operations. redoPatches[0] is the oldest undone entry, i.e. the newest point in the history.
executeCommand dispatches commandTransaction, a thunk consumed by the existing Redux thunk middleware. The transaction supplies the command with a scoped dispatch that copies immutable commandMetadata onto every real action it emits. Arrays, nested thunks, and asynchronous continuations use that same dispatch. Nested command executors inherit the outer invocation, so chained and multicursor commands retain one attribution without adding lifecycle actions to reducers, middleware, or the logger. Because attribution travels on each action, reducer output remains determined by state and action, and an interleaved user edit stays outside an asynchronous command's patch. Adjacent actions with the same invocation may merge; an intervening edit or Undo prevents the invocation from reaching back into earlier history. Repeat checks the invocation id on the newest non-navigation patch so a later no-op execution of the same command cannot replace the last successful command.
An undo step may contain more than one patch. A command's adjacent attributed actions, contiguous text edits, and explicitly bracketed multicursor batches each produce a patch with one source of metadata, while the established directional grouping remains unchanged: Undo groups trailing navigation with the destructive patch before it, and a newly created thought with the edit that gives it a value; Redo applies the corresponding grouping in its forward direction. Formatting held for an empty thought — a formatSelection patch that touches pendingFormat — gives it no value, so it is never grouped with the thought's creation in either direction, or in the slider: undoing it leaves the empty thought in place. getUndoStepCount owns the directional boundary rules used by the reducers and undoSteps. The slider retains its structural grouping of formatting with a newly created thought, while keyboard Undo uses the live diff to keep formatting separate. The undo and redo actions accept an optional patch count so the slider can move across whole grouped steps.
The undo slider (UndoSlider, toggled by its toolbar button or by a long press on Undo or Redo) is an rc-slider range over those steps, capped at twenty, with the present at the right. Its two handles start together at the present with the start handle on top. Dragging the start handle back reveals the end handle, which always stays at least one step after the start (except at the present, where the two coincide); the start handle stops one step before the end handle rather than pushing it. Dragging or tapping either handle moves the thoughtspace to the point in time under it, so after dragging the end handle the user sees the end point while the start handle is preserved, and a tap on the start handle returns to the start point. The command or action label that produced each handle's point in time is rendered under the handle. The slider is not bound to the cursor, so it stays open when a step it moves through removes the cursor thought, as undoing the creation of the only thought does, even though closeDropdownsWhenCursorNull closes the toolbar dropdowns then. The handles live in the state of UndoSlider rather than of the slider itself, which is unmounted while the slider is closed, so that closing and reopening it leaves them where the user put them. They reset to the current state whenever the total number of patches changes, i.e. when a new action discards the history ahead of the current state and they no longer refer to the same steps. Opening the slider flushes any pending throttled edit through commandEmitter, and so does each move (see Flushing pending edits): the slider is not a command, so nothing else would flush for it, and an edit typed just before it was opened would be missing from the steps and lost as soon as the slider moved the thoughtspace.
The copy button to the right of the slider copies a bug report for the undo steps between the handles (stepsToReproduce): under Steps to Reproduce, the whole thoughtspace at the start point, exported as plain text with meta attributes inside a markdown code block (omitted when it is empty, which exports as the root's placeholder value rather than as nothing), followed by one numbered description per undo step up to the end point; under Current Behavior, the thoughtspace at the end point; and an empty Expected Behavior heading to fill in. The selector reconstructs the state before and after every patch by applying history from the current state. Command patches describe how the command was invoked—such as Press `Shift + Backspace`., Tap the Delete button., or Cursor Forward (←). for a gesture, drawn as arrows—using the recorded activation surface and keyboard-shortcut index, then infer the affected thought or structural effect from the operations and surrounding states. Direct-action patches retain the existing action-specific descriptions. Cursor and selection setup is inserted when it differs from the state left by the preceding step.
- Create
src/commands/yourCommand.ts. Default-export aCommandobject with at minimumid,label,exec, andmulticursor. Name the command in the singular —defineTerm, notdefineTerms— since a command affects a single thought by default, and carry the singular through all internal terminology (file name,id, action names, variables). Multiselect support is secondary and may call for plural forms only in certain user-facing labels. - Add
export { default as yourCommand } from './yourCommand'tosrc/commands/index.ts. - Pick at least one activation surface:
keyboard— aKeyobject or string. Theindex()startup pass will warn if you collide with an existing shortcut.gesture— a string ofl/r/u/dcharacters (or array of strings).- Toolbar — add an
svg,isActive, and (optionally)longPress. Add theidto the appropriate category group of the appropriate difficulty level inCOMMAND_DIFFICULTIES(constants.ts).
- Decide multicursor behavior. If you skip this and set
multicursor: true, consider whetherfilterorexecMulticursoris more appropriate before merging. - Add tests under
src/commands/__tests__/.
A command file is typically self-contained: it imports the action creator(s) and selectors it needs and dispatches them in exec. Avoid putting business logic in the command file — keep it in actions/selectors so the command stays a thin wiring layer.
The full list of user-facing commands. For the canonical, always-up-to-date set, read src/commands/ directly.
Move the cursor up a level. If Clear Thought is active, cancel it instead and leave the cursor where it is. When thoughts are selected, deselect them and select the parent of each selected thought instead — except on desktop, where Escape clears the selection rather than moving it.
Escape
back.mov
Move the cursor down a level. When thoughts are selected, deselect them and select the thoughts one level forward instead: the visible children of each selected thought, or its contexts when its context view is active.
Move the cursor up.
↑
Move the cursor down.
↓
Move the cursor to the next thought, skipping expanded children.
Command + ↓
Cursor.Next.Thought.mov
Move the cursor to the previous thought.
Command + ↑
Prev.Thought.mov
Move the current thought to the end of the previous thought or to next column in table view.
Tab
Move the current thought to the next sibling of its context or to previous column in table view.
Shift + Tab
Move the cursor to the last thought that was edited.
Command + j
Jump.Back.mov
Move the cursor to the next edit point. Reverses jump back.
Command + Shift + J
Jump.Forward.mov
Indent the current thought one level deeper.
Tab
Indent.mov
Outdent? De-indent? Whatever the opposite of indent is. Move the current thought up a level.
Shift + Tab
Outdent.mov
Swap the current thought with its parent. The children keep their position while the two thoughts trade places, so the parent's other children end up under the current thought and the current thought's children end up under the parent. The exception is =done, which marks a thought as completed rather than describing its children, so it stays with the thought it belongs to.
swapParent.mov
Swap the current thought with its grandparent, leaving the parent in between where it is. The two thoughts exchange places in the tree and each adopts the other's children.
Opens the Command Universe, where commands can be executed by name.
Command + p
Command.Palette.mov
Opens a special keyboard which contains commands that can be executed on the cursor thought. Opening it selects the cursor thought as a multicursor, so every command tapped there runs through executeCommandWithMulticursor on a selection of exactly one thought. A disallow command is therefore still executable from the Command Center; it only alerts once a second thought is selected.
Both openCommandCenter and closeCommandCenter set showCommandCenter themselves rather than leaving multicursorAlertMiddleware to derive it from the selection they changed. The middleware governs multiselection changes, and it deliberately ignores them in states where the Command Center is hidden with the selection intact — over an edit (Clear Thought), under the Undo Slider, and while isMulticursorExecuting is set. In each of those the user can see the panel's actual state, so an explicit gesture must move it rather than be filtered out: swiping up over a hidden panel re-opens it (the selection is already there, so there is nothing for addMulticursor to add), and swiping down over a visible one closes it outright instead of clearing the selection and waiting for the middleware to notice. Deciding on showCommandCenter rather than on hasMulticursor is what keeps the panel from getting stuck open or stuck closed with its own gesture inert.
Opening it from a note first dismisses the keyboard with dismissKeyboard, so that the note's thought is selected with the caret gone, just as it is when opening from a thought. A thought's editable blurs itself as soon as a selection starts (see Editable), but a note keeps the caret, which would leave the keyboard up beneath the sheet. Clear Thought is different on purpose: there the caret is the multi-edit session, so swiping up re-opens the sheet over it without touching the keyboard.
The sheet is lifted above the keyboard only while it is open (avoidKeyboard={isOpen} in CommandCenter). On Chromium react-modal-sheet pads the sheet with the live env(keyboard-inset-height), so a keyboard that rises while the sheet is closing — as it does after Note — would otherwise grow the sheet back into view in the middle of its exit. The prop only takes effect because of the react-modal-sheet patch: with unstyled, the unpatched library writes that padding into its shared, module-level scroller style object rather than a copy, so after the first open every render would keep it regardless of avoidKeyboard. The patch copies the style before padding it.
Swiping up with no cursor and nothing selected cannot open the Command Center, and swiping up while it is already open is more likely to be an attempt to scroll; both show the scroll zone help alert on the second attempt within ten seconds.
Closes the command center if it's open. You can also just tap on the empty space.
Opens the gesture menu where commands can be executed by gesture.
Opens the Help screen, which contains the tutorials and a list of all commands.
Command + /
Help.mov
Navigate to Home.
Command + Option + h
Home.mov
Create a shiny new thought.
Enter
New.Thought.mov
Create a new thought immediately above the current thought.
New.Thought.above.mov
Create a new subthought in the current thought. Adds it to the bottom of any existing subthoughts.
Command + Enter
New.Subthought.mov
Create a new subthought in the current thought. Add it to the top of any existing subthoughts.
Command + Shift + Enter
New.Subthought.top.mov
Add a new thought one level up. Same as creating a new thought and then outdenting it.
Command + Option + Enter
New.Subthought.next.mov
Create a thought within the first subthought. With a multiselect, a new grandchild is created in every selected thought; the new grandchildren are then selected, with the cursor on the last of them. With no cursor, the root stands in for it, so the new thought is created within the first visible thought in the root. The command is disabled when any selected thought — or the root, when nothing is selected — has no subthought to create the grandchild in.
Move the current thought into a new, empty thought at the same level. With a multiselect, every selected thought moves into the new category — and when every visible sibling is selected, the meta attributes that describe the parent's children — =view, =sort, and the =children, =grandchildren, and =descendants containers — follow them into the category, each moving whole with everything it holds. The parent's own direct =pin stays, since it pins the parent itself rather than the wrapped children. The category takes the place of the thought it wraps, except in a sorted context, where it is ranked by the sort condition like any other newly created thought — so a Created context puts it at the end, newest last. See data-model.md → Visibility and sorting.
Command + Option + o or Command + ]
Subcategorize.mov
Deletes the current thought and moves all its subthoughts up a level. If the thought is a favorite, its =favorite attribute is deleted with it rather than moved up, so the parent does not become a favorite. A thought with no subthoughts other than meta attributes is left unchanged, and the alert "Unable to uncategorize thought with no children" is shown instead.
In the Context View, Uncategorize applies to the selected context, with the same rules applied to its own children: the thought moves up into the context's parent, which replaces the context in the Context View. The Context View stays open with the cursor on that parent, which is the home context when the uncategorized context was top-level. The home context itself is left unchanged.
Command + Option + c
Collapse.mov
Extract selected part of a thought as its child.
Command + Control + e
Extract.mov
Extract selected part of a thought as its new parent. Where Extract Subthought draws the selection down into a child, Extract Category lifts it up into a parent: the rest of the thought — and every other selected thought — is moved into a new category whose value is the extracted text. When the selected thoughts do not share a parent, Categorize refuses and nothing is extracted.
Command + Control + Option + e
Adds a 10–20 word AI-generated dictionary entry as the first subthoughts of each selected thought. The entry is split on semicolons and periods into sibling subthoughts in reading order, each capitalized and stripped of trailing punctuation and whitespace. The selected thought's own value is not changed. The command is disabled when any selected thought is empty or has another AI request in progress.
While the request is in flight, each selected thought is marked generating without changing its stored value. The thought keeps its text and gets the same shimmer highlight as Generate Thought until the request settles. The shimmer masks existing paint so colors, backgrounds and native emoji stay visible. Notes shimmer along with their owning thought, preserving their text and formatting.
On first use, em shows the same blocking AI data disclosure as Generate Thought. The command sends the visible text of all selected thoughts to ${VITE_AI_URL}/defineTerm in one request, and the service generates all definitions in one structured LLM response. A valid response is applied as one undo step; a failed or malformed batch leaves the thoughts unchanged. If an individual thought is edited or deleted before the request finishes, its definition is discarded rather than being added under the newer state. Server errors are shown in the error banner, and rate limiting asks the user to try again later.
Gesture: ↑ → ←
Generates a thought using AI.
On first use of the AI generation path on a device, em shows a blocking disclosure modal. The user can cancel, allow the current use only, or allow future uses without seeing the notice again. Choosing either allow option resumes the request that opened the disclosure. The command's completion remains pending until that request finishes; canceling the disclosure settles it as canceled. A remembered acknowledgement can be removed later under Settings → AI Data Acknowledgment. The command sends the relevant current thought context (ancestors and sibling values around the cursor thought) in a JSON request to the OpenAI-backed AI service at ${VITE_AI_URL}/generateThought. The indented outline marks context thoughts with [] and the replacement target with [x]. Sibling metaprogramming attributes are omitted from the outline, since they configure the app rather than describe the user's thoughts; they are omitted whether or not Show Hidden Thoughts is on, so that a view toggle cannot change what leaves the device. The target itself is always included, even when it is an attribute, and an attribute in the ancestor path is kept so that the outline never places the target under a parent that is not its own. The service returns a complete replacement for each selected thought, not text to append to its current value. Multiple selected thoughts are sent as one request with one outline per thought, generated in one LLM completion, and applied all or nothing: a failed or malformed response leaves every selected thought unchanged. They can be reverted together with one undo. A selected empty thought whose first child is a URL fetches its webpage title alongside the AI request instead of being included in it. VITE_AI_URL is the service's base URL so other AI routes can share it. The AI API is rate-limited per IP; when the limit is reached, the command asks the user to try again later. The URL-title branch of this command does not call the AI service.
While the request is in flight, each selected thought is marked generating without changing its stored value. An empty thought shows a "Generating Thought" placeholder; a thought that already has text keeps that text. Both get a shimmer highlight until the request settles. The shimmer masks existing paint so colors, backgrounds and native emoji stay visible. Notes shimmer along with their owning thought, preserving their text and formatting.
Command + Option + g
Generates ten ordered emoji for the current thought using AI and prepends the best match. Repeating the command cycles through the cached alternatives without making another request; editing the thought after generation causes the next use to request fresh alternatives and replace the previously generated prefix. An existing emoji that was not generated by the command is treated as part of the thought and preserved.
While the request is in flight, each selected thought is marked generating without changing its stored value. An empty thought shows a "Generating Emoji" placeholder; a thought that already has text keeps that text. Both get the same shimmer highlight as Generate Thought until the request settles. The shimmer masks existing paint so colors, backgrounds and native emoji stay visible. Notes shimmer along with their owning thought, preserving their text and formatting.
Generate Emoji uses the same blocking AI data disclosure, rate limiting, failure recovery, and ${VITE_AI_URL} service as Generate Thought, calling /generateEmoji with the thought values. Multiple selected thoughts are sent in one request and generated in one LLM completion, and the batch is applied all or nothing: a failed or malformed response restores every selected thought. Selected thoughts that still show a value the command produced cycle their cached alternatives instantly and are left out of the request. The whole selection can be reverted together with one undo.
Gesture: ↑ → ↓
Restructures the selected sibling thoughts and their descendants using AI. This may categorize them under new parent thoughts, split long thoughts into smaller ones, and reorder them more logically. The parent and unselected siblings are sent as read-only context and are left in place. The command is disabled when the selection spans different parents, when a context view is active, or when any selected thought already has an AI request in progress.
While the request is in flight, each reorganizable thought is marked generating without changing its stored value. An empty thought shows a "Reorganizing Thought" placeholder; a thought that already has text keeps that text. Both get the same shimmer highlight as Generate Thought until the request settles. The shimmer masks existing paint so colors, backgrounds and native emoji stay visible. Notes shimmer along with their owning thought, preserving their text and formatting.
On first use, em shows the same blocking AI data disclosure as Generate Thought. The command sends a numbered indented outline of the selected thoughts (and their descendants), their parent, and unselected siblings to ${VITE_AI_URL}/organizeThought in one request. The model returns the final state in the same indented format, retaining [n] for existing thoughts and using [new] for categories and split pieces; the AI service validates every id and converts that text to the recursive JSON tree consumed by the app. Existing thoughts keep their prompt ids so duplicate text is unambiguous. A valid response is applied as one undo step, and the originally selected thoughts remain selected at their new paths so the bullet indicators stay in sync with the desktop alert and Command Center. A failed or malformed result leaves the thoughts unchanged. If a selected thought is edited or deleted before the request finishes, the reorganization is discarded. Server errors are shown in the error banner, and rate limiting asks the user to try again later.
Gesture: ↑ → ↑
Say goodbye to the current thought. Hit undo if you are not ready to part ways.
Command + Option + Shift + Backspace
Delete.mov
Move the thought to a hidden archive. It can be recovered or viewed by toggling hidden thoughts.
Command + Shift + Backspace
Delete.mov
Clear the text of the current thought. A quick recovery after you have changed your mind.
Command + Option + Shift + C
Clear.Text.mov
Bump the current thought down one level and replace it with a new, empty thought. When multiple thoughts are selected, their parent is bumped down and the selected thoughts are moved into the new thought.
A leading emoji labels the thought it is attached to rather than being part of its text, so it stays behind — along with the whitespace that separates it — and only the text it labels is bumped down. The caret is placed after it, ready for the replacement text. A thought that is nothing but an emoji has no text to separate, so it is bumped down whole as usual.
Command + Shift + D
Bump.Thought.Down.mov
Move the current thought down.
Command + Shift + ↓
Move.Thought.Down.mov
Move the current thought up.
Command + Shift + ↑
Move.Thought.Up.mov
Join all thoughts at the same level into a single thought.
Option + j
Join.Thoughts.mov
Merges all duplicate siblings at the same level as the cursor. The first thought of each duplicated value is kept, and the children of the rest are moved into it. Empty thoughts are never treated as duplicates.
Splits multiple sentences in a single thought into separate thoughts. A parenthetical at the end of the thought is split off into a child before anything else, e.g. This is a thought (and a subthought); otherwise sentence punctuation (.;!?) takes priority over every other delimiter.
A thought that contains only a single sentence is split at its highest-precedence delimiter. A dash surrounded by whitespace or a colon splits it into a main thought and a child, e.g. one - 1 and Start: 1 both become a thought with a single child; the right side is then split on its commas, e.g. Shopping list - apples, bananas becomes a thought with two children. A colon only splits when it is followed by whitespace, so that a time such as 10:30 is left intact. If there is no such dash or colon, a copula splits it into a subject and a predicate as described below. If there is no copula either, a slash splits it into a chain of descendants, e.g. one/two/three. If there is no slash either, it is split into siblings on commas, then on the symbols ↑↓←→+, then on the words "vs", "or", and "and", in that order. Like operators, "vs" binds more loosely than "or", which binds more loosely than "and", so the loosest word present is the one split: A vs B vs C becomes the siblings A, B, and C, and Cats and dogs vs birds becomes Cats and dogs and birds. "vs" and "or" do not split a hyphenated word such as do-or-die. A hyphenated "and" compound at the end of the thought, such as set-and-forget, is a list of single words rather than a sentence joined by "and", so it is split before the word "and" is, though after "vs" and "or": the words before it become the main thought and its words become children, e.g. Implies set-and-forget becomes Implies with the children set and forget, and a thought that is only the compound splits into siblings. A period that belongs to an abbreviation, a decimal, or a url is not a sentence boundary, so Mr. Jones → and me also counts as a single sentence; such a thought splits on its commas and symbols, but not on "vs", "or", or "and", which commonly joins the parts of one sentence, e.g. Fruit cost: apple $10.23 and pear $10.70. A dash without surrounding whitespace is usually part of a compound word, such as Jean-Michel, so it has the lowest precedence of all: Jeff Koons, Jean-Michel Basquiat splits on the comma and a → b-c on the arrow, and only a thought with no other delimiter, such as one-1, splits into a main thought and a child at such a dash.
A copula, that is is, are, was, or were, splits a single sentence that has no colon and no dash surrounded by whitespace into its subject as the main thought and its predicate as the child. The predicate loses its leading article and is capitalized when the subject is, so Attention is the most valuable resource becomes Attention with the child Most valuable resource, and attention is the most valuable resource becomes attention with the child most valuable resource. Like the right side of a dash, the predicate is split on its commas, e.g. Best fruits are apples, bananas, oranges becomes a thought with three children. Only a sentence with exactly one copula is split this way: The sky is blue and the grass is green is two clauses, so it splits into siblings on "and" instead. A sentence whose subject is a bare pronoun, such as This is a single sentence or There is a problem, is left intact, since the pronoun would make a meaningless thought on its own. The copula ranks above a slash, so Input/output is the bottleneck becomes Input/output with the child Bottleneck, and above a dash without surrounding whitespace, so Jean-Michel is a painter becomes Jean-Michel with the child Painter.
A thought that none of the delimiters split is split at the caret instead: the text before the caret becomes the main thought and the text after it becomes its child, e.g. Hello wo|rld becomes Hello wo with the child rld. Only a collapsed selection counts as the caret, and only when it is strictly inside the text, so a caret at the start or the end of the thought, or a range of selected text, leaves the thought intact and the command reports that there is nothing to split. A delimiter always takes priority over the caret. Under a multiselect, the caret belongs to the one thought that owns the browser selection; the other selected thoughts are split only at their delimiters.
Command + Shift + S
Note: There is a known bug where the height of the first thought is incorrect after Split Sentences.
Split.Sentences.mov
Selects all thoughts at the current level. May reduce wrist strain.
Command + Option + a or Command + a
Selects all sibling thoughts between two selected endpoints. On desktop, Command/Ctrl-click an unselected thought to set the fixed anchor, then Shift-click an endpoint. Subsequent Shift-clicks adjust the endpoint, replacing only the active range while preserving separately selected or previously committed ranges. With no active multiselect, Shift-click selects the clicked thought and establishes it as the anchor. If the anchor is deselected, the last remaining selected thought becomes the next anchor.
Copies the cursor and all descendants. When thoughts are selected, copies the selection instead, skipping any selected thought that is already a descendant of another (getMulticursorThoughtIds). It only reads the thoughtspace, so it leaves the cursor, the selection, and the undo history untouched.
Command + c
Backspace
Bolds the current thought or selected text.
Command + b
Italicizes the current thought or selected text.
Command + i
Formats the current thought or selected text with strikethrough.
Command + s
Underlines the current thought or selected text.
Command + u
Formats the current thought or selected text as code.
Command + k
Clears all formatting from the current thought or selected text.
Command + 0
Changes the letter case of the current thought or selected text.
Change the text color or highlight color to your liking.
Add a small note beneath a thought. Cute!
Command + Option + n
Option + Shift + N
Convert a thought to a note.
Convert.to.Note.toggle.on.mov
When activated on a thought that already has a note, converts the note to a thought instead.
Swap Note only converts literal notes. Path-based notes reference existing thoughts and cannot be swapped; use Note to edit their content. A thought in the home context with a path-based note retains the home-context conversion restriction.
Convert.to.Note.toggle.off.mov
Opens the context view of the current thought. The context view shows all contexts throughout your thoughtspace in which the thought can be found. Use the same command to close the context view.
Option + Shift + S
Context.View.mov
Display subthoughts of the current thought as indented paragraphs.
Option + Shift + P
Display the current list as a table, with subthoughts rendered in the second column.
Run it with the cursor on one of the list's children, not on the list thought itself: it sets =view/Table on the cursor's parent.
Option + Shift + T
Table.View.mov
Change the sorting option for the current context. Rotates through manual, alphabetical, and reverse alphabetical.
Command + Option + s
Sort.mov
Open a sort picker to pick the sort option and sort by option.
Sets a heading to normal text.
Command + Option + Control + 0
Turns the thought into a large heading.
Command + Option + Control + 1
Turns the thought into a medium-large heading.
Command + Option + Control + 2
Turns the thought into a medium heading. Perhaps a pattern is emerging?
Command + Option + Control + 3
Turns the thought into a medium-small heading. You get the idea.
Command + Option + Control + 4
Turns the thought into a small heading. Impressive that you read this far.
Command + Option + Control + 5
Pins open a thought so its subthoughts are always visible.
Command + Option + p
Pin.Thought.mov
Pins open all thoughts at the current level.
Command + Shift + P
Pin.Subthughts.mov
Pins open all descendants of the current thought. Sets =descendants/=pin on the cursor thought, which expands the entire subtree whenever the thought itself is expanded.
Command + Option + Shift + P
Crosses out a thought to mark it as completed.
Option + Shift + Enter
Done.mov
Show Hidden Thoughts
Show all hidden thoughts.
Option + Shift + H
Show.Hidden.Thoughts.mov
Add the current thought to your Favorites list.
Open the recently edited sidebar.
Option + r
Increase the font size. Bigger is better!
Decrease the font size. Get your reading glasses.
Undo the last action.
Command + z
Redo the last undone action.
Command + Shift + z
Repeats the last command. Repeats the last command.
Navigation and non-undoable commands are ignored, so Repeat always repeats the last command that changed the thoughtspace.
Command + .
Toggle a handy slider that lets you rewind edits.
Drag the start handle back to rewind, then drag the end handle to a later point; tap a handle to jump to its point in time. The copy button copies a bug report with the steps to reproduce the actions between the two. See Undo history and the undo slider.
Download or copy the current context as plain text or html.
Add or remove buttons from the toolbar.
Customize your experience of em.
Add or remove devices that can access and edit this thoughtspace.
Cancel the current gesture.