@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.
- package/dist/audio-player/index.cjs +2099 -0
- package/dist/audio-player/index.cjs.map +1 -0
- package/dist/audio-player/index.css +65 -0
- package/dist/audio-player/index.css.map +1 -0
- package/dist/audio-player/index.d.cts +174 -0
- package/dist/audio-player/index.d.ts +174 -0
- package/dist/audio-player/index.mjs +2076 -0
- package/dist/audio-player/index.mjs.map +1 -0
- package/dist/composer-registry/index.cjs +45 -0
- package/dist/composer-registry/index.cjs.map +1 -0
- package/dist/composer-registry/index.d.cts +73 -0
- package/dist/composer-registry/index.d.ts +73 -0
- package/dist/composer-registry/index.mjs +39 -0
- package/dist/composer-registry/index.mjs.map +1 -0
- package/dist/file-icon/index.d.cts +1 -1
- package/dist/file-icon/index.d.ts +1 -1
- package/dist/slots-ClRpIzoh.d.cts +88 -0
- package/dist/slots-ClRpIzoh.d.ts +88 -0
- package/dist/tree/index.cjs +2019 -279
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +731 -72
- package/dist/tree/index.d.ts +731 -72
- package/dist/tree/index.mjs +2009 -282
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +18 -9
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +146 -25
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
- package/src/tools/chat/composer/index.ts +22 -0
- package/src/tools/chat/composer/slash/README.md +187 -0
- package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
- package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
- package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
- package/src/tools/chat/composer/slash/index.ts +44 -0
- package/src/tools/chat/composer/slash/labels.ts +19 -0
- package/src/tools/chat/composer/slash/state.ts +168 -0
- package/src/tools/chat/composer/slash/types.ts +64 -0
- package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
- package/src/tools/chat/composer/types.ts +8 -0
- package/src/tools/chat/context/ChatProvider.tsx +13 -78
- package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
- package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
- package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
- package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
- package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
- package/src/tools/chat/shell/index.ts +6 -0
- package/src/tools/data/Listbox/lazy.tsx +1 -1
- package/src/tools/data/Masonry/lazy.tsx +1 -1
- package/src/tools/data/Timeline/lazy.tsx +1 -1
- package/src/tools/data/Tree/FinderTree.tsx +42 -0
- package/src/tools/data/Tree/README.md +337 -208
- package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
- package/src/tools/data/Tree/TreeRoot.tsx +111 -72
- package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
- package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
- package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
- package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
- package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
- package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
- package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +103 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +223 -363
- package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
- package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
- package/src/tools/data/Tree/context/async-children/index.ts +8 -0
- package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
- package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
- package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
- package/src/tools/data/Tree/context/dnd/index.ts +8 -0
- package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
- package/src/tools/data/Tree/context/expansion/index.ts +4 -0
- package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
- package/src/tools/data/Tree/context/hooks.ts +68 -1
- package/src/tools/data/Tree/context/index.ts +3 -0
- package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
- package/src/tools/data/Tree/context/menu/index.ts +11 -0
- package/src/tools/data/Tree/context/menu/render.tsx +75 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +141 -0
- package/src/tools/data/Tree/context/persist/index.ts +4 -0
- package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
- package/src/tools/data/Tree/context/rename/index.ts +4 -0
- package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
- package/src/tools/data/Tree/context/selection/index.ts +4 -0
- package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
- package/src/tools/data/Tree/context/state/index.ts +6 -0
- package/src/tools/data/Tree/context/state/initial.ts +41 -0
- package/src/tools/data/Tree/context/state/reducer.ts +76 -0
- package/src/tools/data/Tree/context/state/types.ts +46 -0
- package/src/tools/data/Tree/data/clipboard.ts +33 -0
- package/src/tools/data/Tree/data/dnd.ts +123 -0
- package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
- package/src/tools/data/Tree/data/index.ts +19 -0
- package/src/tools/data/Tree/data/renameUtils.ts +51 -0
- package/src/tools/data/Tree/data/selection.ts +157 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
- package/src/tools/data/Tree/hooks/index.ts +23 -4
- package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
- package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
- package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
- package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
- package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
- package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
- package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
- package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
- package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
- package/src/tools/data/Tree/index.tsx +26 -2
- package/src/tools/data/Tree/types/activation.ts +30 -0
- package/src/tools/data/Tree/types/adapter.ts +70 -0
- package/src/tools/data/Tree/types/index.ts +27 -0
- package/src/tools/data/Tree/types/labels.ts +97 -0
- package/src/tools/data/Tree/types/loader.ts +9 -0
- package/src/tools/data/Tree/types/node.ts +38 -0
- package/src/tools/data/Tree/types/root-props.ts +158 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
- package/src/tools/forms/MarkdownEditor/index.ts +1 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
- package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
- package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
- package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
- package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
- package/src/tools/forms/MarkdownEditor/styles.css +18 -0
- package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
- package/src/tools/integration/ComposerRegistry/index.ts +105 -0
- package/src/tools/media/AudioPlayer/Player.tsx +2 -0
- package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
- package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
- package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
- package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
- package/src/tools/media/AudioPlayer/types.ts +15 -0
- package/dist/types-j2vhn4Kv.d.cts +0 -241
- package/dist/types-j2vhn4Kv.d.ts +0 -241
- package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
- 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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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: () =>
|
|
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,
|
|
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
|
|
3
|
+
import { type RefObject, useEffect } from 'react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
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 >
|
|
75
|
+
// Resolve in priority order: explicit ref > active composer
|
|
76
|
+
// (from the cross-tool registry).
|
|
76
77
|
resolveTarget: () =>
|
|
77
|
-
(targetRef?.current as Focusable | null) ??
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
return () => register(null);
|
|
110
|
-
}, [register, focus, moveCursorToEnd, getValue, setValue]);
|
|
106
|
+
return attachComposer({ focus, moveCursorToEnd, getValue, setValue });
|
|
107
|
+
}, [focus, moveCursorToEnd, getValue, setValue]);
|
|
111
108
|
}
|