@djangocfg/ui-tools 2.1.415 → 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 (110) 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/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  100. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  101. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  102. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  103. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  104. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  105. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  106. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  107. package/dist/types-j2vhn4Kv.d.cts +0 -241
  108. package/dist/types-j2vhn4Kv.d.ts +0 -241
  109. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  110. 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 ─────────────────────────────────────────
@@ -0,0 +1,194 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ /**
8
+ * SuggestedPrompts — chat empty-state "starter prompts" surface.
9
+ *
10
+ * The pattern every chat consumer wants: a small hero (optional), a
11
+ * title + description (optional), and a row/grid of clickable prompts
12
+ * that seed the composer. Pluggable enough to cover ChatGPT-style
13
+ * card grids and cmdop-style flat chips with the same component.
14
+ *
15
+ * Typical wiring inside `<ChatRoot slots={{ empty: ({ setValue, focus }) =>
16
+ * <SuggestedPrompts items={...} onPick={(p) => { setValue(p.prompt); focus(); }} />
17
+ * }} />`.
18
+ */
19
+
20
+ export type SuggestedPromptItem = {
21
+ id: string;
22
+ /** Displayed on the chip / card. */
23
+ label: string;
24
+ /** Sent on click (often equal to `label`). */
25
+ prompt: string;
26
+ /** Optional leading icon. */
27
+ icon?: ReactNode;
28
+ /** Optional secondary line (rendered in `grid` layout, ignored by `chips`). */
29
+ description?: string;
30
+ };
31
+
32
+ export type SuggestedPromptsLayout = 'chips' | 'grid';
33
+
34
+ export interface SuggestedPromptsProps {
35
+ items: readonly SuggestedPromptItem[];
36
+ onPick: (item: SuggestedPromptItem) => void;
37
+ /** Optional title above the chips. */
38
+ title?: ReactNode;
39
+ /** Optional subtitle/description above the chips. */
40
+ description?: ReactNode;
41
+ /** Optional hero element above the title (icon, logo, …). */
42
+ hero?: ReactNode;
43
+ /** Layout — `chips` (flat rounded-full buttons, default) or `grid` (2-col cards). */
44
+ layout?: SuggestedPromptsLayout;
45
+ /** Container className. */
46
+ className?: string;
47
+ /** ARIA label for the region wrapper. Default `Suggested prompts`. */
48
+ ariaLabel?: string;
49
+ /** Custom render override for one item (escape hatch). */
50
+ renderItem?: (item: SuggestedPromptItem, idx: number) => ReactNode;
51
+ }
52
+
53
+ export function SuggestedPrompts({
54
+ items,
55
+ onPick,
56
+ title,
57
+ description,
58
+ hero,
59
+ layout = 'chips',
60
+ className,
61
+ ariaLabel = 'Suggested prompts',
62
+ renderItem,
63
+ }: SuggestedPromptsProps) {
64
+ const hasHeader = hero != null || title != null || description != null;
65
+
66
+ return (
67
+ <section
68
+ role="region"
69
+ aria-label={ariaLabel}
70
+ className={cn(
71
+ 'flex w-full flex-col items-center text-center',
72
+ className,
73
+ )}
74
+ >
75
+ {hasHeader ? (
76
+ <div className="mb-4 flex flex-col items-center gap-1.5">
77
+ {hero != null ? (
78
+ <div className="text-foreground/90">{hero}</div>
79
+ ) : null}
80
+ {title != null ? (
81
+ <h3 className="text-base font-semibold text-foreground">
82
+ {title}
83
+ </h3>
84
+ ) : null}
85
+ {description != null ? (
86
+ <p className="max-w-md text-sm leading-snug text-muted-foreground">
87
+ {description}
88
+ </p>
89
+ ) : null}
90
+ </div>
91
+ ) : null}
92
+
93
+ {layout === 'grid' ? (
94
+ <div
95
+ className="grid w-full max-w-xl grid-cols-1 gap-2 sm:grid-cols-2"
96
+ role="list"
97
+ >
98
+ {items.map((item, idx) =>
99
+ renderItem ? (
100
+ <span key={item.id} role="listitem">
101
+ {renderItem(item, idx)}
102
+ </span>
103
+ ) : (
104
+ <GridCard key={item.id} item={item} onPick={onPick} />
105
+ ),
106
+ )}
107
+ </div>
108
+ ) : (
109
+ <div
110
+ className="flex w-full max-w-xl flex-wrap justify-center gap-2"
111
+ role="list"
112
+ >
113
+ {items.map((item, idx) =>
114
+ renderItem ? (
115
+ <span key={item.id} role="listitem">
116
+ {renderItem(item, idx)}
117
+ </span>
118
+ ) : (
119
+ <Chip key={item.id} item={item} onPick={onPick} />
120
+ ),
121
+ )}
122
+ </div>
123
+ )}
124
+ </section>
125
+ );
126
+ }
127
+
128
+ function Chip({
129
+ item,
130
+ onPick,
131
+ }: {
132
+ item: SuggestedPromptItem;
133
+ onPick: (item: SuggestedPromptItem) => void;
134
+ }) {
135
+ return (
136
+ <button
137
+ type="button"
138
+ role="listitem"
139
+ onClick={() => onPick(item)}
140
+ className={cn(
141
+ 'inline-flex items-center gap-1.5 rounded-full border border-border',
142
+ 'bg-card/60 px-3 py-1.5 text-xs text-muted-foreground',
143
+ 'transition-colors duration-150',
144
+ 'hover:border-primary/40 hover:bg-card hover:text-foreground',
145
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
146
+ 'active:scale-[0.97]',
147
+ )}
148
+ >
149
+ {item.icon != null ? (
150
+ <span aria-hidden className="shrink-0 text-muted-foreground">
151
+ {item.icon}
152
+ </span>
153
+ ) : null}
154
+ <span>{item.label}</span>
155
+ </button>
156
+ );
157
+ }
158
+
159
+ function GridCard({
160
+ item,
161
+ onPick,
162
+ }: {
163
+ item: SuggestedPromptItem;
164
+ onPick: (item: SuggestedPromptItem) => void;
165
+ }) {
166
+ return (
167
+ <button
168
+ type="button"
169
+ role="listitem"
170
+ onClick={() => onPick(item)}
171
+ className={cn(
172
+ 'group flex flex-col gap-1 rounded-md border border-border',
173
+ 'bg-card/60 p-3 text-left',
174
+ 'transition-colors duration-150',
175
+ 'hover:border-primary/40 hover:bg-card',
176
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
177
+ )}
178
+ >
179
+ <span className="flex items-center gap-2 text-sm font-medium text-foreground">
180
+ {item.icon != null ? (
181
+ <span aria-hidden className="shrink-0 text-muted-foreground group-hover:text-foreground">
182
+ {item.icon}
183
+ </span>
184
+ ) : null}
185
+ <span>{item.label}</span>
186
+ </span>
187
+ {item.description ? (
188
+ <span className="text-xs leading-snug text-muted-foreground">
189
+ {item.description}
190
+ </span>
191
+ ) : null}
192
+ </button>
193
+ );
194
+ }
@@ -13,3 +13,9 @@ export {
13
13
  } from './ChatRoot';
14
14
  export { EmptyState, type EmptyStateProps } from './EmptyState';
15
15
  export { ErrorBanner, type ErrorBannerProps } from './ErrorBanner';
16
+ export {
17
+ SuggestedPrompts,
18
+ type SuggestedPromptsProps,
19
+ type SuggestedPromptItem,
20
+ type SuggestedPromptsLayout,
21
+ } from './SuggestedPrompts';
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { createLazyComponent, LoadingFallback } from '../../../components/lazy-wrapper';
4
- import type { ListboxRootProps } from './context/ListboxProvider';
4
+ import type { ListboxRootProps } from './types';
5
5
 
6
6
  export const LazyListbox = createLazyComponent<ListboxRootProps>(
7
7
  () => import('./context/ListboxProvider').then((m) => ({ default: m.ListboxRoot })),
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { createLazyComponent, LoadingFallback } from '../../../components/lazy-wrapper';
4
- import type { MasonryProps } from './context/MasonryProvider';
4
+ import type { MasonryProps } from './types';
5
5
 
6
6
  export const LazyMasonry = createLazyComponent<MasonryProps>(
7
7
  () => import('./context/MasonryProvider').then((m) => ({ default: m.Masonry })),
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { createLazyComponent, LoadingFallback } from '../../../components/lazy-wrapper';
4
- import type { TimelineProps } from './context/TimelineProvider';
4
+ import type { TimelineProps } from './types';
5
5
 
6
6
  export const LazyTimeline = createLazyComponent<TimelineProps>(
7
7
  () => import('./context/TimelineProvider').then((m) => ({ default: m.Timeline })),