diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 2061a112849..12804ae1066 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -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"]' diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 532edd3bcdc..fbe347398c8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -576,7 +576,12 @@ export const PromptInput: Component = (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() @@ -810,13 +815,20 @@ export const PromptInput: Component = (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() diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index fee6b070d94..d9e2bb91806 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -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) => { diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index a6c3929c10c..d91dc7c0001 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -81,12 +81,19 @@ 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) }) @@ -94,6 +101,7 @@ export function TerminalPanel() { window.setTimeout(() => { if (!opened()) return if (terminal.active() !== id) return + if (promptHasFocus()) return focusTerminalById(id) }, ms), ) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2552682dbe0..193af9315da 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -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 => ({ @@ -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: { @@ -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: {}, } } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3240afab326..a4c1b002917 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -89,6 +89,8 @@ export function Autocomplete(props: { input: "keyboard" as "keyboard" | "mouse", }) + let selecting = false + const [positionTick, setPositionTick] = createSignal(0) createEffect(() => { @@ -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) }, }) } @@ -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() { @@ -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) @@ -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/) || - // "/" is not the sole content - (store.visible === "/" && value.match(/^\S+\s+\S+\s*$/)) + props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ) { hide() } @@ -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("@") @@ -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("/") } } }, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2d99051fb97..a8b7d3585cb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -601,33 +601,59 @@ export function Prompt(props: PromptProps) { } else if ( inputText.startsWith("/") && iife(() => { - const firstLine = inputText.split("\n")[0] - const command = firstLine.split(" ")[0].slice(1) - return sync.data.command.some((x) => x.name === command) + // Check if at least one token starting with "/" matches a known command + const tokens = inputText.match(/\/\S+/g) ?? [] + return tokens.some((t) => sync.data.command.some((x) => x.name === t.slice(1).split(" ")[0])) }) ) { - // Parse command from first line, preserve multi-line content in arguments - const firstLineEnd = inputText.indexOf("\n") - const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) - const [command, ...firstLineArgs] = firstLine.split(" ") - const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) - const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - - sdk.client.session.command({ - sessionID, - command: command.slice(1), - arguments: args, - agent: local.agent.current().name, - model: `${selectedModel.providerID}/${selectedModel.modelID}`, - messageID, - variant, - parts: nonTextParts - .filter((x) => x.type === "file") - .map((x) => ({ - id: Identifier.ascending("part"), - ...x, + // Tokenize into command segments: each "/" starts a new segment + const segments: { command: string; arguments: string }[] = [] + const raw = inputText.split(/(?=\/)/).filter(Boolean) + for (const seg of raw) { + const trimmed = seg.trim() + if (!trimmed.startsWith("/")) continue + const spaceIdx = trimmed.indexOf(" ") + const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx) + if (!sync.data.command.some((x) => x.name === name)) continue + const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim() + segments.push({ command: name, arguments: args }) + } + + if (segments.length === 1) { + const seg = segments[0]! + sdk.client.session.command({ + sessionID, + command: seg.command, + arguments: seg.arguments, + agent: local.agent.current().name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + messageID, + variant, + parts: nonTextParts + .filter((x) => x.type === "file") + .map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + }) + } else { + sdk.client.session.commands({ + sessionID, + agent: local.agent.current().name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + variant, + commands: segments.map((seg) => ({ + command: seg.command, + arguments: seg.arguments, + parts: nonTextParts + .filter((x) => x.type === "file") + .map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), })), - }) + }) + } } else { sdk.client.session .prompt({ diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7456742cdf3..cc2bb37bad7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1236,7 +1236,13 @@ function UserMessage(props: { }) { const ctx = use() const local = useLocal() - const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0]) + const textParts = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))) + const text = createMemo(() => { + const parts = textParts() + if (!parts.length) return undefined + if (parts.length === 1) return parts[0] + return { ...parts[0]!, text: parts.map((x) => x.text).join("\n\n") } + }) const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) const sync = useSync() const { theme } = useTheme() diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 12938aeaba0..cd9f7bbaa7a 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -836,6 +836,43 @@ export const SessionRoutes = lazy(() => return c.json(msg) }, ) + .post( + "/:sessionID/commands", + describeRoute({ + summary: "Send multiple commands", + description: "Send multiple commands sequentially to a session, executing them as a single user turn.", + operationId: "session.commands", + responses: { + 200: { + description: "Last created message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Assistant, + parts: MessageV2.Part.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.MultiCommandInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.multiCommand({ ...body, sessionID }) + return c.json(msg) + }, + ) .post( "/:sessionID/shell", describeRoute({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7698b78baba..05ce6f1961e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1888,6 +1888,128 @@ NOTE: At any point in time through this workflow you should feel free to ask the return result } + export const MultiCommandInput = z.object({ + sessionID: Identifier.schema("session"), + agent: z.string().optional(), + model: z.string().optional(), + variant: z.string().optional(), + commands: z.array( + z.object({ + command: z.string(), + arguments: z.string(), + parts: z + .array( + z.discriminatedUnion("type", [ + MessageV2.FilePart.omit({ + messageID: true, + sessionID: true, + }).partial({ + id: true, + }), + ]), + ) + .optional(), + }), + ), + }) + export type MultiCommandInput = z.infer + + export async function multiCommand(input: MultiCommandInput) { + type Resolved = { + parts: PromptInput["parts"] + agentName: string + model: { providerID: string; modelID: string } + } + + const resolved = await Promise.all( + input.commands.map(async (item): Promise => { + const cmd = await Command.get(item.command) + const agentName = cmd.agent ?? input.agent ?? (await Agent.defaultAgent()) + + const raw = item.arguments.match(argsRegex) ?? [] + const args = raw.map((a) => a.replace(quoteTrimRegex, "")) + + const templateCommand = await cmd.template + const placeholders = templateCommand.match(placeholderRegex) ?? [] + let last = 0 + for (const p of placeholders) { + const v = Number(p.slice(1)) + if (v > last) last = v + } + + const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { + const pos = Number(index) + const idx = pos - 1 + if (idx >= args.length) return "" + if (pos === last) return args.slice(idx).join(" ") + return args[idx] + }) + const usesArgs = templateCommand.includes("$ARGUMENTS") + let template = withArgs.replaceAll("$ARGUMENTS", item.arguments) + if (placeholders.length === 0 && !usesArgs && item.arguments.trim()) { + template = template + "\n\n" + item.arguments + } + + const shell = ConfigMarkdown.shell(template) + if (shell.length > 0) { + const results = await Promise.all( + shell.map(async ([, s]) => { + try { + return await $`${{ raw: s }}`.quiet().nothrow().text() + } catch (error) { + return `Error executing command: ${error instanceof Error ? error.message : String(error)}` + } + }), + ) + let i = 0 + template = template.replace(bashRegex, () => results[i++]) + } + template = template.trim() + + const model = await (async () => { + if (cmd.model) return Provider.parseModel(cmd.model) + if (cmd.agent) { + const a = await Agent.get(cmd.agent) + if (a?.model) return a.model + } + if (input.model) return Provider.parseModel(input.model) + return await lastModel(input.sessionID) + })() + + await Plugin.trigger( + "command.execute.before", + { command: item.command, sessionID: input.sessionID, arguments: item.arguments }, + { parts: [] }, + ) + + const templateParts = await resolvePromptParts(template) + return { parts: [...templateParts, ...(item.parts ?? [])], agentName, model } + }), + ) + + const first = resolved[0]! + const allParts = resolved.flatMap((r) => r.parts) + + const result = (await prompt({ + sessionID: input.sessionID, + model: first.model, + agent: first.agentName, + parts: allParts, + variant: input.variant, + })) as MessageV2.WithParts + + for (const item of input.commands) { + Bus.publish(Command.Event.Executed, { + name: item.command, + sessionID: input.sessionID, + arguments: item.arguments, + messageID: result.info.id, + }) + } + + return result + } + async function ensureTitle(input: { session: Session.Info history: MessageV2.WithParts[] diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2bb2edcd175..4eb4c4c155e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -110,6 +110,8 @@ import type { SessionChildrenResponses, SessionCommandErrors, SessionCommandResponses, + SessionCommandsErrors, + SessionCommandsResponses, SessionCreateErrors, SessionCreateResponses, SessionDeleteErrors, @@ -2066,6 +2068,62 @@ export class Session2 extends HeyApiClient { }) } + /** + * Send multiple commands + * + * Send multiple commands sequentially to a session, executing them as a single user turn. + */ + public commands( + parameters: { + sessionID: string + directory?: string + workspace?: string + agent?: string + model?: string + variant?: string + commands?: Array<{ + command: string + arguments: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource + }> + }> + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "variant" }, + { in: "body", key: "commands" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/commands", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Run shell command * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a47b18db219..a0b9009c5c3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3617,6 +3617,62 @@ export type SessionCommandResponses = { export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] +export type SessionCommandsData = { + body?: { + agent?: string + model?: string + variant?: string + commands: Array<{ + command: string + arguments: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource + }> + }> + } + path: { + /** + * Session ID + */ + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/commands" +} + +export type SessionCommandsErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionCommandsError = SessionCommandsErrors[keyof SessionCommandsErrors] + +export type SessionCommandsResponses = { + /** + * Last created message + */ + 200: { + info: AssistantMessage + parts: Array + } +} + +export type SessionCommandsResponse = SessionCommandsResponses[keyof SessionCommandsResponses] + export type SessionShellData = { body?: { agent: string diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 7ee5378848e..92b3c82eae5 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -21,7 +21,7 @@ align-self: stretch; width: 100%; max-width: 100%; - gap: 0; + gap: 8px; &[data-interrupted] { color: var(--text-weak); @@ -98,10 +98,6 @@ align-items: flex-end; } - [data-slot="user-message-attachments"] + [data-slot="user-message-body"] { - margin-top: 8px; - } - [data-slot="user-message-text"] { display: inline-block; white-space: pre-wrap; @@ -172,7 +168,7 @@ align-items: center; justify-content: flex-end; overflow: hidden; - gap: 0; + gap: 6px; } [data-slot="user-message-meta-tail"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 0815c333367..96b12360289 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -872,11 +872,16 @@ export function UserMessageDisplay(props: { const i18n = useI18n() const [copied, setCopied] = createSignal(false) - const textPart = createMemo( - () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, + const textParts = createMemo( + () => (props.parts?.filter((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart[]) ?? [], ) - const text = createMemo(() => textPart()?.text || "") + const text = createMemo( + () => + textParts() + .map((p) => p.text) + .join("\n\n") || "", + ) const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])