noggin

Component reference

@noggin/ui ships a small React surface. Everything below is exported from the package's default entry point (import { … } from '@noggin/ui') unless a section says otherwise.

Components

Verb dispatch

Controllers, stores, helpers

Tree helpers

Misc

Types

RemoteNoggin (the optimistic client-side adapter) lives in @noggin/rpc, not here — see Working with a remote noggin.

Both top-level components accept a classNames prop — a per-slot map of extra class names that are merged onto the built-in ones. Use it for one-off host tweaks; for global re-skinning, use design tokens instead.

createNogginActions(noggin, opts?)

The verb-dispatch surface every UI component consumes. Returns a NogginActions — one method per logical user intent. Components and hosts invoke the same method regardless of how the user expressed the intent (click, menu pick, keyboard shortcut, drag-drop).

Methods (every one takes a NogginItemKey instead of a path so intermediate re-numbering doesn't strand pending intents):

GroupMethodReturns
Item-localrename(key, title){ key, title }
toggleDone(key, currentlyDone){ key, nowDone }
delete(key, hasChildren){ deletedKey, fallbackFocusKey }
appendNote(key, markdown){ key }
activate(key){ key }
AddsaddSiblingAfter(key){ newKey }
addSiblingBefore(key){ newKey }
addChild(key){ newKey }
addFirstSibling(key){ newKey }
addLastSibling(key){ newKey }
MovesmoveUp(key){ movedKey }
moveDown(key){ movedKey }
moveToFirst(key){ movedKey }
moveToLast(key){ movedKey }
demote(key){ movedKey }
promote(key){ movedKey }
Explicitmove(key, { kind, anchor }){ movedKey }

Every method is async and resolves to its result envelope. A null newKey / movedKey / fallbackFocusKey means the action was a no-op against current state (e.g. moveUp on the first sibling).

import { createNogginActions } from '@noggin/ui';

const actions = createNogginActions(noggin, {
  // Optional middleware: wraps every dispatch. Hosts use it for
  // toasts on error, busy indicators, etc.
  middleware: async (fn) => {
    try { return await fn(); }
    catch (err) { showToast(uiErrorMessage(err)); throw err; }
  },
});

Hosts that need pre-flight confirmation (e.g. "confirm before delete") decorate the returned object:

const base = createNogginActions(noggin);
const actions: NogginActions = {
  ...base,
  delete: async (key, hasKids) => {
    if (hasKids && !(await confirm('Delete subtree?'))) {
      return { deletedKey: key, fallbackFocusKey: null };
    }
    return base.delete(key, hasKids);
  },
};

noggin is any object that satisfies the engine's Noggin interface — an in-process noggin from @noggin/engine, or a RemoteNoggin from @noggin/rpc. The components don't care which. The returned actions object exposes the bound noggin as a read-only noggin field, which buildTreeMenuEntries and the components read for current sibling / active state.

NogginTree

Drag-and-drop tree backed by a virtualized list with keyboard navigation, drag reordering, inline rename, and a right-click context menu.

import { NogginTree, createNogginActions, projectTree } from '@noggin/ui';

const actions = useMemo(() => createNogginActions(noggin), [noggin]);
const nodes = useMemo(() => projectTree(noggin), [noggin, tick]);

<NogginTree
  nodes={nodes}
  activeKey={noggin.active?.key ?? null}
  selectedPath={selectedPath}
  renamingPath={renamingPath}
  actions={actions}
  onSelect={setSelectedPath}
  onRequestRename={(path, opts) => {
    setRenamingPath(path);
    // opts.isNew is true when the tree is following up after an
    // add — wire a "cancel deletes the empty row" fallback if you
    // want one.
    setRenamingIsNew(opts?.isNew === true);
  }}
  onRenameCancel={() => setRenamingPath(null)}
/>

The tree drives default post-action UI orchestration internally:

Hosts that need different orchestration wrap the actions object before handing it to the tree.

Props

PropTypeRequiredWhat it does
nodesNogginNode[]yesThe projected forest. Use projectTree(noggin) to derive from a live noggin.
activeKey`string \null`yesKey of the engine's active item.
actionsNogginActionsyesVerb-dispatch surface. Build with createNogginActions(noggin).
onSelect(path) => voidyesHost-owned selection state. Fires on click and keyboard navigation; also fired by the tree's default post-action orchestration.
selectedPath`string \null`noControlled selection. The host typically mirrors onSelect into this.
renamingPath`string \null`noControlled inline-rename mode. Non-null switches the matching row into an input.
onRequestRename(path, opts?) => voidnoTree asks for rename mode (F2, "Rename" menu pick, or its own post-add follow-up). The second arg is { isNew: true } only for the post-add case; user-driven calls omit it. Note: double-click on a row is bound to activate, not rename.
onRenameCancel() => voidnoRename was abandoned (Escape, blur on unchanged). Host clears renamingPath.
fileId`string \null`noStable id for the open noggin; tree state resets when it changes.
rowHeightnumbernoDefault 22.
indentnumbernoIndent per level. Default 14.
width / heightnumbernoExplicit virtualizer size. Defaults to filling parent.
classNamesNogginTreeClassNamesnoPer-slot class overrides. See below.
renderContextMenu(props) => ReactNodenoSwap the popup chrome (e.g. native VS Code menu). Tree still owns the entries.

Slots

SlotElement
rootOuter wrapper <div>.
rowEach tree row.
rowSelectedExtra class when row is selected.
rowActiveExtra class when row is the engine-active item.
rowDoneExtra class when row is done.
titleThe title <span> inside the row.
pathThe dotted-path <span> (e.g. /1/2).
<NogginTree
  /* ... */
  classNames={{
    rowSelected: 'my-row--highlighted',
    rowActive:   'my-row--active-pulse',
  }}
/>

Gotchas

NogginDetails

Right-hand pane that shows the selected item's title, dotted path, metadata, notes (markdown-rendered), and an inline note editor. Includes a kebab "actions" button that opens the same canonical context menu the tree's right-click produces.

import { NogginDetails } from '@noggin/ui';

<NogginDetails
  item={detailsItem}
  actions={actions}
  onCollapse={() => host.collapsePane()}
  collapseIcon="chevron-right"
/>

Props

PropTypeRequiredWhat it does
item`NogginDetailsItem \null`yesThe item to display. null shows the empty state.
actionsNogginActionsyesVerb-dispatch surface. Same instance the tree consumes.
onCollapse() => voidnoHost should collapse the pane. When omitted, the chevron button is hidden.
collapseIconstringnoCodicon name for the collapse chevron. Default 'chevron-right'.
renderContextMenu(props) => ReactNodenoSwap the kebab-menu popup chrome.
classNamesNogginDetailsClassNamesnoPer-slot class overrides.

Slots

SlotElement
rootOuter pane wrapper.
headerRow with state icon, title, and overflow buttons.
titleThe <h2> title element.
pathThe dotted-path caption (rendered twice — both share this slot).
notesThe notes <ul>.
noteItemEach note <li>.
addNoteThe collapsed "Add note" button.

Gotchas

NogginList

Sidebar list of noggins the user has opened, bookmarked, or wants to keep at hand. Renders one row per URI with a provider-type badge, a completion gauge, cached "last active" hints, per-row copy-to-clipboard chips, and a per-row remove (×) button. The header carries a + menu (with an optional "Recent ▸" submenu) and a ⋮ kebab menu (show-toggles, sort, filter, close-active-entry, plus any host-supplied extras).

NogginList is fully controlled through three collaborators the host constructs and owns:

An optional MRUReader enables the "Recent ▸" submenu, the per-row time chips, and the 'newest' / 'oldest' sort modes.

import {
  NogginList,
  createNogginListStore,
  createNogginProviderRegistry,
  defaultNogginProviders,
  defaultNogginListPrefs,
  createMRUManager,
} from '@noggin/ui';

const store = createNogginListStore({
  initialEntries: loadFromLocalStorage('noggin:list'),
  onStateChange: ({ entries }) => save('noggin:list', entries),
  onUriActivity: (uri) => mru.touch(uri),
});
const providers = createNogginProviderRegistry(defaultNogginProviders);
const mru = createMRUManager({
  initial: loadFromLocalStorage('noggin:mru') ?? {},
  onStateChange: ({ entries }) => save('noggin:mru', entries),
});
const [prefs, setPrefs] = useState(defaultNogginListPrefs);

<NogginList
  store={store}
  providers={providers}
  prefs={prefs}
  onPrefsChange={setPrefs}
  recent={mru}
  onActivate={(uri) => host.openNoggin(uri)}
  onCloseActiveEntry={() => host.closeActive()}
/>

Bridge a live noggin into the store so the row's cached counts and active-item hint stay fresh:

useEffect(() => {
  if (!noggin || !openedUri) return;
  store.add(openedUri);
  store.setSelectedIds([openedUri]);
  return store.observe(openedUri, noggin).dispose;
}, [noggin, openedUri, store]);

Props

PropTypeRequiredWhat it does
storeNogginListStoreyesEntries + selection controller. See createNogginListStore.
providersNogginProviderTypeReaderyesCatalog for badges + the + menu's picker list.
prefsNogginListPrefsyesView preferences (sort, filters, column toggles). Controlled — the component never mutates.
onPrefsChange(next) => voidyesFires with the next prefs when the kebab menu changes a toggle / radio.
onActivate(uri) => voidyesUser clicked or Enter-activated a row. Host opens the noggin.
onCloseActiveEntry() => voidnoWhen present and at least one row is selected, the kebab menu shows "Close active noggin".
extraMenuEntriesreadonly TreeContextMenuEntry[]noExtra entries appended to the kebab menu's footer. Uses the same vocabulary as the tree menu.
recentMRUReadernoEnables the "Recent ▸" submenu, the per-row time chip, and 'newest'/'oldest' sort.
emptyStateReactNodenoOverride the default "no entries yet" copy.
headerTitleReactNodenoOverride the header title (default 'Noggins').
classNamesNogginListClassNamesnoPer-slot class-name overrides.

Slots

SlotElement
rootOuter <aside> wrapper.
rowEach row.
rowSelectedExtra class on rows in store.selectedIds.
rowMissingExtra class on rows whose entry has exists === false.
labelThe row's title element.
badgeProvider-type badge (right-aligned).
gaugeCompletion-gauge wrapper.
copyButtonEach copy-to-clipboard chip.
removeButtonThe per-row (×) button.
emptyStateThe empty-state container.

Gotchas

Icon

Codicon <i> wrapper. Codicons are loaded once by styles.css; this component just standardises the markup.

import { Icon } from '@noggin/ui';

<Icon name="chevron-right" title="Collapse pane" />
PropTypeNotes
namestringCodicon name (without the codicon- prefix).
titlestringOptional title attribute; when set, the icon is announced to screen readers (otherwise aria-hidden).
classNamestringMerged onto codicon codicon-<name>.
styleCSSPropertiesPassthrough.

Controllers & pure helpers

createNogginListStore(opts?)

Build the NogginList controller. Owns the raw entries array, the selected-ids array, and an internal bridge between live Noggin instances and cached row data.

import { createNogginListStore } from '@noggin/ui';

const store = createNogginListStore({
  initialEntries: [{ uri: 'file:///work/today.yaml' }],
  onStateChange: ({ entries }) => save('noggin:list', entries),
  onUriActivity: (uri) => mru.touch(uri),
});

Options:

OptionTypePurpose
initialEntriesreadonly NogginListEntry[]Seed entries (e.g. persisted from localStorage).
onStateChange({ entries }) => voidFires after any change to entries. Not called for selection-only or observation-only shifts. Host persists here.
onUriActivity(uri, at) => voidFires whenever an observed noggin's onDidChange fires. Wire this to an MRU manager. Does not fire on the initial snapshot.

Returned surface (NogginListStore):

MemberPurpose
entriesRaw entries in stored order.
selectedIdsCurrently-selected URIs (v1 UX is single-select).
onDidChange(cb)Subscribe to any state shift. Returns { dispose }.
add(uri, init?)Insert or merge an entry.
remove(uri)Remove; drops from selection if present.
reorder(uri, beforeUri)Move uri before beforeUri (or to end when beforeUri === null).
setSelectedIds(ids)Replace the selection.
observe(uri, noggin)Bridge a live noggin into the entry (updates cached counts + active hint on every change). Returns { dispose }.

Persistence errors thrown by onStateChange are captured and rethrown on the next mutation so hosts see them without losing the in-memory change that triggered them.

defaultNogginListPrefs

Starter NogginListPrefs value. Merge with loaded persisted prefs (spread the default first so newly-added prefs pick up a sensible fallback):

import { defaultNogginListPrefs } from '@noggin/ui';

const prefs = { ...defaultNogginListPrefs, ...(loadPrefs() ?? {}) };

Fields (see NogginListPrefs in the types reference): sortMode, typeFilter, completionFilter, showTitle, showKey, showPath, showType, wrapTitles.

applyListPrefs(entries, prefs, providers, mru?)

Pure projection: apply a NogginListPrefs to an array of entries and return the filtered/sorted view. NogginList runs this internally; exported so hosts and tests can reuse the same logic outside React.

import { applyListPrefs } from '@noggin/ui';

const visible = applyListPrefs(store.entries, prefs, providers, mru);

Ordering:

  1. Filter by prefs.typeFilter (URI scheme match; provider aliases honoured).
  2. Filter by prefs.completionFilter ('unknown' counts as 'incomplete').
  3. Sort by prefs.sortMode: - 'manual' — preserve input order. - 'newest' / 'oldest' — sort by mru.lastUsedAt(uri). Without an mru, falls back to manual order.

completionStatusOf(entry)

Pure derivation of an entry's completion status from its cached counts.

completionStatusOf({ itemsTotal: null }) // 'unknown'
completionStatusOf({ itemsTotal: 3, itemsDone: 3 }) // 'complete'
completionStatusOf({ itemsTotal: 3, itemsDone: 1 }) // 'incomplete'
completionStatusOf({ itemsTotal: 0 })   // 'incomplete' (empty ≠ done)

Provider-type registry

The renderer-side catalog of noggin provider descriptors (label, badge, icon, pickers, read-only flag). NogginList reads it for row badges + the + menu. Also consumed by the desktop app's Help → Installed Providers dialog.

The registry is a pure renderer-side concern — it holds no engine references and is safe in any browser bundle.

createNogginProviderRegistry(seed?)

Build a mutable registry. Pass the default catalog to seed, or undefined for an empty registry.

import {
  createNogginProviderRegistry,
  defaultNogginProviders,
} from '@noggin/ui';

const providers = createNogginProviderRegistry(defaultNogginProviders);

// Register a picker with the file descriptor:
const fileType = providers.get('file');
// (in practice: extend the descriptor before registration, or
//  re-register with picker set — see NogginList docs)

const dispose = providers.register({
  scheme: 'redis',
  label: 'Redis',
  badgeTone: 'accent',
  icon: 'database',
});
// dispose.dispose() removes it later

Reader surface (NogginProviderTypeReader):

Mutable surface adds register(type) (throws on duplicate scheme; returns { dispose }).

defaultNogginProviders

Metadata for the three providers bundled with @noggin/engine (file, https + http alias, memory). Hosts wire pickers on top by re-registering the descriptor after construction (or by providing a pre-seeded catalog).

createMRUManager(opts?)

Small, self-contained registry of URI → ISO-8601 UTC "last used" timestamps with bounded retention and MRU-first enumeration.

import { createMRUManager } from '@noggin/ui';

const mru = createMRUManager({
  initial: JSON.parse(localStorage.getItem('noggin:mru') ?? '{}'),
  onStateChange: ({ entries }) =>
    localStorage.setItem('noggin:mru', JSON.stringify(entries)),
  maxEntries: 20,
});

mru.touch('file:///work/today.yaml');
mru.recent(5); // top 5 URIs, MRU-first

Options: initial, onStateChange, maxEntries (default 10; pass Infinity or 0 to disable eviction).

Reader surface (MRUReader): lastUsedAt(uri), entries(), recent(limit?), onDidChange(cb). Mutable surface adds touch(uri, at?), forget(uri), clear().

The MRU never subscribes to anything; hosts bridge activity into it explicitly. The canonical wiring is from createNogginListStore({ onUriActivity }) (which fires on every observed change of an open noggin) into mru.touch(uri).

buildTreeMenuEntries({ actions, key, onRequestRename? })

The canonical right-click / kebab menu builder. Both NogginTree and NogginDetails call this for their built-in menus; it's also exported publicly so hosts that render the menu in a native popup (e.g. VS Code's showQuickPick) get exactly the same entries the components would have shown.

import { buildTreeMenuEntries } from '@noggin/ui';

const entries = buildTreeMenuEntries({
  actions,
  key: someItemKey,
  onRequestRename: (key) => host.openInlineRename(key),
});
// entries is a readonly array of TreeContextMenuEntry.

The builder reads actions.noggin for current sibling neighbours and active state, so disabled flags ("Move up" on the first sibling, "Promote" on a root, etc.) match the live tree. Returns an empty array when key doesn't resolve.

Generic click-to-open dropdown wrapper using the same Radix chrome the tree's kebab menu uses. Bring your own trigger (an icon button in the sidebar, a header chip, etc.).

import { DropdownActionsMenu } from '@noggin/ui';

<DropdownActionsMenu
  trigger={<button aria-label="More"><Icon name="kebab-vertical" /></button>}
  buildEntries={() => [
    { kind: 'item', key: 'export', label: 'Export…', onClick: doExport },
    { kind: 'separator', key: 's1' },
    { kind: 'item', key: 'settings', label: 'Settings', onClick: openSettings },
  ]}
/>
PropTypeNotes
triggerReactNodeA single focusable element (Radix asChild).
buildEntries() => readonly TreeContextMenuEntry[]Called on open — supports item, checkbox, radio, header, separator.
align`'start' \'center' \'end'`Radix alignment. Default 'end' (right edge).

Tree helpers

Pure walkers over the NogginNode[] forest NogginTree renders. Every helper is synchronous, O(depth) at worst, and does not mutate its input.

projectTree(noggin)

Projects a noggin's flat items accessor into the nested NogginNode forest the tree renders.

import { projectTree } from '@noggin/ui';

const nodes = projectTree(noggin);   // NogginNode[]

Hosts typically subscribe to noggin.onDidChange and re-project on every change. For very large noggins consider memoising or applying incremental patches; the desktop renderer uses an applyChanges helper to patch the projected forest instead of rebuilding it.

Tree navigation helpers

All take a NogginNode[] forest plus a slash-path (/1/2/3) and return a NogginNode (or null).

HelperPurpose
findByPath(nodes, path)Locate a node by its dotted path.
siblingsOf(nodes, path)The sibling list containing path (including itself).
parentOf(nodes, path)Parent node, or null for a root.
prevSibling(nodes, path)Preceding sibling, or null at start.
nextSibling(nodes, path)Following sibling, or null at end.
firstSibling(nodes, path)First node in the containing sibling list.
lastSibling(nodes, path)Last node in the containing sibling list.

Misc

renderMarkdown(source)

Sanitised markdown → HTML string. Matches the flavour the NogginDetails note viewer renders. Returns a plain string suitable for dangerouslySetInnerHTML (safe because the converter escapes/strips inline HTML).

import { renderMarkdown } from '@noggin/ui';

const html = renderMarkdown('**hello** _world_');

uiErrorMessage(err)

Convert a NogginError (or the error field of a JSON envelope — anything with code, message, and optional data) into a short user-facing string keyed off the stable error code. Uses UI vocabulary ("tree", "menu", "drag") rather than the CLI's --flag vocabulary. Falls back to err.message for unknown codes.

import { uiErrorMessage } from '@noggin/ui';

try {
  await actions.moveUp(key);
} catch (err) {
  toast.show(uiErrorMessage(err));
}

The CLI has its own catalog at cli/error-messages.mjs; the MCP server has one too. Same underlying code, different audience.

Keyboard helpers

Two helpers surface the tree's keymap for hosts that need to recognise or intercept the same gestures elsewhere.

import { gestureForKey, shouldInterceptFromRename } from '@noggin/ui';

// In a global keydown listener:
const gesture = gestureForKey(e); // TreeGesture | null
if (gesture === 'moveUp') { … }

// While an inline-rename input is focused:
if (shouldInterceptFromRename(gesture)) {
  // commit rename, then dispatch the gesture
}

cn(...parts)

The tiny class-name composer used internally. Accepts strings, falsy values, and undefined; joins truthy strings with spaces.

import { cn } from '@noggin/ui';

cn('btn', isActive && 'btn--active', extraClass);
// → "btn btn--active foo" (when isActive && extraClass='foo')

Exported so consumers can use the same helper when composing their own classNames slot values.

Public type exports

Every publicly-consumed type is exported from @noggin/ui. Grouped by area:

Components

Data

Actions

Menus

NogginList controllers

Errors

Working with a remote noggin

The optimistic adapter at @noggin/rpc. Wraps a noggin-rpc transport (Electron IPC, postMessage, fetch+SSE, …) and exposes the engine's Noggin interface.

import { RpcClient } from '@noggin/rpc';
import { openRemoteNoggin } from '@noggin/rpc';
import { createNogginActions } from '@noggin/ui';

const client = new RpcClient(myTransport);
const noggin = await openRemoteNoggin({
  client,
  location: 'file:///work/today.yaml',
});
const actions = createNogginActions(noggin);
// Every component works exactly as it does with an in-process noggin.

The components don't know whether they're talking to an in-process engine or a remote one; both satisfy Noggin and both work as the input to createNogginActions.