HookWizard

Create or edit a hook in a modal: pick the event, a tool matcher for tool events, a shell command or an LLM prompt with a timeout, and the scope. The wizard shows the JSON payload the hook receives for the chosen event. A rejected onSubmit keeps the wizard open with the error.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
New hook
Edit a hook
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

hooks-config/HookWizard.tsx
import React, { memo, useMemo } from 'react';
import {
  Autocomplete,
  Code,
  Group,
  NumberInput,
  Radio,
  SegmentedControl,
  Stack,
  Table,
  Text,
  Textarea,
} from '@mantine/core';
import type { WizardStep } from '../primitives/Wizard/wizard-state';
import { WizardModal } from '../primitives/Wizard/WizardModal';
import {
  DEFAULT_HOOK_TOOLS,
  MAX_HOOK_TIMEOUT,
  buildHook,
  createHookDraft,
  createHookId,
  DEFAULT_HOOK_MESSAGES,
  describeMatcher,
  eventSupportsMatcher,
  getHookEventText,
  getHookPayloadExample,
  getHookScopeText,
  validateActionStep,
  validateEventStep,
  validateMatcherStep,
  type HookDraft,
  type HookMessages,
} from './hook-wizard';
import {
  HOOK_EVENTS,
  HOOK_EVENT_ORDER,
  HOOK_SCOPE_ORDER,
  type HookConfig,
  type HookEventTextOverrides,
  type HookScopeTextOverrides,
  type HookType,
} from './types';
import classes from './HookWizard.module.css';
import { FIELD_ORDER_DESCRIPTION_BELOW } from '../utils/field-order';

export interface HookWizardLabels {
  addTitle: string;
  editTitle: string;
  add: string;
  save: string;
  eventStep: string;
  matcherStep: string;
  actionStep: string;
  scopeStep: string;
  command: string;
  prompt: string;
  matcher: string;
  matcherHint: string;
  timeout: string;
  timeoutHint: string;
  exampleInput: string;
  events: HookEventTextOverrides;
  scopes: HookScopeTextOverrides;
  messages: Partial<HookMessages>;
}

export interface HookWizardProps {
  /** Whether the wizard modal is open */
  opened: boolean;
  /** Called when the wizard is cancelled or the hook is saved */
  onClose: () => void;
  /** Hook to edit; a hook without `id` prefills a new hook */
  initialHook?: Partial<HookConfig>;
  /** Called with the built hook, a rejected promise keeps the wizard open and shows the error */
  onSubmit: (hook: HookConfig) => void | Promise<void>;
  /** Tool names suggested for the matcher */
  knownTools?: string[];
  /** Creates ids for new hooks */
  createId?: () => string;
  /** Overrides of the default English labels */
  labels?: Partial<HookWizardLabels>;
}

export const DEFAULT_HOOK_WIZARD_LABELS: HookWizardLabels = {
  addTitle: 'Add hook',
  editTitle: 'Edit hook',
  add: 'Add hook',
  save: 'Save hook',
  eventStep: 'Event',
  matcherStep: 'Matcher',
  actionStep: 'Action',
  scopeStep: 'Scope',
  command: 'Command',
  prompt: 'Prompt',
  matcher: 'Tool matcher',
  matcherHint:
    'An exact tool name, a regular expression such as Edit|Write, or * for all tools. Leave empty to match every tool.',
  timeout: 'Timeout',
  timeoutHint: 'Seconds before the hook is stopped, empty for the default',
  exampleInput: 'Example input',
  events: {},
  scopes: {},
  messages: {},
};

function HookSummary({
  values,
  labels,
  messages,
}: {
  values: HookDraft;
  labels: HookWizardLabels;
  messages: HookMessages;
}) {
  if (!values.event) {
    return null;
  }
  const action = values.type === 'command' ? values.command : values.prompt;
  const rows: [string, React.ReactNode][] = [
    [labels.eventStep, getHookEventText(values.event, labels.events).label],
    ...(HOOK_EVENTS[values.event].supportsMatcher
      ? [
          [labels.matcherStep, describeMatcher(values.matcher, messages)] as [
            string,
            React.ReactNode,
          ],
        ]
      : []),
    [
      values.type === 'command' ? labels.command : labels.prompt,
      <Code key="action" block className={classes.action}>
        {action.trim()}
      </Code>,
    ],
    ...(values.timeout !== ''
      ? [[labels.timeout, `${values.timeout} s`] as [string, React.ReactNode]]
      : []),
    [labels.scopeStep, getHookScopeText(values.scope, labels.scopes).label],
  ];

  return (
    <Stack gap="md">
      <Table variant="vertical" layout="fixed">
        <Table.Tbody>
          {rows.map(([label, value]) => (
            <Table.Tr key={label}>
              <Table.Th w="30%">{label}</Table.Th>
              <Table.Td>{value}</Table.Td>
            </Table.Tr>
          ))}
        </Table.Tbody>
      </Table>
      <Stack gap={6}>
        <Text size="sm" fw={500}>
          {labels.exampleInput}
        </Text>
        <Code block className={classes.payload}>
          {getHookPayloadExample(values.event, values.matcher)}
        </Code>
      </Stack>
    </Stack>
  );
}

/** Wizard that creates or edits a hook: event, tool matcher, action, scope and a review with an example payload */
export const HookWizard = memo(function HookWizard({
  opened,
  onClose,
  initialHook,
  onSubmit,
  knownTools = DEFAULT_HOOK_TOOLS,
  createId = createHookId,
  labels: labelsProp,
}: HookWizardProps) {
  const labels = useMemo(() => ({ ...DEFAULT_HOOK_WIZARD_LABELS, ...labelsProp }), [labelsProp]);
  const messages = useMemo(
    () => ({ ...DEFAULT_HOOK_MESSAGES, ...labels.messages }),
    [labels.messages]
  );
  const isEdit = !!initialHook?.id;
  const initialValues = useMemo(() => createHookDraft(initialHook), [initialHook]);

  const steps = useMemo<WizardStep<HookDraft>[]>(
    () => [
      {
        id: 'event',
        label: labels.eventStep,
        validate: (values) => validateEventStep(values, messages),
        render: ({ values, setValue, errors }) => (
          <Radio.Group
            value={values.event}
            onChange={(event) => setValue('event', event as HookDraft['event'])}
            error={errors.event}
            aria-label={labels.eventStep}
          >
            <Stack gap="xs">
              {HOOK_EVENT_ORDER.map((event) => {
                const text = getHookEventText(event, labels.events);
                return (
                  <Radio.Card key={event} value={event} radius="md" p="sm">
                    <Group wrap="nowrap" align="flex-start" gap="sm">
                      <Radio.Indicator />
                      <Stack gap={2} miw={0}>
                        <Group gap="xs">
                          <Text size="sm" fw={500}>
                            {text.label}
                          </Text>
                          <Code>{event}</Code>
                        </Group>
                        <Text size="xs" c="dimmed">
                          {text.description}
                        </Text>
                      </Stack>
                    </Group>
                  </Radio.Card>
                );
              })}
            </Stack>
          </Radio.Group>
        ),
      },
      {
        id: 'matcher',
        label: labels.matcherStep,
        when: (values) => eventSupportsMatcher(values.event),
        validate: (values) => validateMatcherStep(values, messages),
        render: ({ values, setValue, errors }) => (
          <Stack gap="sm">
            <Autocomplete
              label={labels.matcher}
              description={labels.matcherHint}
              inputWrapperOrder={FIELD_ORDER_DESCRIPTION_BELOW}
              placeholder="Bash"
              data={knownTools}
              value={values.matcher}
              error={errors.matcher}
              onChange={(matcher) => setValue('matcher', matcher)}
              classNames={{ input: classes.mono }}
            />
            {!errors.matcher && (
              <Text size="xs" c="dimmed">
                {describeMatcher(values.matcher, messages)}
              </Text>
            )}
          </Stack>
        ),
      },
      {
        id: 'action',
        label: labels.actionStep,
        validate: (values) => validateActionStep(values, messages),
        render: ({ values, setValue, errors }) => (
          <Stack gap="sm">
            <SegmentedControl
              value={values.type}
              onChange={(type) => setValue('type', type as HookType)}
              data={[
                { value: 'command', label: labels.command },
                { value: 'prompt', label: labels.prompt },
              ]}
            />
            {values.type === 'command' ? (
              <Textarea
                label={labels.command}
                placeholder="npx prettier --write ."
                autosize
                minRows={3}
                maxRows={8}
                value={values.command}
                error={errors.command}
                onChange={(event) => setValue('command', event.currentTarget.value)}
                classNames={{ input: classes.mono }}
              />
            ) : (
              <Textarea
                label={labels.prompt}
                placeholder="Allow the call only when..."
                autosize
                minRows={3}
                maxRows={8}
                value={values.prompt}
                error={errors.prompt}
                onChange={(event) => setValue('prompt', event.currentTarget.value)}
              />
            )}
            <NumberInput
              label={labels.timeout}
              description={labels.timeoutHint}
              inputWrapperOrder={FIELD_ORDER_DESCRIPTION_BELOW}
              min={1}
              max={MAX_HOOK_TIMEOUT}
              allowDecimal={false}
              suffix=" s"
              value={values.timeout}
              error={errors.timeout}
              onChange={(timeout) =>
                setValue('timeout', typeof timeout === 'number' ? timeout : '')
              }
            />
          </Stack>
        ),
      },
      {
        id: 'scope',
        label: labels.scopeStep,
        render: ({ values, setValue }) => (
          <Radio.Group
            value={values.scope}
            onChange={(scope) => setValue('scope', scope as HookDraft['scope'])}
            aria-label={labels.scopeStep}
          >
            <Stack gap="xs">
              {HOOK_SCOPE_ORDER.map((scope) => {
                const text = getHookScopeText(scope, labels.scopes);
                return (
                  <Radio.Card key={scope} value={scope} radius="md" p="sm">
                    <Group wrap="nowrap" align="flex-start" gap="sm">
                      <Radio.Indicator />
                      <Stack gap={2} miw={0}>
                        <Text size="sm" fw={500}>
                          {text.label}
                        </Text>
                        <Text size="xs" c="dimmed">
                          {text.description}
                        </Text>
                      </Stack>
                    </Group>
                  </Radio.Card>
                );
              })}
            </Stack>
          </Radio.Group>
        ),
      },
    ],
    [knownTools, labels, messages]
  );

  return (
    <WizardModal<HookDraft>
      opened={opened}
      onClose={onClose}
      title={isEdit ? labels.editTitle : labels.addTitle}
      steps={steps}
      initialValues={initialValues}
      review={(values) => <HookSummary values={values} labels={labels} messages={messages} />}
      labels={{ finish: isEdit ? labels.save : labels.add }}
      onComplete={async (values) => {
        await onSubmit(buildHook(values, initialHook?.id ?? createId()));
        onClose();
      }}
    />
  );
});

HookWizard.displayName = 'HookWizard';
Depends on:
API reference
Prop
Type
Required
opened
boolean
Yes
Whether the wizard modal is open
onClose
() => void
Yes
Called when the wizard is cancelled or the hook is saved
initialHook
Partial<HookConfig>
No
Hook to edit; a hook without `id` prefills a new hook
onSubmit
(hook: HookConfig) => void | Promise<void>
Yes
Called with the built hook, a rejected promise keeps the wizard open and shows the error
knownTools
string[]
No
Tool names suggested for the matcher
createId
() => string
No
Creates ids for new hooks
labels
Partial<HookWizardLabels>
No
Overrides of the default English labels