feat(chat): slash-command autocomplete

Typing / opens a command panel from the guild's synced OpenClaw catalog
(GET /api/commands): filter + ↑↓/Enter/Esc; pick inserts /<nativeName> ;
arg stage lists args (required/type/desc) with clickable choice chips
(dynamic choices already snapshotted server-side). Sent as a normal
message — execution flows plugin -> OpenClaw command system.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 16:15:02 +01:00
parent 8be68d4c87
commit 46cad0ec7c
3 changed files with 246 additions and 2 deletions

View File

@@ -25,6 +25,9 @@ UI; it is served on the web and **bundled unchanged** into `Fabric.Desktop`
iframe / plain text), live via `canvas.*` sockets; sharer-only edit/remove.
- **Right-click menus** — native menu overridden with resource-specific
context menus (guild / channel / message / user / blank areas).
- **Slash autocomplete** — typing `/` opens a command panel from the
guild's synced OpenClaw catalog (`GET /api/commands`): filter, ↑↓/Enter/
Esc, pick inserts `/<name> `, arg stage shows args + clickable choices.
- **Dev mode** — surfaces guild `/ack` and per-message `wakeup` metadata
(off by default; persisted in `localStorage`).
- Routing is `BrowserRouter` on the web and `HashRouter` under `file://`

View File

@@ -995,3 +995,88 @@ textarea.canvas-src {
max-width: 720px;
width: 92vw;
}
/* ---- slash command autocomplete ---- */
.slash-panel {
border: 1px solid var(--border);
border-bottom: 0;
background: var(--elevated);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
max-height: 320px;
overflow: auto;
padding: 6px;
}
.slash-panel .slash-hint {
font-size: 11px;
color: var(--text-faint);
padding: 4px 8px 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.slash-item {
display: flex;
gap: 10px;
align-items: baseline;
width: 100%;
text-align: left;
background: none;
border: 0;
color: var(--text);
padding: 7px 8px;
border-radius: 5px;
cursor: pointer;
}
.slash-item.sel,
.slash-item:hover {
background: var(--accent-soft);
}
.slash-item .sc-name {
font-family: var(--mono);
font-size: 13px;
color: var(--text-h);
white-space: nowrap;
}
.sc-desc {
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slash-arg {
padding: 6px 8px;
border-top: 1px solid var(--border);
}
.slash-arg .sa-name {
font-family: var(--mono);
font-size: 13px;
color: var(--text-h);
}
.slash-arg .sa-req {
color: var(--danger);
margin-left: 1px;
}
.slash-arg .sa-type {
font-size: 11px;
color: var(--text-faint);
margin: 0 8px;
}
.sa-choices {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.sa-choice {
font-size: 12px;
background: var(--input);
border: 1px solid var(--border);
color: var(--text);
padding: 3px 8px;
border-radius: 999px;
cursor: pointer;
}
.sa-choice:hover {
border-color: var(--accent);
color: var(--text-h);
}

View File

@@ -1,6 +1,12 @@
import axios from 'axios'
import { io, type Socket } from 'socket.io-client'
import { useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react'
import {
useEffect,
useMemo,
useState,
type MouseEvent as ReactMouseEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react'
import { useAuth } from '../auth/auth-context'
import { renderMarkdown } from '../lib/markdown'
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
@@ -20,6 +26,23 @@ type MessageItem = {
}
type CanvasFormat = 'md' | 'html' | 'text'
type SlashArg = {
name: string
description: string
type: string
required: boolean
captureRemaining: boolean
choices: Array<{ value: string; label: string }> | null
}
type SlashCommand = {
name: string
nativeName: string
description: string
acceptsArgs: boolean
args: SlashArg[]
argsParsing: string
}
type CanvasDoc = {
channelId: string
sharerUserId: string
@@ -92,6 +115,8 @@ export default function ChatPage() {
const [canvasFormat, setCanvasFormat] = useState<CanvasFormat>('md')
const [canvasSource, setCanvasSource] = useState('')
const [canvasEditMode, setCanvasEditMode] = useState(false)
const [slashCmds, setSlashCmds] = useState<SlashCommand[]>([])
const [slashSel, setSlashSel] = useState(0)
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
e.preventDefault()
@@ -281,6 +306,20 @@ export default function ChatPage() {
}
}
async function loadCommands() {
if (!guild || !guildToken) {
setSlashCmds([])
return
}
try {
const res = await guildApi().get('/commands')
const list = Array.isArray(res.data?.commands) ? (res.data.commands as SlashCommand[]) : []
setSlashCmds(list)
} catch {
setSlashCmds([])
}
}
function openCanvasShare() {
setCanvasEditMode(false)
setCanvasTitle('')
@@ -490,6 +529,7 @@ export default function ChatPage() {
useEffect(() => {
void loadChannels()
void loadCommands()
setMessages([])
setSelectedChannelId('')
}, [selectedGuildId, guildDbId, guildToken])
@@ -768,6 +808,117 @@ export default function ChatPage() {
)
}
// ---- slash-command autocomplete ----
const slashNameMatch = /^\/(\S*)$/.exec(content)
const slashQuery = slashNameMatch ? slashNameMatch[1].toLowerCase() : ''
const slashMatches =
slashNameMatch && content.startsWith('/')
? slashCmds
.filter((c) => c.name.toLowerCase().includes(slashQuery))
.sort((a, b) => {
const ap = a.name.toLowerCase().startsWith(slashQuery) ? 0 : 1
const bp = b.name.toLowerCase().startsWith(slashQuery) ? 0 : 1
return ap - bp || a.name.localeCompare(b.name)
})
.slice(0, 8)
: []
const showSlashNames = slashMatches.length > 0
const slashArgMatch = /^\/(\S+)\s+/.exec(content)
const slashActiveCmd =
!slashNameMatch && slashArgMatch
? slashCmds.find(
(c) => c.nativeName === slashArgMatch[1] || c.name === slashArgMatch[1],
) ?? null
: null
const showSlashArgs = !!slashActiveCmd && slashActiveCmd.acceptsArgs
function pickSlash(cmd: SlashCommand) {
setContent(`/${cmd.nativeName} `)
setSlashSel(0)
}
function appendSlashValue(v: string) {
setContent((c) => `${c.replace(/\s*$/, '')} ${v} `)
}
function onComposerKey(e: ReactKeyboardEvent<HTMLInputElement>) {
if (!showSlashNames) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setSlashSel((s) => Math.min(s + 1, slashMatches.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSlashSel((s) => Math.max(s - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
pickSlash(slashMatches[slashSel] ?? slashMatches[0])
} else if (e.key === 'Escape') {
e.preventDefault()
setContent('')
}
}
function renderSlashPanel() {
if (showSlashNames) {
return (
<div className="slash-panel">
<div className="slash-hint">commands · select · Enter pick · Esc clear</div>
{slashMatches.map((c, i) => (
<button
key={c.name}
type="button"
className={`slash-item ${i === slashSel ? 'sel' : ''}`}
onMouseEnter={() => setSlashSel(i)}
onClick={() => pickSlash(c)}
>
<span className="sc-name">/{c.nativeName}</span>
<span className="sc-desc">{c.description}</span>
</button>
))}
</div>
)
}
if (showSlashArgs && slashActiveCmd) {
return (
<div className="slash-panel">
<div className="slash-hint">
/{slashActiveCmd.nativeName} {slashActiveCmd.description}
</div>
{slashActiveCmd.args.length === 0 ? (
<div className="slash-arg">(no arguments)</div>
) : (
slashActiveCmd.args.map((a) => (
<div key={a.name} className="slash-arg">
<div>
<span className="sa-name">
{a.name}
{a.required ? <span className="sa-req">*</span> : null}
</span>
<span className="sa-type">{a.type}</span>
<span className="sc-desc">{a.description}</span>
</div>
{a.choices && a.choices.length ? (
<div className="sa-choices">
{a.choices.slice(0, 24).map((ch) => (
<button
key={ch.value}
type="button"
className="sa-choice"
onClick={() => appendSlashValue(ch.value)}
title={ch.label}
>
{ch.label}
</button>
))}
</div>
) : null}
</div>
))
)}
</div>
)
}
return null
}
return (
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
@@ -1009,6 +1160,7 @@ export default function ChatPage() {
<div className="dc-closed-banner">This channel is closed history is read-only.</div>
) : (
<div className="dc-composer-wrap">
{renderSlashPanel()}
{pendingFiles.length ? (
<div className="pending-files">
{pendingFiles.map((f, fi) => (
@@ -1054,7 +1206,11 @@ export default function ChatPage() {
<input
className="input"
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={(e) => {
setContent(e.target.value)
setSlashSel(0)
}}
onKeyDown={onComposerKey}
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel first'}
disabled={!currentChannel}
/>