From 46cad0ec7cb60aa1d44d497f53f246ea39519d84 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 16 May 2026 16:15:02 +0100 Subject: [PATCH] feat(chat): slash-command autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing / opens a command panel from the guild's synced OpenClaw catalog (GET /api/commands): filter + ↑↓/Enter/Esc; pick inserts / ; 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) --- README.md | 3 + src/index.css | 85 ++++++++++++++++++++++ src/pages/ChatPage.tsx | 160 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 246 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ab75962..56aa965 100644 --- a/README.md +++ b/README.md @@ -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 `/ `, 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://` diff --git a/src/index.css b/src/index.css index fbf5925..d23e314 100644 --- a/src/index.css +++ b/src/index.css @@ -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); +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index de82a15..a893626 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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('md') const [canvasSource, setCanvasSource] = useState('') const [canvasEditMode, setCanvasEditMode] = useState(false) + const [slashCmds, setSlashCmds] = useState([]) + 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) { + 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 ( +
+
commands · ↑↓ select · Enter pick · Esc clear
+ {slashMatches.map((c, i) => ( + + ))} +
+ ) + } + if (showSlashArgs && slashActiveCmd) { + return ( +
+
+ /{slashActiveCmd.nativeName} — {slashActiveCmd.description} +
+ {slashActiveCmd.args.length === 0 ? ( +
(no arguments)
+ ) : ( + slashActiveCmd.args.map((a) => ( +
+
+ + {a.name} + {a.required ? * : null} + + {a.type} + {a.description} +
+ {a.choices && a.choices.length ? ( +
+ {a.choices.slice(0, 24).map((ch) => ( + + ))} +
+ ) : null} +
+ )) + )} +
+ ) + } + return null + } + return (
openCtx(e, 'Fabric', blankMenu('root'))}>