@djangocfg/ui-tools 2.1.413 โ†’ 2.1.416

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/file-icon/index.d.cts +1 -1
  2. package/dist/file-icon/index.d.ts +1 -1
  3. package/dist/slots-ClRpIzoh.d.cts +88 -0
  4. package/dist/slots-ClRpIzoh.d.ts +88 -0
  5. package/dist/tree/index.cjs +1994 -276
  6. package/dist/tree/index.cjs.map +1 -1
  7. package/dist/tree/index.d.cts +717 -72
  8. package/dist/tree/index.d.ts +717 -72
  9. package/dist/tree/index.mjs +1984 -279
  10. package/dist/tree/index.mjs.map +1 -1
  11. package/package.json +10 -6
  12. package/src/tools/chat/README.md +111 -1
  13. package/src/tools/chat/composer/Composer.tsx +138 -17
  14. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  15. package/src/tools/chat/composer/index.ts +22 -0
  16. package/src/tools/chat/composer/slash/README.md +187 -0
  17. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  18. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  19. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  20. package/src/tools/chat/composer/slash/index.ts +44 -0
  21. package/src/tools/chat/composer/slash/labels.ts +19 -0
  22. package/src/tools/chat/composer/slash/state.ts +168 -0
  23. package/src/tools/chat/composer/slash/types.ts +64 -0
  24. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  25. package/src/tools/chat/composer/types.ts +8 -0
  26. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  27. package/src/tools/chat/shell/index.ts +6 -0
  28. package/src/tools/data/Listbox/lazy.tsx +1 -1
  29. package/src/tools/data/Masonry/lazy.tsx +1 -1
  30. package/src/tools/data/Timeline/lazy.tsx +1 -1
  31. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  32. package/src/tools/data/Tree/README.md +337 -208
  33. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  34. package/src/tools/data/Tree/TreeRoot.tsx +170 -55
  35. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  36. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  37. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  38. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  39. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  40. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  41. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  42. package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
  43. package/src/tools/data/Tree/components/index.ts +6 -0
  44. package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
  45. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  46. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  47. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  48. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  49. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  50. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  51. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  52. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  53. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  54. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  55. package/src/tools/data/Tree/context/hooks.ts +68 -1
  56. package/src/tools/data/Tree/context/index.ts +3 -0
  57. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  58. package/src/tools/data/Tree/context/menu/index.ts +10 -0
  59. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
  60. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  61. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  62. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  63. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  64. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  65. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  66. package/src/tools/data/Tree/context/state/index.ts +6 -0
  67. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  68. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  69. package/src/tools/data/Tree/context/state/types.ts +46 -0
  70. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  71. package/src/tools/data/Tree/data/dnd.ts +123 -0
  72. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  73. package/src/tools/data/Tree/data/index.ts +19 -0
  74. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  75. package/src/tools/data/Tree/data/selection.ts +157 -0
  76. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  77. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  78. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  79. package/src/tools/data/Tree/hooks/index.ts +23 -4
  80. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  81. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  82. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  83. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  84. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  85. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  86. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  87. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  88. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts โ†’ type-ahead/use-tree-type-ahead.ts} +8 -19
  89. package/src/tools/data/Tree/index.tsx +25 -2
  90. package/src/tools/data/Tree/types/activation.ts +30 -0
  91. package/src/tools/data/Tree/types/adapter.ts +70 -0
  92. package/src/tools/data/Tree/types/index.ts +27 -0
  93. package/src/tools/data/Tree/types/labels.ts +97 -0
  94. package/src/tools/data/Tree/types/loader.ts +9 -0
  95. package/src/tools/data/Tree/types/node.ts +38 -0
  96. package/src/tools/data/Tree/types/root-props.ts +142 -0
  97. package/src/tools/data/Tree/types/selection.ts +3 -0
  98. package/src/tools/data/Tree/types/slots.ts +64 -0
  99. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +1 -1
  100. package/src/tools/dev/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +1 -1
  101. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  102. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  103. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  104. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  105. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  106. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  107. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  108. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  109. package/src/tools/index.ts +2 -2
  110. package/dist/types-j2vhn4Kv.d.cts +0 -241
  111. package/dist/types-j2vhn4Kv.d.ts +0 -241
  112. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  113. package/src/tools/data/Tree/types.ts +0 -217
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.413",
3
+ "version": "2.1.416",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -254,8 +254,8 @@
254
254
  "test:watch": "vitest"
255
255
  },
256
256
  "peerDependencies": {
257
- "@djangocfg/i18n": "^2.1.413",
258
- "@djangocfg/ui-core": "^2.1.413",
257
+ "@djangocfg/i18n": "^2.1.416",
258
+ "@djangocfg/ui-core": "^2.1.416",
259
259
  "consola": "^3.4.2",
260
260
  "lodash-es": "^4.18.1",
261
261
  "lucide-react": "^0.545.0",
@@ -265,6 +265,10 @@
265
265
  "zustand": "^5.0.0"
266
266
  },
267
267
  "dependencies": {
268
+ "@dnd-kit/core": "^6.3.1",
269
+ "@dnd-kit/modifiers": "^9.0.0",
270
+ "@dnd-kit/sortable": "^10.0.0",
271
+ "@dnd-kit/utilities": "^3.2.2",
268
272
  "@floating-ui/dom": "^1.7.4",
269
273
  "@rjsf/core": "^6.1.2",
270
274
  "@rjsf/utils": "^6.1.2",
@@ -315,9 +319,9 @@
315
319
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
316
320
  },
317
321
  "devDependencies": {
318
- "@djangocfg/i18n": "^2.1.413",
319
- "@djangocfg/typescript-config": "^2.1.413",
320
- "@djangocfg/ui-core": "^2.1.413",
322
+ "@djangocfg/i18n": "^2.1.416",
323
+ "@djangocfg/typescript-config": "^2.1.416",
324
+ "@djangocfg/ui-core": "^2.1.416",
321
325
  "@types/lodash-es": "^4.17.12",
322
326
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
323
327
  "@types/node": "^25.2.3",
@@ -762,7 +762,7 @@ inherit the composer `size` via context, no prop needed).
762
762
  | `<ComposerToolPill icon label active onRemove>` | `inlineStart` | Gemini-style capsule for a selected tool/mode with an optional `ร—` to clear it. |
763
763
  | `<ComposerModelPicker value options onChange>` | `inlineEnd` | "Flash-Lite โ–พ" pill โ€” opens a radio-group model picker. |
764
764
  | `<ComposerBanner variant title description actions onDismiss>` | `blockStart` | Standalone notice bubble above the composer (upsell / quota / info). |
765
- | `<ComposerRichTextarea mentions>` | `slots.Textarea` (Tier B) | Ready-made TipTap chat editor โ€” unstyled, no toolbar, size-matched height, Enter-to-send, `@`-mention support. |
765
+ | `<ComposerRichTextarea mentions slashCommands>` | `slots.Textarea` (Tier B) | Ready-made TipTap chat editor โ€” unstyled, no toolbar, size-matched height, Enter-to-send, `@`-mention + `/slash`-chip support. |
766
766
 
767
767
  ```tsx
768
768
  <Composer
@@ -790,6 +790,82 @@ editor shows a text caret and a `mousedown` on the bare surface (not a
790
790
  button) focuses the editor. Works for both the plain `<textarea>` and the
791
791
  TipTap (`contenteditable`) backend.
792
792
 
793
+ ### Slash commands
794
+
795
+ A `/verb` palette layered on top of the composer โ€” same idea as
796
+ mentions, but triggered only when the buffer **starts** with `/` (so it
797
+ never conflicts with `@`-mention matching, which runs anywhere in the
798
+ editor). Works with both the plain `<textarea>` and the TipTap-backed
799
+ `<ComposerRichTextarea>`; in-editor chip highlighting ships for both
800
+ paths.
801
+
802
+ ```tsx
803
+ import { Composer, type SlashCommand } from '@djangocfg/ui-tools/chat';
804
+ import { RefreshCw } from 'lucide-react';
805
+
806
+ const commands: SlashCommand[] = [
807
+ {
808
+ id: 'clear',
809
+ token: '/clear',
810
+ label: 'Clear conversation',
811
+ description: 'Discard the current session.',
812
+ icon: <RefreshCw className="h-3.5 w-3.5" />,
813
+ autoExecute: true,
814
+ onExecute: () => chatActions.clearSession(),
815
+ },
816
+ {
817
+ id: 'connect',
818
+ token: '/connect',
819
+ label: 'Connect to machine',
820
+ argHint: '<hostname>',
821
+ // default insert behavior โ€” user types argument and submits as normal message
822
+ },
823
+ ];
824
+
825
+ <Composer
826
+ composer={composer}
827
+ composerSlots={{ slashCommands: { commands } }}
828
+ />;
829
+ ```
830
+
831
+ **Behavior matrix:**
832
+
833
+ - Default (insert) โ€” Pick โ†’ inserts `/verb ` into the buffer with a
834
+ subtle highlight. Cursor sits ready for argument entry. User submits
835
+ as a normal chat message; the host reads `composer.value` to grab the
836
+ args and dispatches `onExecute` itself.
837
+ - `autoExecute: true` โ€” Pick โ†’ `onExecute()` fires immediately and the
838
+ buffer clears. For action commands (`/clear`, `/settings`) that don't
839
+ produce a chat message.
840
+
841
+ **Visual highlight:**
842
+
843
+ - In the plain textarea โ€” an overlay-mirror paints `/verb` with a
844
+ primary tint while preserving the native caret + selection.
845
+ - In `<ComposerRichTextarea>` (TipTap) โ€” pass the same verb list as
846
+ `slashCommands` to the editor and a `SlashCommandNode` atom
847
+ extension paints the chip in-flow. Same primary-tinted styling as
848
+ the plain mirror; the atom flattens to the bare `/verb` token in
849
+ `getText()` / `getMarkdown()` so `composer.value` stays in plain
850
+ string form and the slash hook keeps driving the menu unchanged.
851
+
852
+ **Keyboard:** `/` opens the menu when at buffer start. โ†‘/โ†“ navigate.
853
+ Enter or Tab pick. Esc closes. Click outside dismisses.
854
+
855
+ **Empty state:** typing `/xyzz` keeps the menu open with a "No commands
856
+ match" row instead of disappearing โ€” discoverability over jank.
857
+
858
+ **Submit gate:** commands with `argHint` block submit until the user
859
+ types something after the verb (`/note ` โ†’ Send disabled, Enter no-op;
860
+ `/note hello` โ†’ Send active). `autoExecute` commands block submit
861
+ unconditionally โ€” they are run from the menu, not dispatched as text.
862
+ The gate works for the plain `<Textarea>`, `<SlashHighlightTextarea>`,
863
+ the TipTap `<ComposerRichTextarea>`, and any `slots.SendButton`.
864
+
865
+ See [`composer/slash/README.md`](./composer/slash/README.md) for the
866
+ full API reference (types, hook contract, mirror internals, direct
867
+ `useSlashCommands` usage, mentions coexistence).
868
+
793
869
  ## Three usage patterns
794
870
 
795
871
  ### 1. One-line preset
@@ -808,6 +884,39 @@ TipTap (`contenteditable`) backend.
808
884
  </ChatProvider>
809
885
  ```
810
886
 
887
+ ### Suggested prompts โ€” `<SuggestedPrompts>`
888
+
889
+ Starter-prompt surface every chat host wants (ChatGPT / Claude / cmdop
890
+ all ship a flavour of it). Drop into `slots.empty` to seed the
891
+ composer from a click. Two layouts: `chips` (flat rounded buttons,
892
+ default) and `grid` (2-col cards with `description`).
893
+
894
+ ```tsx
895
+ import { SuggestedPrompts, type SuggestedPromptItem } from '@djangocfg/ui-tools/chat';
896
+ import { HeartPulse, Server, Sparkles } from 'lucide-react';
897
+
898
+ const PROMPTS: readonly SuggestedPromptItem[] = [
899
+ { id: 'machines', label: 'List my machines', prompt: 'List my connected machines', icon: <Server className="size-3.5" /> },
900
+ { id: 'health', label: 'Run a health check', prompt: 'Run a health check and report issues', icon: <HeartPulse className="size-3.5" /> },
901
+ ];
902
+
903
+ <ChatRoot
904
+ slots={{
905
+ empty: ({ setValue, focus }) => (
906
+ <SuggestedPrompts
907
+ items={PROMPTS}
908
+ onPick={(item) => { setValue(item.prompt); focus(); }}
909
+ hero={<Sparkles className="size-6 text-primary" />}
910
+ title="How can I help?"
911
+ description="Pick a starter, or just start typing."
912
+ />
913
+ ),
914
+ }}
915
+ />
916
+ ```
917
+
918
+ `renderItem` is an escape hatch for one-off chip styling without forking the component.
919
+
811
920
  ### 3. Headless
812
921
 
813
922
  ```tsx
@@ -952,6 +1061,7 @@ plus `Overview` (MDX) and `Showcase` (a one-screen living demo):
952
1061
  | `slots.jumpToLatest` | Sticky overlay | `ReactNode` |
953
1062
  | `composer.slots.actionsStart` / `actionsEnd` | Composer action clusters | `ComposerAction[]` |
954
1063
  | `composer.slots.blockStart` | Above composer textarea | `ReactNode` |
1064
+ | `composer.slots.slashCommands` | `/verb` palette (buffer-start trigger) | `SlashConfig` (`{ commands: SlashCommand[] }`) |
955
1065
  | `composer.footer` | Below composer | `ComposerFooterProps \| false` |
956
1066
  | `composer.render` | Replace `<Composer>` | `({ composer, config }) => ReactNode` |
957
1067
  | `messages.render` | Replace each bubble | `(m, i) => ReactNode` |
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { forwardRef, useEffect, useRef, useState } from 'react';
3
+ import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
4
4
 
5
5
  import { Textarea } from '@djangocfg/ui-core/components';
6
6
  import { cn } from '@djangocfg/ui-core/lib';
@@ -13,6 +13,9 @@ import { ComposerActionBar } from './ComposerActionBar';
13
13
  import { ComposerButton } from './ComposerButton';
14
14
  import { ComposerFooter } from './ComposerFooter';
15
15
  import { ComposerSizeProvider } from './size-context';
16
+ import { SlashHighlightTextarea } from './slash/SlashHighlightTextarea';
17
+ import { SlashMenu } from './slash/SlashMenu';
18
+ import { useSlashCommands } from './slash/useSlashCommands';
16
19
  import { useComposerActions } from './useComposerActions';
17
20
  import { useComposerAttach } from './useComposerAttach';
18
21
  import type {
@@ -205,6 +208,63 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
205
208
  const inlineEnd = composerSlots?.inlineEnd;
206
209
  const blockStart = composerSlots?.blockStart;
207
210
 
211
+ // โ”€โ”€ Slash commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
212
+ // When the host wires `composerSlots.slashCommands`, an internal
213
+ // `<SlashMenu>` is rendered as a floating popover above the input
214
+ // surface and โ†‘/โ†“/Enter/Tab/Esc are routed to the slash hook while
215
+ // the menu is open. The hook is always called (React rules) โ€” it
216
+ // is a no-op when `commands` is the empty array.
217
+ const slashCommands = composerSlots?.slashCommands?.commands;
218
+ const slashEnabled = !!slashCommands && slashCommands.length > 0;
219
+ const slash = useSlashCommands({
220
+ value: composer.value,
221
+ commands: slashCommands ?? [],
222
+ onApply: composer.setValue,
223
+ });
224
+ const slashOnKeyDown = slash.onKeyDown;
225
+ const slashIsOpen = slashEnabled && slash.isOpen;
226
+ // When slash is enabled and the current buffer is a `/verb` missing
227
+ // a required argument (or an `autoExecute` action), submit is blocked:
228
+ // the Send button reads disabled and Enter is a no-op. The host-facing
229
+ // `composer` is wrapped into `gatedComposer` so every downstream
230
+ // surface (built-in Send, custom SendSlot, TipTap `ComposerRichTextarea`,
231
+ // plain `<Textarea>`) shares the same gate without each having to
232
+ // know about slash state.
233
+ const slashAllowsSubmit = slashEnabled ? slash.canSubmit : true;
234
+ const gatedComposer = useMemo<UseChatComposerReturn>(() => {
235
+ if (slashAllowsSubmit) return composer;
236
+ const baseOnKeyDown = composer.textareaProps.onKeyDown;
237
+ return {
238
+ ...composer,
239
+ canSubmit: false,
240
+ submit: async () => {
241
+ // Slash gate refuses โ€” swallow programmatic submit attempts.
242
+ },
243
+ textareaProps: {
244
+ ...composer.textareaProps,
245
+ onKeyDown: (e) => {
246
+ // Block Enter (without Shift) so the textarea's own submit
247
+ // binding cannot fire while the slash gate is closed. Cmd /
248
+ // Ctrl + Enter is treated the same. Other keys (history,
249
+ // arrows, etc.) pass through to the original handler.
250
+ if (e.key === 'Enter' && !e.shiftKey) {
251
+ e.preventDefault();
252
+ return;
253
+ }
254
+ baseOnKeyDown(e);
255
+ },
256
+ },
257
+ };
258
+ }, [composer, slashAllowsSubmit]);
259
+ // Capture-phase handler โ€” runs before the textarea / TipTap own
260
+ // submit binding, so Enter / Tab pick a verb instead of sending.
261
+ const handleSlashKeyDownCapture = !slashEnabled
262
+ ? undefined
263
+ : (e: React.KeyboardEvent<HTMLDivElement>) => {
264
+ if (!slash.isOpen) return;
265
+ slashOnKeyDown(e);
266
+ };
267
+
208
268
  // Clicking the padding around the input (not a button/link) should focus
209
269
  // the editor โ€” same affordance as ChatGPT/Gemini. Works for both the
210
270
  // plain `<textarea>` and the TipTap (`contenteditable`) backend by
@@ -282,14 +342,14 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
282
342
 
283
343
  // Merge built-in send/stop/attach descriptors with host arrays.
284
344
  const { actionsStart, actionsEnd } = useComposerActions({
285
- composer,
345
+ composer: gatedComposer,
286
346
  isStreaming,
287
347
  isDisabled,
288
348
  showAttachmentButton,
289
349
  onPickFiles: pickFiles,
290
350
  actionsStart: composerSlots?.actionsStart,
291
351
  actionsEnd: composerSlots?.actionsEnd,
292
- onSend: () => void composer.submit(),
352
+ onSend: () => void gatedComposer.submit(),
293
353
  onCancel,
294
354
  micSendSwap,
295
355
  });
@@ -300,33 +360,45 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
300
360
  const ActionBarSlot = slots?.ActionBar;
301
361
 
302
362
  // โ”€โ”€ Textarea โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
363
+ // When slash commands are enabled and the host hasn't supplied its own
364
+ // Textarea slot, swap in the overlay-mirror `<SlashHighlightTextarea>`
365
+ // so a leading `/verb` reads as a styled chip while the user types.
366
+ const sharedTextareaClass = cn(
367
+ 'flex-1 resize-none border-0 bg-transparent shadow-none',
368
+ 'focus-visible:ring-0 focus-visible:outline-none',
369
+ sz.textarea,
370
+ sz.text,
371
+ ap.textarea,
372
+ ap.text,
373
+ slotProps?.textarea?.className,
374
+ textareaClassName,
375
+ );
303
376
  const textareaNode = TextareaSlot ? (
304
377
  <TextareaSlot
305
- composer={composer}
378
+ composer={gatedComposer}
306
379
  placeholder={placeholder}
307
380
  disabled={isDisabled}
308
381
  size={size}
309
382
  className={slotProps?.textarea?.className ?? textareaClassName}
310
383
  />
384
+ ) : slashEnabled ? (
385
+ <SlashHighlightTextarea
386
+ composer={gatedComposer}
387
+ placeholder={placeholder}
388
+ disabled={isDisabled}
389
+ size={size}
390
+ textareaClassName={sharedTextareaClass}
391
+ />
311
392
  ) : (
312
393
  <Textarea
313
- {...composer.textareaProps}
394
+ {...gatedComposer.textareaProps}
314
395
  rows={1}
315
396
  placeholder={placeholder}
316
397
  aria-label={placeholder}
317
398
  aria-multiline="true"
318
399
  aria-keyshortcuts="Enter"
319
400
  disabled={isDisabled}
320
- className={cn(
321
- 'flex-1 resize-none border-0 bg-transparent shadow-none',
322
- 'focus-visible:ring-0 focus-visible:outline-none',
323
- sz.textarea,
324
- sz.text,
325
- ap.textarea,
326
- ap.text,
327
- slotProps?.textarea?.className,
328
- textareaClassName,
329
- )}
401
+ className={sharedTextareaClass}
330
402
  />
331
403
  );
332
404
 
@@ -359,9 +431,9 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
359
431
  const customSend = SendSlot ? (
360
432
  <SendSlot
361
433
  streaming={isStreaming}
362
- disabled={!composer.canSubmit}
434
+ disabled={!gatedComposer.canSubmit}
363
435
  size={size}
364
- onSend={() => void composer.submit()}
436
+ onSend={() => void gatedComposer.submit()}
365
437
  onCancel={onCancel}
366
438
  {...slotProps?.send}
367
439
  />
@@ -428,6 +500,51 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
428
500
  />
429
501
  ) : null;
430
502
 
503
+ // Click outside the slash menu (and outside the composer surface)
504
+ // dismisses it. Mounted only while the menu is open to keep the
505
+ // listener footprint minimal.
506
+ const slashMenuRef = useRef<HTMLDivElement>(null);
507
+ const slashClear = slash.clear;
508
+ useEffect(() => {
509
+ if (!slashIsOpen) return;
510
+ const onPointerDown = (e: MouseEvent) => {
511
+ const target = e.target as Node | null;
512
+ if (!target) return;
513
+ if (slashMenuRef.current?.contains(target)) return;
514
+ // Clicks inside the input surface (textarea, action buttons) are
515
+ // fine โ€” only outside-of-composer interactions should dismiss.
516
+ // `surfaceRef` is only mounted in the stacked layout; fall back
517
+ // to the active editable node so the inline layout works too.
518
+ if (surfaceRef.current?.contains(target)) return;
519
+ const editable = composer.textareaRef.current;
520
+ if (editable && editable.contains(target)) return;
521
+ slashClear();
522
+ };
523
+ document.addEventListener('mousedown', onPointerDown);
524
+ return () => document.removeEventListener('mousedown', onPointerDown);
525
+ }, [slashIsOpen, slashClear, composer.textareaRef]);
526
+
527
+ // Slash-command floating popover. Anchored to the top of the input
528
+ // surface; appears just above it so it doesn't cover the textarea.
529
+ const slashOverlay =
530
+ slashIsOpen ? (
531
+ <div
532
+ className={cn(
533
+ 'pointer-events-auto absolute left-2.5 right-2.5 z-20',
534
+ 'bottom-full mb-1.5',
535
+ )}
536
+ >
537
+ <SlashMenu
538
+ ref={slashMenuRef}
539
+ matches={slash.matches}
540
+ highlight={slash.highlight}
541
+ query={slash.query}
542
+ onPick={slash.pick}
543
+ onHighlight={slash.setHighlight}
544
+ />
545
+ </div>
546
+ ) : null;
547
+
431
548
  // Start cluster sits left of the textarea, end cluster right of it โ€”
432
549
  // `[๐Ÿ“Ž][actionsStart] [textarea] [actionsEnd][๐ŸŽ™][โ–ถ]` (ยง3.6).
433
550
  if (layout === 'inline') {
@@ -437,6 +554,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
437
554
  <div
438
555
  ref={ref}
439
556
  {...dragProps}
557
+ onKeyDownCapture={handleSlashKeyDownCapture}
440
558
  className={cn(
441
559
  'relative border-t border-border bg-background/95',
442
560
  sz.containerPadding,
@@ -446,6 +564,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
446
564
  >
447
565
  {hiddenFileInput}
448
566
  {dragOverlay}
567
+ {slashOverlay}
449
568
  {trayNode ? <div className="mb-1.5">{trayNode}</div> : null}
450
569
  <div
451
570
  className={cn(
@@ -554,6 +673,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
554
673
  <div
555
674
  ref={ref}
556
675
  {...dragProps}
676
+ onKeyDownCapture={handleSlashKeyDownCapture}
557
677
  className={cn(
558
678
  'relative border-t border-border bg-background/95',
559
679
  sz.containerPadding,
@@ -563,6 +683,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
563
683
  >
564
684
  {hiddenFileInput}
565
685
  {dragOverlay}
686
+ {slashOverlay}
566
687
  {trayNode ? <div className="mb-2">{trayNode}</div> : null}
567
688
  {surfaceWrapper}
568
689
  {footerNode}
@@ -6,6 +6,7 @@ import {
6
6
  LazyMarkdownEditor as MarkdownEditor,
7
7
  type MarkdownEditorHandle,
8
8
  type MentionConfig,
9
+ type SlashCommandInfo,
9
10
  } from '@djangocfg/ui-tools/markdown-editor';
10
11
 
11
12
  import { useRegisterComposer } from '../hooks/useAutoFocusOnStreamEnd';
@@ -21,6 +22,23 @@ const MIN_HEIGHT: Record<ComposerSize, number> = {
21
22
  export interface ComposerRichTextareaProps extends ComposerTextareaProps {
22
23
  /** `@`-mention autocomplete config. Omit for a plain rich textarea. */
23
24
  mentions?: MentionConfig;
25
+ /**
26
+ * Slash-command verb list. When set, a leading `/verb` in the editor
27
+ * is painted as an atom chip (same primary-tinted styling as the
28
+ * plain `<SlashHighlightTextarea>` mirror). The chat composer's slash
29
+ * menu already lives outside the editor and keeps working without
30
+ * this prop โ€” pass the verb list here when you also want the
31
+ * in-editor highlight to match.
32
+ *
33
+ * Structurally compatible with `SlashCommand` from
34
+ * `@djangocfg/ui-tools/chat` โ€” passing the same array you wire into
35
+ * `composerSlots.slashCommands` is the normal pattern.
36
+ *
37
+ * Note: like `mentions`, the underlying TipTap extension is captured
38
+ * once on first render. If you may register slash commands later,
39
+ * pass `[]` from the very first render and mutate the list in place.
40
+ */
41
+ slashCommands?: readonly SlashCommandInfo[];
24
42
  }
25
43
 
26
44
  /**
@@ -42,10 +60,16 @@ export function ComposerRichTextarea({
42
60
  size,
43
61
  className,
44
62
  mentions,
63
+ slashCommands,
45
64
  }: ComposerRichTextareaProps) {
46
65
  // Tiptap captures the mentions object once on first render โ€” keep its
47
66
  // identity stable so the suggestion plugin stays wired.
48
67
  const stableMentions = useMemo(() => mentions, [mentions]);
68
+ // `slashCommands` flows through every render โ€” the editor's
69
+ // SlashCommandNode is registered once on first mount (gated on
70
+ // `slashCommands !== undefined`) and the current list is read via a
71
+ // ref by the value-sync effect, so runtime additions / removals
72
+ // still light up the chip.
49
73
 
50
74
  // The TipTap editor has no `<textarea>`, so the composer's default
51
75
  // `focus` handle (which targets `textareaRef`) is a no-op here.
@@ -73,6 +97,7 @@ export function ComposerRichTextarea({
73
97
  value={composer.value}
74
98
  onChange={composer.setValue}
75
99
  mentions={stableMentions}
100
+ slashCommands={slashCommands}
76
101
  placeholder={placeholder}
77
102
  minHeight={MIN_HEIGHT[size]}
78
103
  showToolbar={false}
@@ -40,6 +40,28 @@ export {
40
40
  } from './useComposerAttach';
41
41
  export { useComposerAttachContext } from './AttachContext';
42
42
  export { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
43
+ export {
44
+ parseSlashState,
45
+ filterCommands,
46
+ applyCommand,
47
+ resolveCommandAction,
48
+ extractSlashToken,
49
+ useSlashCommands,
50
+ SlashMenu,
51
+ SlashToken,
52
+ SlashHighlightTextarea,
53
+ DEFAULT_SLASH_LABELS,
54
+ type SlashCommand,
55
+ type SlashState,
56
+ type SlashConfig,
57
+ type SlashCommandAction,
58
+ type UseSlashCommandsOptions,
59
+ type UseSlashCommandsReturn,
60
+ type SlashMenuProps,
61
+ type SlashTokenProps,
62
+ type SlashHighlightTextareaProps,
63
+ type SlashMenuLabels,
64
+ } from './slash';
43
65
  export type {
44
66
  ComposerAction,
45
67
  ComposerActionVisibility,