Skip to content

Commit 400874b

Browse files
committed
feat(commands): back command surfaces with xstate stores
Squash feat/commands-store after rebasing onto current main. Adds the @xstate/store v4 command and overlay stores, React adapter seams, command palette reactivity, typed command context augmentations, store conventions, and regression/typecheck coverage.
1 parent 7bae5c5 commit 400874b

28 files changed

Lines changed: 2620 additions & 465 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ bun run lint # Lint with oxlint
2828
- `@tooee/shell` is the composition layer — `TooeeProvider` wraps all providers, `launchCli()` creates renderers
2929
- Hotkey format: `ctrl+x`, sequences `g g`, leader keys `<leader>n`
3030
- **Raw `useKeyboard` policy**: app-level `useKeyboard` handlers MUST guard with `useHasOverlay()` (from `@tooee/overlays`) or be ported to `useCommand`. Raw handlers subscribe before the command dispatcher (child effects run first), so modal command surfaces cannot suspend them and `key.preventDefault()` does not protect them — an unguarded handler double-handles keys while an overlay is open. See the `@tooee/commands` README.
31+
- **Store conventions**: stateful interaction systems use `@xstate/store` event stores with thin React adapters — see [docs/store-conventions.md](docs/store-conventions.md) for when to use a store vs `useState` vs an effect, file layout, testing, and selector discipline.
3132

3233
## Documentation
3334

3435
- [docs/testing.md](docs/testing.md) — Testing guide (component tests with testRender, e2e tests with tuistory)
36+
- [docs/store-conventions.md](docs/store-conventions.md) — Store conventions (`@xstate/store` usage, selectors, testing pattern)

bun.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/store-conventions.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Store conventions
2+
3+
Tooee models stateful interaction systems (command registry, surface stack,
4+
overlay stack, sequence display, nav/search) as explicit event-driven stores
5+
built on `@xstate/store`, with thin React adapters on top. This note records
6+
when to reach for a store, where store code lives, and how it is tested.
7+
8+
Rule of thumb: **transition logic moves into store events; IO stays at the
9+
boundary.** An effect that only *repairs state after render* is a store
10+
transition in disguise. An effect that talks to a renderer, timer, stream, or
11+
process is doing its job.
12+
13+
## When to use what
14+
15+
| Situation | Use |
16+
|---|---|
17+
| Synchronous state updated by named events; cross-component; needs subscription/selectors (registry, surface stack, overlay stack, nav/search, sequence display) | `@xstate/store` |
18+
| Async lifecycles with real states and cancellable work (content loader) | `@xstate/store` + request-ids first; full `xstate` only if a written justification shows the statechart pays for itself |
19+
| Component-local input state that never crosses a component boundary (a filter query local to one overlay, a focus flag) | React `useState` |
20+
| Purely derived render data | `useMemo`/selectors — never an effect, never a store |
21+
| Imperative OpenTUI ref sync, subscriptions, timers, async IO | effects (or store-adjacent wrappers) — these are legitimate external synchronization and stay effects |
22+
23+
## File layout
24+
25+
```
26+
packages/<pkg>/src/<domain>-store.ts # createXxxStore() factory: context type, events, pure transitions, selectors. No React imports.
27+
packages/<pkg>/src/use<Domain>.ts # React wrappers: context provider plumbing, useSelector hooks. (May be folded into an existing provider file when the provider is the only consumer.)
28+
```
29+
30+
- Stores are **per-provider instances** created by a factory
31+
(`createCommandStore(options)`), never module-level singletons — tests and
32+
multiple app roots need isolated instances.
33+
- Selectors are exported from the store file as named pure functions
34+
(`selectActiveModalSurface(ctx)`) so pure tests and React hooks share them.
35+
- Side effects (callbacks, mode restoration, handler invocation) leave the
36+
store via emitted events (`enqueue.emit.*` / `store.on`) or via a thin
37+
imperative wrapper that owns IO-adjacent state (timers, key buffers) and is
38+
the store's single writer.
39+
40+
## Event typing
41+
42+
Stray event names must be compile errors. Two equivalent, accepted ways to
43+
get there:
44+
45+
- **Handler inference** (commands store): type each transition's `event`
46+
parameter inline and let `createStore` infer the event payload map from
47+
`on`. Works when the store emits nothing.
48+
- **Explicit closed generics** (overlay store): declare event/emitted payload
49+
map types and pass them as `createStore<TContext, TEvents, TEmitted>`
50+
generics. Required when transitions `enqueue.emit`, since v4 removed the
51+
`emits` config.
52+
53+
Either way the payload maps must stay **closed**: never add a
54+
`[key: string]: ...` index signature to satisfy a constraint — it silently
55+
makes every `trigger.*`/`emit.*`/`send`/`on` name compile.
56+
`overlay-store.typecheck.ts` pins this with `@ts-expect-error` assertions
57+
checked by `tsc -b`. v4's `schemas` config (Standard Schema) is for runtime
58+
validation and type inference from schema libraries; do not add type-only
59+
schema objects just for event-name strictness — the generics already provide
60+
it.
61+
62+
## Testing pattern
63+
64+
1. **Pure transition tests first** (`test/<domain>-store.test.ts`): create
65+
store, trigger events, assert snapshots/selectors/emits. No React, no
66+
renderer. This is the bulk of coverage.
67+
2. **Minimal React tests second**: only for the adapter seams — hook
68+
reactivity, provider wiring, arbitration visible through rendering. Use the
69+
existing `test/support/test-render.ts` harness.
70+
71+
## Selector discipline (TUI render cost)
72+
73+
TUI frames are expensive; store subscriptions must not cause avoidable
74+
re-renders.
75+
76+
- Components subscribe with `useSelector(store, selector)` and the
77+
**narrowest selector that answers the question** (`selectHasOverlay`, not
78+
the whole stack; `selectActiveSurfaceMeta`, not the surfaces array).
79+
- Selectors returning fresh arrays/objects must either be memoized against
80+
context identity or passed an equality function; prefer selecting primitives
81+
and stable references.
82+
- Transitions must preserve reference identity of untouched slices
83+
(spread-copy only the slice that changed) so selector equality checks
84+
short-circuit.
85+
- Never subscribe a hot render path (per-row components) to a store that
86+
changes per keystroke; select at the provider/container level and pass
87+
values down.
88+
89+
## Dependency policy
90+
91+
`@xstate/store` (v4) is the only store dependency, plus `@xstate/store-react`
92+
(v2) for the React `useSelector` adapter in packages with React seams (v4
93+
moved framework adapters out of the core package). Do not add full `xstate`
94+
(statecharts, actors) without a written justification in the relevant design
95+
doc — synchronous UI transitions do not earn it.
96+
97+
## One lifecycle: surfaces and overlays
98+
99+
The command surface stack (`@tooee/commands`) and the overlay stack
100+
(`@tooee/overlays`) are separate stores in separate packages, but they model
101+
one lifecycle: opening an overlay with `ownCommands` mounts a command surface;
102+
closing it pops the surface. `@tooee/overlays` must not depend on
103+
`@tooee/commands`; the shell's `OverlayProvider` is the bridge that ties the
104+
two stores together (mode restoration, sequence reset on same-id overlay
105+
replacement).

packages/ask/src/Ask.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ import {
2929
type VimMotionState,
3030
} from "./vim-motions.js"
3131

32+
declare module "@tooee/commands" {
33+
interface CommandContext {
34+
/** Contributed by Ask: the current input value. */
35+
ask: { value: string }
36+
}
37+
}
38+
3239
interface AskProps extends AskOptions {
3340
actions?: ActionDefinition[]
3441
}

packages/choose/src/Choose.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ import type { ActionDefinition } from "@tooee/commands"
1717
import type { ChooseItem, ChooseContentProvider, ChooseOptions, ChooseResult } from "./types.js"
1818
import { fuzzyFilter } from "./fuzzy.js"
1919

20+
declare module "@tooee/commands" {
21+
interface CommandContext {
22+
/** Contributed by Choose: the current selection state. */
23+
choose: {
24+
activeItem: ChooseItem | undefined
25+
selectedItems: ChooseItem[]
26+
filterQuery: string
27+
}
28+
}
29+
}
30+
2031
interface ChooseProps {
2132
contentProvider: ChooseContentProvider
2233
options?: ChooseOptions

packages/commands/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,9 @@
4646
"@opentui/core": "^0.2.15",
4747
"@opentui/react": "^0.2.15",
4848
"react": "^18.0.0 || ^19.0.0"
49+
},
50+
"dependencies": {
51+
"@xstate/store": "^4",
52+
"@xstate/store-react": "^2"
4953
}
5054
}

0 commit comments

Comments
 (0)