feat(web-ui): precise slash-command matcher#1023
Conversation
Replace the nine loose `text.startsWith('/btw')` checks scattered
across ChatInput.tsx with a single `isSlashCommand(text, command)`
helper that enforces a proper word boundary. This fixes two real
bugs in one place:
1. Word-boundary miss: `/btwextra hello` used to be treated as
`/btw hello` and dispatched as a side-question. The new helper
requires `/btw` to be followed by whitespace or end-of-string,
so `/btwextra` now correctly falls through to the generic-send
path. The same fix applies to `/goals`, `/compactor`, `/usage2`,
etc.
2. Trailing whitespace handling (Claude Code 2.1.147): the new
`(?=\s|$)` lookahead treats tab and newline as valid
trailing boundaries, so `/goal focus the bug\t` and
`/btw\n` are still recognised as their command instead of
being mis-classified as unknown.
Updated call sites (nine in total) cover all three detection
paths in ChatInput.tsx:
* `handleInputChange` (3 sites) — picker activation gating
* `handleSendOrCancel` (5 sites) — Send-button dispatch
* Enter-key handler (1 site) — direct-keyboard submit
The helper is centralised in `src/flow_chat/utils/slashCommand.ts`
and exported with three signatures:
* `matchesSlashCommand(text)` — returns the matched token
* `isSlashCommand(text, command)` — predicate for one command
* `stripSlashCommand(text, command)` — extracts the argument
20 unit tests cover the happy path, the 2.1.147 trailing-tab /
newline cases, the `/btwextra` boundary cases, the MCP prompt
names (containing `:` and `-`), and defensive non-string inputs.
wsp1911
left a comment
There was a problem hiding this comment.
Thanks for tightening the slash-command matcher. The helper and focused tests look good, and the /btwextra / /usage2 cases are exactly the kind of regression coverage we want here.
One merge-sensitive issue: this PR is currently behind main, and the conflicted area in ChatInput.tsx overlaps with ACP slash-command handling. main added localSlashCommandsEnabled = !isAcpInputSession gates so ACP sessions can pass their own slash commands through instead of being captured by local /btw, /compact, /usage, etc. When resolving the conflict, please keep those gates around the new matcher, e.g. localSlashCommandsEnabled && isSlashCommand(...).
Small consistency issue: there is still one loose /goal prefix check in the send-button path:
message.toLowerCase().startsWith('/goal') && !isGoalSlashCommand(message)The normal keyboard flow for /goals shows “no matching command” in the picker, but if the picker is still open and the user clicks the send button, this branch fires and shows the /goal usage warning. That preserves the old prefix-conflation behavior for this click path. Could we align this branch with the new boundary matcher as well?
What this PR does
Replaces the nine loose
text.startsWith('/btw')checks scattered acrossChatInput.tsxwith a singleisSlashCommand(text, command)helper thatenforces a proper word boundary. This fixes two real bugs in one place.
Bug 1 — word-boundary miss (the headline fix)
Before this PR, the chat composer used
text.toLowerCase().startsWith('/btw')to detect the
/btwcommand. That predicate has no word boundary, soevery command that happened to start with the same prefix was
accidentally dispatched as that command.
/btw 你好/btw(correct)/btw(correct)/btwextra 你好/btw 你好(sent to btw side-session)/goals 我的目标/goal 我的目标(entered goal mode!)/compactor/compact(showed usage error)/usage2/usage(showed usage error)Bug 2 — Claude Code 2.1.147: trailing tab / newline
A slash command followed by a stray tab or newline used to be rejected
by the strict regexes in the Send path. The new helper uses the
lookahead
(?=\s|$), which treats tab and newline as valid trailingwhitespace.
/goal 修 bug\t/goal 修 bug/btw\n/btw/btw hello/btw hello/btw hello(unchanged)How it works
A new module at
src/flow_chat/utils/slashCommand.tsexports threehelpers:
The internal regex is
^(\/[a-zA-Z][\w:-]*)(?=\s|$)/— the(?=\s|$)lookahead is what enforces a proper boundary at the firstwhitespace or end-of-string.
All nine existing call sites in
ChatInput.tsxare routed throughisSlashCommand. The scope covers every detection path:handleInputChange(3 sites) — picker activation gatinghandleSendOrCancel(5 sites) — Send-button dispatchThe call at
ChatInput.tsx:2005(
message.toLowerCase().startsWith('/goal') && !isGoalSlashCommand(message))is deliberately kept as a loose check — it is the fallback that
shows "Use /goal with optional focus text…" when the user typed
something like
/goals foothat almost matches but is malformed.Tests
20 unit tests in
src/flow_chat/utils/slashCommand.test.ts:Coverage includes:
/btw,/goal,/usage,/DeepReview)/btwextra,/goals,/compactor,/usage2):and-in the nameCode review
Reviewed by an independent codex review pass. The two highest-severity
findings were:
expanding the PR to cover all 9 in this same commit.
with the metacharacter-isolation test.
All other findings were LOW (JSDoc nit on
\t/\nreading asliterals;
command: stringcould be a tighter/${string}templateliteral type) and are documented but not blocking.
Files changed
Test plan for review
cd src/web-ui && npx vitest run slashCommand→ 20/20 passcd src/web-ui && npx tsc --noEmit→ 0 errors/btwextra hello, press Enter → messageis dispatched as a generic user message (NOT to a btw side-session)
/goals my goal, press Enter → message dispatched as a genericuser message (NOT goal mode)
/btw\t, press Enter → opens a btw side-session as expected/goal 修 bug, press Tab (in a non-shift-target context), pressEnter → enters goal mode as expected