mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): :shortcode: emoji completions in both composers
A third trigger kind beside @ and / — same detection, same popover, same commit path. :jo opens 😂/🤣/… fed by the bundled emojibase shortcode data (search hits shortcodes first, then tags and labels); picking inserts the emoji character as plain inline text, not a chip. Boundary-anchored with a two-char minimum so localhost:8080, timestamps, and :D never trigger it. Wired in the main composer and the edit composer's duplicated trigger loop.
This commit is contained in:
parent
6ec319f530
commit
a90ccd46b7
6 changed files with 181 additions and 8 deletions
|
|
@ -30,6 +30,8 @@ interface UseComposerTriggerOptions {
|
|||
at: CompletionSource
|
||||
draftRef: MutableRefObject<string>
|
||||
editorRef: RefObject<HTMLDivElement | null>
|
||||
/** `:joy` emoji completions — inserts the emoji character, never a chip. */
|
||||
emoji?: CompletionSource
|
||||
/** Bank the pre-commit state so a popover pick is a single undo step. */
|
||||
recordUndoPoint?: () => void
|
||||
requestMainFocus: () => void
|
||||
|
|
@ -50,6 +52,7 @@ export function useComposerTrigger({
|
|||
at,
|
||||
draftRef,
|
||||
editorRef,
|
||||
emoji,
|
||||
recordUndoPoint,
|
||||
requestMainFocus,
|
||||
setComposerText,
|
||||
|
|
@ -91,7 +94,7 @@ export function useComposerTrigger({
|
|||
// is present do we pay the cost of the full walk + DOM range work.
|
||||
const rawText = editor.textContent ?? ''
|
||||
|
||||
if (!rawText.includes('@') && !rawText.includes('/')) {
|
||||
if (!rawText.includes('@') && !rawText.includes('/') && !rawText.includes(':')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
resetTriggerActive()
|
||||
|
|
@ -128,7 +131,13 @@ export function useComposerTrigger({
|
|||
}, [editorRef, resetTriggerActive, trigger])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
trigger?.kind === '@'
|
||||
? at.adapter
|
||||
: trigger?.kind === '/'
|
||||
? slash.adapter
|
||||
: trigger?.kind === ':'
|
||||
? (emoji?.adapter ?? null)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
|
|
@ -146,7 +155,14 @@ export function useComposerTrigger({
|
|||
setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items)
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
const triggerLoading =
|
||||
trigger?.kind === '@'
|
||||
? at.loading
|
||||
: trigger?.kind === '/'
|
||||
? slash.loading
|
||||
: trigger?.kind === ':'
|
||||
? (emoji?.loading ?? false)
|
||||
: false
|
||||
|
||||
// Suppress the "No matches" empty state once a slash command is past its name:
|
||||
// a no-arg command has nothing to offer, and a fully-typed arg commits on
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { useCallback } from 'react'
|
||||
|
||||
import { type CompletionEntry, type CompletionPayload, useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
|
||||
/**
|
||||
* `:shortcode:` completions for the composers, Slack-style (`:joy` → 😂).
|
||||
*
|
||||
* Draws from the same bundled emojibase-data the reaction picker uses (served
|
||||
* at ./emojibase by the `hermes:emojibase-assets` vite plugin — offline, no
|
||||
* CDN). The index lazy-loads on the first `:` trigger, then every query is
|
||||
* answered from memory, so `isCached` skips the debounce and loading state
|
||||
* after that first load.
|
||||
*
|
||||
* A pick inserts the emoji CHARACTER as plain text — not a chip. Directive
|
||||
* chips exist to carry machine-readable references the backend resolves
|
||||
* (@file:, /skill); a picked emoji is just text, so it rides the formatter's
|
||||
* `rawText` path and lands inline.
|
||||
*/
|
||||
|
||||
interface EmojiEntry {
|
||||
emoji: string
|
||||
/** Primary shortcode, e.g. "joy". */
|
||||
code: string
|
||||
/** Every shortcode, tag, and label that should match a search. */
|
||||
haystack: string[]
|
||||
}
|
||||
|
||||
let indexPromise: Promise<EmojiEntry[]> | null = null
|
||||
let indexLoaded = false
|
||||
|
||||
async function loadIndex(): Promise<EmojiEntry[]> {
|
||||
const [dataRes, codesRes] = await Promise.all([
|
||||
fetch('./emojibase/en/data.json'),
|
||||
fetch('./emojibase/en/shortcodes/emojibase.json')
|
||||
])
|
||||
|
||||
const data: { emoji: string; hexcode: string; label: string; tags?: string[] }[] = await dataRes.json()
|
||||
const codes: Record<string, string | string[]> = await codesRes.json()
|
||||
const entries: EmojiEntry[] = []
|
||||
|
||||
for (const item of data) {
|
||||
const raw = codes[item.hexcode]
|
||||
|
||||
if (!raw) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shortcodes = Array.isArray(raw) ? raw : [raw]
|
||||
|
||||
entries.push({
|
||||
emoji: item.emoji,
|
||||
code: shortcodes[0],
|
||||
haystack: [...shortcodes, ...(item.tags ?? []), item.label.toLowerCase()]
|
||||
})
|
||||
}
|
||||
|
||||
indexLoaded = true
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Prefix matches on shortcodes rank first, then tag/label substring hits. */
|
||||
async function searchEmoji(query: string, limit = 8): Promise<EmojiEntry[]> {
|
||||
const index = await (indexPromise ??= loadIndex())
|
||||
const q = query.toLowerCase()
|
||||
const prefix: EmojiEntry[] = []
|
||||
const loose: EmojiEntry[] = []
|
||||
|
||||
for (const entry of index) {
|
||||
if (entry.code.startsWith(q) || entry.haystack.some(h => h.startsWith(q))) {
|
||||
prefix.push(entry)
|
||||
} else if (entry.haystack.some(h => h.includes(q))) {
|
||||
loose.push(entry)
|
||||
}
|
||||
|
||||
if (prefix.length >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [...prefix, ...loose].slice(0, limit)
|
||||
}
|
||||
|
||||
export function useEmojiCompletions() {
|
||||
const fetcher = useCallback(async (query: string): Promise<CompletionPayload> => {
|
||||
const entries = await searchEmoji(query)
|
||||
|
||||
return {
|
||||
query,
|
||||
items: entries.map(entry => ({
|
||||
text: entry.emoji,
|
||||
display: `${entry.emoji} :${entry.code}:`,
|
||||
meta: ''
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toItem = useCallback(
|
||||
(entry: CompletionEntry, index: number) => ({
|
||||
id: `${entry.text}|${index}`,
|
||||
type: 'emoji',
|
||||
label: typeof entry.display === 'string' ? entry.display : entry.text,
|
||||
metadata: {
|
||||
display: typeof entry.display === 'string' ? entry.display : entry.text,
|
||||
// The formatter's serialize() returns rawText verbatim → the emoji
|
||||
// character lands as plain inline text, no chip.
|
||||
rawText: entry.text,
|
||||
meta: '',
|
||||
group: '',
|
||||
action: ''
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return useLiveCompletionAdapter({
|
||||
enabled: true,
|
||||
fetcher,
|
||||
isCached: () => indexLoaded,
|
||||
toItem
|
||||
})
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-aff
|
|||
import { markActiveComposer } from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useEmojiCompletions } from './hooks/use-emoji-completions'
|
||||
import { useComposerBranch } from './hooks/use-composer-branch'
|
||||
import { useComposerDraft } from './hooks/use-composer-draft'
|
||||
import { useComposerDrop } from './hooks/use-composer-drop'
|
||||
|
|
@ -184,6 +185,7 @@ export function ChatBar({
|
|||
const { availableThemes, themeName } = useTheme()
|
||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
|
||||
const emoji = useEmojiCompletions()
|
||||
|
||||
const { t } = useI18n()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
|
|
@ -346,7 +348,7 @@ export function ChatBar({
|
|||
triggerItems,
|
||||
triggerKeyConsumedRef,
|
||||
triggerLoading
|
||||
} = useComposerTrigger({ at, draftRef, editorRef, recordUndoPoint, requestMainFocus, setComposerText, slash })
|
||||
} = useComposerTrigger({ at, draftRef, editorRef, emoji, recordUndoPoint, requestMainFocus, setComposerText, slash })
|
||||
|
||||
// Pull the live contentEditable text into draftRef + the AUI composer state
|
||||
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export interface TriggerState {
|
|||
/** True for a `/` typed mid-message — an inline skill/command reference in
|
||||
* prose rather than a command invocation. Arg completion doesn't apply. */
|
||||
inline?: boolean
|
||||
kind: '@' | '/'
|
||||
kind: '@' | '/' | ':'
|
||||
query: string
|
||||
tokenLength: number
|
||||
}
|
||||
|
|
@ -44,6 +44,10 @@ export interface TriggerState {
|
|||
const AT_TRIGGER_RE = /(?:^|[\s\uFFFC])(@)([^\s@\uFFFC]*)$/
|
||||
const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||
const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/
|
||||
// `:joy` → emoji completions, Slack-style. Boundary-anchored so a mid-word
|
||||
// colon (`localhost:8080`, `note:`) never fires; two chars minimum so a bare
|
||||
// `:` or `:D` smiley doesn't open a popover the user didn't ask for.
|
||||
const EMOJI_TRIGGER_RE = /(?:^|[\s\uFFFC])(:)([a-zA-Z0-9_+-]{2,})$/
|
||||
|
||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||
export function blobDedupeKey(blob: Blob): string {
|
||||
|
|
@ -180,5 +184,12 @@ export function detectTrigger(textBefore: string): TriggerState | null {
|
|||
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||
}
|
||||
|
||||
// After `@` so a directive starter's colon (`@file:`) stays an `@` query.
|
||||
const emoji = EMOJI_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (emoji) {
|
||||
return { kind: ':', query: emoji[2], tokenLength: 1 + emoji[2].length }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const ROW_BASE_CLASS = [
|
|||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
kind: '@' | '/'
|
||||
kind: '@' | '/' | ':'
|
||||
loading: boolean
|
||||
onHover: (index: number) => void
|
||||
onPick: (item: Unstable_TriggerItem) => void
|
||||
|
|
@ -93,6 +93,10 @@ export function ComposerTriggerPopover({
|
|||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : kind === ':' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">:joy:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
|
|
@ -154,6 +158,9 @@ export function ComposerTriggerPopover({
|
|||
</span>
|
||||
)}
|
||||
</>
|
||||
) : kind === ':' ? (
|
||||
// Just the emoji + :shortcode:, Slack-style — no icon column.
|
||||
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
|
||||
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
|
|
@ -112,6 +113,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
const canSubmit = draft.trim().length > 0
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
const emoji = useEmojiCompletions()
|
||||
|
||||
// This is the one composer that routinely unmounts, so it is where the focus
|
||||
// bus leaks: confirming or cancelling an edit tears the composer down while
|
||||
|
|
@ -282,7 +284,13 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
}, [])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
trigger?.kind === '@'
|
||||
? at.adapter
|
||||
: trigger?.kind === '/'
|
||||
? slash.adapter
|
||||
: trigger?.kind === ':'
|
||||
? emoji.adapter
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
|
|
@ -298,7 +306,14 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
const triggerLoading =
|
||||
trigger?.kind === '@'
|
||||
? at.loading
|
||||
: trigger?.kind === '/'
|
||||
? slash.loading
|
||||
: trigger?.kind === ':'
|
||||
? emoji.loading
|
||||
: false
|
||||
|
||||
const replaceTriggerWithChip = useCallback(
|
||||
(item: Unstable_TriggerItem) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue