'use client'
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react'
import {
SlashCommandMenu,
type SlashCommand,
} from '@/components/ui/slash-command-menu'
const COMMANDS: SlashCommand[] = [
{
name: 'reconcile',
description: 'Match invoices against Xero for a period',
args: '<org> --since <date>',
group: 'ledger',
},
{
name: 'trial-balance',
description: 'Run the trial balance and surface exceptions',
args: '--period <month>',
group: 'ledger',
},
{
name: 'refund-review',
description: 'List Stripe refunds awaiting approval',
args: '--status open',
group: 'payments',
},
{
name: 'charge-lookup',
description: 'Find a Stripe charge by id or amount',
args: '<id|amount>',
group: 'payments',
},
{
name: 'digest',
description: 'Draft the weekly ops digest for #ops',
args: '--channel <name>',
group: 'comms',
},
{
name: 'escalate',
description: 'Surface stalled Linear tickets',
args: '<team> --days <n>',
group: 'comms',
},
{
name: 'spawn-verify',
description: 'Spin a verification sub-agent on exceptions',
args: '<run-id>',
group: 'agents',
},
{
name: 'memory-write',
description: 'Pin a fact to the session ledger',
args: '<key> <value>',
group: 'agents',
},
]
function extractSlashQuery(value: string): string | null {
// active slash command: last token starting with /
const match = value.match(/(?:^|\s)\/([^\s]*)$/)
if (!match) return null
return match[1] ?? ''
}
export default function SlashCommandMenuDemo() {
const listboxId = useId().replace(/:/g, '') + '-slash'
const inputRef = useRef<HTMLTextAreaElement>(null)
const [value, setValue] = useState('/')
const [activeIndex, setActiveIndex] = useState(0)
const [note, setNote] = useState<string | null>(null)
const query = useMemo(() => extractSlashQuery(value), [value])
const open = query !== null
const filtered = useMemo(() => {
if (query === null) return []
const q = query.trim().toLowerCase()
if (!q) return COMMANDS
return COMMANDS.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.description?.toLowerCase().includes(q) ?? false) ||
(c.group?.toLowerCase().includes(q) ?? false)
)
}, [query])
useEffect(() => {
setActiveIndex(0)
}, [query])
useEffect(() => {
if (filtered.length === 0) return
setActiveIndex((i) => Math.min(i, filtered.length - 1))
}, [filtered.length])
const insertCommand = useCallback(
(name: string) => {
setValue((prev) => {
const replaced = prev.replace(/(?:^|\s)\/[^\s]*$/, (m) => {
const lead = m.startsWith(' ') || m.startsWith('\n') ? m[0] : ''
return `${lead}/${name} `
})
return replaced.endsWith(`/${name} `) ? replaced : `/${name} `
})
setNote(`inserted /${name}`)
requestAnimationFrame(() => inputRef.current?.focus())
},
[]
)
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (!open || filtered.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((i) => (i + 1) % filtered.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((i) => (i - 1 + filtered.length) % filtered.length)
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
const cmd = filtered[activeIndex]
if (cmd) insertCommand(cmd.name)
} else if (e.key === 'Escape') {
e.preventDefault()
setValue((prev) => prev.replace(/(?:^|\s)\/[^\s]*$/, '').trimEnd())
}
}
const activeDescendant =
open && filtered.length > 0
? `${listboxId}-option-${activeIndex}`
: undefined
return (
<div className="mx-auto flex min-h-[480px] w-full max-w-lg flex-col justify-end gap-3 px-4 py-12 sm:px-6 sm:py-16">
<div className="rounded-2xl border border-border/60 bg-card px-4 py-3 sm:px-5">
<p className="font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
meridian-ops · composer
</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Type <span className="font-mono text-foreground">/</span> for
ledger and payment commands. Arrow keys move the highlight; Enter
inserts.
</p>
{note && (
<p className="mt-2 font-mono text-[11px] text-muted-foreground">
{note}
</p>
)}
</div>
<div className="relative w-full min-w-0">
{open && (
<div className="mb-2 w-full">
<SlashCommandMenu
query={query}
commands={COMMANDS}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
onSelect={insertCommand}
listboxId={listboxId}
/>
</div>
)}
<div className="rounded-2xl border border-border/60 bg-background px-3 py-2.5">
<textarea
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
rows={2}
placeholder="Message Meridian…"
aria-label="Composer"
role="combobox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
aria-activedescendant={activeDescendant}
aria-autocomplete="list"
className="w-full resize-none bg-transparent text-sm leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden"
/>
<div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-2">
<span className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
gpt-4.1-mini
</span>
<span className="font-mono text-[11px] text-muted-foreground">
↵ select · esc dismiss
</span>
</div>
</div>
</div>
</div>
)
}