@djangocfg/ui-tools 2.1.415 → 2.1.417

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 (140) hide show
  1. package/dist/audio-player/index.cjs +2099 -0
  2. package/dist/audio-player/index.cjs.map +1 -0
  3. package/dist/audio-player/index.css +65 -0
  4. package/dist/audio-player/index.css.map +1 -0
  5. package/dist/audio-player/index.d.cts +174 -0
  6. package/dist/audio-player/index.d.ts +174 -0
  7. package/dist/audio-player/index.mjs +2076 -0
  8. package/dist/audio-player/index.mjs.map +1 -0
  9. package/dist/composer-registry/index.cjs +45 -0
  10. package/dist/composer-registry/index.cjs.map +1 -0
  11. package/dist/composer-registry/index.d.cts +73 -0
  12. package/dist/composer-registry/index.d.ts +73 -0
  13. package/dist/composer-registry/index.mjs +39 -0
  14. package/dist/composer-registry/index.mjs.map +1 -0
  15. package/dist/file-icon/index.d.cts +1 -1
  16. package/dist/file-icon/index.d.ts +1 -1
  17. package/dist/slots-ClRpIzoh.d.cts +88 -0
  18. package/dist/slots-ClRpIzoh.d.ts +88 -0
  19. package/dist/tree/index.cjs +2019 -279
  20. package/dist/tree/index.cjs.map +1 -1
  21. package/dist/tree/index.d.cts +731 -72
  22. package/dist/tree/index.d.ts +731 -72
  23. package/dist/tree/index.mjs +2009 -282
  24. package/dist/tree/index.mjs.map +1 -1
  25. package/package.json +18 -9
  26. package/src/tools/chat/README.md +111 -1
  27. package/src/tools/chat/composer/Composer.tsx +146 -25
  28. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  29. package/src/tools/chat/composer/index.ts +22 -0
  30. package/src/tools/chat/composer/slash/README.md +187 -0
  31. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  32. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  33. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  34. package/src/tools/chat/composer/slash/index.ts +44 -0
  35. package/src/tools/chat/composer/slash/labels.ts +19 -0
  36. package/src/tools/chat/composer/slash/state.ts +168 -0
  37. package/src/tools/chat/composer/slash/types.ts +64 -0
  38. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  39. package/src/tools/chat/composer/types.ts +8 -0
  40. package/src/tools/chat/context/ChatProvider.tsx +13 -78
  41. package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
  42. package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
  43. package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
  44. package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
  45. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  46. package/src/tools/chat/shell/index.ts +6 -0
  47. package/src/tools/data/Listbox/lazy.tsx +1 -1
  48. package/src/tools/data/Masonry/lazy.tsx +1 -1
  49. package/src/tools/data/Timeline/lazy.tsx +1 -1
  50. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  51. package/src/tools/data/Tree/README.md +337 -208
  52. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  53. package/src/tools/data/Tree/TreeRoot.tsx +111 -72
  54. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  55. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  56. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  57. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  58. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  59. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  60. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  61. package/src/tools/data/Tree/components/TreeRow.tsx +103 -8
  62. package/src/tools/data/Tree/components/index.ts +6 -0
  63. package/src/tools/data/Tree/context/TreeContext.tsx +223 -363
  64. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  65. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  66. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  67. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  68. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  69. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  70. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  71. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  72. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  73. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  74. package/src/tools/data/Tree/context/hooks.ts +68 -1
  75. package/src/tools/data/Tree/context/index.ts +3 -0
  76. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  77. package/src/tools/data/Tree/context/menu/index.ts +11 -0
  78. package/src/tools/data/Tree/context/menu/render.tsx +75 -0
  79. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +141 -0
  80. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  81. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  82. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  83. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  84. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  85. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  86. package/src/tools/data/Tree/context/state/index.ts +6 -0
  87. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  88. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  89. package/src/tools/data/Tree/context/state/types.ts +46 -0
  90. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  91. package/src/tools/data/Tree/data/dnd.ts +123 -0
  92. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  93. package/src/tools/data/Tree/data/index.ts +19 -0
  94. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  95. package/src/tools/data/Tree/data/selection.ts +157 -0
  96. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  97. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  98. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  99. package/src/tools/data/Tree/hooks/index.ts +23 -4
  100. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  101. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  102. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  103. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  104. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  105. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  106. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  107. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  108. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
  109. package/src/tools/data/Tree/index.tsx +26 -2
  110. package/src/tools/data/Tree/types/activation.ts +30 -0
  111. package/src/tools/data/Tree/types/adapter.ts +70 -0
  112. package/src/tools/data/Tree/types/index.ts +27 -0
  113. package/src/tools/data/Tree/types/labels.ts +97 -0
  114. package/src/tools/data/Tree/types/loader.ts +9 -0
  115. package/src/tools/data/Tree/types/node.ts +38 -0
  116. package/src/tools/data/Tree/types/root-props.ts +158 -0
  117. package/src/tools/data/Tree/types/selection.ts +3 -0
  118. package/src/tools/data/Tree/types/slots.ts +64 -0
  119. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
  120. package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
  121. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  122. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  123. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  124. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  125. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  126. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  127. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  128. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  129. package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
  130. package/src/tools/integration/ComposerRegistry/index.ts +105 -0
  131. package/src/tools/media/AudioPlayer/Player.tsx +2 -0
  132. package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
  133. package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
  134. package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
  135. package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
  136. package/src/tools/media/AudioPlayer/types.ts +15 -0
  137. package/dist/types-j2vhn4Kv.d.cts +0 -241
  138. package/dist/types-j2vhn4Kv.d.ts +0 -241
  139. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  140. package/src/tools/data/Tree/types.ts +0 -217
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Slash-command state machine — pure functions, no React.
3
+ *
4
+ * A small model for the composer's `/` surface: derive `SlashState` from
5
+ * the raw editor value, narrow the verb list by a filter, and produce
6
+ * the next editor value when a verb is picked.
7
+ *
8
+ * Triggering rule: a slash verb only counts when it is the first token
9
+ * of the buffer (`"/clear"` matches, `"hello /clear"` does not). The
10
+ * caret is irrelevant — the machine is buffer-shape driven, matching
11
+ * the Warp `slash_command_model.rs` design the cmdop port came from.
12
+ *
13
+ * Layering: this file imports types only — keep it React-free so it
14
+ * stays trivially unit-testable.
15
+ */
16
+ import type { SlashCommand, SlashState } from './types';
17
+
18
+ /** A `/verb` only counts when it is the very first token of the input. */
19
+ const SLASH_RE = /^\/([a-zA-Z][\w-]*)?(?:[ \n]([\s\S]*))?$/;
20
+
21
+ /**
22
+ * Derive the slash state from the raw editor value.
23
+ *
24
+ * - Buffer empty or not starting with `/` → `none`.
25
+ * - `/`, `/c`, `/cle` (no space yet) → `composing` with `filter`.
26
+ * - `/clear`, `/clear stuff` (verb followed by space) → `command` if
27
+ * `verb` matches a known id, else `none`.
28
+ */
29
+ export function parseSlashState(
30
+ value: string,
31
+ commands: readonly SlashCommand[],
32
+ ): SlashState {
33
+ if (!value.startsWith('/')) return { kind: 'none' };
34
+ const m = SLASH_RE.exec(value);
35
+ if (!m) return { kind: 'none' };
36
+ const verb = (m[1] ?? '').toLowerCase();
37
+ const rest = m[2];
38
+ // The buffer has advanced past the verb (a space/newline follows).
39
+ if (rest !== undefined) {
40
+ const command = commands.find((c) => c.id === verb);
41
+ if (!command) return { kind: 'none' };
42
+ return { kind: 'command', command, argument: rest };
43
+ }
44
+ // Still typing the verb — `/` alone or `/cle`.
45
+ return { kind: 'composing', filter: verb };
46
+ }
47
+
48
+ /**
49
+ * Narrow a verb list by the `composing` filter.
50
+ *
51
+ * Empty filter returns every command in input order. Otherwise the
52
+ * filter is matched (case-insensitive) against `id`, `token`, `label`
53
+ * and any `keywords`: prefix matches rank above substring matches.
54
+ */
55
+ export function filterCommands(
56
+ commands: readonly SlashCommand[],
57
+ filter: string,
58
+ ): SlashCommand[] {
59
+ const q = filter.trim().toLowerCase();
60
+ if (q.length === 0) return [...commands];
61
+ const scored: { cmd: SlashCommand; score: number; idx: number }[] = [];
62
+ commands.forEach((cmd, idx) => {
63
+ const terms = [cmd.id, cmd.token, cmd.label, ...(cmd.keywords ?? [])];
64
+ let best = -1;
65
+ for (const term of terms) {
66
+ const t = term.toLowerCase();
67
+ if (t.startsWith(q) || t.startsWith(`/${q}`)) best = Math.max(best, 2);
68
+ else if (t.includes(q)) best = Math.max(best, 1);
69
+ }
70
+ if (best >= 0) scored.push({ cmd, score: best, idx });
71
+ });
72
+ scored.sort((a, b) => (b.score - a.score) || (a.idx - b.idx));
73
+ return scored.map((s) => s.cmd);
74
+ }
75
+
76
+ /**
77
+ * What should happen when the user picks a verb in the menu.
78
+ *
79
+ * - `execute` — the command explicitly opted in via `autoExecute: true`.
80
+ * The host should call `command.onExecute?.()` and clear the editor
81
+ * buffer. Use for action commands that produce no chat message.
82
+ * - `insert` — default. The host should replace the leading `/partial`
83
+ * with `text` (which is `<token> `) so the user can keep typing and
84
+ * submit normally.
85
+ */
86
+ export type SlashCommandAction =
87
+ | { kind: 'execute'; command: SlashCommand }
88
+ | { kind: 'insert'; command: SlashCommand; text: string };
89
+
90
+ /**
91
+ * Decide whether a picked verb runs immediately or just inserts a token.
92
+ *
93
+ * Pure — does not touch the editor value. The React layer in
94
+ * `useSlashCommands` consumes this to branch between `onExecute`
95
+ * + clear-buffer and `applyCommand` + keep-buffer.
96
+ *
97
+ * Default is `insert`. Only `autoExecute === true` triggers `execute`
98
+ * — `argHint` is display-only and does NOT influence this decision.
99
+ */
100
+ export function resolveCommandAction(
101
+ value: string,
102
+ command: SlashCommand,
103
+ ): SlashCommandAction {
104
+ const shouldAutoExecute = command.autoExecute === true;
105
+ if (shouldAutoExecute) return { kind: 'execute', command };
106
+ return { kind: 'insert', command, text: applyCommand(value, command) };
107
+ }
108
+
109
+ /**
110
+ * Find the `/verb` slice at the start of the buffer (if any). Returns
111
+ * the matched token + its end offset so an overlay-mirror highlighter
112
+ * can wrap the exact same characters the parser considers a slash verb.
113
+ *
114
+ * - `/`, `/cle` (still composing) → returns the partial slice.
115
+ * - `/clear`, `/clear stuff` (resolved) → returns the bare `/verb`.
116
+ * - `hello /clear` (slash not at start) → returns `null`.
117
+ */
118
+ export function extractSlashToken(
119
+ value: string,
120
+ ): { token: string; end: number } | null {
121
+ if (!value.startsWith('/')) return null;
122
+ const m = SLASH_RE.exec(value);
123
+ if (!m) return null;
124
+ const verb = m[1] ?? '';
125
+ const token = `/${verb}`;
126
+ return { token, end: token.length };
127
+ }
128
+
129
+ /**
130
+ * Whether the current buffer is a slash command that may be submitted.
131
+ *
132
+ * Returns `true` when the buffer is NOT a resolved slash command — the
133
+ * host decides on its own emptiness gates for plain messages. Returns
134
+ * `true` for resolved commands whose `argHint` is unset (the command is
135
+ * self-contained) and whose `autoExecute` is not `true`. Returns
136
+ * `false` when:
137
+ *
138
+ * - the resolved command has `autoExecute: true` (action commands are
139
+ * picked from the menu, not submitted as a text message),
140
+ * - or the resolved command declares an `argHint` and the trimmed
141
+ * argument is empty.
142
+ *
143
+ * Pure — no React, no DOM. The composer uses this to gate the Send
144
+ * button + Enter keypress so half-typed `/note` etc. cannot be
145
+ * submitted as a message.
146
+ */
147
+ export function isSubmittableSlash(
148
+ value: string,
149
+ commands: readonly SlashCommand[],
150
+ ): boolean {
151
+ const state = parseSlashState(value, commands);
152
+ if (state.kind !== 'command') return true;
153
+ if (state.command.autoExecute === true) return false;
154
+ if (!state.command.argHint) return true;
155
+ return state.argument.trim().length > 0;
156
+ }
157
+
158
+ /**
159
+ * Apply a picked command to the editor value — replaces the leading
160
+ * `/partial` with `"<token> "` so the user can immediately type the
161
+ * argument. Any existing argument text is preserved.
162
+ */
163
+ export function applyCommand(value: string, command: SlashCommand): string {
164
+ const m = SLASH_RE.exec(value);
165
+ const rest = m?.[2];
166
+ const tail = rest !== undefined && rest.length > 0 ? rest : '';
167
+ return `${command.token} ${tail}`;
168
+ }
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Slash-command types — public surface for the composer's `/` UI.
5
+ *
6
+ * Pure type module — no React, no DOM, no side effects. Safe to import
7
+ * from anywhere (including the pure state machine in `state.ts`).
8
+ */
9
+ import type { ReactNode } from 'react';
10
+
11
+ /** One slash verb the composer exposes in the dropdown. */
12
+ export interface SlashCommand {
13
+ /** Stable id — verb without the leading slash (e.g. `"clear"`). */
14
+ id: string;
15
+ /** Display token incl. the slash (e.g. `"/clear"`). */
16
+ token: string;
17
+ /** Row label — short, action-style ("Clear conversation"). */
18
+ label: string;
19
+ /** Optional one-line description shown beneath the label. */
20
+ description?: string;
21
+ /**
22
+ * Display-only hint rendered next to the label in the menu (e.g.
23
+ * `<text>`, `<host>`). Does NOT affect selection behavior — use
24
+ * `autoExecute` to control whether picking runs immediately.
25
+ */
26
+ argHint?: string;
27
+ /** Optional row icon — host-supplied so this module ships zero icon deps. */
28
+ icon?: ReactNode;
29
+ /** Extra match terms beyond `id` (aliases used by `filterCommands`). */
30
+ keywords?: readonly string[];
31
+ /**
32
+ * Optional executor. When `autoExecute: true`, the composer invokes
33
+ * this on selection (with `''` as args) and clears the buffer. For
34
+ * insert-style commands (the default) hosts typically call this
35
+ * themselves on submit, with the user-typed arguments.
36
+ */
37
+ onExecute?: (args: string) => void | Promise<void>;
38
+ /**
39
+ * When true, picking this command runs `onExecute()` immediately and
40
+ * clears the composer buffer (no `/verb` is inserted). Default false —
41
+ * commands are inserted as `/verb ` with the cursor positioned for
42
+ * argument entry, and submitted by the user as a normal message.
43
+ *
44
+ * Set to `true` for action commands that should not produce a message
45
+ * (e.g. `/clear`, `/settings`, `/help`).
46
+ */
47
+ autoExecute?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Three states of the slash machine — the editor either has no leading
52
+ * slash (`none`), is typing a verb (`composing`, menu open), or has
53
+ * resolved a known verb followed by free-text args (`command`).
54
+ */
55
+ export type SlashState =
56
+ | { kind: 'none' }
57
+ | { kind: 'composing'; filter: string }
58
+ | { kind: 'command'; command: SlashCommand; argument: string };
59
+
60
+ /** Per-composer slash config — currently just the verb list. */
61
+ export interface SlashConfig {
62
+ /** The verbs available in this composer instance. */
63
+ commands: SlashCommand[];
64
+ }
@@ -0,0 +1,204 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `useSlashCommands` — React glue over the pure slash machine.
5
+ *
6
+ * Given the live editor value, exposes:
7
+ * - the current `SlashState`,
8
+ * - the filtered verb list while composing,
9
+ * - a `highlight` index + keyboard handler for the menu,
10
+ * - `pick(cmd)` which either executes the verb (`autoExecute: true`)
11
+ * or inserts `"<token> "` so the user can type arguments (default),
12
+ * - `clear()` which strips the leading slash to close the menu.
13
+ *
14
+ * The hook owns no editor state — the host passes `value` in and
15
+ * applies the strings `pick` / `onKeyDown` hand back to its editor's
16
+ * setter. This keeps it usable from the chat composer (driven by
17
+ * `useChatComposer`) and from any plain `useState` driven input.
18
+ */
19
+ import { useCallback, useMemo, useState, type KeyboardEvent } from 'react';
20
+
21
+ import {
22
+ filterCommands,
23
+ isSubmittableSlash,
24
+ parseSlashState,
25
+ resolveCommandAction,
26
+ } from './state';
27
+ import type { SlashCommand, SlashState } from './types';
28
+
29
+ export interface UseSlashCommandsOptions {
30
+ /** Live editor value (the full draft). */
31
+ value: string;
32
+ /** Verb set surfaced to the user. */
33
+ commands: readonly SlashCommand[];
34
+ /**
35
+ * Apply a new editor value. Called by `pick` (after an `insert`
36
+ * action), `clear` (Escape), and the auto-execute branch (clears
37
+ * the buffer after running the command).
38
+ */
39
+ onApply: (next: string) => void;
40
+ }
41
+
42
+ export interface UseSlashCommandsReturn {
43
+ /** Current machine state — `none | composing | command`. */
44
+ state: SlashState;
45
+ /** True while the dropdown should be visible (including empty-match state). */
46
+ isOpen: boolean;
47
+ /** Filtered verbs (empty list is possible when `isOpen` is true). */
48
+ matches: SlashCommand[];
49
+ /** Current filter string (the partial verb after the leading slash). */
50
+ query: string;
51
+ /** Index of the highlighted row in `matches`. */
52
+ highlight: number;
53
+ /** Set the highlight index (pointer-move sync). */
54
+ setHighlight: (i: number) => void;
55
+ /** The resolved verb once `state.kind === "command"`, else null. */
56
+ active: SlashCommand | null;
57
+ /**
58
+ * Whether the current buffer may be submitted as a chat message.
59
+ *
60
+ * Mirrors `isSubmittableSlash(value, commands)`. The composer ANDs
61
+ * this with `composer.canSubmit` to disable Send and turn Enter into
62
+ * a no-op while a slash command is missing its required argument or
63
+ * is an `autoExecute` action (which must be picked from the menu,
64
+ * not dispatched as a text message).
65
+ *
66
+ * When `commands` is empty (slash effectively disabled) this is
67
+ * always `true` — the gate is a no-op.
68
+ */
69
+ canSubmit: boolean;
70
+ /**
71
+ * Apply a verb. Commands with `autoExecute: true` invoke
72
+ * `command.onExecute?.('')` and clear the buffer. All other commands
73
+ * (the default) replace the leading `/partial` with `"<token> "` so
74
+ * the caret lands on the argument, and the user submits normally.
75
+ */
76
+ pick: (command: SlashCommand) => void;
77
+ /** Close the menu by dropping the leading slash. */
78
+ clear: () => void;
79
+ /**
80
+ * Keydown handler for the host editor. Consumes ↑/↓/Enter/Tab/Esc
81
+ * while the menu is open (calling `e.preventDefault()`); otherwise
82
+ * the event passes through untouched.
83
+ */
84
+ onKeyDown: (e: KeyboardEvent<HTMLElement>) => void;
85
+ }
86
+
87
+ export function useSlashCommands({
88
+ value,
89
+ commands,
90
+ onApply,
91
+ }: UseSlashCommandsOptions): UseSlashCommandsReturn {
92
+ const [highlight, setHighlightState] = useState(0);
93
+
94
+ const state = useMemo(
95
+ () => parseSlashState(value, commands),
96
+ [value, commands],
97
+ );
98
+
99
+ const matches = useMemo(
100
+ () =>
101
+ state.kind === 'composing'
102
+ ? filterCommands(commands, state.filter)
103
+ : [],
104
+ [state, commands],
105
+ );
106
+
107
+ // Menu stays open for the whole `composing` state — even when the
108
+ // filter narrows to nothing, so we can render a "No commands match"
109
+ // line instead of silently closing on the user.
110
+ const isOpen = state.kind === 'composing';
111
+ const query = state.kind === 'composing' ? state.filter : '';
112
+ const active = state.kind === 'command' ? state.command : null;
113
+ // Empty `commands` means slash is effectively off — never block submit
114
+ // (the composer instantiates the hook unconditionally, see Composer.tsx).
115
+ const canSubmit = useMemo(
116
+ () => (commands.length === 0 ? true : isSubmittableSlash(value, commands)),
117
+ [value, commands],
118
+ );
119
+
120
+ // Clamp the highlight whenever the match list shrinks.
121
+ const safeHighlight =
122
+ matches.length > 0 ? Math.min(highlight, matches.length - 1) : 0;
123
+
124
+ const setHighlight = useCallback((i: number) => {
125
+ setHighlightState(i);
126
+ }, []);
127
+
128
+ const pick = useCallback(
129
+ (command: SlashCommand) => {
130
+ const action = resolveCommandAction(value, command);
131
+ if (action.kind === 'execute') {
132
+ // Fire-and-forget; the host owns error handling. Clear the
133
+ // buffer so the dropdown closes and the next draft starts fresh.
134
+ try {
135
+ void command.onExecute?.('');
136
+ } finally {
137
+ onApply('');
138
+ }
139
+ } else {
140
+ onApply(action.text);
141
+ }
142
+ setHighlightState(0);
143
+ },
144
+ [value, onApply],
145
+ );
146
+
147
+ const clear = useCallback(() => {
148
+ onApply(value.replace(/^\//, ''));
149
+ setHighlightState(0);
150
+ }, [value, onApply]);
151
+
152
+ const onKeyDown = useCallback(
153
+ (e: KeyboardEvent<HTMLElement>) => {
154
+ if (!isOpen) return;
155
+ switch (e.key) {
156
+ case 'ArrowDown':
157
+ if (matches.length === 0) return;
158
+ e.preventDefault();
159
+ setHighlightState((h) => (h + 1) % matches.length);
160
+ break;
161
+ case 'ArrowUp':
162
+ if (matches.length === 0) return;
163
+ e.preventDefault();
164
+ setHighlightState((h) => (h - 1 + matches.length) % matches.length);
165
+ break;
166
+ case 'Enter':
167
+ case 'Tab': {
168
+ // When there are no matches, swallow Enter so the composer
169
+ // doesn't submit a `/xyzzy` literal — but leave Tab to the
170
+ // browser so focus management still works.
171
+ if (matches.length === 0) {
172
+ if (e.key === 'Enter') e.preventDefault();
173
+ return;
174
+ }
175
+ e.preventDefault();
176
+ const sel = matches[safeHighlight];
177
+ if (sel) pick(sel);
178
+ break;
179
+ }
180
+ case 'Escape':
181
+ e.preventDefault();
182
+ clear();
183
+ break;
184
+ default:
185
+ break;
186
+ }
187
+ },
188
+ [isOpen, matches, safeHighlight, pick, clear],
189
+ );
190
+
191
+ return {
192
+ state,
193
+ isOpen,
194
+ matches,
195
+ query,
196
+ highlight: safeHighlight,
197
+ setHighlight,
198
+ active,
199
+ canSubmit,
200
+ pick,
201
+ clear,
202
+ onKeyDown,
203
+ };
204
+ }
@@ -4,6 +4,7 @@ import type { ComponentType, ReactNode } from 'react';
4
4
 
5
5
  import type { UseChatComposerReturn } from '../hooks/useChatComposer';
6
6
  import type { ChatAttachment } from '../types';
7
+ import type { SlashConfig } from './slash/types';
7
8
 
8
9
  /** Composer visual size — shared across the `<Composer>` API. */
9
10
  export type ComposerSize = 'sm' | 'md' | 'lg';
@@ -68,6 +69,13 @@ export interface ComposerSlots {
68
69
  inlineStart?: ReactNode;
69
70
  /** Raw nodes placed right of the textarea in the `inline` layout. */
70
71
  inlineEnd?: ReactNode;
72
+ /**
73
+ * Slash-command surface. When provided, the composer mounts an
74
+ * internal `<SlashMenu>` as a floating popover anchored above the
75
+ * input surface and routes ↑/↓/Enter/Tab/Esc to the slash hook
76
+ * while the menu is open. See `./slash/README.md`.
77
+ */
78
+ slashCommands?: SlashConfig;
71
79
  }
72
80
 
73
81
  // ── Tier B — full slot replacement ─────────────────────────────────────────
@@ -8,9 +8,10 @@ import {
8
8
  useEffect,
9
9
  useMemo,
10
10
  useRef,
11
- useState,
12
11
  } from 'react';
13
12
 
13
+ import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry';
14
+
14
15
  import type { ChatConfig, ChatLabels, ChatTransport } from '../types';
15
16
  import { DEFAULT_LABELS } from '../types';
16
17
  import type { BlockRegistry } from '../messages/blocks';
@@ -34,18 +35,12 @@ import type { ChatAudioConfig, UseChatAudioReturn } from '../core/audio/types';
34
35
  * Consumed by `VoiceComposerSlot` for the focus / move-caret behaviour
35
36
  * during live dictation.
36
37
  */
37
- export interface ComposerHandle {
38
- focus: () => void;
39
- /** Move the caret to the very end of the input. */
40
- moveCursorToEnd?: () => void;
41
- /** Read the current draft text. Needed by voice dictation to anchor
42
- * partial transcripts onto the user's already-typed prefix. */
43
- getValue?: () => string;
44
- /** Replace the current draft text. Voice dictation uses this to push
45
- * interim + final transcripts into the composer without owning a
46
- * controlled binding. */
47
- setValue?: (value: string) => void;
48
- }
38
+ // `ComposerHandle` lives in `@djangocfg/ui-tools/composer-registry` —
39
+ // the cross-tool registry shared by chat (producer) and
40
+ // speech-recognition (consumer). Re-export here so existing call sites
41
+ // `import { ComposerHandle } from '@djangocfg/ui-tools/chat'` keep
42
+ // working unchanged.
43
+ export type { ComposerHandle } from '@djangocfg/ui-tools/composer-registry';
49
44
 
50
45
  export interface ChatContextValue extends UseChatReturn {
51
46
  layout: UseChatLayoutReturn;
@@ -56,13 +51,6 @@ export interface ChatContextValue extends UseChatReturn {
56
51
  * Components like ``AudioToggle`` use this to auto-hide when there
57
52
  * is nothing to mute. */
58
53
  hasAudio: boolean;
59
- /** Composer registry. The built-in `<Composer>` calls
60
- * `registerComposer({ focus })` on mount; custom composers (e.g.
61
- * cmdop's MarkdownEditor wrapper) do the same via the
62
- * `useRegisterComposer` helper. Read it via `composer?.focus()` —
63
- * null until any composer has mounted. Plan64 follow-up. */
64
- composer: ComposerHandle | null;
65
- registerComposer: (handle: ComposerHandle | null) => void;
66
54
  /** Registry of `kind` → renderer for `message.blocks`. `null` when the
67
55
  * host wired none — `<MessageBubble>` then uses `BUILTIN_BLOCK_REGISTRY`. */
68
56
  blockRegistry: BlockRegistry | null;
@@ -70,48 +58,6 @@ export interface ChatContextValue extends UseChatReturn {
70
58
 
71
59
  const Ctx = createContext<ChatContextValue | null>(null);
72
60
 
73
- // ─── Dual-bundle guard ────────────────────────────────────────────────────
74
- //
75
- // `@djangocfg/ui-tools` ships its root export through `dist/` (compiled) but
76
- // most subpaths resolve to raw `src/`. If a consumer mixes them — e.g.
77
- // `ChatRoot` from the root barrel and `VoiceComposerSlot` from
78
- // `@djangocfg/ui-tools/speech-recognition` — `createContext(...)` runs twice
79
- // and produces two distinct context instances. The Composer registers its
80
- // handle on one; `useChatContextOptional()` in the voice slot reads from the
81
- // other, sees `null`, and silently drops every transcript.
82
- //
83
- // We tag the global with our module identity. When more than one tag is
84
- // observed the consumer is told exactly what to do.
85
- type GuardSlot = { stamps: Set<symbol>; warned: boolean };
86
- const GLOBAL_KEY = '__djangocfg_chat_ctx_stamps__';
87
- const stamp = Symbol('djangocfg.chat.ctx');
88
- function markCtxLoad(): void {
89
- if (typeof globalThis === 'undefined') return;
90
- const g = globalThis as unknown as Record<string, GuardSlot | undefined>;
91
- let slot = g[GLOBAL_KEY];
92
- if (!slot) {
93
- slot = { stamps: new Set(), warned: false };
94
- g[GLOBAL_KEY] = slot;
95
- }
96
- slot.stamps.add(stamp);
97
- if (slot.stamps.size > 1 && !slot.warned && process.env.NODE_ENV !== 'production') {
98
- slot.warned = true;
99
- // eslint-disable-next-line no-console
100
- console.warn(
101
- '[@djangocfg/ui-tools/chat] Two ChatProvider context instances detected — ' +
102
- 'this means `@djangocfg/ui-tools` was loaded twice (one bundle via the root ' +
103
- '`.` export → `dist/`, another via a `./chat` / `./speech-recognition` subpath → `src/`). ' +
104
- 'Symptom: `useChatContextOptional()` returns `null` for descendants of `<ChatProvider>`, ' +
105
- 'so VoiceComposerSlot drops transcripts, useChatReset silently no-ops, etc. ' +
106
- '\n\nFix: import every Chat surface from the SAME subpath. Recommended:\n' +
107
- " import { ChatRoot, ChatLauncher, useChatContextOptional, … } from '@djangocfg/ui-tools/chat';\n" +
108
- " import { VoiceComposerSlot } from '@djangocfg/ui-tools/speech-recognition';\n" +
109
- '\n(See packages/ui-tools/src/tools/Chat/README.md → "Anti-patterns".)',
110
- );
111
- }
112
- }
113
- markCtxLoad();
114
-
115
61
  export interface ChatProviderProps {
116
62
  transport: ChatTransport;
117
63
  config?: ChatConfig;
@@ -240,26 +186,17 @@ export function ChatProvider({
240
186
  );
241
187
  }, [audio]);
242
188
 
243
- // Composer registry — kept in state (not a ref) so consumers
244
- // observing `ctx.composer` re-render when a composer mounts /
245
- // unmounts. The setter is the API surface; pass it to the composer
246
- // on mount and call with `null` on unmount.
247
- const [composer, setComposer] = useState<ComposerHandle | null>(null);
248
- const registerComposer = useCallback((handle: ComposerHandle | null) => {
249
- setComposer(handle);
250
- }, []);
251
-
252
189
  // Re-focus the composer on the streaming → idle edge. Lives here (not
253
190
  // in `ChatRoot`) so it works for *every* usage pattern — `ChatRoot`,
254
191
  // a hand-rolled `ChatProvider` + `Composer` layout, or headless — as
255
- // long as a composer registered its handle.
256
- const composerRef = useRef<ComposerHandle | null>(composer);
257
- composerRef.current = composer;
192
+ // long as a composer registered its handle. The active handle lives
193
+ // in `@djangocfg/ui-tools/composer-registry` a single cross-tool
194
+ // registry shared with `<VoiceComposerSlot>`.
258
195
  useStreamEndFocus({
259
196
  isStreaming: chat.isStreaming,
260
197
  enabled: autoFocusOnStreamEnd,
261
198
  delayMs: 0,
262
- resolveTarget: () => composerRef.current,
199
+ resolveTarget: () => getActiveComposer(),
263
200
  });
264
201
 
265
202
  const value = useMemo<ChatContextValue>(
@@ -270,11 +207,9 @@ export function ChatProvider({
270
207
  labels,
271
208
  audio: audioApi,
272
209
  hasAudio,
273
- composer,
274
- registerComposer,
275
210
  blockRegistry: blockRegistry ?? null,
276
211
  }),
277
- [chat, layout, config, labels, audioApi, hasAudio, composer, registerComposer, blockRegistry],
212
+ [chat, layout, config, labels, audioApi, hasAudio, blockRegistry],
278
213
  );
279
214
 
280
215
  return (
@@ -1,8 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { type RefObject, useEffect, useRef } from 'react';
3
+ import { type RefObject, useEffect } from 'react';
4
4
 
5
- import { useChatContextOptional, type ComposerHandle } from '../context';
5
+ import {
6
+ attachComposer,
7
+ getActiveComposer,
8
+ type ComposerHandle,
9
+ } from '@djangocfg/ui-tools/composer-registry';
10
+ import { useChatContextOptional } from '../context';
6
11
  import { useStreamEndFocus, type Focusable } from './useStreamEndFocus';
7
12
 
8
13
  export type { Focusable } from './useStreamEndFocus';
@@ -63,18 +68,14 @@ export function useAutoFocusOnStreamEnd(
63
68
  // Prefer the prop (caller knows best), fall back to context.
64
69
  const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false;
65
70
 
66
- // Keep latest ctx-composer in a ref so target resolution always sees
67
- // the freshest registered handle.
68
- const composerHandleRef = useRef<ComposerHandle | null>(null);
69
- composerHandleRef.current = ctx?.composer ?? null;
70
-
71
71
  useStreamEndFocus({
72
72
  isStreaming,
73
73
  enabled,
74
74
  delayMs,
75
- // Resolve in priority order: explicit ref > registered composer.
75
+ // Resolve in priority order: explicit ref > active composer
76
+ // (from the cross-tool registry).
76
77
  resolveTarget: () =>
77
- (targetRef?.current as Focusable | null) ?? composerHandleRef.current,
78
+ (targetRef?.current as Focusable | null) ?? getActiveComposer(),
78
79
  });
79
80
  }
80
81
 
@@ -94,8 +95,6 @@ export function useAutoFocusOnStreamEnd(
94
95
  * No-op when called outside a `<ChatProvider>`.
95
96
  */
96
97
  export function useRegisterComposer(handle: ComposerHandle): void {
97
- const ctx = useChatContextOptional();
98
- const register = ctx?.registerComposer;
99
98
  const focus = handle.focus;
100
99
  const moveCursorToEnd = handle.moveCursorToEnd;
101
100
  // Forward `getValue/setValue` too — voice dictation reads/writes the
@@ -104,8 +103,6 @@ export function useRegisterComposer(handle: ComposerHandle): void {
104
103
  const getValue = handle.getValue;
105
104
  const setValue = handle.setValue;
106
105
  useEffect(() => {
107
- if (!register) return;
108
- register({ focus, moveCursorToEnd, getValue, setValue });
109
- return () => register(null);
110
- }, [register, focus, moveCursorToEnd, getValue, setValue]);
106
+ return attachComposer({ focus, moveCursorToEnd, getValue, setValue });
107
+ }, [focus, moveCursorToEnd, getValue, setValue]);
111
108
  }