@kitn.ai/ui 0.17.0 → 0.18.0
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/README.md +28 -5
- package/dist/components/artifact.d.ts +39 -1
- package/dist/components/attachment-types.d.ts +12 -0
- package/dist/components/attachments.d.ts +2 -10
- package/dist/components/card.d.ts +27 -0
- package/dist/components/chain-of-thought.d.ts +78 -2
- package/dist/components/chat-scope-picker.d.ts +7 -0
- package/dist/components/chat-thread.d.ts +37 -12
- package/dist/components/choice-card.d.ts +26 -0
- package/dist/components/coachmark.d.ts +49 -0
- package/dist/components/composer-dom.d.ts +35 -0
- package/dist/components/composer-highlight.d.ts +48 -0
- package/dist/components/composer-history.d.ts +34 -0
- package/dist/components/composer.d.ts +86 -0
- package/dist/components/confirm-card.d.ts +17 -0
- package/dist/components/conversation-item.d.ts +9 -0
- package/dist/components/conversation-list.d.ts +36 -1
- package/dist/components/file-tree.d.ts +15 -0
- package/dist/components/form.d.ts +34 -0
- package/dist/components/markdown.d.ts +3 -0
- package/dist/components/message.d.ts +12 -0
- package/dist/components/model-switcher.d.ts +7 -0
- package/dist/components/prompt-input.d.ts +13 -4
- package/dist/components/prompt-suggestion.d.ts +9 -1
- package/dist/components/reasoning.d.ts +14 -1
- package/dist/components/response-compare.d.ts +14 -0
- package/dist/components/screen.d.ts +46 -0
- package/dist/components/tasks-card.d.ts +49 -1
- package/dist/components/toast.d.ts +12 -2
- package/dist/components/tool-types.d.ts +10 -0
- package/dist/components/tool.d.ts +8 -8
- package/dist/components/voice-input.d.ts +27 -0
- package/dist/components/voice-output.d.ts +33 -0
- package/dist/core-C8fzo39E.js +12 -0
- package/dist/custom-elements.json +8392 -1860
- package/dist/elements/agent-card.d.ts +1 -0
- package/dist/elements/agent-card.js +1 -0
- package/dist/elements/artifact.js +1 -1
- package/dist/elements/attachments.js +1 -1
- package/dist/elements/autoloader.js +1 -1
- package/dist/elements/avatar.d.ts +1 -0
- package/dist/elements/avatar.js +1 -0
- package/dist/elements/badge.d.ts +1 -0
- package/dist/elements/badge.js +1 -0
- package/dist/elements/button.d.ts +1 -0
- package/dist/elements/button.js +1 -0
- package/dist/elements/card.js +1 -1
- package/dist/elements/cards.js +1 -1
- package/dist/elements/chain-of-thought.js +1 -1
- package/dist/elements/chat-scope-picker.js +1 -1
- package/dist/elements/chat-types.d.ts +47 -0
- package/dist/elements/chat-workspace.js +1 -1
- package/dist/elements/chat.js +1 -1
- package/dist/elements/checkpoint.js +1 -1
- package/dist/elements/choice.js +1 -1
- package/dist/elements/chunks/Icon-DLLBeLG5.js +1 -0
- package/dist/elements/chunks/action-icons-UB4V5FNq.js +1 -0
- package/dist/elements/chunks/arrow-left-C-gwlUmx.js +1 -0
- package/dist/elements/chunks/attachments-CgbErf1r.js +1 -0
- package/dist/elements/chunks/badge-DTQpgB6N.js +1 -0
- package/dist/elements/chunks/button-XDUM6lXs.js +1 -0
- package/dist/elements/chunks/card-CBlFZ-7I.js +1 -0
- package/dist/elements/chunks/chat-thread-BIhmqwNL.js +1 -0
- package/dist/elements/chunks/check-hGUoVIKb.js +1 -0
- package/dist/elements/chunks/chevron-down-DDoaSUtO.js +1 -0
- package/dist/elements/chunks/chevron-right-CdGEUakB.js +1 -0
- package/dist/elements/chunks/circle-BKAZdmzb.js +1 -0
- package/dist/elements/chunks/circle-check-CmYG-y3I.js +1 -0
- package/dist/elements/chunks/circle-x-DewgAEym.js +1 -0
- package/dist/elements/chunks/cn-CU7UAWzk.js +1 -0
- package/dist/elements/chunks/code-block-DedwxwFI.js +1 -0
- package/dist/elements/chunks/collapsible-FfgmTHtM.js +1 -0
- package/dist/elements/chunks/composer-Cfbw5HKT.js +65 -0
- package/dist/elements/chunks/context-DwJmxlRE.js +1 -0
- package/dist/elements/chunks/conversation-list-DsCQMfPq.js +1 -0
- package/dist/elements/chunks/default-input-BuqsoENC.js +1 -0
- package/dist/elements/chunks/define-rGsaWsLL.js +1 -0
- package/dist/elements/chunks/disclosure-CwoAkpMH.js +1 -0
- package/dist/elements/chunks/download-DJ06G3Bz.js +1 -0
- package/dist/elements/chunks/dropdown-BFg8ri_R.js +1 -0
- package/dist/elements/chunks/ellipsis-dlN6YvGw.js +1 -0
- package/dist/elements/chunks/external-link-DBX2lc_R.js +1 -0
- package/dist/elements/chunks/file-text-HfwUx4F9.js +1 -0
- package/dist/elements/chunks/file-tree-C6Toj71u.js +1 -0
- package/dist/elements/chunks/folder-MYuqa9Xp.js +1 -0
- package/dist/elements/chunks/hover-card-gE7iyXdW.js +1 -0
- package/dist/elements/chunks/icon-Vqz4ivrY.js +1 -0
- package/dist/elements/chunks/{index-CTBEJeo6.js → index-BCRhi84a.js} +1 -1
- package/dist/elements/chunks/info-B_toFS0l.js +1 -0
- package/dist/elements/chunks/input-gzbHYZvQ.js +1 -0
- package/dist/elements/chunks/kbd-n49c4KZm.js +1 -0
- package/dist/elements/chunks/link-CKPs9yM0.js +1 -0
- package/dist/elements/chunks/loader-q6MamGus.js +1 -0
- package/dist/elements/chunks/{markdown-CMTGx9r8.js → markdown-DBrcqq4S.js} +9 -9
- package/dist/elements/chunks/message-CTqaYI0s.js +1 -0
- package/dist/elements/chunks/message-feedback-Cvzm03w0.js +1 -0
- package/dist/elements/chunks/message-square-CUg9eRCi.js +1 -0
- package/dist/elements/chunks/minimize-2-l2klxP6K.js +1 -0
- package/dist/elements/chunks/model-switcher-CmyjiHO1.js +1 -0
- package/dist/elements/chunks/overlay-C8sOYqjT.js +1 -0
- package/dist/elements/chunks/paperclip-42Ia7xHD.js +1 -0
- package/dist/elements/chunks/progress-bar-tzKMWAwO.js +1 -0
- package/dist/elements/chunks/prompt-suggestion-D3F6vP_A.js +1 -0
- package/dist/elements/chunks/reasoning-BJNmmyov.js +1 -0
- package/dist/elements/chunks/resizable-BRsk19qI.js +1 -0
- package/dist/elements/chunks/rotate-cw-BgcggLHC.js +1 -0
- package/dist/elements/chunks/scroll-area-CgGbFFbT.js +1 -0
- package/dist/elements/chunks/separator-8hM4GdHE.js +1 -0
- package/dist/elements/chunks/settings-CaUIYOg-.js +1 -0
- package/dist/elements/chunks/settings-group-CYjOVIZQ.js +1 -0
- package/dist/elements/chunks/share-BTYM3Ddt.js +1 -0
- package/dist/elements/chunks/skeleton-DsTYrPUe.js +1 -0
- package/dist/elements/chunks/slots-DkZ-Cmha.js +1 -0
- package/dist/elements/chunks/star-9R3LBQ9n.js +1 -0
- package/dist/elements/chunks/store-CYl5Y0_u.js +1 -0
- package/dist/elements/chunks/text-shimmer-CaYGv8Se.js +1 -0
- package/dist/elements/chunks/textarea-NEr2SLcD.js +1 -0
- package/dist/elements/chunks/thumbs-up-DZYZkFfc.js +1 -0
- package/dist/elements/chunks/toast-store-ClxVZRcc.js +1 -0
- package/dist/elements/chunks/tool-CHZOmoe9.js +1 -0
- package/dist/elements/chunks/tooltip-WpHElELC.js +1 -0
- package/dist/elements/chunks/triangle-alert-BlS80PUg.js +1 -0
- package/dist/elements/chunks/use-card-resolution-VSVoxDeJ.js +1 -0
- package/dist/elements/chunks/video-DxTPZqT8.js +1 -0
- package/dist/elements/chunks/x-C1qtndxn.js +1 -0
- package/dist/elements/coachmark.d.ts +1 -0
- package/dist/elements/coachmark.js +1 -0
- package/dist/elements/code-block.js +1 -1
- package/dist/elements/command.d.ts +1 -0
- package/dist/elements/command.js +1 -0
- package/dist/elements/compare.js +1 -1
- package/dist/elements/composer.d.ts +1 -0
- package/dist/elements/composer.js +1 -0
- package/dist/elements/confirm-card.js +1 -1
- package/dist/elements/context-meter.js +1 -1
- package/dist/elements/conversation-list.js +1 -1
- package/dist/elements/css.d.ts +1 -0
- package/dist/elements/default-input.d.ts +51 -0
- package/dist/elements/define.d.ts +53 -0
- package/dist/elements/dialog.d.ts +1 -0
- package/dist/elements/dialog.js +1 -0
- package/dist/elements/disclosure.d.ts +37 -0
- package/dist/elements/editable-label.d.ts +1 -0
- package/dist/elements/editable-label.js +1 -0
- package/dist/elements/embed.js +1 -1
- package/dist/elements/empty.js +1 -1
- package/dist/elements/feedback-bar.js +1 -1
- package/dist/elements/file-tree.js +1 -1
- package/dist/elements/file-upload.js +1 -1
- package/dist/elements/form.js +1 -1
- package/dist/elements/hover-card.d.ts +1 -0
- package/dist/elements/hover-card.js +1 -0
- package/dist/elements/icon.d.ts +1 -0
- package/dist/elements/icon.js +1 -0
- package/dist/elements/image.js +1 -1
- package/dist/elements/input.d.ts +1 -0
- package/dist/elements/input.js +1 -0
- package/dist/elements/kbd.d.ts +1 -0
- package/dist/elements/kbd.js +1 -0
- package/dist/elements/link-preview.js +1 -1
- package/dist/elements/loader.js +1 -1
- package/dist/elements/markdown.js +1 -1
- package/dist/elements/menu.d.ts +1 -0
- package/dist/elements/menu.js +1 -0
- package/dist/elements/message-skills.js +1 -1
- package/dist/elements/message.js +1 -1
- package/dist/elements/model-switcher.js +1 -1
- package/dist/elements/nav.d.ts +1 -0
- package/dist/elements/nav.js +1 -0
- package/dist/elements/notice.d.ts +1 -0
- package/dist/elements/notice.js +1 -0
- package/dist/elements/pane-group.d.ts +1 -0
- package/dist/elements/pane-group.js +1 -0
- package/dist/elements/pane.d.ts +1 -0
- package/dist/elements/pane.js +1 -0
- package/dist/elements/popover.js +1 -1
- package/dist/elements/progress-bar.d.ts +1 -0
- package/dist/elements/progress-bar.js +1 -0
- package/dist/elements/prompt-dock.d.ts +1 -0
- package/dist/elements/prompt-dock.js +1 -0
- package/dist/elements/prompt-input.js +1 -1
- package/dist/elements/prompt-suggestions.js +1 -1
- package/dist/elements/reasoning.js +1 -1
- package/dist/elements/register-impl.d.ts +0 -0
- package/dist/elements/register.d.ts +13 -0
- package/dist/elements/remote.d.ts +1 -0
- package/dist/elements/remote.js +1 -0
- package/dist/elements/resizable.js +1 -1
- package/dist/elements/response-stream.js +2 -2
- package/dist/elements/screen.d.ts +1 -0
- package/dist/elements/screen.js +1 -0
- package/dist/elements/scroll-area.d.ts +1 -0
- package/dist/elements/scroll-area.js +1 -0
- package/dist/elements/scroll-button.js +1 -1
- package/dist/elements/search.d.ts +1 -0
- package/dist/elements/search.js +1 -0
- package/dist/elements/segmented.d.ts +1 -0
- package/dist/elements/segmented.js +1 -0
- package/dist/elements/separator.d.ts +1 -0
- package/dist/elements/separator.js +1 -0
- package/dist/elements/setting-item.d.ts +1 -0
- package/dist/elements/setting-item.js +1 -0
- package/dist/elements/settings-group.d.ts +1 -0
- package/dist/elements/settings-group.js +1 -0
- package/dist/elements/skeleton.d.ts +1 -0
- package/dist/elements/skeleton.js +1 -0
- package/dist/elements/slots.d.ts +176 -0
- package/dist/elements/source.js +1 -1
- package/dist/elements/status.d.ts +1 -0
- package/dist/elements/status.js +1 -0
- package/dist/elements/switch.js +1 -1
- package/dist/elements/tabs.d.ts +1 -0
- package/dist/elements/tabs.js +1 -0
- package/dist/elements/tasks.js +1 -1
- package/dist/elements/text-shimmer.js +1 -1
- package/dist/elements/thinking-bar.js +1 -1
- package/dist/elements/toast.js +1 -1
- package/dist/elements/tool.js +1 -1
- package/dist/elements/tooltip.d.ts +1 -0
- package/dist/elements/tooltip.js +1 -0
- package/dist/elements/voice-input.js +1 -1
- package/dist/elements/voice-output.d.ts +1 -0
- package/dist/elements/voice-output.js +1 -0
- package/dist/elements.d.ts +733 -58
- package/dist/engine-javascript-C1x7zo1_.js +141 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +8256 -5703
- package/dist/kai.es.js +1 -0
- package/dist/llms/llms-full.txt +1146 -64
- package/dist/llms/llms.txt +4 -4
- package/dist/mcp.es.js +62 -7
- package/dist/primitives/composer-model.d.ts +22 -0
- package/dist/primitives/composer-triggers.d.ts +19 -0
- package/dist/primitives/create-kai-chat.d.ts +38 -0
- package/dist/primitives/toast-store.d.ts +32 -2
- package/dist/primitives/use-speech-recognition.d.ts +26 -0
- package/dist/react/index.d.ts +1085 -97
- package/dist/react/runtime.d.ts +9 -2
- package/dist/react/use-kai-chat.d.ts +41 -0
- package/dist/react.js +572 -193
- package/dist/register-impl-CYWGkgbI.js +141 -0
- package/dist/schemas/tasks.schema.json +2 -2
- package/dist/state/index.d.ts +4 -0
- package/dist/state/messages.d.ts +11 -0
- package/dist/state/stream.d.ts +22 -0
- package/dist/state/suggestions.d.ts +4 -0
- package/dist/state.js +117 -0
- package/dist/theme.tokens.css +87 -0
- package/dist/types.d.ts +4 -0
- package/dist/ui/agent-card.d.ts +61 -0
- package/dist/ui/button.d.ts +5 -2
- package/dist/ui/card.d.ts +61 -0
- package/dist/ui/collapsible.d.ts +15 -1
- package/dist/ui/command.d.ts +33 -0
- package/dist/ui/dialog.d.ts +42 -0
- package/dist/ui/dropdown.d.ts +95 -1
- package/dist/ui/editable-label.d.ts +26 -0
- package/dist/ui/hover-card.d.ts +13 -1
- package/dist/ui/icon.d.ts +18 -0
- package/dist/ui/input.d.ts +33 -0
- package/dist/ui/kbd.d.ts +21 -0
- package/dist/ui/nav.d.ts +62 -0
- package/dist/ui/notice.d.ts +31 -0
- package/dist/ui/overlay.d.ts +12 -0
- package/dist/ui/pane-grid.d.ts +58 -0
- package/dist/ui/pane-group.d.ts +75 -0
- package/dist/ui/pane.d.ts +78 -0
- package/dist/ui/popover.d.ts +11 -1
- package/dist/ui/progress-bar.d.ts +23 -0
- package/dist/ui/prompt-dock.d.ts +96 -0
- package/dist/ui/resizable.d.ts +12 -0
- package/dist/ui/scroll-area.d.ts +4 -0
- package/dist/ui/segmented.d.ts +28 -0
- package/dist/ui/settings-group.d.ts +32 -0
- package/dist/ui/skeleton.d.ts +12 -0
- package/dist/ui/stat.d.ts +16 -0
- package/dist/ui/status.d.ts +13 -0
- package/dist/ui/switch.d.ts +8 -1
- package/dist/ui/tabs.d.ts +35 -0
- package/dist/ui/tooltip.d.ts +19 -1
- package/frameworks/react/index.tsx +1042 -157
- package/frameworks/react/runtime.tsx +30 -1
- package/frameworks/react/use-kai-chat.tsx +87 -0
- package/llms-full.txt +1146 -64
- package/llms.txt +4 -4
- package/package.json +23 -8
- package/src/agent-tooling/mcp/manifest.ts +14 -0
- package/src/agent-tooling/mcp/tools/debug.ts +10 -6
- package/src/agent-tooling/mcp/tools/reference.ts +32 -0
- package/src/agent-tooling/mcp/tools/scaffold.ts +30 -1
- package/src/components/artifact.tsx +98 -13
- package/src/components/attachment-types.ts +13 -0
- package/src/components/attachments.tsx +4 -14
- package/src/components/card.tsx +109 -15
- package/src/components/chain-of-thought.tsx +210 -1
- package/src/components/chat-scope-picker.tsx +13 -3
- package/src/components/chat-thread.tsx +225 -116
- package/src/components/choice-card.tsx +103 -11
- package/src/components/coachmark.tsx +245 -0
- package/src/components/component-meta.json +691 -102
- package/src/components/composer-dom.ts +159 -0
- package/src/components/composer-highlight.ts +242 -0
- package/src/components/composer-history.ts +82 -0
- package/src/components/composer.tsx +979 -0
- package/src/components/confirm-card.tsx +54 -1
- package/src/components/conversation-item.tsx +69 -7
- package/src/components/conversation-list.tsx +134 -37
- package/src/components/file-tree.tsx +197 -47
- package/src/components/form-widgets.tsx +9 -7
- package/src/components/form.tsx +140 -17
- package/src/components/image.tsx +3 -5
- package/src/components/link-preview.tsx +5 -4
- package/src/components/markdown.tsx +5 -2
- package/src/components/message.tsx +23 -4
- package/src/components/model-switcher.tsx +19 -4
- package/src/components/prompt-input.tsx +46 -75
- package/src/components/prompt-suggestion.tsx +48 -16
- package/src/components/reasoning.tsx +29 -6
- package/src/components/response-compare.tsx +31 -2
- package/src/components/screen.tsx +176 -0
- package/src/components/tasks-card.tsx +342 -21
- package/src/components/toast.tsx +119 -31
- package/src/components/tool-types.ts +10 -0
- package/src/components/tool.tsx +22 -16
- package/src/components/voice-input.tsx +102 -19
- package/src/components/voice-output.tsx +202 -0
- package/src/elements/agent-card.tsx +67 -0
- package/src/elements/artifact.tsx +53 -4
- package/src/elements/attachments.tsx +1 -0
- package/src/elements/avatar.tsx +33 -0
- package/src/elements/badge.tsx +32 -0
- package/src/elements/button.tsx +126 -0
- package/src/elements/card-media.jpg +0 -0
- package/src/elements/card.tsx +124 -31
- package/src/elements/cards.tsx +81 -7
- package/src/elements/chain-of-thought.tsx +77 -32
- package/src/elements/chat-scope-picker.tsx +35 -8
- package/src/elements/chat-types.ts +2 -2
- package/src/elements/chat-workspace.tsx +153 -41
- package/src/elements/chat.tsx +45 -23
- package/src/elements/choice.tsx +71 -19
- package/src/elements/coachmark.tsx +90 -0
- package/src/elements/command.tsx +209 -0
- package/src/elements/compare.tsx +24 -2
- package/src/elements/compiled.css +1 -1
- package/src/elements/composer.tsx +151 -0
- package/src/elements/confirm-card.tsx +36 -11
- package/src/elements/conversation-list.tsx +80 -11
- package/src/elements/default-input.tsx +75 -53
- package/src/elements/define.tsx +47 -6
- package/src/elements/dialog.tsx +98 -0
- package/src/elements/disclosure.ts +73 -0
- package/src/elements/editable-label.tsx +125 -0
- package/src/elements/element-manifest.json +129 -1
- package/src/elements/element-meta.json +5562 -1336
- package/src/elements/element-types.d.ts +732 -57
- package/src/elements/file-tree.tsx +12 -2
- package/src/elements/file-upload.tsx +1 -1
- package/src/elements/form.tsx +54 -11
- package/src/elements/hover-card.tsx +80 -0
- package/src/elements/icon-names.json +48 -0
- package/src/elements/icon.tsx +41 -0
- package/src/elements/input.tsx +203 -0
- package/src/elements/kbd.tsx +45 -0
- package/src/elements/menu.tsx +217 -0
- package/src/elements/message.tsx +58 -11
- package/src/elements/model-switcher.tsx +27 -1
- package/src/elements/nav.tsx +87 -0
- package/src/elements/notice.tsx +53 -0
- package/src/elements/pane-group.tsx +119 -0
- package/src/elements/pane.tsx +129 -0
- package/src/elements/popover.tsx +39 -17
- package/src/elements/progress-bar.tsx +39 -0
- package/src/elements/prompt-dock.tsx +81 -0
- package/src/elements/prompt-input.tsx +112 -63
- package/src/elements/prompt-suggestions.tsx +25 -9
- package/src/elements/reasoning.tsx +30 -9
- package/src/elements/register-impl.ts +38 -3
- package/src/elements/register.ts +11 -3
- package/src/elements/resizable.tsx +48 -11
- package/src/elements/screen.tsx +90 -0
- package/src/elements/scroll-area.tsx +33 -0
- package/src/elements/search.tsx +180 -0
- package/src/elements/segmented.tsx +135 -0
- package/src/elements/separator.tsx +36 -0
- package/src/elements/setting-item.tsx +50 -0
- package/src/elements/settings-group.tsx +37 -0
- package/src/elements/skeleton.tsx +45 -0
- package/src/elements/slots.ts +607 -0
- package/src/elements/source.tsx +13 -6
- package/src/elements/status.tsx +42 -0
- package/src/elements/switch.tsx +89 -14
- package/src/elements/tabs.tsx +95 -0
- package/src/elements/tasks.tsx +70 -14
- package/src/elements/toast.tsx +17 -2
- package/src/elements/tool.tsx +43 -9
- package/src/elements/tooltip.tsx +85 -0
- package/src/elements/video-poster.jpg +0 -0
- package/src/elements/voice-input.tsx +66 -16
- package/src/elements/voice-output.tsx +69 -0
- package/src/index.ts +5 -2
- package/src/primitives/card-schemas/tasks.schema.json +2 -2
- package/src/primitives/composer-model.ts +49 -0
- package/src/primitives/composer-triggers.ts +35 -0
- package/src/primitives/create-kai-chat.ts +66 -0
- package/src/primitives/toast-store.ts +43 -2
- package/src/primitives/use-speech-recognition.ts +135 -0
- package/src/state/index.ts +6 -0
- package/src/state/messages.ts +36 -0
- package/src/state/stream.ts +92 -0
- package/src/state/suggestions.ts +9 -0
- package/src/types.ts +4 -0
- package/src/ui/agent-card.tsx +212 -0
- package/src/ui/button.tsx +16 -5
- package/src/ui/card.tsx +245 -0
- package/src/ui/collapsible.tsx +30 -4
- package/src/ui/command.tsx +99 -0
- package/src/ui/dialog.tsx +217 -0
- package/src/ui/dropdown.tsx +375 -13
- package/src/ui/editable-label.tsx +112 -0
- package/src/ui/hover-card.tsx +28 -5
- package/src/ui/icon.tsx +110 -0
- package/src/ui/input.tsx +132 -0
- package/src/ui/kbd.tsx +118 -0
- package/src/ui/nav.tsx +282 -0
- package/src/ui/notice.tsx +84 -0
- package/src/ui/overlay.tsx +108 -2
- package/src/ui/pane-grid.tsx +116 -0
- package/src/ui/pane-group.tsx +280 -0
- package/src/ui/pane.tsx +193 -0
- package/src/ui/popover.tsx +16 -2
- package/src/ui/progress-bar.tsx +83 -0
- package/src/ui/prompt-dock.tsx +145 -0
- package/src/ui/resizable.tsx +69 -1
- package/src/ui/scroll-area.tsx +14 -3
- package/src/ui/segmented.tsx +109 -0
- package/src/ui/settings-group.tsx +67 -0
- package/src/ui/skeleton.tsx +62 -5
- package/src/ui/stat.tsx +41 -0
- package/src/ui/status.tsx +48 -0
- package/src/ui/switch.tsx +49 -23
- package/src/ui/tabs.tsx +168 -0
- package/src/ui/tooltip.tsx +52 -8
- package/theme.css +113 -0
- package/dist/components/slash-command.d.ts +0 -15
- package/dist/elements/chunks/Icon-D_Zmwmwh.js +0 -1
- package/dist/elements/chunks/action-icons-PVfIvcuG.js +0 -1
- package/dist/elements/chunks/attachments-Dp6vqtrc.js +0 -1
- package/dist/elements/chunks/button-CKyYD9G4.js +0 -1
- package/dist/elements/chunks/card-Cjho-PYk.js +0 -1
- package/dist/elements/chunks/chat-thread-RT808iPf.js +0 -1
- package/dist/elements/chunks/check-CDYaMYrM.js +0 -1
- package/dist/elements/chunks/chevron-down-3SBiq8HF.js +0 -1
- package/dist/elements/chunks/chevron-right-k2UFlIVK.js +0 -1
- package/dist/elements/chunks/cn-Dogu6PbV.js +0 -1
- package/dist/elements/chunks/code-block-BYUmfFSd.js +0 -1
- package/dist/elements/chunks/collapsible-BiNwdOn6.js +0 -1
- package/dist/elements/chunks/context-18g6oi4J.js +0 -1
- package/dist/elements/chunks/conversation-list-D9Y0NVdo.js +0 -1
- package/dist/elements/chunks/default-input-CSAoYYMd.js +0 -1
- package/dist/elements/chunks/download-D8K0Je8r.js +0 -1
- package/dist/elements/chunks/dropdown-bUs2iTia.js +0 -1
- package/dist/elements/chunks/external-link-6mA0wxab.js +0 -1
- package/dist/elements/chunks/file-text-DnaYk3Dm.js +0 -1
- package/dist/elements/chunks/file-tree-CgtRLlxK.js +0 -1
- package/dist/elements/chunks/hover-card-CIGw62gP.js +0 -1
- package/dist/elements/chunks/link-BlGSeoPy.js +0 -1
- package/dist/elements/chunks/loader-DvhR4qa0.js +0 -1
- package/dist/elements/chunks/message-CDZwXjhL.js +0 -1
- package/dist/elements/chunks/message-feedback-CYx81QiA.js +0 -1
- package/dist/elements/chunks/model-switcher-O_tUhoOT.js +0 -1
- package/dist/elements/chunks/overlay-DydXYMQW.js +0 -1
- package/dist/elements/chunks/prompt-suggestion-ldlWV5kd.js +0 -1
- package/dist/elements/chunks/reasoning-2PUnHlmr.js +0 -1
- package/dist/elements/chunks/resizable-B5WNbSeb.js +0 -1
- package/dist/elements/chunks/star-t9tI-P0q.js +0 -1
- package/dist/elements/chunks/store-C6rIooZz.js +0 -1
- package/dist/elements/chunks/text-shimmer-uh9V_KUh.js +0 -1
- package/dist/elements/chunks/textarea-3nm6Ia9U.js +0 -1
- package/dist/elements/chunks/thumbs-up-BBtguGld.js +0 -1
- package/dist/elements/chunks/toast-store-D8cL3fWl.js +0 -1
- package/dist/elements/chunks/tool-GJ3vwp1U.js +0 -1
- package/dist/elements/chunks/tooltip-DwKAz4XR.js +0 -1
- package/dist/elements/chunks/triangle-alert-Ct1zt2JL.js +0 -1
- package/dist/elements/chunks/use-card-resolution-D-_0Alzy.js +0 -1
- package/dist/elements/chunks/video-mVYiM99-.js +0 -1
- package/dist/elements/chunks/x-CzxL27Ch.js +0 -1
- package/dist/kitn-chat.es.js +0 -1
- package/dist/register-JyTglR6m.js +0 -1
- package/dist/register-impl-C6wlSsts.js +0 -77
- package/src/components/slash-command.tsx +0 -232
- package/src/elements/framework-usage.json +0 -588
- /package/dist/{kitn-chat-provider.d.ts → kai-provider.d.ts} +0 -0
- /package/dist/{kitn-chat-provider.es.js → kai-provider.es.js} +0 -0
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type JSX,
|
|
3
|
+
createSignal,
|
|
4
|
+
createEffect,
|
|
5
|
+
createMemo,
|
|
6
|
+
createUniqueId,
|
|
7
|
+
on,
|
|
8
|
+
onMount,
|
|
9
|
+
onCleanup,
|
|
10
|
+
Show,
|
|
11
|
+
For,
|
|
12
|
+
} from 'solid-js';
|
|
13
|
+
import { cn } from '../utils/cn';
|
|
14
|
+
import {
|
|
15
|
+
type ComposerDoc,
|
|
16
|
+
type EntityRef,
|
|
17
|
+
normalizeValue,
|
|
18
|
+
serializeToText,
|
|
19
|
+
entitiesOf,
|
|
20
|
+
docIsEmpty,
|
|
21
|
+
} from '../primitives/composer-model';
|
|
22
|
+
import {
|
|
23
|
+
parseDom,
|
|
24
|
+
renderDoc,
|
|
25
|
+
ZWSP,
|
|
26
|
+
createEntityEl,
|
|
27
|
+
createTextWalker,
|
|
28
|
+
entityStore,
|
|
29
|
+
isEntityEl,
|
|
30
|
+
kindGlyph,
|
|
31
|
+
} from './composer-dom';
|
|
32
|
+
import { activeTriggerFor } from '../primitives/composer-triggers';
|
|
33
|
+
import { usePosition, useDismiss, createPresence } from '../ui/overlay';
|
|
34
|
+
import { findHighlightMatches, applyHighlights } from './composer-highlight';
|
|
35
|
+
import { createHistory } from './composer-history';
|
|
36
|
+
|
|
37
|
+
export interface TriggerItem {
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
icon?: string;
|
|
41
|
+
/** Secondary muted text shown beside the label in the menu (Codex-style). */
|
|
42
|
+
description?: string;
|
|
43
|
+
/** Section header to group this item under in the menu (e.g. 'Plugins', 'Agents'). */
|
|
44
|
+
group?: string;
|
|
45
|
+
/** Entity kind for the inserted pill; overrides the trigger's default `kind` so
|
|
46
|
+
* one trigger can mix kinds across sections (e.g. agents + plugins under `@`). */
|
|
47
|
+
kind?: string;
|
|
48
|
+
promptText?: string;
|
|
49
|
+
data?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
export interface TriggerDef {
|
|
52
|
+
char: string;
|
|
53
|
+
kind: string;
|
|
54
|
+
items?: TriggerItem[];
|
|
55
|
+
}
|
|
56
|
+
export type HighlightRule =
|
|
57
|
+
| string
|
|
58
|
+
| { pattern: string; flags?: string; class?: string };
|
|
59
|
+
export interface ComposerChange {
|
|
60
|
+
doc: ComposerDoc;
|
|
61
|
+
text: string;
|
|
62
|
+
entities: EntityRef[];
|
|
63
|
+
}
|
|
64
|
+
/** Imperative handle exposed via `controllerRef` — surfaces the composer's latent
|
|
65
|
+
* capabilities (focus/blur the editable, clear the doc, submit, insert a pill) so
|
|
66
|
+
* the `<kai-composer>` facade can forward them as instance methods. */
|
|
67
|
+
export interface ComposerController {
|
|
68
|
+
focus(options?: FocusOptions): void;
|
|
69
|
+
blur(): void;
|
|
70
|
+
clear(): void;
|
|
71
|
+
send(): void;
|
|
72
|
+
insertEntity(entity: EntityRef): void;
|
|
73
|
+
}
|
|
74
|
+
export interface ComposerProps {
|
|
75
|
+
value?: string | ComposerDoc;
|
|
76
|
+
placeholder?: string;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
loading?: boolean;
|
|
79
|
+
maxHeight?: number | string;
|
|
80
|
+
submitOnEnter?: boolean;
|
|
81
|
+
triggers?: TriggerDef[];
|
|
82
|
+
highlights?: HighlightRule[];
|
|
83
|
+
/** Default icon per entity kind (kind → image URL/data-URI), shown on a pill +
|
|
84
|
+
* menu item when the item has no `icon` of its own. Overrides the built-in
|
|
85
|
+
* glyphs (agent/plugin). Example: `{ agent: '/icons/bot.svg' }`. */
|
|
86
|
+
kindIcons?: Record<string, string>;
|
|
87
|
+
/** Render WITHOUT the rounded frame/background/padding — just the editable +
|
|
88
|
+
* placeholder + menu. For embedding inside another frame (e.g. PromptInput). */
|
|
89
|
+
bare?: boolean;
|
|
90
|
+
/** Override the editable element's classes (used in `bare` mode to match an
|
|
91
|
+
* existing input's exact look). The placeholder mirrors these for alignment. */
|
|
92
|
+
editableClass?: string;
|
|
93
|
+
/** Accessible name for the editable (role=textbox). Falls back to the
|
|
94
|
+
* placeholder, then "Message input". */
|
|
95
|
+
ariaLabel?: string;
|
|
96
|
+
/** Receive the editable element (e.g. to register it for click-to-focus). */
|
|
97
|
+
editableRef?: (el: HTMLDivElement) => void;
|
|
98
|
+
/** Receive the imperative controller once mounted. The `<kai-composer>` facade
|
|
99
|
+
* forwards these as element methods (focus/blur/clear/send/insertEntity). */
|
|
100
|
+
controllerRef?: (controller: ComposerController) => void;
|
|
101
|
+
onChange?: (change: ComposerChange) => void;
|
|
102
|
+
onSubmit?: (change: ComposerChange) => void;
|
|
103
|
+
onTrigger?: (info: { char: string; query: string; rect: DOMRect }) => void;
|
|
104
|
+
onTriggerClose?: () => void;
|
|
105
|
+
onEntityAdd?: (entity: EntityRef) => void;
|
|
106
|
+
onEntityRemove?: (entity: EntityRef) => void;
|
|
107
|
+
/** The editable gained focus. `focus`/`blur` are NOT composed, so they don't
|
|
108
|
+
* escape the shadow root — this re-exposes them. (keydown/paste/focusin/focusout
|
|
109
|
+
* are composed and already reach the host as native events; no wrapper needed.) */
|
|
110
|
+
onFocus?: (e: FocusEvent) => void;
|
|
111
|
+
/** The editable lost focus. */
|
|
112
|
+
onBlur?: (e: FocusEvent) => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Shadow-DOM-aware selection. `document.getSelection()` does NOT expose a
|
|
117
|
+
* selection that lives inside an open ShadowRoot in Chromium — it retargets the
|
|
118
|
+
* range to the host — so caret math and node insertion would silently operate
|
|
119
|
+
* OUTSIDE the editable (pills land in the light DOM, the menu never closes).
|
|
120
|
+
* `ShadowRoot.getSelection()` (Chrome) returns the real in-shadow selection. Fall
|
|
121
|
+
* back to the document selection for the light DOM / jsdom (no shadow root).
|
|
122
|
+
*/
|
|
123
|
+
function getActiveSelection(node: Node): Selection | null {
|
|
124
|
+
const root = node.getRootNode();
|
|
125
|
+
if (
|
|
126
|
+
typeof ShadowRoot !== 'undefined' &&
|
|
127
|
+
root instanceof ShadowRoot &&
|
|
128
|
+
typeof (root as unknown as { getSelection?: () => Selection | null }).getSelection === 'function'
|
|
129
|
+
) {
|
|
130
|
+
return (root as unknown as { getSelection: () => Selection | null }).getSelection();
|
|
131
|
+
}
|
|
132
|
+
return node.ownerDocument?.getSelection() ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Compute the full ZWSP-stripped text of all text nodes in the editable. */
|
|
136
|
+
function getFullText(root: HTMLElement): string {
|
|
137
|
+
let text = '';
|
|
138
|
+
const walker = createTextWalker(root);
|
|
139
|
+
let node = walker.nextNode() as Text | null;
|
|
140
|
+
while (node) {
|
|
141
|
+
text += (node.textContent ?? '').split(ZWSP).join('');
|
|
142
|
+
node = walker.nextNode() as Text | null;
|
|
143
|
+
}
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compute the global text offset for the current caret position.
|
|
149
|
+
* Walks SHOW_TEXT nodes, strips ZWSP, sums lengths before the caret node.
|
|
150
|
+
*/
|
|
151
|
+
function getCaretTextOffset(root: HTMLElement): number {
|
|
152
|
+
const sel = getActiveSelection(root);
|
|
153
|
+
if (!sel || sel.rangeCount === 0) return 0;
|
|
154
|
+
const range = sel.getRangeAt(0);
|
|
155
|
+
if (!range.collapsed) return 0;
|
|
156
|
+
|
|
157
|
+
const { startContainer, startOffset } = range;
|
|
158
|
+
let offset = 0;
|
|
159
|
+
const walker = createTextWalker(root);
|
|
160
|
+
let node = walker.nextNode() as Text | null;
|
|
161
|
+
while (node) {
|
|
162
|
+
if (node === startContainer) {
|
|
163
|
+
// Strip ZWSP chars that come before startOffset
|
|
164
|
+
const raw = (node.textContent ?? '').slice(0, startOffset);
|
|
165
|
+
offset += raw.split(ZWSP).join('').length;
|
|
166
|
+
return offset;
|
|
167
|
+
}
|
|
168
|
+
offset += (node.textContent ?? '').split(ZWSP).join('').length;
|
|
169
|
+
node = walker.nextNode() as Text | null;
|
|
170
|
+
}
|
|
171
|
+
return offset;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get the client rect of the caret. Uses a collapsed range if possible.
|
|
176
|
+
* If the rect is zero-sized, inserts a temporary measuring span briefly.
|
|
177
|
+
*/
|
|
178
|
+
function getCaretRect(root: HTMLElement): DOMRect | null {
|
|
179
|
+
const ownerDoc = root.ownerDocument;
|
|
180
|
+
const sel = getActiveSelection(root);
|
|
181
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
182
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
183
|
+
range.collapse(true);
|
|
184
|
+
|
|
185
|
+
const rects = range.getClientRects();
|
|
186
|
+
if (rects.length > 0 && (rects[0].width > 0 || rects[0].height > 0)) {
|
|
187
|
+
return rects[0];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fallback: insert a measuring span, read its rect, remove it.
|
|
191
|
+
const marker = ownerDoc.createElement('span');
|
|
192
|
+
marker.textContent = '';
|
|
193
|
+
marker.style.position = 'absolute';
|
|
194
|
+
marker.style.visibility = 'hidden';
|
|
195
|
+
range.insertNode(marker);
|
|
196
|
+
const rect = marker.getBoundingClientRect();
|
|
197
|
+
marker.remove();
|
|
198
|
+
return rect.width > 0 || rect.height > 0 ? rect : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Place the caret at a stripped-text offset (inverse of getCaretTextOffset).
|
|
203
|
+
* Walks pill-skipping text nodes; pills are 0-width in this coordinate space, so
|
|
204
|
+
* an offset lands in the surrounding text. Best-effort: falls back to end-of-content.
|
|
205
|
+
*/
|
|
206
|
+
function setCaretToOffset(root: HTMLElement, offset: number): void {
|
|
207
|
+
const sel = getActiveSelection(root);
|
|
208
|
+
if (!sel) return;
|
|
209
|
+
const range = root.ownerDocument.createRange();
|
|
210
|
+
const walker = createTextWalker(root);
|
|
211
|
+
let remaining = offset;
|
|
212
|
+
let node = walker.nextNode() as Text | null;
|
|
213
|
+
let placed = false;
|
|
214
|
+
while (node) {
|
|
215
|
+
const raw = node.textContent ?? '';
|
|
216
|
+
const strippedLen = raw.split(ZWSP).join('').length;
|
|
217
|
+
if (remaining <= strippedLen) {
|
|
218
|
+
// Convert the stripped offset to a raw offset within this node.
|
|
219
|
+
let rawOffset = 0;
|
|
220
|
+
let seen = 0;
|
|
221
|
+
while (rawOffset < raw.length && seen < remaining) {
|
|
222
|
+
if (raw[rawOffset] !== ZWSP) seen++;
|
|
223
|
+
rawOffset++;
|
|
224
|
+
}
|
|
225
|
+
// Skip past any ZWSP filler so the caret sits after the pill marker.
|
|
226
|
+
while (rawOffset < raw.length && raw[rawOffset] === ZWSP) rawOffset++;
|
|
227
|
+
range.setStart(node, rawOffset);
|
|
228
|
+
placed = true;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
remaining -= strippedLen;
|
|
232
|
+
node = walker.nextNode() as Text | null;
|
|
233
|
+
}
|
|
234
|
+
if (!placed) {
|
|
235
|
+
range.selectNodeContents(root);
|
|
236
|
+
range.collapse(false);
|
|
237
|
+
} else {
|
|
238
|
+
range.collapse(true);
|
|
239
|
+
}
|
|
240
|
+
sel.removeAllRanges();
|
|
241
|
+
sel.addRange(range);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function Composer(props: ComposerProps): JSX.Element {
|
|
245
|
+
let editable!: HTMLDivElement;
|
|
246
|
+
// A SIGNAL (not a plain `let`): usePosition's floating ref must be reactive so
|
|
247
|
+
// Floating UI recomputes once the menu mounts. A non-reactive ref left the menu
|
|
248
|
+
// parked at the page origin (0,0) instead of anchored at the caret.
|
|
249
|
+
const [menuRef, setMenuRef] = createSignal<HTMLDivElement>();
|
|
250
|
+
const highlightName = `kai-composer-highlight-${createUniqueId()}`;
|
|
251
|
+
const [empty, setEmpty] = createSignal(docIsEmpty(normalizeValue(props.value)));
|
|
252
|
+
|
|
253
|
+
// --- Atomic pill selection (Claude Code-style: arrow onto a pill selects the
|
|
254
|
+
// whole unit; arrow again steps the caret past it; Backspace/Delete removes
|
|
255
|
+
// it). The selected pill carries `data-selected` for the highlight. This is
|
|
256
|
+
// a caret/visual affordance only — it never mutates the doc. ---
|
|
257
|
+
const [selectedPill, setSelectedPill] = createSignal<HTMLElement | null>(null);
|
|
258
|
+
const selectPill = (el: HTMLElement) => {
|
|
259
|
+
const cur = selectedPill();
|
|
260
|
+
if (cur && cur !== el) cur.removeAttribute('data-selected');
|
|
261
|
+
el.setAttribute('data-selected', '');
|
|
262
|
+
setSelectedPill(el);
|
|
263
|
+
};
|
|
264
|
+
const clearPillSelection = () => {
|
|
265
|
+
const cur = selectedPill();
|
|
266
|
+
if (!cur) return;
|
|
267
|
+
cur.removeAttribute('data-selected');
|
|
268
|
+
setSelectedPill(null);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// --- Undo/redo history (we own it; native contenteditable undo corrupts pills) ---
|
|
272
|
+
const history = createHistory({ doc: normalizeValue(props.value), caret: 0 });
|
|
273
|
+
let lastEditAt = 0;
|
|
274
|
+
const COALESCE_MS = 500;
|
|
275
|
+
|
|
276
|
+
// --- Trigger menu state ---
|
|
277
|
+
type ActiveTriggerState = {
|
|
278
|
+
def: TriggerDef;
|
|
279
|
+
query: string;
|
|
280
|
+
start: number;
|
|
281
|
+
caretRect: DOMRect;
|
|
282
|
+
};
|
|
283
|
+
const [activeTrigger, setActiveTrigger] = createSignal<ActiveTriggerState | null>(null);
|
|
284
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
285
|
+
|
|
286
|
+
const filteredItems = createMemo((): TriggerItem[] => {
|
|
287
|
+
const t = activeTrigger();
|
|
288
|
+
if (!t?.def.items) return [];
|
|
289
|
+
// Exclude entities already present in the doc (by effective kind + id) — don't
|
|
290
|
+
// offer a skill/agent/plugin the user has already added.
|
|
291
|
+
const present = new Set(entitiesOf(parseDom(editable)).map((e) => `${e.kind}:${e.id}`));
|
|
292
|
+
const q = t.query.toLowerCase();
|
|
293
|
+
return t.def.items.filter((item) => {
|
|
294
|
+
const kind = item.kind ?? t.def.kind;
|
|
295
|
+
if (present.has(`${kind}:${item.id}`)) return false;
|
|
296
|
+
if (q === '') return true;
|
|
297
|
+
// Match label or description (Codex filters across both).
|
|
298
|
+
return item.label.toLowerCase().includes(q) || (item.description?.toLowerCase().includes(q) ?? false);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Group the filtered items into sections by `group`, preserving first-appearance
|
|
303
|
+
// order. Each item keeps its FLAT index so selection/keyboard-nav stays simple.
|
|
304
|
+
const groupedItems = createMemo(() => {
|
|
305
|
+
const groups: { group: string | undefined; items: { item: TriggerItem; index: number }[] }[] = [];
|
|
306
|
+
filteredItems().forEach((item, index) => {
|
|
307
|
+
let g = groups.find((x) => x.group === item.group);
|
|
308
|
+
if (!g) { g = { group: item.group, items: [] }; groups.push(g); }
|
|
309
|
+
g.items.push({ item, index });
|
|
310
|
+
});
|
|
311
|
+
return groups;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// The built-in menu is open only when there are items left to show (after
|
|
315
|
+
// query filtering AND excluding already-added entities). `kai-trigger` still
|
|
316
|
+
// fires regardless, so consumers driving a custom menu aren't affected.
|
|
317
|
+
const menuOpen = createMemo(() => filteredItems().length > 0);
|
|
318
|
+
|
|
319
|
+
// Icon resolution for a menu item: its own icon → the per-kind default.
|
|
320
|
+
// (When neither, the menu renders the built-in kind glyph as a fallback.)
|
|
321
|
+
const itemKind = (item: TriggerItem) => item.kind ?? activeTrigger()?.def.kind ?? '';
|
|
322
|
+
const itemIconSrc = (item: TriggerItem) => item.icon ?? props.kindIcons?.[itemKind(item)];
|
|
323
|
+
|
|
324
|
+
// Keep selectedIndex in bounds when filteredItems changes
|
|
325
|
+
createEffect(() => {
|
|
326
|
+
const max = filteredItems().length;
|
|
327
|
+
if (selectedIndex() >= max) setSelectedIndex(Math.max(0, max - 1));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// --- Floating menu positioning ---
|
|
331
|
+
// Virtual reference element built from the live caret rect
|
|
332
|
+
const virtualRef = createMemo((): HTMLElement | undefined => {
|
|
333
|
+
const t = activeTrigger();
|
|
334
|
+
if (!t) return undefined;
|
|
335
|
+
const rect = t.caretRect;
|
|
336
|
+
return {
|
|
337
|
+
getBoundingClientRect: () => rect,
|
|
338
|
+
} as unknown as HTMLElement;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const { pos, hidden } = usePosition(virtualRef, menuRef, { placement: 'bottom-start', gutter: 4 });
|
|
342
|
+
const { present, state, setRef } = createPresence(menuOpen);
|
|
343
|
+
|
|
344
|
+
useDismiss({
|
|
345
|
+
enabled: menuOpen,
|
|
346
|
+
onDismiss: () => closeTrigger(),
|
|
347
|
+
refs: () => [editable, menuRef()],
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
function closeTrigger() {
|
|
351
|
+
if (activeTrigger()) {
|
|
352
|
+
setActiveTrigger(null);
|
|
353
|
+
props.onTriggerClose?.();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// --- Core logic ---
|
|
358
|
+
|
|
359
|
+
const snapshot = (): ComposerChange => {
|
|
360
|
+
const doc = parseDom(editable);
|
|
361
|
+
return { doc, text: serializeToText(doc), entities: entitiesOf(doc) };
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** Recompute and register CSS Custom Highlight ranges for keyword rules. No
|
|
365
|
+
* rules → apply an empty set, which clears any prior registration. */
|
|
366
|
+
function recomputeHighlights() {
|
|
367
|
+
const rules = props.highlights;
|
|
368
|
+
const matches = rules?.length ? findHighlightMatches(getFullText(editable), rules) : [];
|
|
369
|
+
applyHighlights(editable, matches, ZWSP, highlightName);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
onMount(() => {
|
|
373
|
+
renderDoc(editable, normalizeValue(props.value), editable.ownerDocument, props.kindIcons);
|
|
374
|
+
setEmpty(docIsEmpty(parseDom(editable)));
|
|
375
|
+
recomputeHighlights();
|
|
376
|
+
history.reset({ doc: parseDom(editable), caret: 0 });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Re-render when the controlled `value` prop changes, but ONLY when the
|
|
380
|
+
// editable is NOT focused (don't stomp the caret while the user is typing).
|
|
381
|
+
createEffect(on(() => props.value, (v) => {
|
|
382
|
+
// Re-render only when the incoming value actually differs from what the DOM
|
|
383
|
+
// already shows. This skips the echo of our own onChange (so the caret isn't
|
|
384
|
+
// stomped while typing) while still honoring genuine external changes —
|
|
385
|
+
// including a clear-after-submit that fires while the editable is focused.
|
|
386
|
+
const incoming = serializeToText(normalizeValue(v));
|
|
387
|
+
if (incoming === serializeToText(parseDom(editable))) return;
|
|
388
|
+
clearPillSelection(); // selected pill node is about to be replaced
|
|
389
|
+
renderDoc(editable, normalizeValue(v), editable.ownerDocument, props.kindIcons);
|
|
390
|
+
setEmpty(docIsEmpty(parseDom(editable)));
|
|
391
|
+
recomputeHighlights();
|
|
392
|
+
history.reset({ doc: parseDom(editable), caret: 0 }); // external value = new baseline
|
|
393
|
+
}, { defer: true }));
|
|
394
|
+
|
|
395
|
+
// Re-decorate when `highlights` changes on its own — e.g. a consumer (or the
|
|
396
|
+
// docs <Example>) assigns `value` then `highlights` as separate properties
|
|
397
|
+
// after mount. Without this, highlights set after the value-effect ran would
|
|
398
|
+
// never apply. `defer` skips the mount run (onMount already decorates).
|
|
399
|
+
createEffect(on(() => props.highlights, () => recomputeHighlights(), { defer: true }));
|
|
400
|
+
|
|
401
|
+
function updateTriggerState() {
|
|
402
|
+
const defs = props.triggers;
|
|
403
|
+
if (!defs?.length) {
|
|
404
|
+
if (activeTrigger()) closeTrigger();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const text = getFullText(editable);
|
|
408
|
+
const caret = getCaretTextOffset(editable);
|
|
409
|
+
const hit = activeTriggerFor(text, caret, defs);
|
|
410
|
+
if (hit) {
|
|
411
|
+
const rect = getCaretRect(editable);
|
|
412
|
+
if (rect) {
|
|
413
|
+
const prev = activeTrigger();
|
|
414
|
+
setActiveTrigger({ def: hit.def, query: hit.query, start: hit.start, caretRect: rect });
|
|
415
|
+
// Fire onTrigger if char/query changed or first activation
|
|
416
|
+
if (!prev || prev.def.char !== hit.def.char || prev.query !== hit.query) {
|
|
417
|
+
props.onTrigger?.({ char: hit.def.char, query: hit.query, rect });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
if (activeTrigger()) closeTrigger();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Update derived state + notify the consumer (no history side effects).
|
|
426
|
+
const syncState = (change: ComposerChange) => {
|
|
427
|
+
setEmpty(docIsEmpty(change.doc));
|
|
428
|
+
props.onChange?.(change);
|
|
429
|
+
updateTriggerState();
|
|
430
|
+
recomputeHighlights();
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Native input (typing / deleting): sync + record a (time-coalesced) history step.
|
|
434
|
+
const handleInput = () => {
|
|
435
|
+
clearPillSelection(); // any text edit drops a stale pill highlight
|
|
436
|
+
const change = snapshot();
|
|
437
|
+
syncState(change);
|
|
438
|
+
const now = Date.now();
|
|
439
|
+
history.record({ doc: change.doc, caret: getCaretTextOffset(editable) }, now - lastEditAt < COALESCE_MS);
|
|
440
|
+
lastEditAt = now;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// Structural edit (pill insert/delete): sync + record a DISTINCT history step.
|
|
444
|
+
const commitChange = () => {
|
|
445
|
+
const change = snapshot();
|
|
446
|
+
syncState(change);
|
|
447
|
+
history.record({ doc: change.doc, caret: getCaretTextOffset(editable) }, false);
|
|
448
|
+
lastEditAt = 0; // next typing starts its own entry
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// Restore a snapshot (undo/redo). Re-renders the doc (pills included) + caret,
|
|
452
|
+
// syncs derived state, but does NOT push a new history entry.
|
|
453
|
+
const applySnapshot = (snap: { doc: ComposerDoc; caret: number }) => {
|
|
454
|
+
clearPillSelection(); // the selected pill node is about to be replaced
|
|
455
|
+
renderDoc(editable, snap.doc, editable.ownerDocument, props.kindIcons);
|
|
456
|
+
setCaretToOffset(editable, snap.caret);
|
|
457
|
+
syncState(snapshot());
|
|
458
|
+
lastEditAt = 0;
|
|
459
|
+
};
|
|
460
|
+
const doUndo = () => { const s = history.undo(); if (s) applySnapshot(s); };
|
|
461
|
+
const doRedo = () => { const s = history.redo(); if (s) applySnapshot(s); };
|
|
462
|
+
|
|
463
|
+
// Also update trigger state on selectionchange (while focused). These handlers
|
|
464
|
+
// double as the consumer-facing focus/blur surface (focus/blur are NOT composed,
|
|
465
|
+
// so they don't escape the shadow root natively — we re-expose them as callbacks
|
|
466
|
+
// the element turns into kai-focus/kai-blur).
|
|
467
|
+
let focused = false;
|
|
468
|
+
const handleFocus = (e: FocusEvent) => { focused = true; props.onFocus?.(e); };
|
|
469
|
+
const handleBlur = (e: FocusEvent) => { focused = false; clearPillSelection(); props.onBlur?.(e); };
|
|
470
|
+
|
|
471
|
+
onMount(() => {
|
|
472
|
+
const onSelectionChange = () => {
|
|
473
|
+
if (focused) updateTriggerState();
|
|
474
|
+
};
|
|
475
|
+
document.addEventListener('selectionchange', onSelectionChange);
|
|
476
|
+
onCleanup(() => document.removeEventListener('selectionchange', onSelectionChange));
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// --- Imperative controller (Pattern C): hand the facade a handle over the
|
|
480
|
+
// composer's latent capabilities. `editable`/`snapshot`/`insertEntity` are
|
|
481
|
+
// already wired internally — this just surfaces them without changing any
|
|
482
|
+
// existing behavior. ---
|
|
483
|
+
onMount(() => {
|
|
484
|
+
props.controllerRef?.({
|
|
485
|
+
focus: (options) => editable.focus(options),
|
|
486
|
+
blur: () => editable.blur(),
|
|
487
|
+
// Empty the composer to a blank doc + reset the history baseline. Mirrors
|
|
488
|
+
// the external-value clear path so derived state syncs and onChange fires
|
|
489
|
+
// (which the facade turns into kai-value-change).
|
|
490
|
+
clear: () => {
|
|
491
|
+
clearPillSelection();
|
|
492
|
+
renderDoc(editable, [], editable.ownerDocument, props.kindIcons);
|
|
493
|
+
syncState(snapshot());
|
|
494
|
+
history.reset({ doc: parseDom(editable), caret: 0 });
|
|
495
|
+
lastEditAt = 0;
|
|
496
|
+
},
|
|
497
|
+
// Submit the current content programmatically — same path as Enter.
|
|
498
|
+
send: () => props.onSubmit?.(snapshot()),
|
|
499
|
+
// Insert an atomic pill at the caret (no trigger token to delete).
|
|
500
|
+
insertEntity: (entity) => insertEntity(entity),
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Select an item from the trigger menu.
|
|
506
|
+
*/
|
|
507
|
+
function selectItem(item: TriggerItem) {
|
|
508
|
+
const t = activeTrigger();
|
|
509
|
+
if (!t) return;
|
|
510
|
+
const entity: EntityRef = {
|
|
511
|
+
kind: item.kind ?? t.def.kind,
|
|
512
|
+
id: item.id,
|
|
513
|
+
label: item.label,
|
|
514
|
+
icon: item.icon,
|
|
515
|
+
promptText: item.promptText,
|
|
516
|
+
data: item.data,
|
|
517
|
+
};
|
|
518
|
+
// Delete the trigger token = the char + query ("/re" → 3) before the caret.
|
|
519
|
+
insertEntity(entity, { deleteBefore: 1 + t.query.length });
|
|
520
|
+
closeTrigger();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Insert an entity pill at the caret.
|
|
525
|
+
* If `opts.deleteBefore` is given, that many non-ZWSP characters immediately
|
|
526
|
+
* before the caret are removed first (the trigger token). The deletion walks
|
|
527
|
+
* BACKWARD from the caret within its text run, so it can never cross an earlier
|
|
528
|
+
* pill — a global-offset delete used to eat the previous pill, capping the field
|
|
529
|
+
* at two pills. After insertion the caret sits just after the new ZWSP marker.
|
|
530
|
+
*/
|
|
531
|
+
const insertEntity = (entity: EntityRef, opts?: { deleteBefore?: number }) => {
|
|
532
|
+
const ownerDoc = editable.ownerDocument;
|
|
533
|
+
const sel = getActiveSelection(editable);
|
|
534
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
535
|
+
|
|
536
|
+
// Delete the trigger token: walk BACK from the caret over `deleteBefore`
|
|
537
|
+
// non-ZWSP chars to find the token start, then delete start→caret. Walking
|
|
538
|
+
// back stays within the caret's text run (stops at a pill), so it never eats
|
|
539
|
+
// an earlier pill. Deleting via a range collapses the live selection cleanly.
|
|
540
|
+
const caret = sel.getRangeAt(0);
|
|
541
|
+
if (opts?.deleteBefore && caret.collapsed) {
|
|
542
|
+
let remaining = opts.deleteBefore;
|
|
543
|
+
let node: Node | null = caret.startContainer;
|
|
544
|
+
let offset = caret.startOffset;
|
|
545
|
+
while (remaining > 0 && node && node.nodeType === 3) {
|
|
546
|
+
const text = node.textContent ?? '';
|
|
547
|
+
while (offset > 0 && remaining > 0) {
|
|
548
|
+
offset--;
|
|
549
|
+
if (text[offset] !== ZWSP) remaining--;
|
|
550
|
+
}
|
|
551
|
+
if (remaining > 0) {
|
|
552
|
+
const prev: ChildNode | null = node.previousSibling;
|
|
553
|
+
if (prev && prev.nodeType === 3) { node = prev; offset = (prev.textContent ?? '').length; }
|
|
554
|
+
else break; // a pill (or nothing) precedes — stop, never delete across it
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const del = ownerDoc.createRange();
|
|
558
|
+
del.setStart(node as Node, offset);
|
|
559
|
+
del.setEnd(caret.startContainer, caret.startOffset);
|
|
560
|
+
del.deleteContents();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Insert at the live caret (re-collapsed at the deletion point by the browser).
|
|
564
|
+
const range = sel.getRangeAt(0);
|
|
565
|
+
if (!range.collapsed) range.deleteContents();
|
|
566
|
+
const pill = createEntityEl(ownerDoc, entity, props.kindIcons);
|
|
567
|
+
const zwspNode = ownerDoc.createTextNode(ZWSP);
|
|
568
|
+
range.insertNode(zwspNode);
|
|
569
|
+
range.insertNode(pill); // inserted before the zwsp → DOM order [pill][zwsp]
|
|
570
|
+
|
|
571
|
+
const after = ownerDoc.createRange();
|
|
572
|
+
after.setStartAfter(zwspNode);
|
|
573
|
+
after.collapse(true);
|
|
574
|
+
sel.removeAllRanges();
|
|
575
|
+
sel.addRange(after);
|
|
576
|
+
|
|
577
|
+
props.onEntityAdd?.(entity);
|
|
578
|
+
commitChange();
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// A string consisting only of ZWSP markers (an empty string counts — `[].every`
|
|
582
|
+
// is true). Used to recognize the zero-width filler that trails a pill.
|
|
583
|
+
const onlyZwsp = (s: string | null): boolean =>
|
|
584
|
+
(s ?? '').split('').every((ch) => ch === ZWSP);
|
|
585
|
+
|
|
586
|
+
// Zero-width filler between a pill and adjacent content: an empty OR ZWSP-only
|
|
587
|
+
// text node. Inserting a pill (and then typing) leaves these behind — e.g.
|
|
588
|
+
// `[pill][t""][t"<ZWSP> world"]` — so adjacency checks MUST skip them to find
|
|
589
|
+
// the pill. (This was the forward/backward asymmetry: a pill's `nextSibling` is
|
|
590
|
+
// the pill itself, but its following text node's `previousSibling` is filler.)
|
|
591
|
+
const isFiller = (n: Node | null): boolean =>
|
|
592
|
+
!!n && n.nodeType === Node.TEXT_NODE && onlyZwsp(n.textContent);
|
|
593
|
+
|
|
594
|
+
/** The entity pill immediately BEFORE a collapsed caret (skipping zero-width
|
|
595
|
+
* filler), or null. Shared by Backspace and ArrowLeft selection. */
|
|
596
|
+
const pillBeforeCaret = (range: Range): HTMLElement | null => {
|
|
597
|
+
const { startContainer, startOffset } = range;
|
|
598
|
+
if (startContainer === editable) {
|
|
599
|
+
let i = startOffset - 1;
|
|
600
|
+
while (isFiller(editable.childNodes[i])) i--;
|
|
601
|
+
const node = editable.childNodes[i];
|
|
602
|
+
return isEntityEl(node) ? (node as HTMLElement) : null;
|
|
603
|
+
}
|
|
604
|
+
if (startContainer.nodeType === Node.TEXT_NODE) {
|
|
605
|
+
// Only treat the caret as "right after a pill" when nothing but filler
|
|
606
|
+
// precedes it within this text run.
|
|
607
|
+
if (!onlyZwsp((startContainer.textContent ?? '').slice(0, startOffset))) return null;
|
|
608
|
+
let prev = startContainer.previousSibling;
|
|
609
|
+
while (isFiller(prev)) prev = prev!.previousSibling;
|
|
610
|
+
return isEntityEl(prev) ? (prev as HTMLElement) : null;
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
/** The entity pill immediately AFTER a collapsed caret (skipping zero-width
|
|
616
|
+
* filler), or null. Shared by Delete and ArrowRight selection. */
|
|
617
|
+
const pillAfterCaret = (range: Range): HTMLElement | null => {
|
|
618
|
+
const { startContainer, startOffset } = range;
|
|
619
|
+
if (startContainer === editable) {
|
|
620
|
+
let i = startOffset;
|
|
621
|
+
while (isFiller(editable.childNodes[i])) i++;
|
|
622
|
+
const node = editable.childNodes[i];
|
|
623
|
+
return isEntityEl(node) ? (node as HTMLElement) : null;
|
|
624
|
+
}
|
|
625
|
+
if (startContainer.nodeType === Node.TEXT_NODE) {
|
|
626
|
+
if (!onlyZwsp((startContainer.textContent ?? '').slice(startOffset))) return null;
|
|
627
|
+
let next = startContainer.nextSibling;
|
|
628
|
+
while (isFiller(next)) next = next!.nextSibling;
|
|
629
|
+
return isEntityEl(next) ? (next as HTMLElement) : null;
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
/** Remove a pill (and its trailing ZWSP marker), fire onEntityRemove, commit. */
|
|
635
|
+
const removePillEl = (pill: HTMLElement) => {
|
|
636
|
+
const entity = entityStore.get(pill);
|
|
637
|
+
const after = pill.nextSibling;
|
|
638
|
+
if (after?.nodeType === Node.TEXT_NODE && onlyZwsp(after.textContent)) after.remove();
|
|
639
|
+
pill.remove();
|
|
640
|
+
if (entity) props.onEntityRemove?.(entity);
|
|
641
|
+
commitChange();
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/** Collapse the caret just before a pill. */
|
|
645
|
+
const caretBeforePill = (pill: HTMLElement) => {
|
|
646
|
+
const sel = getActiveSelection(editable);
|
|
647
|
+
if (!sel) return;
|
|
648
|
+
const r = editable.ownerDocument.createRange();
|
|
649
|
+
r.setStartBefore(pill);
|
|
650
|
+
r.collapse(true);
|
|
651
|
+
sel.removeAllRanges();
|
|
652
|
+
sel.addRange(r);
|
|
653
|
+
};
|
|
654
|
+
/** Collapse the caret just after a pill (past its trailing zero-width filler,
|
|
655
|
+
* so the next character lands outside the pill). */
|
|
656
|
+
const caretAfterPill = (pill: HTMLElement) => {
|
|
657
|
+
const sel = getActiveSelection(editable);
|
|
658
|
+
if (!sel) return;
|
|
659
|
+
let node: Node = pill;
|
|
660
|
+
while (isFiller(node.nextSibling)) node = node.nextSibling!;
|
|
661
|
+
const r = editable.ownerDocument.createRange();
|
|
662
|
+
r.setStartAfter(node);
|
|
663
|
+
r.collapse(true);
|
|
664
|
+
sel.removeAllRanges();
|
|
665
|
+
sel.addRange(r);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
669
|
+
// --- Undo / redo (we own the history; native contenteditable undo corrupts
|
|
670
|
+
// pills, so suppress it and drive our doc-snapshot stack instead) ---
|
|
671
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
|
|
672
|
+
e.preventDefault();
|
|
673
|
+
if (e.shiftKey) doRedo();
|
|
674
|
+
else doUndo();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'y' || e.key === 'Y')) { // Windows redo
|
|
678
|
+
e.preventDefault();
|
|
679
|
+
doRedo();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// --- Trigger menu keyboard navigation (takes priority) ---
|
|
684
|
+
if (menuOpen() && filteredItems().length > 0) {
|
|
685
|
+
if (e.key === 'ArrowDown') {
|
|
686
|
+
e.preventDefault();
|
|
687
|
+
setSelectedIndex((i) => (i + 1) % filteredItems().length);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (e.key === 'ArrowUp') {
|
|
691
|
+
e.preventDefault();
|
|
692
|
+
setSelectedIndex((i) => (i - 1 + filteredItems().length) % filteredItems().length);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
// Enter OR Tab selects the highlighted item (Tab is a common picker accept).
|
|
696
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
697
|
+
const items = filteredItems();
|
|
698
|
+
if (items.length > 0) {
|
|
699
|
+
e.preventDefault();
|
|
700
|
+
e.stopPropagation();
|
|
701
|
+
selectItem(items[selectedIndex()]);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (e.key === 'Escape') {
|
|
706
|
+
e.preventDefault();
|
|
707
|
+
closeTrigger();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
} else if (activeTrigger() && e.key === 'Escape') {
|
|
711
|
+
// No items shown but trigger is active (e.g. only onTrigger fired) — close on Escape
|
|
712
|
+
e.preventDefault();
|
|
713
|
+
closeTrigger();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// --- A selected pill behaves as one unit (Claude Code-style) ---
|
|
718
|
+
const selPill = selectedPill();
|
|
719
|
+
if (selPill && selPill.isConnected) {
|
|
720
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); caretBeforePill(selPill); clearPillSelection(); return; }
|
|
721
|
+
if (e.key === 'ArrowRight') { e.preventDefault(); caretAfterPill(selPill); clearPillSelection(); return; }
|
|
722
|
+
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
723
|
+
e.preventDefault();
|
|
724
|
+
const anchor = selPill.previousSibling; // text node the caret should return to
|
|
725
|
+
clearPillSelection();
|
|
726
|
+
removePillEl(selPill);
|
|
727
|
+
const sel = getActiveSelection(editable);
|
|
728
|
+
if (sel) {
|
|
729
|
+
const r = editable.ownerDocument.createRange();
|
|
730
|
+
if (anchor && anchor.isConnected) r.setStartAfter(anchor);
|
|
731
|
+
else r.setStart(editable, 0);
|
|
732
|
+
r.collapse(true);
|
|
733
|
+
sel.removeAllRanges();
|
|
734
|
+
sel.addRange(r);
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (e.key === 'Escape') { e.preventDefault(); clearPillSelection(); return; }
|
|
739
|
+
// A bare modifier keypress shouldn't drop the selection.
|
|
740
|
+
if (e.key === 'Shift' || e.key === 'Meta' || e.key === 'Control' || e.key === 'Alt') return;
|
|
741
|
+
// Any other key (typing, Enter, …): drop the highlight and let the key act
|
|
742
|
+
// from just after the pill — then fall through to the normal handlers.
|
|
743
|
+
caretAfterPill(selPill);
|
|
744
|
+
clearPillSelection();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// --- ArrowLeft/Right INTO an adjacent pill selects it as one unit ---
|
|
748
|
+
if (
|
|
749
|
+
!selectedPill() &&
|
|
750
|
+
(e.key === 'ArrowLeft' || e.key === 'ArrowRight') &&
|
|
751
|
+
!e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey
|
|
752
|
+
) {
|
|
753
|
+
const sel = getActiveSelection(editable);
|
|
754
|
+
if (sel && sel.rangeCount > 0) {
|
|
755
|
+
const range = sel.getRangeAt(0);
|
|
756
|
+
if (range.collapsed) {
|
|
757
|
+
const pill = e.key === 'ArrowLeft' ? pillBeforeCaret(range) : pillAfterCaret(range);
|
|
758
|
+
if (pill) { e.preventDefault(); selectPill(pill); return; }
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// --- Backspace / Delete: atomically remove pill immediately before/after caret ---
|
|
764
|
+
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
765
|
+
const sel = getActiveSelection(editable);
|
|
766
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
767
|
+
|
|
768
|
+
const range = sel.getRangeAt(0);
|
|
769
|
+
if (!range.collapsed) return; // let the browser handle a selection deletion
|
|
770
|
+
|
|
771
|
+
const pill = e.key === 'Backspace' ? pillBeforeCaret(range) : pillAfterCaret(range);
|
|
772
|
+
if (pill) {
|
|
773
|
+
e.preventDefault();
|
|
774
|
+
removePillEl(pill);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// --- Enter: submit (only when menu is NOT open or no item is highlighted) ---
|
|
780
|
+
if (e.key === 'Enter' && !e.shiftKey && props.submitOnEnter !== false) {
|
|
781
|
+
e.preventDefault();
|
|
782
|
+
if (props.disabled || props.loading) return;
|
|
783
|
+
props.onSubmit?.(snapshot());
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// Close trigger when space is typed (handled via input event — space moves caret
|
|
788
|
+
// past whitespace boundary, so updateTriggerState will return null anyway).
|
|
789
|
+
// This is covered by updateTriggerState() in handleInput.
|
|
790
|
+
|
|
791
|
+
const maxH = () => props.maxHeight ?? 240;
|
|
792
|
+
const editableCls = () =>
|
|
793
|
+
props.editableClass ??
|
|
794
|
+
'text-foreground min-h-[44px] w-full overflow-y-auto outline-none whitespace-pre-wrap break-words';
|
|
795
|
+
|
|
796
|
+
const inner = (
|
|
797
|
+
<>
|
|
798
|
+
{/* Static style for CSS Custom Highlight API decoration.
|
|
799
|
+
No-op in browsers that don't support ::highlight(); the selector
|
|
800
|
+
is simply unrecognized and dropped. */}
|
|
801
|
+
<style>{`::highlight(${highlightName}) { background-color: color-mix(in srgb, var(--color-primary, #6366f1) 22%, transparent); }`}</style>
|
|
802
|
+
{/* Atomic entity pill styling. Self-contained (currentColor-based) so it
|
|
803
|
+
renders correctly without depending on a Tailwind rebuild for the
|
|
804
|
+
custom .kai-composer-pill class. */}
|
|
805
|
+
<style>{`
|
|
806
|
+
.kai-composer-pill {
|
|
807
|
+
display: inline-flex; align-items: center; gap: 0.2rem;
|
|
808
|
+
/* middle (not baseline): a flex pill's baseline differs with/without an
|
|
809
|
+
icon, which misaligns adjacent pills — middle aligns them consistently. */
|
|
810
|
+
vertical-align: middle; height: 1.5em;
|
|
811
|
+
/* Asymmetric margin: a roomier trailing gap (so typed text isn't flush
|
|
812
|
+
after a pill) + a smaller leading gap. */
|
|
813
|
+
margin: 0 0.3rem 0 0.1rem;
|
|
814
|
+
border-radius: 0.375rem;
|
|
815
|
+
font-weight: 500; white-space: nowrap; user-select: none; cursor: default;
|
|
816
|
+
box-sizing: border-box; line-height: 1;
|
|
817
|
+
}
|
|
818
|
+
/* Skills + agents: LIGHT — decorated inline text led by a sigil, NOT a
|
|
819
|
+
chip. A distinct colour per kind (overridable); the sigil is dimmer
|
|
820
|
+
than the label. (A GUI affords this richer-but-light look; a CLI can't.) */
|
|
821
|
+
.kai-composer-pill[data-kind="skill"],
|
|
822
|
+
.kai-composer-pill[data-kind="agent"] {
|
|
823
|
+
padding: 0 0.1rem; background: transparent;
|
|
824
|
+
/* Read like a command, not a title — lowercase the DISPLAY only; the
|
|
825
|
+
entity label + emitted payload keep the consumer's original casing. */
|
|
826
|
+
text-transform: lowercase;
|
|
827
|
+
}
|
|
828
|
+
.kai-composer-pill[data-kind="skill"] { color: var(--kai-pill-skill, #2563eb); }
|
|
829
|
+
.kai-composer-pill[data-kind="agent"] { color: var(--kai-pill-agent, #7c3aed); }
|
|
830
|
+
/* Brighter hues on dark so they don't muddy against a dark field. The
|
|
831
|
+
consumer's --kai-pill-* override still wins (it's the var value); only
|
|
832
|
+
the FALLBACK shifts per scheme. */
|
|
833
|
+
@media (prefers-color-scheme: dark) {
|
|
834
|
+
.kai-composer-pill[data-kind="skill"] { color: var(--kai-pill-skill, #6ea8fe); }
|
|
835
|
+
.kai-composer-pill[data-kind="agent"] { color: var(--kai-pill-agent, #c4a7fc); }
|
|
836
|
+
}
|
|
837
|
+
.kai-composer-pill-sigil { opacity: 0.6; font-weight: 600; }
|
|
838
|
+
/* Plugins (and any other/unknown kind): the richer CHIP — background +
|
|
839
|
+
icon — so a composite bundle reads differently at a glance. */
|
|
840
|
+
.kai-composer-pill:not([data-kind="skill"]):not([data-kind="agent"]) {
|
|
841
|
+
padding: 0 0.4rem;
|
|
842
|
+
background-color: color-mix(in srgb, currentColor 10%, transparent);
|
|
843
|
+
}
|
|
844
|
+
/* Selected (caret arrowed onto the pill): a highlight box, like Claude
|
|
845
|
+
Code web. Tinted by the pill's own colour via currentColor. */
|
|
846
|
+
.kai-composer-pill[data-selected] {
|
|
847
|
+
background-color: color-mix(in srgb, currentColor 18%, transparent);
|
|
848
|
+
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent);
|
|
849
|
+
}
|
|
850
|
+
/* Fixed height + an icon never taller than the box → iconed and iconless
|
|
851
|
+
pills are exactly the same height (no baseline shift in the line). */
|
|
852
|
+
.kai-composer-pill-icon { width: 1em; height: 1em; flex-shrink: 0; }
|
|
853
|
+
img.kai-composer-pill-icon { border-radius: 9999px; object-fit: cover; }
|
|
854
|
+
.kai-composer-pill-glyph { display: inline-flex; align-items: center; justify-content: center; opacity: 0.8; }
|
|
855
|
+
.kai-composer-pill-glyph svg { width: 1em; height: 1em; display: block; }
|
|
856
|
+
/* The editable is the containing block for the placeholder pseudo-element. */
|
|
857
|
+
[data-kai-composer-editable] { position: relative; }
|
|
858
|
+
/* Placeholder via pseudo-element — exempt from axe color-contrast like a
|
|
859
|
+
native <textarea> placeholder. position:absolute (with auto offsets =
|
|
860
|
+
its static text-origin spot) takes it OUT of flow, so the caret sits at
|
|
861
|
+
the START of the field instead of after the placeholder text. */
|
|
862
|
+
[data-kai-composer-editable].kai-composer-empty::before {
|
|
863
|
+
content: attr(data-placeholder);
|
|
864
|
+
position: absolute;
|
|
865
|
+
color: var(--color-muted-foreground, #9ca3af);
|
|
866
|
+
pointer-events: none;
|
|
867
|
+
}
|
|
868
|
+
`}</style>
|
|
869
|
+
{/* Placeholder is a `::before` pseudo-element (see the style block) — it sits
|
|
870
|
+
at the text origin automatically (respecting the editable's padding/font),
|
|
871
|
+
and, like a native <textarea> placeholder, is exempt from axe color-contrast
|
|
872
|
+
(a real text node would fail it at the muted color). */}
|
|
873
|
+
<div class="relative">
|
|
874
|
+
<div
|
|
875
|
+
ref={(el) => { editable = el; props.editableRef?.(el); }}
|
|
876
|
+
data-kai-composer-editable
|
|
877
|
+
data-placeholder={props.placeholder ?? ''}
|
|
878
|
+
contentEditable={props.disabled ? false : ('plaintext-only' as unknown as boolean)}
|
|
879
|
+
role="textbox"
|
|
880
|
+
aria-multiline="true"
|
|
881
|
+
aria-label={props.ariaLabel || props.placeholder || 'Message input'}
|
|
882
|
+
onInput={handleInput}
|
|
883
|
+
onKeyDown={handleKeyDown}
|
|
884
|
+
onMouseDown={clearPillSelection}
|
|
885
|
+
onFocus={handleFocus}
|
|
886
|
+
onBlur={handleBlur}
|
|
887
|
+
// The empty/placeholder class is folded into `class` (NOT a separate
|
|
888
|
+
// `classList`) — a reactive `class` string would otherwise clobber a
|
|
889
|
+
// classList toggle (Solid sets className wholesale on re-eval).
|
|
890
|
+
class={cn(editableCls(), empty() && props.placeholder ? 'kai-composer-empty' : '')}
|
|
891
|
+
style={{
|
|
892
|
+
'max-height':
|
|
893
|
+
typeof maxH() === 'number' ? `${maxH()}px` : String(maxH()),
|
|
894
|
+
}}
|
|
895
|
+
/>
|
|
896
|
+
</div>
|
|
897
|
+
|
|
898
|
+
{/* Trigger menu */}
|
|
899
|
+
<Show when={present()}>
|
|
900
|
+
<div
|
|
901
|
+
ref={(el) => { setMenuRef(el); setRef(el); }}
|
|
902
|
+
role="listbox"
|
|
903
|
+
aria-label="Suggestions"
|
|
904
|
+
data-state={state()}
|
|
905
|
+
class={cn(
|
|
906
|
+
'absolute z-50 min-w-[240px] max-w-[420px] max-h-[320px] overflow-y-auto rounded-lg bg-card shadow-lg py-1',
|
|
907
|
+
'border border-border',
|
|
908
|
+
)}
|
|
909
|
+
style={{
|
|
910
|
+
position: 'fixed',
|
|
911
|
+
left: `${pos().x}px`,
|
|
912
|
+
top: `${pos().y}px`,
|
|
913
|
+
visibility: hidden() ? 'hidden' : 'visible',
|
|
914
|
+
}}
|
|
915
|
+
>
|
|
916
|
+
<For each={groupedItems()}>
|
|
917
|
+
{(section) => (
|
|
918
|
+
<>
|
|
919
|
+
<Show when={section.group}>
|
|
920
|
+
<div class="px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground select-none">{section.group}</div>
|
|
921
|
+
</Show>
|
|
922
|
+
<For each={section.items}>
|
|
923
|
+
{(entry) => (
|
|
924
|
+
<button
|
|
925
|
+
role="option"
|
|
926
|
+
aria-selected={selectedIndex() === entry.index}
|
|
927
|
+
data-index={entry.index}
|
|
928
|
+
class={cn(
|
|
929
|
+
'w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
|
|
930
|
+
selectedIndex() === entry.index
|
|
931
|
+
? 'bg-muted text-foreground'
|
|
932
|
+
: 'text-foreground hover:bg-muted',
|
|
933
|
+
)}
|
|
934
|
+
onMouseEnter={() => setSelectedIndex(entry.index)}
|
|
935
|
+
// Keep focus (and the caret) in the editable so insertion works.
|
|
936
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
937
|
+
onClick={(e) => { e.preventDefault(); selectItem(entry.item); }}
|
|
938
|
+
>
|
|
939
|
+
<Show
|
|
940
|
+
when={itemIconSrc(entry.item)}
|
|
941
|
+
fallback={
|
|
942
|
+
<Show when={kindGlyph(itemKind(entry.item))}>
|
|
943
|
+
{(glyph) => (
|
|
944
|
+
<span class="kai-composer-pill-glyph w-4 h-4 shrink-0" aria-hidden="true" innerHTML={glyph()} />
|
|
945
|
+
)}
|
|
946
|
+
</Show>
|
|
947
|
+
}
|
|
948
|
+
>
|
|
949
|
+
{(src) => <img src={src()} alt="" class="w-4 h-4 rounded object-cover shrink-0" />}
|
|
950
|
+
</Show>
|
|
951
|
+
<span class="font-medium whitespace-nowrap shrink-0">{entry.item.label}</span>
|
|
952
|
+
<Show when={entry.item.description}>
|
|
953
|
+
<span class="text-muted-foreground truncate min-w-0">{entry.item.description}</span>
|
|
954
|
+
</Show>
|
|
955
|
+
</button>
|
|
956
|
+
)}
|
|
957
|
+
</For>
|
|
958
|
+
</>
|
|
959
|
+
)}
|
|
960
|
+
</For>
|
|
961
|
+
</div>
|
|
962
|
+
</Show>
|
|
963
|
+
</>
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
// Bare mode: no frame (the host owns radius/bg/padding). Standalone: full frame.
|
|
967
|
+
return props.bare ? (
|
|
968
|
+
inner
|
|
969
|
+
) : (
|
|
970
|
+
<div
|
|
971
|
+
class={cn(
|
|
972
|
+
'kai-composer relative rounded-xl bg-surface p-2',
|
|
973
|
+
props.disabled && 'cursor-not-allowed opacity-60',
|
|
974
|
+
)}
|
|
975
|
+
>
|
|
976
|
+
{inner}
|
|
977
|
+
</div>
|
|
978
|
+
);
|
|
979
|
+
}
|