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
2 changes: 1 addition & 1 deletion packages/app/e2e/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const terminalSelector = "#terminal-panel"
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
Expand Down
20 changes: 16 additions & 4 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
closePopover()

if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
const rawText = editorRef.textContent ?? ""
const cursor = getCursorPosition(editorRef)
const before = rawText.substring(0, cursor)
const lastSlash = before.lastIndexOf("/")
const prefix = lastSlash !== -1 ? rawText.substring(0, lastSlash) : ""
const text = prefix + `/${cmd.trigger} `
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
focusEditorEnd()
Expand Down Expand Up @@ -810,13 +815,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {

if (!shellMode) {
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
const slashMatch = rawText.match(/^\/(\S*)$/)
const textBefore = rawText.substring(0, cursorPosition)
const lastSlash = textBefore.lastIndexOf("/")
const slashMatch =
lastSlash !== -1 &&
(lastSlash === 0 || /\s/.test(textBefore[lastSlash - 1]!)) &&
!textBefore.slice(lastSlash).match(/\s/)
? textBefore.slice(lastSlash + 1)
: null

if (atMatch) {
atOnInput(atMatch[1])
setStore("popover", "at")
} else if (slashMatch) {
slashOnInput(slashMatch[1])
} else if (slashMatch !== null) {
slashOnInput(slashMatch)
setStore("popover", "slash")
} else {
closePopover()
Expand Down
60 changes: 48 additions & 12 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,25 +263,61 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}

if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
const fileParts = images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
}))

// Parse all slash-command segments from the input
const segments: { command: string; arguments: string }[] = []
for (const seg of text.split(/(?=\/)/).filter(Boolean)) {
const t = seg.trim()
if (!t.startsWith("/")) continue
const spaceIdx = t.indexOf(" ")
const name = spaceIdx === -1 ? t.slice(1) : t.slice(1, spaceIdx)
if (!sync.data.command.some((c) => c.name === name)) continue
const args = spaceIdx === -1 ? "" : t.slice(spaceIdx + 1).trim()
segments.push({ command: name, arguments: args })
}

if (segments.length === 1) {
const seg = segments[0]!
clearInput()
client.session
.command({
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
command: seg.command,
arguments: seg.arguments,
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: fileParts,
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
}

if (segments.length > 1) {
clearInput()
client.session
.commands({
sessionID: session.id,
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
commands: segments.map((seg) => ({
command: seg.command,
arguments: seg.arguments,
parts: fileParts,
})),
})
.catch((err) => {
Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/pages/session/terminal-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,27 @@ export function TerminalPanel() {
),
)

const promptHasFocus = () => {
const active = document.activeElement
if (!(active instanceof HTMLElement)) return false
return !!active.closest('[data-component="prompt-input"]')
}

const focus = (id: string) => {
focusTerminalById(id)
if (!promptHasFocus()) focusTerminalById(id)

const frame = requestAnimationFrame(() => {
if (!opened()) return
if (terminal.active() !== id) return
if (promptHasFocus()) return
focusTerminalById(id)
})

const timers = [120, 240].map((ms) =>
window.setTimeout(() => {
if (!opened()) return
if (terminal.active() !== id) return
if (promptHasFocus()) return
focusTerminalById(id)
}, ms),
)
Expand Down
80 changes: 45 additions & 35 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1371,17 +1371,27 @@ export namespace ACP {

log.info("parts", { parts })

const cmd = (() => {
// Collect all slash commands from text parts
const cmds = (() => {
const text = parts
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("")
.trim()

if (!text.startsWith("/")) return

const [name, ...rest] = text.slice(1).split(/\s+/)
return { name, args: rest.join(" ").trim() }
if (!text.startsWith("/")) return []

const tokens = text.split(/(?=\/)/).filter(Boolean)
return tokens
.map((seg) => {
const trimmed = seg.trim()
if (!trimmed.startsWith("/")) return null
const spaceIdx = trimmed.indexOf(" ")
const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx)
const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim()
return { name, args }
})
.filter((x): x is { name: string; args: string } => x !== null)
})()

const buildUsage = (msg: AssistantMessage): Usage => ({
Expand All @@ -1398,7 +1408,7 @@ export namespace ACP {
cachedWriteTokens: msg.tokens.cache?.write || undefined,
})

if (!cmd) {
if (cmds.length === 0) {
const response = await this.sdk.session.prompt({
sessionID,
model: {
Expand All @@ -1421,47 +1431,47 @@ export namespace ACP {
}
}

const command = await this.config.sdk.command
.list({ directory }, { throwOnError: true })
.then((x) => x.data!.find((c) => c.name === cmd.name))
if (command) {
const known = await this.config.sdk.command.list({ directory }, { throwOnError: true }).then((x) => x.data!)

// Filter to recognized commands; handle /compact separately
const recognized = cmds.filter((c) => known.find((k) => k.name === c.name))
const hasCompact = cmds.some((c) => c.name === "compact" && !known.find((k) => k.name === c.name))

if (hasCompact) {
await this.config.sdk.session.summarize(
{ sessionID, directory, providerID: model.providerID, modelID: model.modelID },
{ throwOnError: true },
)
}

let lastMsg: AssistantMessage | undefined
if (recognized.length === 1) {
const item = recognized[0]!
const response = await this.sdk.session.command({
sessionID,
command: command.name,
arguments: cmd.args,
command: item.name,
arguments: item.args,
model: model.providerID + "/" + model.modelID,
agent,
directory,
})
const msg = response.data?.info

await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)

return {
stopReason: "end_turn" as const,
usage: msg ? buildUsage(msg) : undefined,
_meta: {},
}
}

switch (cmd.name) {
case "compact":
await this.config.sdk.session.summarize(
{
sessionID,
directory,
providerID: model.providerID,
modelID: model.modelID,
},
{ throwOnError: true },
)
break
lastMsg = response.data?.info
} else if (recognized.length > 1) {
const response = await this.sdk.session.commands({
sessionID,
agent,
model: model.providerID + "/" + model.modelID,
directory,
commands: recognized.map((c) => ({ command: c.name, arguments: c.args })),
})
lastMsg = response.data?.info
}

await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)

return {
stopReason: "end_turn" as const,
usage: lastMsg ? buildUsage(lastMsg) : undefined,
_meta: {},
}
}
Expand Down
65 changes: 41 additions & 24 deletions packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export function Autocomplete(props: {
input: "keyboard" as "keyboard" | "mouse",
})

let selecting = false

const [positionTick, setPositionTick] = createSignal(0)

createEffect(() => {
Expand Down Expand Up @@ -365,9 +367,11 @@ export function Autocomplete(props: {
onSelect: () => {
const newText = "/" + serverCommand.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().cursorOffset = store.index
const startCursor = props.input().logicalCursor
props.input().deleteRange(startCursor.row, startCursor.col, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
props.input().cursorOffset = store.index + Bun.stringWidth(newText)
},
})
}
Expand Down Expand Up @@ -450,8 +454,10 @@ export function Autocomplete(props: {
function select() {
const selected = options()[store.selected]
if (!selected) return
hide()
hide(false)
selecting = true
selected.onSelect?.()
selecting = false
}

function expandDirectory() {
Expand Down Expand Up @@ -483,15 +489,20 @@ export function Autocomplete(props: {
})
}

function hide() {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
// Sync the prompt store immediately since onContentChange is async
props.setPrompt((draft) => {
draft.input = props.input().plainText
})
function hide(cancel = true) {
if (cancel) {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ")) {
props.input().cursorOffset = store.index
const startCursor = props.input().logicalCursor
props.input().cursorOffset = props.input().plainText.length
const endCursor = props.input().logicalCursor
props.input().deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
// Sync the prompt store immediately since onContentChange is async
props.setPrompt((draft) => {
draft.input = props.input().plainText
})
}
}
command.keybinds(true)
setStore("visible", false)
Expand All @@ -503,14 +514,13 @@ export function Autocomplete(props: {
return store.visible
},
onInput(value) {
if (selecting) return
if (store.visible) {
if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
) {
hide()
}
Expand All @@ -521,19 +531,24 @@ export function Autocomplete(props: {
const offset = props.input().cursorOffset
if (offset === 0) return

// Check for "/" at position 0 - reopen slash commands
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
show("/")
setStore("index", 0)
return
// Check for "/" before cursor with no whitespace between — reopen slash commands
const textBefore = value.slice(0, offset)
const slashIdx = textBefore.lastIndexOf("/")
if (slashIdx !== -1) {
const before = slashIdx === 0 ? undefined : value[slashIdx - 1]
const between = textBefore.slice(slashIdx)
if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) {
show("/")
setStore("index", slashIdx)
return
}
}

// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const text = value.slice(0, offset)
const idx = text.lastIndexOf("@")
const idx = textBefore.lastIndexOf("@")
if (idx === -1) return

const between = text.slice(idx)
const between = textBefore.slice(idx)
const before = idx === 0 ? undefined : value[idx - 1]
if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) {
show("@")
Expand Down Expand Up @@ -590,7 +605,9 @@ export function Autocomplete(props: {
}

if (e.name === "/") {
if (props.input().cursorOffset === 0) show("/")
const off = props.input().cursorOffset
const before = off === 0 ? undefined : props.input().getTextRange(off - 1, off)
if (before === undefined || before === "" || /\s/.test(before)) show("/")
}
}
},
Expand Down
Loading
Loading