Tulip Logo IconTulip
Commands

Intro

What Commands are, why they exist, and which problems they solve.

Tulip Commands are a typed action system for business UIs. You define command behavior once, then reuse it across inline actions, dropdown menus, context menus, responsive headers, and table bulk actions.

Entry points

Use the shared package entry points depending on where the code runs:

import { commandBuilder, createCommandBuilder, defineCommands } from "@tulip-systems/core/commands";
import { InlineCommandMenu, DeleteCommand } from "@tulip-systems/core/commands/client";
  • @tulip-systems/core/commands: command definitions and registries
  • @tulip-systems/core/commands/client: menu components, command primitives, utilities, and hooks

What it is

At a high level, Commands are:

  • Typed definitions: each command has input schema, optional permission, optional visibility/disabled rules, and render function
  • Registry-based: commands are grouped with defineCommands({...})
  • UI-agnostic: the same command definition can render in different menu surfaces
  • Composable: each screen picks the subset it needs via .pick({...}).toArray()

Why we need it

Without a command system, action code is usually duplicated across pages:

  • a delete button in row actions
  • another delete action in context menu
  • another delete action in detail header

That leads to drift in permissions, behavior, labels, and mutation side-effects.

Commands solve this by centralizing the action logic and letting UI only decide where to render it.

Problems it solves

Repeated action logic

One command definition can be rendered in many places.

Inconsistent permission checks

Permissions live with the command (.permission(...)) instead of being scattered.

Inconsistent visibility rules

Context-aware visibility belongs in .visibleWhen(...).

Inconsistent disabled behavior

Context-aware disabled state belongs in .disabledWhen(...).

Screen-specific action sets

Pages can pick exactly what they need:

commands={projectCommands.pick({ updateStatus: true, archive: true, delete: true }).toArray()}

Different UI, same behavior

The same command works in:

  • InlineCommandMenu
  • DropdownCommandMenu
  • ContextCommandMenuContent
  • FloatingCommandMenu
  • ResponsiveCommandMenu

Core building blocks

  • commandBuilder / createCommandBuilder<TMeta>()
  • defineCommands({...})
  • menu components from @tulip-systems/core/commands/client
  • utility primitives like DeleteCommand, ArchiveCommand, CommandClick, CommandDialog, and CommandFormDialog
  • Export one registry per module (xCommands)
  • Select commands at usage site with .pick({...}).toArray()
  • Avoid creating extra alias constants only for selected subsets
  • Never pass registry objects directly to menu components; pass arrays

First command

This is the smallest useful command setup: define a schema, register the command, then render a selected array through a menu.

import { commandBuilder, defineCommands } from "@tulip-systems/core/commands";
import { CommandClick, CommandLabel, InlineCommandMenu } from "@tulip-systems/core/commands/client";
import { ArchiveIcon } from "lucide-react";
import z from "zod";
import { orpc } from "@/server/router/client";

const projectSchema = z.object({ id: z.string() });

export const projectCommands = defineCommands({
  archive: commandBuilder
    .input(projectSchema)
    .permission({ project: ["archive"] })
    .render(({ data }) => (
      <CommandClick
        label="Archive"
        variables={{ ids: [data.id] }}
        mutation={orpc.projects.archive.mutationOptions()}
      >
        <ArchiveIcon className="w-4" />
        <CommandLabel />
      </CommandClick>
    )),
});

export function ProjectActions({ project }: { project: { id: string } }) {
  return (
    <InlineCommandMenu
      data={project}
      commands={projectCommands.pick({ archive: true }).toArray()}
    />
  );
}

When a command does not render

Check these in order:

  • the menu received an empty commands array
  • the command was not included in .pick(...) or was removed by .omit(...)
  • data failed the command .input(...) schema
  • .visibleWhen(...) returned false or an array containing false
  • the current user does not satisfy .permission(...)

On this page