StateKernel
The chart's single source of truth, the signal primitives it is built on, and why Agent tools are a subset of its actions.
Every piece of chart state lives in one place: the symbol, period, viewport, zoom, data, indicators, drawings, interaction, settings and theme. The UI, the framework bindings and the Agent all read the same signals and write through the same actions. Nothing keeps a second copy.
Four rules
The contract is written down in engine/state/stateKernel.ts and enforced by the type system.
- One writable signal per piece of state. No shadow fields, no caches to keep in sync.
- Derived values are
computed(). They re-evaluate when their sources change. There are nosyncXxx()methods. - Consumers get
ReadonlySignal. It has no.set(), so a renderer or a component cannot write even by accident. - Only actions write. Every change goes through a named action such as
scrollToorzoomTo, which is the one place to validate, batch or trigger side effects.
ChartStateKernel composes the sub-states (options, zoom, data, data manager, viewport, pane, settings, theme, drawing, interaction, indicator, marker, renderer) and exposes their readonly views and actions. The kernel itself holds no business logic; it only wires modules together.
The primitives
The kernel is built on a small push-based signal library with no dependencies. It is public at @363045841yyt/klinechart-core/reactivity.
| Export | What it does |
|---|---|
createSignal(initial) | A WritableSignal: call it to read, .peek() to read without tracking, .set() to write, .subscribe() to listen. writableRef is an alias |
computed(fn) | A ReadonlySignal derived from the signals fn reads |
effect(fn) | Re-runs fn when any signal it read changes; returns a cleanup function |
batch(fn) | Defers notifications until the outermost batch exits, then fires each subscriber once |
selectSignal(source, selector, equal?) | A readonly projection that only notifies when the selected value changes; call dispose() when done |
createSubState(initial, computedFns?) | Builds a sub-state: private signals, public readonly, and snapshot() |
createFrameTransaction(options) | Coalesces high-frequency input into one published snapshot per frame |
The semantics are deliberately plain. Outside a batch, set notifies synchronously, with no microtask. Equality is Object.is. There is no Proxy and no deep tracking, only top-level reads and writes. subscribe returns an unsubscribe function, which is what lets React's useSyncExternalStore, Vue effects and Angular's toSignal consume the same signals.
Writing a sub-state
A sub-state keeps its writable handles private and returns only readonly signals and actions.
import { batch, createSubState } from '@363045841yyt/klinechart-core/reactivity'
export function createViewportState() {
const { signals, readonly } = createSubState(
{ scrollLeft: 0, viewWidth: 0 },
{ scrollRight: (s) => s.scrollLeft() + s.viewWidth() },
)
return {
readonly,
actions: {
scrollTo: (value: number) => signals.scrollLeft.set(Math.max(0, value)),
resize: (width: number, scrollLeft: number) =>
batch(() => {
signals.viewWidth.set(width)
signals.scrollLeft.set(scrollLeft)
}),
},
}
}readonly is not just a type-level promise. createSubState builds new wrapper functions without .set, so the write handle is absent at runtime too.
Reading from outside
Bindings and host code see the kernel through ChartController, where every state field is a ReadonlySignal.
const theme = controller.settings().theme // read
const stop = controller.viewport.subscribe(() => {
const viewport = controller.viewport.peek() // read without tracking
// update your own UI
})
stop()Atomic snapshots
Two mechanisms make sure no one reads half an update.
batch()groups writes into one notification cycle. A subscriber that fires afterresizesees both the new width and the new scroll position. The flush keeps draining until no listener is pending, because acomputedcan schedule further listeners while it runs.- Frozen state. Committed values are frozen.
deepFreezeSnapshotcopies and freezes JSON-like parameters so a nested object cannot be mutated around an action;deepFreezeOwnedfreezes large results in place without copying;immutableMapthrows onset,deleteandclear.FrameTransactionfreezes the root of each published frame snapshot, and input written while a frame is rendering goes to the next generation.
The kernel also controls when rendering happens. State changes ask for a draw; the frame transaction decides when to paint, so a burst of pointer events produces one frame, not one per event. The story behind that design is told in frame transactions, timing and effect (in Chinese).
Why tools are a subset of actions
An Agent tool is not a parallel API. It is an action with a schema attached. The @Tool decorator registers a public method on the same class the UI calls, and getRegisteredChartTools() hands those methods to the Agent runtime.
export class SettingsCommands {
@Tool({
name: 'settings_update',
label: 'Update chart settings',
parameters: SettingsUpdateToolParameters,
safety: 'destructive',
executionMode: 'sequential',
// description omitted
})
async updateSettings(input: SettingsUpdateInput): Promise<SettingsChangeResult> {
return this.applyValues(input.values) // the same action the settings dialog calls
}
}The settings dialog, the command palette and the Agent all end up in applyValues. That gives three properties for free:
- One state. The Agent reads exactly what the user sees, at no extra cost, because there is nothing to copy or sync.
- One execution path. Validation, batching and undo data are written once. A fix applies to people and the Agent together.
- Not every action is a tool. Only actions that make sense for the Agent are decorated, each with an input schema and a safety level (
read-onlyordestructive). The set of tools can only ever be a subset of what the kernel allows.
ADR 0006 records the settings side of this: settings apply instantly, with no draft copy.