@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.
- 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 +1994 -276
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +717 -72
- package/dist/tree/index.d.ts +717 -72
- package/dist/tree/index.mjs +1984 -279
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +10 -6
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +138 -17
- 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/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 +170 -55
- 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 +92 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +204 -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 +10 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -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 +25 -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 +142 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- 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/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 ─────────────────────────────────────────
|
|
@@ -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 './
|
|
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 './
|
|
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 './
|
|
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 })),
|