# Slash commands

A small, generic `/verb` surface for the chat composer — analogous to
the `@`-mention system, but layered outside the editor (so it works
with both the plain `<Textarea>` and the TipTap-based
`<ComposerRichTextarea>`).

## Files

| File | Layer |
|---|---|
| `types.ts` | Public types — `SlashCommand`, `SlashState`, `SlashConfig`. No React. |
| `state.ts` | Pure state machine — `parseSlashState`, `filterCommands`, `applyCommand`, `resolveCommandAction`, `extractSlashToken`. No React. |
| `labels.ts` | Localisable empty-state strings. |
| `useSlashCommands.ts` | React hook — owns highlight + keyboard, returns `pick` / `clear` / `onKeyDown` / `query`. |
| `SlashMenu.tsx` | Dropdown — rows and empty-state line. |
| `SlashHighlightTextarea.tsx` | Overlay-mirror textarea that paints `/verb` as a chip in-place. |
| `SlashToken.tsx` | Chip for a resolved verb (host-rendered, optional). |
| `index.ts` | Barrel — public API. |

## Behaviour

The machine has three states:

- `none` — buffer does not start with `/`.
- `composing` — buffer starts with `/` followed by an optional partial
  verb (`/`, `/cl`, `/clear`). The menu is open; `filter` narrows the
  verb list.
- `command` — buffer is `/verb<space>...`. The verb resolves and the
  rest is treated as `argument`.

Trigger fires only at buffer start. Mentions (which match `@` anywhere
in the editor) coexist without conflict.

## Usage

```tsx
import {
  Composer,
  type SlashConfig,
} from '@djangocfg/ui-tools/chat';
import { Sparkles, Trash2 } from 'lucide-react';

const slash: SlashConfig = {
  commands: [
    {
      id: 'clear',
      token: '/clear',
      label: 'Clear conversation',
      description: 'Discard the current session and start fresh.',
      icon: <Trash2 className="h-3.5 w-3.5" />,
    },
    {
      id: 'help',
      token: '/help',
      label: 'Show help',
      description: 'List available commands.',
      icon: <Sparkles className="h-3.5 w-3.5" />,
    },
  ],
};

<Composer composer={composer} composerSlots={{ slashCommands: slash }} />;
```

The host owns the icon nodes — this module ships zero icon dependencies.

## Selection behaviour

When the user picks a verb, the hook branches on
`resolveCommandAction(value, command)`:

- **Insert** (default) — the leading `/partial` is replaced with
  `"<token> "` and the caret lands on the argument. The verb stays
  visible in the buffer (highlighted as a chip) and the user submits
  with Send / Enter as a normal chat message. The host reads
  `composer.value` on submit to grab the args, then dispatches
  `command.onExecute?.(args)` itself.
- **Auto-execute** — opt in with `autoExecute: true`. The hook invokes
  `command.onExecute?.('')` and clears the editor buffer; no message
  is produced. Use this for action commands that should not turn into
  a chat message (e.g. `/clear`, `/settings`, `/help`).

`argHint` is a display hint (shown next to the label in the menu,
e.g. `<text>`, `<host>`). It does not affect selection behavior — use
`autoExecute` to control that.

The hook never invokes `submit()` — the host stays in control of when
the chat transport actually fires.

## Submit gate

Commands with `argHint` block submit until an argument is provided. The
Send button is disabled and Enter is a no-op while the trimmed argument
after the verb is empty (`/note `, `/summon`). Once the user adds text
(`/note hello`), Send activates and Enter sends as usual.

Commands marked `autoExecute: true` block submit unconditionally — they
are picked from the menu (which fires `onExecute`), never dispatched
as a text message via Enter.

The gate is a pure helper, `isSubmittableSlash(value, commands)`, and
is surfaced through the hook as `useSlashCommands().canSubmit`. The
composer ANDs it with `composer.canSubmit` so every Send surface
(built-in send action, `slots.SendButton`, TipTap-backed
`<ComposerRichTextarea>`, plain `<Textarea>`) shares the same gate.

## In-input highlight

`<SlashHighlightTextarea>` is mounted automatically whenever
`composerSlots.slashCommands` is set and no `slots.Textarea` is
overridden. It uses an overlay-mirror technique: a hidden
`<div>` underneath the textarea renders the same text with
the `/verb` slice wrapped in a styled span, and the real textarea
runs with `color: transparent` + `caret-color: currentColor` so the
native caret + selection stay alive.

## TipTap-backed composer

The in-editor `/verb` chip works in both composer paths:

- **Plain** — `<SlashHighlightTextarea>` (overlay-mirror, mounted
  automatically when `composerSlots.slashCommands` is set and no
  custom `slots.Textarea` is provided).
- **TipTap** — `<ComposerRichTextarea>` with `slashCommands` set
  uses a `SlashCommandNode` atom extension (lives in
  `@djangocfg/ui-tools/markdown-editor`). The atom flattens to the
  bare `/verb` token in `editor.getText()` and
  `editor.getMarkdown()`, so `composer.value` round-trips to the
  same string the plain mirror produces — the slash hook keeps
  driving the menu off pure strings, oblivious to the node form.

Pattern with both `@`-mention and slash chips:

```tsx
function RichTextarea(props: ComposerTextareaProps) {
  return (
    <ComposerRichTextarea
      {...props}
      mentions={mentionConfig}
      slashCommands={commands}
    />
  );
}

<Composer
  composer={composer}
  composerSlots={{ slashCommands: { commands } }}
  slots={{ Textarea: RichTextarea }}
/>
```

The TipTap extension is registered once on first render (gated on
`slashCommands !== undefined`). Pass `[]` from mount if you may
register verbs later — the conversion effect reads the current list
through a ref so runtime additions still light up the chip.

## Direct hook usage

Hosts that drive their own editor (without `<Composer>`) can wire the
hook up by hand:

```tsx
const slash = useSlashCommands({
  value,
  commands,
  onApply: setValue,
});

return (
  <div>
    <textarea
      value={value}
      onChange={(e) => setValue(e.target.value)}
      onKeyDown={slash.onKeyDown}
    />
    {slash.isOpen ? (
      <SlashMenu
        matches={slash.matches}
        highlight={slash.highlight}
        onPick={slash.pick}
        onHighlight={slash.setHighlight}
      />
    ) : null}
  </div>
);
```
