A command menu you can tab through

Its own list rather than the name picker's, because the rows carry more:
the command, what it takes, what it does, and who answers it - laid out as
a name over an explanation so the eye runs down the names. The heading says
what was searched for, because a fuzzy match reads as a bug otherwise: "dv"
finding devoice needs the line above it to make sense.

Tab and Enter both take the highlighted one, and taking it writes it into
the box rather than firing it - almost every command wants something after
its name, and a menu that ran on the first press would be a menu you had to
avoid. A command that takes nothing has nothing to wait for, so that one
runs.

An application command's name, once completed, is remembered: what follows
it is arguments for a bot rather than a line to say out loud. Built-ins need
no such memory - the daemon reads those out of the message text, the way it
always has.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-05 19:32:35 -04:00
co-authored by Claude Opus 5
parent 38458b61bf
commit f0dbc405ad
3 changed files with 188 additions and 30 deletions
+1 -1
Submodule nobilis updated: 89b180ebd6...d37a795f22
+80
View File
@@ -766,6 +766,86 @@
border-radius: var(--corner-radius-small);
}
/*
* What can be typed after a slash, above the box.
*
* Its own list rather than the name picker's, because the rows carry more:
* the command, what it takes, what it does, and who answers it. Laid out as
* one line of name and arguments over a line of explanation, so the eye runs
* down the names rather than reading each row.
*/
.command-picker {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
margin: 0 var(--spacing-s) var(--spacing-xs);
padding: var(--spacing-xs);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.command-picker-head {
padding: var(--spacing-xs) var(--spacing-s);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.command-option {
display: flex;
align-items: center;
gap: var(--spacing-s);
padding: var(--spacing-xs) var(--spacing-s);
border: none;
border-radius: var(--corner-radius-small);
background: none;
color: var(--surface-text);
text-align: left;
cursor: pointer;
}
.command-option.active,
.command-option:hover {
background: var(--surface-container-highest);
}
.command-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
border-radius: var(--corner-radius-small);
background: var(--surface-container-highest);
color: var(--surface-variant-text);
}
.command-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.command-name {
font-weight: 500;
}
/* The arguments are not the name: same line, quieter, so the name still
reads as the thing being chosen. */
.command-usage {
color: var(--surface-variant-text);
font-weight: 400;
}
.command-source {
flex: none;
margin-left: auto;
}
/*
* The controls a service put on a message.
*
+107 -29
View File
@@ -69,14 +69,23 @@ function caretToEnd(el: HTMLElement): void {
* encryption indicator. A fixed bar rather than part of the scrolling message
* area, so arriving messages can never visually intrude under it.
*/
/** One slash command a channel offers, as the daemon describes it. */
interface DiscordCommand {
id: string
/**
* One command that can be typed here, whoever implements it.
*
* The daemon answers with its own and the channel's bots' in one list, since
* somebody typing a slash is asking what happens next rather than which half
* of the program answers.
*/
interface TypedCommand {
name: string
/** `<required> [optional]`, as a manual page would write it. */
usage?: string
description?: string
application?: string
/** "Built-in", or the bot that answers it. */
source?: string
kind: 'builtin' | 'application'
/** Discord's own description of the command, handed back to run it. */
command: { options?: { name: string; type?: number; required?: boolean }[] }
command?: { options?: { name: string; type?: number; required?: boolean }[] }
}
export function Composer(): JSX.Element | null {
@@ -127,8 +136,17 @@ export function Composer(): JSX.Element | null {
* bots a server has installed, they differ per channel, and there is no
* list a client could hold. Only Discord has them.
*/
const [commands, setCommands] = useState<DiscordCommand[]>([])
const [commands, setCommands] = useState<TypedCommand[]>([])
const [commandIndex, setCommandIndex] = useState(0)
/**
* The application command whose name has been completed into the box.
*
* Held because what follows it is arguments rather than a message: without
* this, finishing "/wordle" and pressing Enter would say "/wordle" out loud
* in the channel. A built-in needs no such memory - the daemon reads those
* out of the text itself, which is how they have always worked.
*/
const [pendingCommand, setPendingCommand] = useState<TypedCommand | null>(null)
/** Discord's mentionable roles here; empty everywhere else. */
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
const emojiButtonRef = useRef<HTMLButtonElement>(null)
@@ -183,7 +201,7 @@ export function Composer(): JSX.Element | null {
// What has been typed as a command, if anything: a slash at the very start
// and the word after it. Not mid-message - "and/or" is not a command.
const typedCommand = service === 'discord' && /^\/\S*$/.test(text) ? text.slice(1) : null
const typedCommand = /^\/\S*$/.test(text) ? text.slice(1) : null
useEffect(() => {
if (typedCommand === null || !buffer) {
@@ -192,7 +210,7 @@ export function Composer(): JSX.Element | null {
}
const timer = setTimeout(() => {
void window.moho
.rpc<{ commands: DiscordCommand[] }>('listDiscordCommands', {
.rpc<{ commands: TypedCommand[] }>('listCommands', {
bufferId: buffer.id,
query: typedCommand
})
@@ -237,6 +255,15 @@ export function Composer(): JSX.Element | null {
const body = text.trim()
if (!body && staged.length === 0) return
// A command whose name was completed into the box: what follows it is
// arguments for a bot, not a line to say in the channel. Built-ins fall
// through to the ordinary send, because the daemon reads those out of
// the message text itself.
if (pendingCommand && body.startsWith(`/${pendingCommand.name}`)) {
runCommand(pendingCommand)
return
}
// Aimed at one person rather than at the room. Attachments are not
// carried: a whisper is a line of text on this service, and silently
// posting somebody's picture to the whole room instead would be the worst
@@ -323,16 +350,51 @@ export function Composer(): JSX.Element | null {
}
/**
* Runs the chosen command, with whatever was typed after it as its
* arguments, in the order the command declares them.
* Takes the highlighted command: writes its name into the box, and gets out
* of the way so the arguments can be typed.
*
* Positional rather than named: Discord's own client draws a form built
* from the command's own description of itself, which is a great deal of
* interface for something people type. What somebody types after the name
* is what they mean by the first argument.
* Completing rather than running, because almost every command takes
* something after it and a menu that fired on the first press would be a
* menu you had to avoid. A command that takes nothing has nothing to wait
* for, so that one runs.
*/
const runCommand = (command: DiscordCommand): void => {
const words = text.trim().split(/\s+/).slice(1)
const takeCommand = (command: TypedCommand): void => {
const wants = (command.usage ?? '').trim().length > 0
setCommands([])
if (!wants && command.kind === 'application') {
setPendingCommand(command)
runCommand(command, `/${command.name}`)
return
}
const written = `/${command.name}${wants ? ' ' : ''}`
setText(written)
if (inputRef.current) {
inputRef.current.textContent = written
// The caret goes to the end, where the next thing typed belongs.
const range = document.createRange()
range.selectNodeContents(inputRef.current)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
inputRef.current.focus()
}
// Remembered only for the ones this client has to run itself; a built-in
// is read out of the message text by the daemon, the way it always was.
setPendingCommand(command.kind === 'application' ? command : null)
}
/**
* Runs an application command with whatever was typed after its name, in
* the order the command declares its arguments.
*
* Positional rather than a form: Discord's own client builds one out of the
* command's description of itself, which is a great deal of interface for
* something people type. What somebody writes after the name is what they
* mean by the first argument.
*/
const runCommand = (command: TypedCommand, typed = text): void => {
const words = typed.trim().split(/\s+/).slice(1)
const declared = (command.command?.options ?? []).filter((o) => (o.type ?? 3) <= 10)
const options = declared
.map((option, i) => ({ option, word: words[i] }))
@@ -340,7 +402,7 @@ export function Composer(): JSX.Element | null {
.map(({ option, word }) => ({
type: option.type ?? 3,
name: option.name,
// Numbers and booleans are sent as themselves; everything else, a
// Numbers and booleans are sent as themselves; everything else the
// service resolves for itself from what was written.
value:
option.type === 4 || option.type === 10
@@ -357,6 +419,7 @@ export function Composer(): JSX.Element | null {
setText('')
if (inputRef.current) inputRef.current.textContent = ''
setCommands([])
setPendingCommand(null)
void window.moho
.rpc('runDiscordCommand', { bufferId: buffer!.id, command: command.command, options })
.catch((e: Error) => store.toast('error', e.message))
@@ -462,30 +525,42 @@ export function Composer(): JSX.Element | null {
put it, and the only place it can go without covering what is being
typed. Members first, then roles, then the service's whole-room
words, because that is the order of how often each is meant. */}
{/* The commands a channel offers, in the same place and with the same
keys as the name picker above the box - two lists that behave
differently would be two things to learn. */}
{/* What can be typed here, and what it takes. Above the box in the
same place as the name picker, and with the same keys - two lists
that behaved differently would be two things to learn.
The heading names what was typed, because a fuzzy match can return
something that looks nothing like it: "dv" finding "devoice" reads
as a bug until the line above says what was searched for. */}
{commands.length > 0 && (
<div className="mention-picker" role="listbox" aria-label="Run a command">
<div className="command-picker" role="listbox" aria-label="Run a command">
<div className="command-picker-head small muted">
Commands matching /{typedCommand}
</div>
{commands.map((c, i) => (
<button
key={c.id}
key={`${c.kind}:${c.name}`}
type="button"
role="option"
aria-selected={i === commandIndex}
className={classes('mention-option', i === commandIndex && 'active')}
className={classes('command-option', i === commandIndex && 'active')}
onMouseDown={(e) => {
e.preventDefault()
runCommand(c)
takeCommand(c)
}}
onMouseEnter={() => setCommandIndex(i)}
>
<span className="mention-glyph">
<span className="command-glyph">
<Icon name="terminal" size={16} />
</span>
<span className="ellipsis">/{c.name}</span>
{c.description && <span className="small muted ellipsis mention-detail">{c.description}</span>}
{c.application && <span className="small muted">{c.application}</span>}
<span className="command-text">
<span className="command-name ellipsis">
/{c.name}
{c.usage && <span className="command-usage"> {c.usage}</span>}
</span>
{c.description && <span className="small muted ellipsis">{c.description}</span>}
</span>
{c.source && <span className="small muted command-source">{c.source}</span>}
</button>
))}
</div>
@@ -615,9 +690,12 @@ export function Composer(): JSX.Element | null {
setCommandIndex((commandIndex + step) % commands.length)
return
}
// Both take it, and taking it means writing it into the box
// rather than firing it: almost every command wants something
// after its name.
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
runCommand(commands[commandIndex])
takeCommand(commands[commandIndex])
return
}
if (e.key === 'Escape') {