Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/components/chat/message-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,72 @@ describe("MessageInput slash menu while the agent connects", () => {
)
}

it("keeps `/` as a slash-command trigger", async () => {
await mountAndType({
commandsLoading: false,
availableCommands: COMMANDS,
})
const menu = await screen.findByTestId("slash-menu")
expect(within(menu).getByText("/compact")).toBeInTheDocument()
})

it("treats `、` as the same slash-command trigger", async () => {
await mountAndType(
{ commandsLoading: false, availableCommands: COMMANDS },
"、"
)
const menu = await screen.findByTestId("slash-menu")
expect(within(menu).getByText("/compact")).toBeInTheDocument()
})

it("preserves `、` in ordinary Chinese text", async () => {
const { editor } = await mountAndType(
{ commandsLoading: false, availableCommands: COMMANDS },
"苹果、香蕉"
)
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50))
})
expect(serializeDocToText(editor.state.doc)).toBe("苹果、香蕉")
expect(screen.queryByTestId("slash-menu")).toBeNull()
})

it("serializes a command selected through `、` with `/`", async () => {
const { editor } = await mountAndType(
{ commandsLoading: false, availableCommands: COMMANDS },
"、"
)
const menu = await screen.findByTestId("slash-menu")
fireEvent.mouseDown(within(menu).getByRole("button", { name: /\/compact/ }))
await waitFor(() =>
expect(serializeDocToText(editor.state.doc).trim()).toBe("/compact")
)
expect(serializeDocToText(editor.state.doc)).not.toContain("、")
})

it("does not select a slash command while the IME is composing", async () => {
const { editor } = await mountAndType(
{ commandsLoading: false, availableCommands: COMMANDS },
"、"
)
await screen.findByTestId("slash-menu")

act(() => {
;(editor.view.dom as HTMLElement).dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
isComposing: true,
})
)
})

const text = serializeDocToText(editor.state.doc)
expect(text).toContain("、")
expect(text).not.toContain("/compact")
})

it("opens the panel on a loading row while commands are still on their way", async () => {
await mountAndType({ commandsLoading: true, availableCommands: [] })
const menu = await screen.findByTestId("slash-menu")
Expand Down
16 changes: 9 additions & 7 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -794,8 +794,9 @@ export function MessageInput({
// ── Editor-driven `/` (commands) and `$` (Codex skills) trigger detection ──
// The `@` mention panel is now owned by RichComposer; this only handles the
// runtime-command menus. We inspect the text before the collapsed caret in the
// current block: a `/` (any agent) or `$` (Codex) at the start or right after
// whitespace, and not inside inline code / a code block, opens the menu.
// current block: a `/` or its Chinese-IME alias `、` (any agent), or `$`
// (Codex), at the start or right after whitespace, and not inside inline code
// / a code block, opens the menu.
const detectSlashTrigger = useCallback(() => {
const editor = editorRef.current?.getEditor()
const close = () => {
Expand All @@ -814,10 +815,11 @@ export function MessageInput({
" "
)
const regex =
agentType === "codex" ? /(^|\s)([/$])(\S*)$/ : /(^|\s)(\/)(\S*)$/
agentType === "codex" ? /(^|\s)([/$])(\S*)$/ : /(^|\s)([/、])(\S*)$/
const match = before.match(regex)
if (!match) return close()
const trigger = match[2] as "/" | "$"
const matchedTrigger = match[2] as "/" | "$" | "、"
const trigger = matchedTrigger === "、" ? "/" : matchedTrigger
// Only `/` is gated here. Its source is the agent's own command list, which
// exists only once the connection is up — so an empty list means "nothing to
// show" unless the connection is still coming, where the panel opens on a
Expand Down Expand Up @@ -860,7 +862,7 @@ export function MessageInput({
setSlashTriggerChar(null)
}, [])

// Replace the live `/`-or-`$` token immediately before the caret with
// Replace the live `/`-, `、`-, or `$` token immediately before the caret with
// an inline reference badge (+ a trailing space unless one already follows),
// then close the menu. Used by both the command (`/`) and Codex-skill (`$`)
// selections — the badge serializes back to its literal `/cmd` / `$skill`
Expand All @@ -876,7 +878,7 @@ export function MessageInput({
undefined,
" "
)
const match = before.match(/(^|\s)([/$])(\S*)$/)
const match = before.match(/(^|\s)([/$])(\S*)$/)
const charAfter =
$from.parentOffset < $from.parent.content.size
? $from.parent.textBetween(
Expand All @@ -889,7 +891,7 @@ export function MessageInput({
const suffix = charAfter && /\s/.test(charAfter) ? "" : " "
let chain = editor.chain().focus()
if (match) {
// Remove the live `/…` / `$…` token before the caret.
// Remove the live `/…` / `、…` / `$…` token before the caret.
const tokenLen = match[2].length + match[3].length
chain = chain.deleteRange({ from: $from.pos - tokenLen, to: $from.pos })
}
Expand Down